戰(zhàn):協(xié)議設(shè)計(jì)與粘包處理)
最近在幫朋友整理內(nèi)部服務(wù)器之間的日志同步方案時(shí)遇到一個(gè)非常典型的場(chǎng)景兩臺(tái)機(jī)器在同一內(nèi)網(wǎng)里需要把幾個(gè)GB的數(shù)據(jù)包從一臺(tái)傳到另一臺(tái)FTP服務(wù)沒(méi)搭、專門的HTTP上傳服務(wù)也沒(méi)人維護(hù)臨時(shí)裝個(gè)網(wǎng)盤工具又不現(xiàn)實(shí)。最后又繞回老路子——直接用TCP通信自己寫一個(gè)文件上傳工具。這篇文章就把這個(gè)綜合案例完整拆開(kāi)講清楚從應(yīng)用層協(xié)議設(shè)計(jì)、服務(wù)端實(shí)現(xiàn)、客戶端實(shí)現(xiàn)到實(shí)測(cè)過(guò)程中踩過(guò)的坑和進(jìn)階思路覆蓋到Socket編程里最容易被忽略、但實(shí)際開(kāi)發(fā)又一定會(huì)遇到的細(xì)節(jié)。無(wú)論你是剛開(kāi)始學(xué)網(wǎng)絡(luò)編程的學(xué)生還是工作中突然要處理內(nèi)網(wǎng)文件傳輸?shù)拈_(kāi)發(fā)者照著這套邏輯都能快速落地一套可靠的方案。1. 動(dòng)手前先想清楚這個(gè)案例到底在解決什么問(wèn)題1.1 既要傳得對(duì)又要傳得完文件上傳這件事聽(tīng)上去很簡(jiǎn)單但放到TCP通信的場(chǎng)景里難點(diǎn)立刻浮出水面。TCP本身只保證字節(jié)流是可靠有序的它并不理解文件這種業(yè)務(wù)概念。你往Socket里寫一段字節(jié)對(duì)端讀到的可能是半段數(shù)據(jù)也可能是兩段數(shù)據(jù)黏在一起。所以這個(gè)綜合案例真正要解決的是在TCP提供的原始字節(jié)流之上設(shè)計(jì)一套能讓雙方都理解的消息協(xié)議把文件的名字、大小、內(nèi)容、校驗(yàn)信息完整地送達(dá)對(duì)端。另一個(gè)容易忽略的痛點(diǎn)是傳得完。小文件一次性讀進(jìn)內(nèi)存再write很快就完事但到了幾個(gè)GB的日志文件內(nèi)存根本扛不住必須用緩沖流分塊讀取、分塊發(fā)送。再加上網(wǎng)絡(luò)抖動(dòng)、客戶端斷線、服務(wù)端磁盤寫入失敗這些情況一個(gè)看起來(lái)簡(jiǎn)單的上傳工具實(shí)際上要把連接管理、流處理、異?;謴?fù)、資源釋放全部串起來(lái)。1.2 為什么這個(gè)場(chǎng)景必須選TCP而不是UDP文件上傳在傳輸層幾乎沒(méi)有選擇的余地必須用TCP。UDP雖然發(fā)送快、沒(méi)有連接維護(hù)成本但它不保證數(shù)據(jù)包的到達(dá)順序也不保證不丟包。如果基于UDP做文件傳輸要么自己實(shí)現(xiàn)一套復(fù)雜的確認(rèn)重傳機(jī)制要么接受文件內(nèi)容可能損壞的后果。相比之下TCP把擁塞控制、超時(shí)重傳、順序校驗(yàn)這些機(jī)制全部?jī)?nèi)置了你要做的只是在流上面劃分消息邊界已經(jīng)省去了最麻煩的部分。不過(guò)這也引出一個(gè)很多新手會(huì)犯的錯(cuò)誤覺(jué)得TCP可靠就可以把文件名、文件大小、文件內(nèi)容一次性write出去然后以為對(duì)端一定能原樣收到。這個(gè)理解是錯(cuò)的——TCP的可靠是字節(jié)層面的可靠不是消息層面的可靠。發(fā)送方一次write比如write 1MB數(shù)據(jù)接收方可能分好幾次read才能讀完發(fā)送方連續(xù)write兩次接收方可能一次read就把兩段數(shù)據(jù)一起讀出來(lái)了。這就是后面要重點(diǎn)處理的粘包、拆包問(wèn)題也是整個(gè)協(xié)議設(shè)計(jì)的最核心動(dòng)機(jī)。1.3 邊界判斷什么時(shí)候該自研什么時(shí)候該用現(xiàn)成方案在做這個(gè)工具之前我也認(rèn)真想過(guò)是不是直接用成熟方案更省事。如果只是偶爾在內(nèi)網(wǎng)傳幾個(gè)文件SFTP、NFS、HTTP文件服務(wù)甚至直接掛個(gè)網(wǎng)盤都比自己寫Socket要快。但如果你面對(duì)的是批量機(jī)器之間的程序化傳輸、需要在傳輸過(guò)程中嵌入特殊的業(yè)務(wù)校驗(yàn)比如上傳后觸發(fā)后續(xù)任務(wù)、或者處于一個(gè)不允許額外安裝服務(wù)的受限環(huán)境自己實(shí)現(xiàn)一個(gè)輕量TCP上傳通道反而是更理智的選擇。自研方案的邊界也很明確單機(jī)吞吐、并發(fā)連接數(shù)、斷點(diǎn)續(xù)傳這些能力都取決于你自己的設(shè)計(jì)和代碼質(zhì)量出了問(wèn)題沒(méi)有現(xiàn)成文檔能查全靠對(duì)TCP機(jī)制的理解。所以這個(gè)案例的定位不是替代FTP而是一種在可控場(chǎng)景下靈活定制的技術(shù)方案。理解清楚這一點(diǎn)你才知道哪些協(xié)議字段要考慮、哪些異常分支要處理程序才不會(huì)寫到一半失控。2. 應(yīng)用層協(xié)議設(shè)計(jì)先把字節(jié)流翻譯成消息2.1 協(xié)議頭結(jié)構(gòu)固定頭部加變長(zhǎng)字段TCP是流協(xié)議沒(méi)有天然的消息邊界所以應(yīng)用層必須自己定義幀。我的做法是設(shè)計(jì)一個(gè)固定長(zhǎng)度的協(xié)議頭后面跟著文件名和文件內(nèi)容。固定頭部長(zhǎng)度的好處是接收方可以先讀固定字節(jié)解析出各個(gè)字段的寬度再按需讀取后續(xù)內(nèi)容。我設(shè)計(jì)的協(xié)議頭格式如下字段類型字節(jié)數(shù)說(shuō)明魔數(shù)int4固定為0x4D41494E用于快速識(shí)別合法連接協(xié)議版本int4當(dāng)前為1方便后續(xù)協(xié)議演進(jìn)文件名長(zhǎng)度int4文件名字節(jié)長(zhǎng)度UTF-8編碼后文件大小long8文件內(nèi)容的總字節(jié)數(shù)支持超過(guò)2GB的文件上傳模式int41覆蓋、2追加、3拒絕默認(rèn)1MD5摘要byte[16]16整個(gè)文件內(nèi)容的MD5值用于完整性校驗(yàn)固定頭部一共40字節(jié)。文件名是變長(zhǎng)字段跟在固定頭部之后隨后才是文件內(nèi)容。接收方拿到前40字節(jié)后解析出文件名長(zhǎng)度再精確讀取文件名最后根據(jù)文件大小循環(huán)讀取剩余內(nèi)容。這里有個(gè)經(jīng)驗(yàn)文件名長(zhǎng)度用int而不是short雖然實(shí)際場(chǎng)景里文件名幾乎不會(huì)超過(guò)65535字節(jié)但用int可以避免一個(gè)很隱蔽的邊界問(wèn)題。Java的DataInputStream.readUTF內(nèi)部就是用short存長(zhǎng)度的文件名稍微長(zhǎng)一點(diǎn)就拋UTFDataFormatException排查起來(lái)很費(fèi)勁。既然協(xié)議是自己設(shè)計(jì)的就不要把這種限制帶進(jìn)來(lái)。2.2 粘包與拆包的解決思路定長(zhǎng)頭加變長(zhǎng)體粘包接收方一次讀到了兩條消息的內(nèi)容和拆包一條消息被分成多次讀取是TCP編程的經(jīng)典問(wèn)題。網(wǎng)上??吹絻煞N解法一種是約定固定長(zhǎng)度消息不足補(bǔ)位另一種是使用特殊的結(jié)束符。固定長(zhǎng)度協(xié)議浪費(fèi)帶寬結(jié)束符方案又要求文件內(nèi)容里不能出現(xiàn)結(jié)束符都不太適合文件傳輸。最實(shí)用的還是定長(zhǎng)頭加變長(zhǎng)體的思路接收方先把40字節(jié)的頭部讀完整通過(guò)readFully保證讀取的字節(jié)數(shù)恰好等于頭部長(zhǎng)度然后根據(jù)頭部里的文件名長(zhǎng)度、文件大小再精確讀取對(duì)應(yīng)字節(jié)數(shù)的數(shù)據(jù)。因?yàn)槊看巫x取的量都有明確數(shù)字約束即使消息在底層被拆成了多個(gè)TCP包接收方也只需要循環(huán)讀到指定字節(jié)數(shù)即可粘包也不會(huì)影響后續(xù)解析——因?yàn)槊織l消息的長(zhǎng)度是確定的剩余緩沖區(qū)里的字節(jié)自然屬于下一條消息。在Java里實(shí)現(xiàn)這一點(diǎn)很簡(jiǎn)單使用DataInputStream.readFully(byte[])比反復(fù)調(diào)用read()可靠得多。它內(nèi)部幫你循環(huán)讀取直到讀滿指定長(zhǎng)度或者流已經(jīng)結(jié)束。很多初學(xué)Socket的代碼里喜歡這么寫byte[] header new byte[40]; in.read(header);這個(gè)寫法是有隱患的read不保證一次讀滿40字節(jié)。必須改成byte[] header new byte[40]; in.readFully(header);不要小看這個(gè)差異實(shí)測(cè)中普通局域網(wǎng)環(huán)境還好跨交換機(jī)、跨網(wǎng)段傳輸時(shí)read一次返回不滿的情況非常常見(jiàn)這也是為什么我的接收數(shù)據(jù)總是缺一截的高頻根源。2.3 三個(gè)容易被忽略的協(xié)議細(xì)節(jié)第一個(gè)細(xì)節(jié)是文件名的編碼問(wèn)題。文件名在傳輸前用UTF-8編碼成字節(jié)數(shù)組長(zhǎng)度是字節(jié)數(shù)組的長(zhǎng)度而不是字符串的字符數(shù)。接收方讀出字節(jié)數(shù)組后用UTF-8解碼恢復(fù)文件名。如果不統(tǒng)一編碼發(fā)送端用GBK、接收端按UTF-8解析中文文件名必然亂碼服務(wù)端寫出去的文件名也會(huì)出問(wèn)題。第二個(gè)細(xì)節(jié)是目錄穿越。協(xié)議里收到的是客戶端發(fā)來(lái)的文件名服務(wù)端如果直接拿它拼路徑比如new File(baseDir, fileName)一旦fileName里包含../服務(wù)端的文件就會(huì)寫到baseDir以外這是嚴(yán)重的安全漏洞。服務(wù)端必須對(duì)文件名做凈化處理最簡(jiǎn)單的方式是取文件名中的最后一個(gè)路徑分隔符之后的部分或者用白名單字符集過(guò)濾。第三個(gè)細(xì)節(jié)是響應(yīng)反饋。協(xié)議不能只允許客戶端向服務(wù)端發(fā)送服務(wù)端也需要告訴客戶端上傳結(jié)果。所以客戶端發(fā)完文件內(nèi)容后調(diào)用shutdownOutput進(jìn)入半關(guān)閉狀態(tài)讓服務(wù)端讀到文件流的末尾隨后服務(wù)端寫一個(gè)int類型的狀態(tài)碼比如0表示成功1表示失敗2表示校驗(yàn)錯(cuò)誤回送給客戶端。這里的半關(guān)閉是關(guān)鍵它告訴TCP我的發(fā)送方向已經(jīng)結(jié)束但連接仍然可以用來(lái)接收數(shù)據(jù)。3. 服務(wù)端實(shí)現(xiàn)從accept到落盤的完整鏈路3.1 服務(wù)端骨架ServerSocket加線程池服務(wù)端要做的事情很簡(jiǎn)單監(jiān)聽(tīng)端口、接受連接、為每個(gè)連接分配合適的處理器。但簡(jiǎn)單的背后有幾處要注意。一是監(jiān)聽(tīng)的端口要設(shè)置合理過(guò)高如65535在生產(chǎn)環(huán)境容易被防火墻攔截過(guò)低如1024以下又需要權(quán)限。案例里我用9999這種高位端口。二是處理方式是單線程還是多線程如果只有一個(gè)客戶端傳文件單線程也夠用但實(shí)際環(huán)境里總會(huì)有多個(gè)上傳任務(wù)所以我在最外層放ServerSocket每拿到一個(gè)Socket就交給一個(gè)獨(dú)立的線程去處理。ServerSocket serverSocket new ServerSocket(9999); ExecutorService pool Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); while (!Thread.currentThread().isInterrupted()) { Socket socket serverSocket.accept(); socket.setTcpNoDelay(true); pool.submit(() - handleClient(socket)); }Executors.newFixedThreadPool在線程數(shù)量上可以控制并發(fā)上限避免因?yàn)榇罅可蟼髡?qǐng)求導(dǎo)致服務(wù)端創(chuàng)建上千個(gè)線程直接打爆內(nèi)存。生產(chǎn)環(huán)境里我一般推薦用ThreadPoolExecutor手動(dòng)指定核心線程數(shù)和隊(duì)列容量但在教學(xué)案例里固定線程池夠用規(guī)則也簡(jiǎn)單。setTcpNoDelay(true)也值得說(shuō)明它關(guān)閉了Nagle算法。Nagle算法會(huì)把小的數(shù)據(jù)包攢在一起發(fā)送以減少網(wǎng)絡(luò)報(bào)文數(shù)量但對(duì)于文件上傳這種需要頻繁寫入小塊數(shù)據(jù)的場(chǎng)景Nagle帶來(lái)的延遲會(huì)讓交互式確認(rèn)變得拖沓。文件上傳更看重吞吐關(guān)閉Nagle后小包的發(fā)送延遲明顯降低。3.2 協(xié)議解析與文件寫入的核心邏輯每個(gè)客戶端連接進(jìn)來(lái)后真正的核心邏輯是固定的幾步讀固定頭部、讀文件名、按文件大小循環(huán)讀文件體、寫磁盤、校驗(yàn)、回送結(jié)果。private static final int HEADER_SIZE 40; private static final int MAGIC 0x4D41494E; private void handleClient(Socket socket) { String filePath null; try (DataInputStream in new DataInputStream(new BufferedInputStream(socket.getInputStream())); DataOutputStream out new DataOutputStream(socket.getOutputStream())) { byte[] headerBytes new byte[HEADER_SIZE]; in.readFully(headerBytes); ByteBuffer header ByteBuffer.wrap(headerBytes).order(ByteOrder.BIG_ENDIAN); int magic header.getInt(); int version header.getInt(); int nameLen header.getInt(); long fileSize header.getLong(); int mode header.getInt(); byte[] expectMd5 new byte[16]; header.get(expectMd5); if (magic ! MAGIC) { out.writeInt(STATUS_PROTOCOL_ERROR); return; } byte[] nameBytes new byte[nameLen]; in.readFully(nameBytes); String fileName new String(nameBytes, StandardCharsets.UTF_8); String safeName sanitizeFileName(fileName); File target resolvePath(safeName, mode); if (target null) { out.writeInt(STATUS_EXISTS); return; } filePath target.getAbsolutePath(); long totalRead 0; try (FileOutputStream fos new FileOutputStream(target); BufferedOutputStream bos new BufferedOutputStream(fos)) { byte[] buffer new byte[64 * 1024]; while (totalRead fileSize) { int need (int) Math.min(buffer.length, fileSize - totalRead); int len in.read(buffer, 0, need); if (len -1) break; bos.write(buffer, 0, len); totalRead len; } bos.flush(); } if (totalRead ! fileSize) { out.writeInt(STATUS_INCOMPLETE); return; } byte[] actualMd5 md5(Paths.get(filePath)); if (!MessageDigest.isEqual(expectMd5, actualMd5)) { out.writeInt(STATUS_MD5_ERROR); return; } out.writeInt(STATUS_OK); } catch (IOException e) { if (filePath ! null) { new File(filePath).delete(); } e.printStackTrace(); } }一定要注意文件體的讀取循環(huán)。因?yàn)閕n.read(buffer, 0, need)在一次調(diào)用中不一定能把need個(gè)字節(jié)全部讀滿所以循環(huán)條件是基于totalRead fileSize每個(gè)分片按剩余字節(jié)數(shù)取need這樣既避免讀了下一幀的數(shù)據(jù)如果還有的話也保證了大文件能被完整讀完。3.3 路徑處理與服務(wù)端的安全考慮sanitizeFileName和resolvePath這兩個(gè)方法雖然代碼不多卻是服務(wù)端最值得花心思的地方。sanitizeFileName的邏輯是把傳入文件名的所有路徑分隔符替換為下劃線或者提取最后一個(gè)分隔符后面的部分。因?yàn)槲募膩?lái)源是網(wǎng)絡(luò)對(duì)端永遠(yuǎn)不能信任。同時(shí)還要過(guò)濾掉Windows下不合法字符比如:/\\|?*否則服務(wù)端落在Windows目錄時(shí)文件寫入會(huì)拋IOException。resolvePath則處理上傳模式。如果模式是覆蓋直接返回baseDir下的目標(biāo)文件如果是追加返回目標(biāo)文件并準(zhǔn)備好以追加方式寫入如果是拒絕且文件已存在就返回null通知客戶端上傳被拒絕?;具壿嬍莗rivate File resolvePath(String fileName, int mode) { File baseDir new File(System.getProperty(user.home), uploads); if (!baseDir.exists()) baseDir.mkdirs(); File target new File(baseDir, fileName); if (mode MODE_APPEND) return target; if (mode MODE_REFUSE target.exists()) return null; return target; }這個(gè)邏輯的好處是覆蓋模式天然支持重復(fù)上傳適合批處理場(chǎng)景拒絕模式則避免了不小心覆蓋重要文件。你完全可以根據(jù)自己場(chǎng)景調(diào)整但安全邊界一定不能省。3.4 完整性校驗(yàn)與異常清理MD5校驗(yàn)放在文件全部寫入磁盤之后執(zhí)行。理論上應(yīng)該在內(nèi)存里邊收邊算摘要節(jié)省一遍文件的完整讀取但這樣對(duì)內(nèi)存的占用太高大文件場(chǎng)景不可行。案例里選擇的是接收完成后對(duì)落盤文件重新計(jì)算MD5再與協(xié)議頭攜帶的預(yù)期MD5比較。代價(jià)是文件會(huì)被讀第二遍增加了耗時(shí)好處是邏輯簡(jiǎn)單、可靠性高。如果文件特別大可以折算一下耗時(shí)能接受就用不能接受就把校驗(yàn)機(jī)制降級(jí)為只校驗(yàn)文件大小。校驗(yàn)為什么重要我在一開(kāi)始就強(qiáng)調(diào)TCP是可靠傳輸?shù)玊CP的可靠不等于端到端的內(nèi)容不變。中間設(shè)備改寫、服務(wù)端磁盤壞道、程序緩存沒(méi)刷干凈都可能導(dǎo)致落盤文件和原始文件不一致。有了MD5對(duì)比至少能發(fā)現(xiàn)絕大多數(shù)數(shù)據(jù)損壞的情況。也不要忘了totalRead ! fileSize這種最基本的判斷在客戶端還沒(méi)發(fā)完就斷開(kāi)連接時(shí)文件往往只有原始大小的一部分這時(shí)候必須把半成品文件刪除并回送錯(cuò)誤碼。4. 客戶端實(shí)現(xiàn)連接、按協(xié)議發(fā)送、等待反饋4.1 客戶端骨架與連接超時(shí)設(shè)置客戶端的實(shí)現(xiàn)和服務(wù)端恰好互逆它的職責(zé)是把本地文件轉(zhuǎn)換成協(xié)議頭加文件體的字節(jié)流發(fā)送到服務(wù)端然后等待確認(rèn)。代碼的第一步是建立連接。這個(gè)步驟有幾個(gè)常見(jiàn)的坑。用的是Socket()無(wú)參構(gòu)造加上connect方法可以在建立連接前設(shè)置超時(shí)時(shí)間Socket socket new Socket(); socket.connect(new InetSocketAddress(host, port), 5000); socket.setSoTimeout(30000);connect方法的第二個(gè)參數(shù)是連接超時(shí)毫秒。不設(shè)置的話new Socket(host, port)遇到不可達(dá)地址時(shí)會(huì)卡很久用戶體驗(yàn)極差。setSoTimeout(30000)設(shè)置的是讀超時(shí)也就是等待服務(wù)端響應(yīng)結(jié)果時(shí)的最大阻塞時(shí)間防止服務(wù)端一直不返回導(dǎo)致客戶端線程永久掛掉。這兩個(gè)超時(shí)是網(wǎng)絡(luò)編程里最容易漏掉、但生產(chǎn)環(huán)境又一定必須加的配置。4.2 大文件分塊發(fā)送不要一次性讀進(jìn)內(nèi)存很多初學(xué)者的代碼喜歡這樣寫byte[] data Files.readAllBytes(file.toPath()); out.write(data);這種寫法處理幾MB的小文件問(wèn)題不大但文件一旦到了幾百M(fèi)B甚至幾GBreadAllBytes會(huì)直接把內(nèi)存撐爆。正確做法是分塊讀取并循環(huán)發(fā)送建議的緩沖區(qū)大小是64KB到1MB之間。實(shí)際傳輸中我習(xí)慣用64KB這是一個(gè)在性能和內(nèi)存占用之間比較平衡的值。緩沖區(qū)太小系統(tǒng)調(diào)用次數(shù)變多吞吐上不去緩沖區(qū)太大單個(gè)TCP分片也裝不下反而增加內(nèi)存壓力。try (FileInputStream fis new FileInputStream(file); BufferedInputStream bis new BufferedInputStream(fis)) { byte[] buffer new byte[64 * 1024]; int len; long sent 0; while ((len bis.read(buffer)) ! -1) { out.write(buffer, 0, len); sent len; long percent sent * 100 / fileSize; if (percent lastPercent 5) { System.out.printf(已發(fā)送%d / %d%d%%%n, sent, fileSize, percent); lastPercent percent; } } out.flush(); }out.write(buffer, 0, len)里的len來(lái)自bis.read的返回值這個(gè)返回值決定了本次寫入的實(shí)際字節(jié)數(shù)。絕大多數(shù)情況下一次read返回的量不會(huì)超過(guò)64KB所以out.write一次也會(huì)把這個(gè)緩沖區(qū)的數(shù)據(jù)盡可能推入Socket發(fā)送緩沖區(qū)。由于網(wǎng)絡(luò)擁塞write本身可能導(dǎo)致線程阻塞但這是正常的流量控制不需要額外處理。4.3 半關(guān)閉連接與讀取服務(wù)端響應(yīng)文件內(nèi)容發(fā)送完畢后客戶端不能直接調(diào)用close關(guān)閉連接因?yàn)槿绻B接直接關(guān)閉服務(wù)端可能還沒(méi)有機(jī)會(huì)把處理結(jié)果回傳客戶端就收不到了。正確做法是調(diào)用shutdownOutput()這會(huì)讓TCP連接進(jìn)入半關(guān)閉狀態(tài)告訴服務(wù)端我的發(fā)送方向已經(jīng)結(jié)束但仍然保留接收方向可以繼續(xù)等待服務(wù)端返回結(jié)果。socket.shutdownOutput(); DataInputStream din new DataInputStream(socket.getInputStream()); int status din.readInt(); if (status STATUS_OK) { System.out.println(上傳成功服務(wù)端已校驗(yàn)MD5一致); } else { System.out.println(上傳失敗狀態(tài)碼 status); }這里服務(wù)端也要配合好。服務(wù)端讀取到文件流末尾read返回-1后才會(huì)寫結(jié)果給客戶端。如果數(shù)據(jù)還沒(méi)讀完就寫結(jié)果客戶端那邊會(huì)因?yàn)樵谕粋€(gè)Socket里先收到了結(jié)果字節(jié)而把結(jié)果誤當(dāng)成文件內(nèi)容的一部分導(dǎo)致協(xié)議錯(cuò)亂。所以服務(wù)端一定要保證文件內(nèi)容讀取循環(huán)結(jié)束后再調(diào)用out.writeInt(status)。有一個(gè)小細(xì)節(jié)是客戶端這邊的DataInputStream也需要設(shè)置超時(shí)。我在前面設(shè)置了setSoTimeout(30000)這樣即使服務(wù)端因?yàn)楫惓?ㄔ谖募x取階段客戶端最多等30秒就會(huì)收到SocketTimeoutException不會(huì)永遠(yuǎn)阻塞下去。4.4 進(jìn)度感知與回滾提示進(jìn)度反饋并不是協(xié)議的一部分但卻是用戶體感的重要部分。一個(gè)上傳任務(wù)跑十分鐘沒(méi)有進(jìn)度提示用戶的第一反應(yīng)是程序卡死了。我這邊實(shí)現(xiàn)的方式是每發(fā)送5%就打印一次日志同時(shí)記錄最后的百分比避免重復(fù)打印。日志的粒度沒(méi)必要太細(xì)5%已經(jīng)足夠。如果打印太頻繁大量日志輸出本身也會(huì)消耗CPU和磁盤IO。實(shí)測(cè)下來(lái)5%到10%的粒度最舒服。進(jìn)度日志的另一個(gè)作用是可以作為故障排查的線索。如果一次上傳卡住了日志停留在某個(gè)百分比位置不動(dòng)你就能判斷問(wèn)題出在哪個(gè)階段是發(fā)送過(guò)程中網(wǎng)絡(luò)斷開(kāi)還是服務(wù)端處理卡住還是等待響應(yīng)超時(shí)??床坏竭M(jìn)度時(shí)排查這種問(wèn)題只能靠猜體驗(yàn)完全不一樣。5. 實(shí)測(cè)踩坑有些問(wèn)題不跑一遍大數(shù)據(jù)根本想不到5.1 粘包半包的真實(shí)復(fù)現(xiàn)案例剛跑通的時(shí)候我用一個(gè)100MB的文件測(cè)試一切正常。然后換成一個(gè)1.2GB的文件第一次傳輸就出了問(wèn)題服務(wù)端接收文件到一定大小后拋異常提示文件提前結(jié)束。排查后發(fā)現(xiàn)客戶端在發(fā)送文件內(nèi)容的同時(shí)我把服務(wù)端的響應(yīng)邏輯寫在了文件內(nèi)容讀取完成之前。服務(wù)端一旦讀了幾個(gè)字節(jié)作為響應(yīng)看待整個(gè)消息結(jié)構(gòu)就亂了。這個(gè)坑的本質(zhì)是TCP的字節(jié)流沒(méi)有邊界如果你在接收文件內(nèi)容時(shí)還試圖從同一個(gè)輸入流里提前讀響應(yīng)那讀到的其實(shí)是文件內(nèi)容的一部分。根源是因?yàn)槲募?nèi)容很大在底層會(huì)被拆成多個(gè)TCP包發(fā)送read第一次拿到的可能只有一部分導(dǎo)致代碼邏輯把后面的某些字節(jié)誤判為標(biāo)志位。解決方式也很簡(jiǎn)單嚴(yán)格執(zhí)行協(xié)議順序。文件內(nèi)容的讀取循環(huán)必須跑完totalRead fileSize然后才允許服務(wù)端從輸入流讀取后續(xù)內(nèi)容??蛻舳艘脖仨氂胹hutdownOutput明確表示發(fā)送結(jié)束才能進(jìn)入等待響應(yīng)階段。順序?qū)憣?duì)了粘包半包問(wèn)題就從設(shè)計(jì)上被壓住了剩下交給readFully去兜底。5.2 long和int的選擇2GB限制第一次設(shè)計(jì)協(xié)議頭時(shí)我圖省事文件大小用的是int。測(cè)試到1.8GB一切正常但傳到2.3GB時(shí)客戶端報(bào)告發(fā)送了-1字節(jié)服務(wù)端那邊還沒(méi)收到數(shù)據(jù)就已經(jīng)報(bào)錯(cuò)。后來(lái)查了一下問(wèn)題就是int溢出2.3GB換算成字節(jié)已經(jīng)超過(guò)了int的最大值約21.47億算出來(lái)的字節(jié)數(shù)變成了負(fù)數(shù)整個(gè)發(fā)送循環(huán)直接崩潰。教訓(xùn)很清楚凡是和文件大小相關(guān)的字段一律用long。Java的數(shù)組下標(biāo)仍然是int限制但你真正要寫進(jìn)Socket的是分片不是整個(gè)文件的byte數(shù)組。用long記錄總大小、用int記錄每次的分片大小兩者組合起來(lái)就沒(méi)問(wèn)題了。這個(gè)教訓(xùn)也提醒我協(xié)議設(shè)計(jì)階段最好把字段寬度一次性想清楚。文件大小、偏移量這些天然有變大趨勢(shì)的字段寧可用8字節(jié)的long也不要為了省幾個(gè)字節(jié)埋雷。5.3 客戶端斷網(wǎng)之后服務(wù)端留下一堆半成品有一輪測(cè)試我模擬了客戶端中途斷開(kāi)上傳到一半直接切斷了網(wǎng)絡(luò)。服務(wù)端那邊沒(méi)有及時(shí)感知到客戶端斷開(kāi)read方法在等著數(shù)據(jù)直到TCP超時(shí)重傳徹底失敗后才拋出SocketException。這個(gè)過(guò)程中服務(wù)端已經(jīng)創(chuàng)建了目標(biāo)文件并寫入了一部分?jǐn)?shù)據(jù)異常發(fā)生后這部分就是殘留的半成品文件。處理方案分兩步第一步在handleClient方法里記錄本次任務(wù)的目標(biāo)文件路徑第二步在catch塊中捕獲所有IOException后把記錄的目標(biāo)文件刪除。刪除動(dòng)作本身也可能失敗文件被占用或權(quán)限不足那就再想辦法做一次補(bǔ)償或者至少輸出一條足夠顯眼的日志提醒人工介入。另一個(gè)很實(shí)用的技巧是給服務(wù)端設(shè)置SO_TIMEOUT也就是讀超時(shí)。如果一定時(shí)間內(nèi)沒(méi)有字節(jié)到達(dá)就直接拋異常結(jié)束任務(wù)而不是無(wú)限等待。文件上傳的場(chǎng)景里客戶端長(zhǎng)時(shí)間不發(fā)送任何數(shù)據(jù)基本可以判定連接異常等待沒(méi)有意義。5.4 Windows和Linux路徑分隔符差異這個(gè)坑很隱蔽客戶端在Windows下把文件路徑D:\logs\access.log直接拼到協(xié)議里服務(wù)端在Linux下按/分割路徑文件名變成了D:\logs\access.log整個(gè)字符串。Windows下執(zhí)行File寫入時(shí)反斜杠會(huì)被當(dāng)作轉(zhuǎn)義字符的一部分結(jié)果文件名異常打不開(kāi)目標(biāo)文件。我給的方案是在sanitizeFileName里統(tǒng)一過(guò)濾所有\(zhòng)\和/提取最后一個(gè)分隔符之后的部分作為文件名。這樣客戶端傳D:\logs\access.log過(guò)來(lái)服務(wù)端寫出的文件名就是access.log。如果確實(shí)需要保持目錄結(jié)構(gòu)那就讓協(xié)議再增加一個(gè)字段顯式表示相對(duì)路徑而不是依賴文件名里的分隔符自動(dòng)解析。6. 進(jìn)階方向并發(fā)、續(xù)傳與更高效的數(shù)據(jù)通道6.1 多客戶端并發(fā)上傳的線程池參數(shù)如果同時(shí)上傳的客戶端有好幾個(gè)服務(wù)端的線程池參數(shù)就不能隨便寫了。前面用availableProcessors() * 2只適合CPU密集型的推算文件傳輸其實(shí)是IO密集型任務(wù)線程數(shù)可以略多。一個(gè)比較穩(wěn)的起步配置是ThreadPoolExecutor pool new ThreadPoolExecutor( 4, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() );核心線程數(shù)4最大線程數(shù)16隊(duì)列容量100。超出隊(duì)列容量時(shí)CallerRunsPolicy讓提交任務(wù)的線程自己執(zhí)行這比直接拋拒絕異常更溫和至少不會(huì)讓客戶端瞬間收到失敗。但也要注意如果并發(fā)太高IO和磁盤會(huì)成為瓶頸線程再多也沒(méi)有收益反而增加上下文切換的成本。實(shí)測(cè)幾GB大數(shù)據(jù)并發(fā)時(shí)磁盤寫入速度通常是第一瓶頸并發(fā)數(shù)控制在磁盤能承受的范圍內(nèi)才是對(duì)的。6.2 斷點(diǎn)續(xù)傳協(xié)議加offset服務(wù)端改用RandomAccessFile斷點(diǎn)續(xù)傳的關(guān)鍵是讓服務(wù)端知道這次從哪里繼續(xù)寫。在協(xié)議頭里增加一個(gè)8字節(jié)的offset字段客戶端發(fā)送前先和服務(wù)端確認(rèn)已有文件大小然后從偏移位置開(kāi)始讀取并發(fā)送文件剩余部分。服務(wù)端接收時(shí)改用RandomAccessFile打開(kāi)文件后先seek(offset)再按循環(huán)寫入文件體。這里要注意校驗(yàn)策略也要跟著變?nèi)绻募粋髁艘话刖蛿嗔苏募﨧D5還沒(méi)算出來(lái)續(xù)傳后的MD5計(jì)算必須在文件補(bǔ)齊之后才能做。另一種做法是傳輸時(shí)對(duì)每個(gè)分片計(jì)算CRC32服務(wù)端每收到一片校驗(yàn)一片這樣不用等整個(gè)文件送完就能及時(shí)發(fā)現(xiàn)損壞。代價(jià)是協(xié)議頭里需要再增加分片序號(hào)和分片CRC字段復(fù)雜度上了一個(gè)臺(tái)階。如果只是作為學(xué)習(xí)案例先把offset機(jī)制實(shí)現(xiàn)出來(lái)就夠理解了。真正的生產(chǎn)級(jí)續(xù)傳還要解決服務(wù)端如何知道文件已有多少數(shù)據(jù)的問(wèn)題常見(jiàn)做法是客戶端先發(fā)一個(gè)查詢請(qǐng)求或者協(xié)議頭里帶上服務(wù)端文件的舊大小服務(wù)端比較后決定是覆蓋、跳過(guò)還是續(xù)傳。6.3 零拷貝數(shù)據(jù)通道FileChannel.transferTo前面的代碼用的是傳統(tǒng)InputStream/OutputStream復(fù)制每個(gè)字節(jié)都要在用戶態(tài)和內(nèi)核態(tài)之間拷貝多次。如果數(shù)據(jù)量極大這部分的CPU消耗非常明顯。Java NIO提供的FileChannel.transferTo可以借助操作系統(tǒng)內(nèi)核的sendfile機(jī)制Linux直接把文件內(nèi)容從文件系統(tǒng)發(fā)送到Socket緩沖省去用戶態(tài)拷貝。發(fā)送端的核心代碼可以簡(jiǎn)化為FileChannel channel FileChannel.open(file.toPath(), StandardOpenOption.READ); long position offset; long count fileSize - offset; while (count 0) { long transferred channel.transferTo(position, count, socketChannel); if (transferred 0) break; position transferred; count - transferred; }需要留意的是transferTo并不是每調(diào)用一次就把count全傳完它返回的是實(shí)際傳輸?shù)淖止?jié)數(shù)所以必須用循環(huán)。另外SocketChannel要設(shè)置為阻塞模式默認(rèn)就是transferTo才能正常工作。我實(shí)測(cè)過(guò)使用零拷貝之后同文件傳輸?shù)腃PU占用幾乎下降了一半對(duì)于大批量文件同步的場(chǎng)景提升非常明顯。零拷貝也不是銀彈如果文件特別小、或者網(wǎng)絡(luò)非??於疟P很慢收益就無(wú)法體現(xiàn)。但作為一種進(jìn)階優(yōu)化思路它確實(shí)是值得了解的經(jīng)典技術(shù)。6.4 還能往哪里擴(kuò)展這個(gè)案例做完之后可以延伸的方向很多。最容易想到的是加密用TLS替換裸的Socket或者對(duì)文件內(nèi)容做AES加密后再傳輸解決明文數(shù)據(jù)在網(wǎng)絡(luò)上被截獲的問(wèn)題。其次是壓縮在客戶端讀文件時(shí)用GZIPOutputStream包一層文件變小了傳輸自然變快代價(jià)是CPU占用增加。再往下就是秒傳客戶端先發(fā)送文件MD5服務(wù)端如果已經(jīng)有相同摘要的文件就直接返回成功不需要真的傳輸數(shù)據(jù)。還有一個(gè)值得嘗試的方向是傳輸限速和失敗自動(dòng)重試。限速可以防止一個(gè)上傳任務(wù)占滿整個(gè)出口帶寬影響其他服務(wù)失敗重試則要和斷點(diǎn)續(xù)傳結(jié)合才能做到斷網(wǎng)恢復(fù)后自動(dòng)接著傳。把這些機(jī)制一個(gè)個(gè)加進(jìn)去一個(gè)簡(jiǎn)單的文件上傳Demo就會(huì)慢慢變成一個(gè)真正能扛生產(chǎn)環(huán)境的小型分發(fā)系統(tǒng)。這個(gè)案例本身不算復(fù)雜核心就是一段清晰的協(xié)議、一個(gè)按部就班的接收循環(huán)、加上對(duì)異常邊界的處理。但把它真正跑通、跑穩(wěn)你會(huì)發(fā)現(xiàn)對(duì)TCP通信的理解會(huì)扎實(shí)不少以后再碰到消息隊(duì)列、RPC框架里的傳輸設(shè)計(jì)和粘包處理背后都是同一套原理。如果實(shí)際操作中遇到了奇怪的問(wèn)題優(yōu)先從消息邊界是否分明順序是否嚴(yán)格異常是否被吞掉這三個(gè)角度排查大概率能很快找到答案。