絡(luò)編程面經(jīng):從TCP/UDP選型到同步方案與弱網(wǎng)優(yōu)化)
1. 面試官問TCP/UDP其實(shí)是想聽你說“選型邏輯”初級網(wǎng)絡(luò)面經(jīng)里TCP和UDP的區(qū)別是最高頻的開場題幾乎每輪技術(shù)面都會出現(xiàn)在前三問。很多同學(xué)背了一堆“TCP面向連接、可靠傳輸、慢UDP無連接、不可靠、快”但面試官真正想聽的不是背誦而是你能不能從游戲開發(fā)的實(shí)際場景出發(fā)說清楚“什么情況用哪個”以及“為什么”。1.1 三次握手為什么是三次不是兩次先講個最基礎(chǔ)的TCP的三次握手到底在干什么。我習(xí)慣用“打電話確認(rèn)”來理解——第一次握手是客戶端說“喂我要打電話”第二次是服務(wù)端回“聽到了你現(xiàn)在能聽到我嗎”第三次是客戶端再回“能聽到咱倆鏈路通了”。它本質(zhì)上解決的是“雙方都確認(rèn)對方能收能發(fā)”的問題。為什么不是兩次假設(shè)只有兩次握手客戶端發(fā)了一個連接請求但因?yàn)榫W(wǎng)絡(luò)延遲這個請求被卡了很久客戶端超時重發(fā)了一次第二次成功建連并傳輸數(shù)據(jù)后關(guān)閉了。這時候第一次的遲到請求才到達(dá)服務(wù)端服務(wù)端以為客戶端要重新建連就回了一個確認(rèn)并進(jìn)入等待狀態(tài)白白占著一個連接資源。三次握手可以做到“客戶端收到服務(wù)端的確認(rèn)后再回一次”這樣遲到的舊請求即使到了服務(wù)端會因?yàn)榭蛻舳瞬辉倩貞?yīng)而讓服務(wù)端主動放棄這次連接。面經(jīng)里如果問到這塊建議大家把“防止歷史重復(fù)連接請求干擾”這個點(diǎn)答出來這比只說“確??煽總鬏敗币钊胍粰n。1.2 TCP和UDP的核心差異表我整理過一張對比表面試前背熟但要能展開維度TCPUDP連接狀態(tài)面向連接需要三次握手無連接直接發(fā)包可靠性確認(rèn)重傳、序號保證有序不保證到達(dá)不保證有序傳輸效率有ACK確認(rèn)和滑動窗口慢無確認(rèn)機(jī)制快數(shù)據(jù)邊界面向字節(jié)流會出現(xiàn)粘包面向報文自帶邊界應(yīng)用場景資源下載、登錄驗(yàn)證、聊天消息幀同步戰(zhàn)斗、語音、視頻流在Unity游戲里登錄、存檔、商城這些需要“必須到達(dá)且不能出錯”的請求走TCP實(shí)時對戰(zhàn)里的位置/朝向/操作指令對延遲敏感而能容忍少量丟失走UDP或基于UDP的自定義協(xié)議。1.3 游戲里“可靠UDP”是怎么回事這里有個容易被初級候選人忽視的知識點(diǎn)像幀同步對戰(zhàn)里很多團(tuán)隊(duì)并不是直接用裸UDP而是在UDP之上自己封裝一套可靠傳輸層實(shí)現(xiàn)“UDP的速度 TCP的可靠性補(bǔ)償”業(yè)界管這叫可靠UDP。思路其實(shí)不復(fù)雜給每個數(shù)據(jù)包加一個自增序列號接收端維護(hù)一個滑動窗口發(fā)現(xiàn)序號不連續(xù)就發(fā)NACKNegative ACK請求重傳丟掉的包而不是像TCP那樣把后面的包全部緩存等待。這樣網(wǎng)絡(luò)抖動時只補(bǔ)傳真正丟失的包后續(xù)更新包照常處理延遲上比TCP更可控。面試?yán)锬馨堰@一層說出來面試官基本就能確認(rèn)你不只是背過“TCP/UDP區(qū)別”而是真的理解實(shí)時游戲?qū)ρ舆t的要求。2. Unity客戶端的網(wǎng)絡(luò)API家族你必須能畫出這張選型表初級崗位考察網(wǎng)絡(luò)不會停在純理論第二波問題會集中在“你用Unity做過什么網(wǎng)絡(luò)功能”“用的什么API”。其實(shí)Unity從入門到現(xiàn)在網(wǎng)絡(luò)相關(guān)的API已經(jīng)更新了好幾代每個API適合的場景不同踩坑的方式也不同。2.1 UnityWebRequest日常大頭HTTP請求首選UnityWebRequest是Unity官方主推的HTTP通信API用來替代老舊的WWW類。它封裝了POST/GET/PUT/DELETE等方法支持文本、二進(jìn)制、音頻、視頻等多種數(shù)據(jù)類型也支持分塊下載。一個核心注意點(diǎn)是要搞清楚它和協(xié)程怎么配合。很多新手寫下載會直接寫using UnityEngine; using UnityEngine.Networking; using System.Collections; public class DownloadExample : MonoBehaviour { IEnumerator DownloadText(string url) { using (UnityWebRequest request UnityWebRequest.Get(url)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { string content request.downloadHandler.text; Debug.Log(content); } else { Debug.LogError(請求失敗: request.error); } } } }這段代碼看著對實(shí)際項(xiàng)目里有幾個坑一是SendWebRequest()在校驗(yàn)result前務(wù)必判斷request.result而不是request.isError。老版本的isError在部分情況下會返回錯誤判斷而result UnityWebRequest.Result.Success才是新版官方推薦的判定方式。二是using必須寫UnityWebRequest實(shí)現(xiàn)了IDisposable接口不釋放會在真機(jī)上積累內(nèi)存垃圾。別問我怎么知道的線上包內(nèi)存峰值就是這么搞上去的。三是超時設(shè)置默認(rèn)的timeout是0表示永不超時。真機(jī)弱網(wǎng)下一個不超時的HTTP請求會直接把用戶卡死在加載頁。我建議所有請求都顯式設(shè)置超時時間request.timeout 10; // 10秒超時如果是下載大文件比如幾十MB的AB包或視頻還需要監(jiān)聽downloadProgress做進(jìn)度條同時注意斷點(diǎn)續(xù)傳時要用UnityWebRequest的SetRequestHeader(Range, bytes...)。2.2 Socket基于TCP/UDP的靈活控制當(dāng)游戲需要長連接或自定義協(xié)議時UnityWebRequest就不夠用了。舉個例子MMO游戲里玩家的移動、聊天、戰(zhàn)斗結(jié)算都是持續(xù)高頻的消息流HTTP那種“請求-響應(yīng)”模式完全不適合。這時候直接用System.Net.Sockets下的TcpClient和UdpClient寫Socket層。千萬不要一上來就異步回調(diào)滿天飛初級候選人最容易在這塊寫崩。我的建議是先封裝一個簡單的NetworkManager用異步接收循環(huán)加線程安全隊(duì)列using System; using System.Net.Sockets; using System.Threading; using System.Collections.Concurrent; public class TcpClientWrapper { private TcpClient client; private NetworkStream stream; private ConcurrentQueuebyte[] receiveQueue new ConcurrentQueuebyte[](); private Thread receiveThread; public void Connect(string host, int port) { client new TcpClient(); client.Connect(host, port); stream client.GetStream(); receiveThread new Thread(ReceiveLoop); receiveThread.IsBackground true; receiveThread.Start(); } private void ReceiveLoop() { byte[] buffer new byte[4096]; while (client.Connected) { int len stream.Read(buffer, 0, buffer.Length); if (len 0) { byte[] data new byte[len]; Buffer.BlockCopy(buffer, 0, data, 0, len); receiveQueue.Enqueue(data); } } } public bool TryDequeue(out byte[] data) { return receiveQueue.TryDequeue(out data); } }這里用ConcurrentQueue是為了避免主線程和接收線程同時操作List導(dǎo)致索引越界算是一個基礎(chǔ)但典型的跨線程數(shù)據(jù)交互解法。面試時面試官常會追問“客戶端怎么把收到的字節(jié)流轉(zhuǎn)成消息對象”這就引出下一節(jié)要聊的粘包問題。2.3 WebSocket小游戲和社交功能的黃金選擇如果你做的是Unity微信小游戲、WebGL版本或者游戲內(nèi)有聊天室、郵件、活動公告這類需要服務(wù)端主動推送的功能WebSocket就非常合適。它是基于TCP的全雙工通信協(xié)議瀏覽器原生支持Unity側(cè)可以用NativeWebSocket插件或官方?jīng)]有內(nèi)置的第三方庫。和原生Socket最大的區(qū)別是WebSocket是“先HTTP握手升級再二進(jìn)制/文本幀通信”。在Unity里要注意的是消息格式——服務(wù)端通常發(fā)的是字節(jié)數(shù)組客戶端要處理好大端字節(jié)序和JSON序列化的組合問題。using System; using System.Text; using NativeWebSocket; public class WebSocketClient : MonoBehaviour { WebSocket websocket; async void Start() { websocket new WebSocket(ws://example.com:8080/ws); websocket.OnMessage (bytes) { string message Encoding.UTF8.GetString(bytes); Debug.Log(收到服務(wù)端消息: message); }; await websocket.Connect(); } public async void SendJson(string json) { if (websocket.State WebSocketState.Open) { await websocket.SendText(json); } } }這段代碼里有個我踩過的坑OnMessage回調(diào)是在Unity主線程里觸發(fā)的所以可以直接操作UI對象但如果你用了某些第三方原生WebSocket庫回調(diào)可能是異步線程這時候必須用UnityMainThreadDispatcher之類的組件丟回主線程。面試時提一句“UI操作有線程要求”會顯得你考慮得很周全。2.4 一張表理清API選型需求場景推薦API理由登錄/注冊/拉取配置UnityWebRequest短連接、HTTP請求、天然REST接口下載AB包/熱更資源UnityWebRequest 斷點(diǎn)續(xù)傳支持進(jìn)度回調(diào)和Range分塊MMO游戲?qū)崟r移動/戰(zhàn)斗Socket (TcpClient/UdpClient)需要自定協(xié)議、長連接?;钚∮螒?WEBGL/聊天室WebSocket服務(wù)端主動推送、瀏覽器兼容好幀同步對戰(zhàn)可靠UDP封裝低延遲 丟包重傳補(bǔ)償這四類覆蓋了90%以上的客戶端網(wǎng)絡(luò)需求能把這套選型邏輯講明白面經(jīng)的網(wǎng)絡(luò)基礎(chǔ)部分已經(jīng)過了一大半。3. 幀同步 vs 狀態(tài)同步初級崗位也要能說清差異網(wǎng)絡(luò)面經(jīng)的第二塊重頭戲是同步方案。你不需要講得很深但基本的邏輯鏈必須通什么是狀態(tài)同步、什么是幀同步、兩者優(yōu)劣勢、各自適合什么類型游戲。3.1 狀態(tài)同步權(quán)威服務(wù)器邏輯更重狀態(tài)同步的特點(diǎn)是“服務(wù)器擁有最終權(quán)威的游戲狀態(tài)”。客戶端把操作發(fā)給服務(wù)器服務(wù)器模擬運(yùn)算后把修正后的位置、血量、Buff狀態(tài)等廣播給所有客戶端。典型的例子是各類MMO你點(diǎn)了一下移動客戶端發(fā)出移動請求服務(wù)器校驗(yàn)后下發(fā)新坐標(biāo)你再看到自己的人物繼續(xù)往前走。好處是邏輯全在服務(wù)器上掛機(jī)、封號檢測反作弊容易做客戶端邏輯簡單就是“發(fā)請求、收狀態(tài)、表現(xiàn)”。麻煩在于它需要頻繁同步狀態(tài)數(shù)據(jù)帶寬消耗大而且操作到狀態(tài)回來之間的延遲感明顯必須要靠客戶端預(yù)測和延遲補(bǔ)償來磨平手感。3.2 幀同步客戶端只傳操作邏輯全靠自己算幀同步剛好反過來服務(wù)器只負(fù)責(zé)收集所有客戶端的操作指令然后按固定幀率把指令打包廣播出去每個客戶端用自己的邏輯代碼計(jì)算整個游戲世界。這樣同一個輸入序列只要邏輯一致結(jié)果就一致。它最大的優(yōu)點(diǎn)是同步數(shù)據(jù)量小、網(wǎng)絡(luò)消耗低極適合MOBA、格斗這種高頻操作對戰(zhàn)。但幀同步有幾個讓初級候選人直接懵的問題邏輯必須確定性不能用Time.deltaTime、Random這類每次運(yùn)行結(jié)果可能不同的東西否則不同設(shè)備算出來的位置會漂移。浮點(diǎn)數(shù)精度問題不同手機(jī)CPU對浮點(diǎn)運(yùn)算結(jié)果存在微小差異量變引起質(zhì)變。很多幀同步項(xiàng)目干脆把邏輯層改成定點(diǎn)數(shù)運(yùn)算用整數(shù)模擬小數(shù)。斷線重連難做因?yàn)榭蛻舳吮镜鼐褪且慌_“世界模擬器”如果中途掉線重連需要服務(wù)器把掉線期間所有操作幀補(bǔ)給你再快速回放到當(dāng)前幀這個鏡像和回放邏輯的實(shí)現(xiàn)復(fù)雜度比狀態(tài)同步高不少。初級崗位面試不用全答但至少要說清楚“狀態(tài)同步同步的是結(jié)果幀同步同步的是操作”然后再補(bǔ)一兩個各自優(yōu)缺點(diǎn)就夠用了。3.3 一個真實(shí)項(xiàng)目里的踩坑案例我之前做過一個小規(guī)模多人對戰(zhàn)的小游戲早期圖省事直接選了幀同步結(jié)果剛開始聯(lián)調(diào)就崩了兩個手機(jī)在同一個操作序列下走個幾十幀就開始偏移后來排查發(fā)現(xiàn)是有個隨機(jī)音效的播放器在邏輯層用了Unity自帶的Random.Range導(dǎo)致客戶端邏輯分支出現(xiàn)了不確定性。這個坑的教訓(xùn)是幀同步的邏輯層一定要和表現(xiàn)層嚴(yán)格分離哪怕一個毫不起眼的隨機(jī)數(shù)調(diào)用都可能讓整局戰(zhàn)斗分叉。后來我們直接在編譯階段加了一個靜態(tài)代碼檢查凡是邏輯層代碼文件里出現(xiàn)Random、Time、DateTime這幾個關(guān)鍵字就直接報編譯錯誤從根上杜絕。面經(jīng)里把這個案例講出來面試官會認(rèn)為你有實(shí)際項(xiàng)目經(jīng)驗(yàn)而不只是知道概念。這個案例在面試效果上比背十條定義都好。4. 移動端弱網(wǎng)下的四道送命題超時、重連、粘包、緩存最后這部分是初級面試?yán)镒钊菀妆弧敖稻S打擊”的地方。很多候選人能答上TCP和UDP的區(qū)別卻答不出“用戶在電梯里、地鐵上、Wi-Fi和4G切換時游戲該怎么辦”。移動網(wǎng)絡(luò)環(huán)境遠(yuǎn)比PC復(fù)雜弱網(wǎng)處理能力和經(jīng)驗(yàn)往往是區(qū)分“會寫代碼”和“能做上線項(xiàng)目”的分水嶺。4.1 超時和重試別讓用戶卡死在轉(zhuǎn)圈HTTP請求超時的問題上面提過這里說重試策略。很多初級開發(fā)者寫的重試邏輯是“失敗后馬上再發(fā)一次”這在移動網(wǎng)絡(luò)上幾乎等于自殺——如果第一次失敗是因?yàn)樾盘柌铖R上重試大概率還會失敗還白白消耗電量。正確的做法是帶退避的指數(shù)重試第一次失敗等1秒再試第二次失敗等2秒第三次等4秒最多重試N次。我給一個常用模板public IEnumerator RequestWithRetry(FuncUnityWebRequest createRequest, int maxRetry 3) { int retry 0; while (retry maxRetry) { using (var request createRequest()) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { // 成功處理 yield break; } retry; Debug.LogWarning($第{retry}次請求失敗: {request.error}); yield return new WaitForSeconds(Mathf.Pow(2f, retry)); } } Debug.LogError(多次重試仍然失敗); // 走失敗UI流程 }指數(shù)退避的本質(zhì)是“給網(wǎng)絡(luò)一個恢復(fù)的時間窗口”。面試時把這個點(diǎn)答出來再順帶提一句“要考慮業(yè)務(wù)冪等性比如支付回調(diào)類的請求不能盲目重試”基本就是一道加分回答了。4.2 長連接斷線重連心跳機(jī)制是保命符TCP長連接在移動網(wǎng)絡(luò)下最常見的問題是假死看起來連接還在實(shí)際上鏈路早就斷了用戶點(diǎn)擊“發(fā)送”卻遲遲沒反應(yīng)。避免假死要靠心跳包——客戶端每隔一段固定時間比如10秒發(fā)一個極小的心跳消息服務(wù)端收到后回一個心跳ACK如果連續(xù)多次比如3次沒收到ACK就判定連接已死走重連流程。心跳包還有個細(xì)節(jié)它會占用一點(diǎn)點(diǎn)帶寬在超低流量套餐用戶那里不致命但要采用“心跳間期可調(diào)、空閑才發(fā)”的策略避免在頻繁交互時重復(fù)發(fā)包。比如這樣public class HeartbeatManager : MonoBehaviour { float heartbeatInterval 10f; float missTimeout 30f; float lastSendTime; float lastReceiveTime; void Update() { if (Time.time - lastSendTime heartbeatInterval) { SendHeartbeatPacket(); lastSendTime Time.time; } if (Time.time - lastReceiveTime missTimeout) { Debug.Log(心跳超時觸發(fā)重連); Reconnect(); } } }重連要跟“弱網(wǎng)判定”聯(lián)動起來不是所有失敗都立刻重連而是先做一次網(wǎng)絡(luò)狀態(tài)檢測比如Ping一下域名或者發(fā)一個空請求如果當(dāng)前網(wǎng)絡(luò)本來就不可用就先提示用戶“網(wǎng)絡(luò)異?!钡染W(wǎng)絡(luò)恢復(fù)后再嘗試重連。這樣不會在網(wǎng)絡(luò)恢復(fù)前無限發(fā)起垃圾請求。4.3 TCP粘包一定要講的字節(jié)流陷阱粘包問題在Socket類的網(wǎng)絡(luò)編程里幾乎是必問的。TCP是字節(jié)流協(xié)議底層只保證字節(jié)按順序到達(dá)不保證“發(fā)送方每次Write的數(shù)據(jù)”和“接收方每次Read到的數(shù)據(jù)”是一一對應(yīng)的??蛻舳诉B續(xù)發(fā)了兩條消息服務(wù)端可能一次Read就把兩條都讀到了也可能一條都沒讀全。這就是粘包和半包。解決辦法業(yè)界很成熟應(yīng)用層自己定義消息邊界。最常用的是“包頭 包體”包頭固定幾個字節(jié)包含消息長度字段。比如定義一個結(jié)構(gòu)字段占用字節(jié)說明消息ID2字節(jié)標(biāo)識消息類型消息長度4字節(jié)包體字節(jié)數(shù)包體N字節(jié)業(yè)務(wù)序列化數(shù)據(jù)接收端的處理邏輯是先讀取固定長度的包頭解析出消息長度字段再繼續(xù)讀取該長度的包體。如果收到的數(shù)據(jù)不夠一個包頭就等下一批如果數(shù)據(jù)超出包體長度把剩余數(shù)據(jù)留給下一次消息解析。在Unity里我比較推薦寫一個PacketParser的類維護(hù)一個Listbyte緩沖區(qū)每次從Socket讀取入隊(duì)后立刻嘗試解析完整消息public class PacketParser { private Listbyte buffer new Listbyte(); public void Append(byte[] data) { buffer.AddRange(data); } public byte[][] ParseMessages() { var result new Listbyte[](); int offset 0; while (buffer.Count - offset 6) // 包頭固定6字節(jié) { int msgId BitConverter.ToUInt16(buffer.GetRange(offset, 2).ToArray(), 0); int length BitConverter.ToInt32(buffer.GetRange(offset 2, 4).ToArray(), 0); if (buffer.Count - offset - 6 length) { break; // 包體還沒完整到達(dá)跳出等待更多數(shù)據(jù) } byte[] body buffer.GetRange(offset 6, length).ToArray(); result.Add(body); offset 6 length; } if (offset 0) { buffer.RemoveRange(0, offset); } return result.ToArray(); } }注意這里用的Listbyte.GetRange在頻繁收包時會產(chǎn)生不少GC進(jìn)階優(yōu)化可以用環(huán)形緩沖區(qū)或MemoryStream來避免。面試時先給出能跑通的版本然后主動說“如果包量大我會用對象池和環(huán)形緩沖來降低GC”這個從“能跑”到“上線”的思考轉(zhuǎn)變非常加分。4.4 斷線緩存玩家體驗(yàn)的最后一道防線移動端網(wǎng)絡(luò)切換是常態(tài)從Wi-Fi出門切到4G或者進(jìn)了電梯直接無信號。玩家在這個瞬間正在打副本、正在看直播、正在下載資源包如果直接報錯“網(wǎng)絡(luò)連接失敗”體驗(yàn)會非常差。這也是初級面經(jīng)里比較容易忽略的考點(diǎn)。斷線緩存可以從幾個層面做小數(shù)據(jù)玩家操作、日志上報先入本地隊(duì)列等網(wǎng)絡(luò)恢復(fù)后按序補(bǔ)發(fā)下載類的資源包每塊下載完成后寫入本地文件恢復(fù)后只補(bǔ)下載缺失的部分UI界面給出統(tǒng)一的“連接中斷正在重連”提示而不是每次請求失敗都彈一遍錯誤框。本地緩存方案在Unity里最簡單的是用PlayerPrefs但只能存少量字符串和數(shù)值不能當(dāng)隊(duì)列用。要存積壓指令建議用SQLite或者簡單的文件系統(tǒng)在每次成功收到服務(wù)器ACK后才刪除對應(yīng)緩存項(xiàng)。這里有一個特別容易踩的坑緩存必須有“最大長度上限”。如果玩家在斷網(wǎng)狀態(tài)下連續(xù)操作了幾百次每次操作都往本地緩存里塞又不清理內(nèi)存和磁盤都會漲得很難看。所以我會設(shè)置一個上限比如最多緩存50條操作超過上限就強(qiáng)制丟棄最舊的同時給玩家一個“操作過于頻繁請稍候再試”的提示。這既保護(hù)了客戶端也簡化了服務(wù)端處理的復(fù)雜度。5. 面經(jīng)不是背答案而是把經(jīng)驗(yàn)穿成線寫到這里想說說我對初級網(wǎng)絡(luò)面經(jīng)的看法。很多同學(xué)會把面經(jīng)當(dāng)成“題庫”來背今天背三次握手明天背粘包后天背幀同步。這樣面試的時候確實(shí)能答出幾個名詞但面試官一旦追問“具體什么時候會遇到”或者“你怎么排查”立馬露餡。我的建議是把面經(jīng)當(dāng)線索用項(xiàng)目把知識串起來。比如你做一個登錄功能就順著想一遍HTTP請求怎么發(fā)超時怎么設(shè)失敗重試怎么做弱網(wǎng)怎么提示這一個小功能就能帶出UnityWebRequest、超時重試、UI狀態(tài)管理、錯誤日志上報一整條鏈路。你做一個隊(duì)伍聊天就自然涉及Socket長連接、心跳保活、消息粘包、斷線重連。這些功能做完你再回頭背三次握手、背TCP與UDP的區(qū)別會發(fā)現(xiàn)它們不再是死記硬背而是一個個你真實(shí)處理過的問題。最后再分享一個面試技巧當(dāng)被問到“TCP為什么可靠”這類基礎(chǔ)題時不要只回答“有確認(rèn)和重傳”而是盡量補(bǔ)充“我在項(xiàng)目里發(fā)現(xiàn)如果不設(shè)超時重傳有些請求會一直掛在系統(tǒng)的發(fā)送隊(duì)列里導(dǎo)致后續(xù)請求全被阻塞所以我一般會設(shè)置超時并配合重試”。這種回答方式把知識點(diǎn)落回到你做過的事情上面試官會明顯更愿意往下聊。面經(jīng)的作用是幫你梳理知識體系但真正的底氣還是來自項(xiàng)目里一行行寫出來的代碼和一個個排查過的線上問題。基礎(chǔ)打扎實(shí)多動手初級崗位的網(wǎng)絡(luò)關(guān)沒那么難過。