實現(xiàn))
這標題看著長其實拆開就三層Spring Boot 里起一個 WebSocket 服務端再內(nèi)嵌一個 WebSocket 客戶端然后讓這個客戶端具備“發(fā)一條消息、同步等結(jié)果”的能力。別小看最后這點大部分網(wǎng)上的示例都卡在廣播和聊天室的階段真正拿進業(yè)務系統(tǒng)里用的時候誰都需要“我這端發(fā)出去一條指令對面到底處理成什么樣了”這種確定性的返回。這篇文章就把完整做法拆開講透從依賴配置、端點編寫、會話管理到客戶端封裝、同步回調(diào)協(xié)議、超時清理和重連?;钊拷o出可直接抄的代碼和參數(shù)建議。適合正在做即時通訊、設備指令下發(fā)或者想把長連接能力接進 Spring Boot 業(yè)務代碼的朋友。1. 項目價值拆解為什么“同步回調(diào)”才是真正的業(yè)務剛需1.1 這個項目到底解決了什么問題普通的 HTTP 接口天然就是“請求-響應”模型你調(diào)一次接口拿到返回值事情就結(jié)束了。WebSocket 不一樣它是全雙工長連接消息推出去之后對面的響應什么時候回來、回沒回來都是異步事件。這就帶來一個很現(xiàn)實的矛盾Spring Boot 的業(yè)務代碼大部分是同步風格Service 層調(diào)一個方法、拿到返回值、再往下走大家早就習慣了這種寫法。如果 WebSocket 做成了“發(fā)消息 監(jiān)聽回調(diào)”業(yè)務方就要被拆成兩段一段負責發(fā)一段負責在回調(diào)里繼續(xù)執(zhí)行代碼邏輯四分五裂排查問題也很痛苦。標題里的“客戶端消息同步回調(diào)”解決的就是這個矛盾對外暴露的接口仍然像普通方法一樣調(diào)用方傳參、等待、拿結(jié)果內(nèi)部則通過 WebSocket 長連接異步收發(fā)再靠請求 ID 把響應和請求重新對齊。說白了這是把異步通道“偽裝”回同步語義讓長連接能力和常規(guī)業(yè)務代碼無縫銜接。我見過不少團隊一開始做長連接模塊都是先寫廣播 demo結(jié)果一接到真實業(yè)務里就發(fā)現(xiàn)各種不對勁核心原因就是沒想清楚“請求-響應要對得上號”這件事。這個項目本質(zhì)上解決的不是連接問題而是“確定性”問題。1.2 三個典型業(yè)務場景我舉三個最常見的場景基本覆蓋了標題里這套方案會被用到的位置。第一個是設備指令下發(fā)。管理平臺要控制一臺終端設備比如“開啟攝像頭”“重啟服務”“調(diào)整參數(shù)”這種指令光發(fā)出去不算完你一定想知道設備端是否收到、是否執(zhí)行成功、如果失敗了錯誤信息是什么。只有同步回調(diào)才能讓指令下發(fā)服務保持業(yè)務一致性不然指令狀態(tài)就只能靠輪詢數(shù)據(jù)庫去猜效率低而且實時性差。第二個是在線用戶操作回執(zhí)。IM 系統(tǒng)里不僅有聊天消息還有“拉黑”“撤回”“已讀回執(zhí)”這類帶狀態(tài)的操作。服務端需要向指定的在線連接推送一條指令并等待用戶客戶端應答。這種場景如果做成異步廣播你根本不知道被拉黑的那個用戶到底有沒有收到很多業(yè)務邏輯就沒法收斂。第三個是實時業(yè)務場景比如游戲?qū)纸Y(jié)算、交易確認、協(xié)同編輯的版本同步。這些業(yè)務對結(jié)果返回的實時性要求極高HTTP 輪詢的延遲和開銷都不合適WebSocket 長連接加上同步回調(diào)能把原先“發(fā)了不管”的廣播式通信固化成一個帶結(jié)果的調(diào)用業(yè)務代碼寫起來也自然很多??梢哉f只要你的系統(tǒng)里存在“在線設備或用戶 需要知道處理結(jié)果”的組合這套方案就是繞不開的。1.3 標題里的三層架構(gòu)每一層都缺一不可標題雖然長但核心架構(gòu)其實只有三層。服務端層負責連接管理、會話存儲、消息接收與推送解決的是“誰能連、連上了往哪發(fā)”的問題??蛻舳藢邮乔度?Spring Boot 工程里的一個 Java WebSocket 客戶端組件它對外暴露業(yè)務方法對內(nèi)維護長連接狀態(tài)解決的是“業(yè)務代碼怎么調(diào)用”的問題。同步回調(diào)層則是一套消息協(xié)議和等待機制通過請求 ID 把響應消息和正在等待的請求重新配對解決的是“結(jié)果怎么回到調(diào)用方手里”的問題。三層缺了任何一層都不完整沒有服務端客戶端沒東西可連沒有客戶端業(yè)務代碼就得裸寫一堆 WebSocket 回調(diào)代碼難看還難維護沒有同步回調(diào)前面做的所有連接工作都停留在“能通”的水平業(yè)務根本沒法用起來。所以這篇博文會按這三層逐步拆開寫順序剛好就是標題的閱讀順序。2. 技術(shù)選型與設計思路哪些方案能選、我為什么這樣選2.1 服務端集成 WebSocket 的三種主流姿勢Spring Boot 里做 WebSocket 服務端最常見的有三種路子基于ServerEndpoint注解、基于 Spring 的WebSocketHandler注冊、以及直接集成 Netty。我給它們拉了張對比表只看它們在“快速集成”這個場景下的表現(xiàn)方案上手難度代碼量生命周期處理適合場景ServerEndpoint注解低最少注解自帶 OnOpen/OnMessage/OnClose/OnError大多數(shù)業(yè)務項目快速接入沒問題SpringWebSocketHandler中中等實現(xiàn)接口方法需要和 Spring MVC 深度整合、做攔截握手時集成 Netty高多自己管理通道和線程模型超大規(guī)模連接、TCP 層定制要求很高時標題里明確寫了“快速集成”那我的答案很直接選ServerEndpoint注解方案。原因不復雜一是它完全由 Java 生態(tài)規(guī)范定義和容器兼容性好代碼量最少二是每個生命周期回調(diào)都有明確的注解位置不需要去記 Spring 的接口簽名三是不需要額外引入路由邏輯路徑參數(shù)直接寫在注解里比如/ws/{userId}在 OnOpen 方法里就能拿到用戶 ID非常貼合業(yè)務需求。2.2 客戶端選型與依賴引入Java 生態(tài)里做 WebSocket 客戶端我一般直接用 Java-WebSocket 這個開源庫。它提供了完整的客戶端實現(xiàn)連接、發(fā)送、監(jiān)聽消息、心跳、重連這些能力都覆蓋了而且它是一個輕量庫不需要和 Spring 容器做任何綁定非常適合作為獨立組件封裝進業(yè)務代碼里。相比之下用 Spring 自帶的WebSocketStompClient也不是不行但它強依賴 STOMP 協(xié)議棧如果你的服務端是自己實現(xiàn)的普通 WebSocket 端點沒必要為握手過程引入這么重的依賴。Maven 坐標很簡單服務端只需要引入 Spring Boot 的 websocket starter客戶端則額外引入 Java-WebSocket 庫。注意版本方面選一個穩(wěn)定版本就行這十年下來它的 API 變化不大核心用法比較穩(wěn)定。在后面的代碼里我會把這兩段依賴分開寫清楚避免一些人把服務端和客戶端的依賴混在一起出現(xiàn)莫名奇妙的沖突。2.3 “同步回調(diào)”的本質(zhì)把異步消息拉回到同步時間線要說清楚同步回調(diào)我打個比方。你去銀行柜臺辦事取了一個號碼牌然后坐下來等廣播叫號廣播喊到你的號你再上去拿結(jié)果。這里的號碼牌就是請求 ID廣播就是 WebSocket 的消息通知等待區(qū)就是你正在阻塞的業(yè)務線程。實現(xiàn)上就是用并發(fā)包里的CompletableFuture作為“等待憑證”每次發(fā)送請求前生成一個全局唯一的請求 ID同時創(chuàng)建一個CompletableFuture放進一個ConcurrentHashMap里key 就是請求 ID。消息發(fā)出去之后調(diào)用方執(zhí)行future.get(timeout)阻塞等待。服務端處理完業(yè)務后回包時必須帶上同一個請求 ID客戶端在收到消息時用這個 ID 去 Map 里找到對應的 Future調(diào)用complete()把結(jié)果填進去阻塞中的調(diào)用方立刻被喚醒拿到返回值繼續(xù)往下走。這就是“異步轉(zhuǎn)同步”的核心機制。需要注意這種同步是調(diào)用方線程層面的等待不是整個系統(tǒng)變成串行服務端依然是并發(fā)的只是每個請求在自己的線程里等待自己的結(jié)果彼此之間不干擾。同時future.get()一定要帶超時時間避免服務端一直不回包把業(yè)務線程全部打滿這一點在后面的代碼里也會處理掉。3. 服務端實現(xiàn)從握手建連到一對一消息推送3.1 依賴與基礎(chǔ)配置服務端的第一步是加依賴。Spring Boot 項目里引入spring-boot-starter-websocket就夠了它會把 WebSocket 相關(guān)的容器能力帶進來同時和原有的 Web MVC 共存不會影響普通 HTTP 接口。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency有了依賴之后還需要一個配置類注冊ServerEndpointExporter。這個類非常關(guān)鍵它的作用是掃描所有標注了ServerEndpoint的類把它們注冊到底層 WebSocket 容器里讓路徑真正生效。如果少了這一步你寫的端點類不會報錯但連接時永遠是連接失敗這個坑不少人都踩過。Configuration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }這一步完成后服務端的骨架就搭好了。后面寫的端點類、會話管理工具、消息中心都會被 Spring Boot 的自動配置能力串起來。3.2 會話管理并發(fā)安全是第一個考點WebSocket 服務端的核心資源就是Session每個連接對應一個會話我們得把“某個用戶 ID ? 某個 Session”的對應關(guān)系管理好。因為 WebSocket 連接的建立和斷開是隨時的可能幾十個線程同時在線增刪會話所以這里必須用并發(fā)安全的容器。我用一個獨立的工具類來管理在線會話內(nèi)部使用ConcurrentHashMap以用戶 ID 為 key以Session對象為 value。提供四個最基本的方法加會話、刪會話、查會話、統(tǒng)計在線數(shù)。為什么單獨抽一個類因為端點類和消息中心都會用到這份數(shù)據(jù)如果堆在前端類里代碼會越來越亂還會出現(xiàn)靜態(tài)變量和實例變量混淆的問題。public class SessionManager { private static final MapString, Session ONLINE_SESSIONS new ConcurrentHashMap(); public static void add(String userId, Session session) { ONLINE_SESSIONS.put(userId, session); } public static void remove(String userId) { ONLINE_SESSIONS.remove(userId); } public static Session get(String userId) { return ONLINE_SESSIONS.get(userId); } public static int onlineCount() { return ONLINE_SESSIONS.size(); } }這里要特別提醒一點同一個用戶如果重復連接比如瀏覽器刷新、客戶端斷線重連舊的 Session 沒有正常關(guān)閉Map 里就會殘留失效連接。所以在add的時候建議先判斷一下舊 Session 是否還存在如果存在就先關(guān)掉再放新的進去。否則你后續(xù)推送時可能拿到一個已經(jīng)關(guān)閉的 Session白白增加很多isOpen()判斷。3.3 WebSocket 端點的完整實現(xiàn)端點類直接寫在 Spring Boot 工程里加Component和ServerEndpoint兩個注解。路徑我設計成/ws/{userId}這樣連接時天然帶上了身份信息服務端不用再通過消息內(nèi)容去猜是哪個用戶。Component ServerEndpoint(/ws/{userId}) public class ChatWebSocketEndpoint { OnOpen public void onOpen(Session session, PathParam(userId) String userId) { SessionManager.add(userId, session); System.out.println(連接建立: userId userId , sessionId session.getId()); } OnMessage public void onMessage(String message, Session session) { System.out.println(收到消息: message); // 這里交給業(yè)務邏輯處理注意線程池隔離 MessageRouter.route(session, message); } OnClose public void onClose(Session session, PathParam(userId) String userId) { SessionManager.remove(userId); System.out.println(連接關(guān)閉: userId userId); } OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }這段代碼看起來簡單但有幾個細節(jié)值得說。第一PathParam必須和路徑占位符里的名字一致不然后臺會一直打參數(shù)解析異常的日志連接成功率看起來沒問題但用戶 ID 永遠是空值。第二onError里最好不要把異常只打日志就算了應該把當前會話刪掉避免一個半死不活的連接還掛在在線列表里占位置。第三onMessage收到的消息體格式建議直接約定為 JSON服務端不要接收裸字符串否則后面做回調(diào)配對時請求 ID 都沒地方放。3.4 消息中心設計對點推送與廣播有了一對一的 Session 映射下一步就是封裝一個消息中心讓業(yè)務代碼不用直接碰Session對象。推送消息的核心方法是sendToUser它負責根據(jù)用戶 ID 找到 Session檢查連接狀態(tài)然后發(fā)送文本消息。public class MessageCenter { public static void sendToUser(String userId, String message) { Session session SessionManager.get(userId); if (session ! null session.isOpen()) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { SessionManager.remove(userId); e.printStackTrace(); } } } public static void broadcast(String message) { for (String userId : SessionManager.allUserIds()) { sendToUser(userId, message); } } }這里我用了getBasicRemote().sendText()它是同步發(fā)送線程會等消息寫完才返回。如果推送的頻率特別高建議改用getAsyncRemote().sendText()異步發(fā)送不用等 IO 完成但這意味著你要自己處理消息順序和發(fā)送失敗的回調(diào)復雜度會上去。另外一個很多人在推送時忽略的點同一個Session不要多個線程同時調(diào)用sendText()會出現(xiàn)消息交錯甚至直接拋異常。并發(fā)量大的時候可以考慮給每個 Session 加一個寫鎖或者用隊列把所有推送任務排隊處理。我在實際排查過的問題里見過好幾次“消息偶爾串線”的詭異現(xiàn)象最后定位就是并發(fā)寫同一個 Session 造成的。3.5 一個容易踩的坑Spring 容器與端點實例的隔離ServerEndpoint的端點類和普通的Component有一個本質(zhì)區(qū)別端點實例是由 WebSocket 容器創(chuàng)建和管理的每次連接通常對應新的實例它不是 Spring 的單例。很多人在這里栽過跟頭在端點類里直接Autowired一個 Service結(jié)果發(fā)現(xiàn)是 null因為 Spring 根本沒有管理那個端點實例。解決辦法是用靜態(tài)字段加 setter 注入的方式把 Spring 容器里的 Service 暴露到端點類里。具體做法是端點類加Component讓 Spring 掃描到然后在類里定義一個靜態(tài)的 Service 字段和 setter 方法setter 上加Autowired。這樣 Spring 在初始化時會把 Service 注入到 setter 里再賦給靜態(tài)字段端點實例后續(xù)就可以直接使用這個靜態(tài)字段了。Component ServerEndpoint(/ws/{userId}) public class ChatWebSocketEndpoint { private static MessageService messageService; Autowired public void setMessageService(MessageService service) { ChatWebSocketEndpoint.messageService service; } OnMessage public void onMessage(String message, Session session) { Object result messageService.handle(message); // 繼續(xù)處理 result ... } }這是很多教程里不會提到的關(guān)鍵細節(jié)。如果你打電話報錯找不到 Service十有八九就是這里沒處理好。3.6 服務端如何配合同步回調(diào)協(xié)議服務端本身不一定知道“同步回調(diào)”這回事它只需要遵守一條協(xié)議回包時必須帶上請求里攜帶的reqId。這個約定是整個同步機制成立的基礎(chǔ)。我建議在項目里提前定好統(tǒng)一的消息外殼格式。請求消息和響應消息都包含三個基礎(chǔ)字段reqId表示請求 IDtype表示消息類型payload放業(yè)務數(shù)據(jù)。這樣無論消息走的是同步請求還是異步事件接收方只要掃一眼type就能分流通過reqId就能配對。OnMessage public void onMessage(String message, Session session) { JSONObject request JSON.parseObject(message); String reqId request.getString(reqId); String payload request.getString(payload); Object result messageService.handle(payload); JSONObject response new JSONObject(); response.put(reqId, reqId); response.put(status, 200); response.put(data, result); MessageCenter.sendToUser(getUserId(session), response.toJSONString()); }服務端回包時只要找回請求里的reqId填進響應體客戶端的同步等待就能被正確喚醒。如果哪天發(fā)現(xiàn)客戶端一直超時第一個排查點就是服務端有沒有把reqId原樣返回。這個看起來不起眼卻是我見過最多的失誤原因之一。4. 客戶端封裝與同步回調(diào)落地4.1 客戶端核心骨架與連接管理這里的客戶端指的是嵌入 Spring Boot 工程里的 Java WebSocket 客戶端組件不是瀏覽器頁面。它會去連接遠端的 WebSocket 服務然后把一個“同步請求方法”暴露給上層業(yè)務調(diào)用。依賴如下我選用 Java-WebSocket 庫來做客戶端dependency groupIdorg.java-websocket/groupId artifactIdJava-WebSocket/artifactId version1.5.4/version /dependency客戶端組件核心就是創(chuàng)建一個WebSocket實例綁定服務端地址并在onMessage回調(diào)里集中處理收到的消息。連接建立之后上層業(yè)務不需要關(guān)心連接對象只需要調(diào)用我們封裝的同步方法。public class WsClient { private WebSocket socket; public void connect(String serverUrl) throws Exception { socket new WebSocket(new URI(serverUrl)) { Override public void onMessage(String message) { handleResponse(message); } Override public void onClose(int code, String reason, boolean remote) { scheduleReconnect(serverUrl); } }; socket.connect(); // 等待連接完成 CountDownLatch latch new CountDownLatch(1); // 這里可做握手完成的標志位簡化示例直接按需等待 } }有一點要注意connect()是非阻塞的真正建立連接需要幾毫秒到幾十毫秒不等。所以如果你的業(yè)務方剛調(diào)用完connect()立刻就去發(fā)消息很可能會拋“連接未打開”的異常。一個穩(wěn)妥的做法是在 onOpen 回調(diào)里放一個門閂等連接建立后再開放發(fā)消息。我實際做的時候是在內(nèi)部維護了一個AtomicBoolean connected標志每次發(fā)送前檢查沒連上就阻塞等待一會兒。4.2 請求-響應對齊的關(guān)鍵結(jié)構(gòu)整個同步回調(diào)機制最關(guān)鍵的數(shù)據(jù)結(jié)構(gòu)有兩塊一個是請求 ID 生成器一個是等待響應的 Future 容器。請求 ID 我用AtomicLong自增序列來生成配合一個簡單的前綴保證在本進程內(nèi)唯一。如果多個實例部署建議用 UUID 或者在前綴里加上實例標識避免把不同實例的請求響應匹配錯。public class WsClient { private static final AtomicLong REQ_SEQ new AtomicLong(0); private final MapString, CompletableFutureString pendingMap new ConcurrentHashMap(); private String generateReqId() { return REQ_ REQ_SEQ.incrementAndGet(); } }pendingMap就是那個“等待區(qū)”。每個請求發(fā)出前往 Map 里放一個CompletableFuture收到對應響應時從這個 Map 里取出 Future把它完成掉。Map 用ConcurrentHashMap是因為可能有多個業(yè)務線程同時發(fā)起請求、多個消息回調(diào)線程同時處理響應必須在并發(fā)場景下保證安全。4.3 封裝同步請求方法核心方法sendSync的邏輯這樣設計生成請求 ID、封裝消息、注冊 Future、發(fā)送消息、阻塞等待、最后無論如何都要從 Map 里清理掉這個 Future。清理這一步絕對不能省否則服務端一直不回包Map 里就會堆積大量的 Future 對象時間一長就是內(nèi)存泄漏。public String sendSync(String payload, long timeout, TimeUnit unit) throws Exception { String reqId generateReqId(); CompletableFutureString future new CompletableFuture(); pendingMap.put(reqId, future); JSONObject request new JSONObject(); request.put(reqId, reqId); request.put(type, BIZ_REQUEST); request.put(payload, payload); socket.send(request.toJSONString()); try { return future.get(timeout, unit); } finally { pendingMap.remove(reqId); } }這個方法的調(diào)用方式非常直白業(yè)務方傳一段 JSON 字符串傳入超時時間然后同步拿到服務端返回的另一個 JSON 字符串。如果服務端那邊處理時間大致可預估建議把超時設置成 3 到 5 秒如果包含復雜的業(yè)務鏈路可以放寬到 10 秒但超過 30 秒我一般不建議因為長時間阻塞業(yè)務線程會讓整個應用的線程池很快耗盡。4.4 心跳?;钆c斷線重連長連接有個天然的敵人網(wǎng)絡中間設備和服務器本身會清理閑置連接。Tomcat 默認會有空閑連接超時Nginx 代理也有讀寫超時如果客戶端長時間不發(fā)送任何數(shù)據(jù)連接就會被對端靜默關(guān)閉??蛻舳俗约焊兄坏降鹊较麓伟l(fā)消息才發(fā)現(xiàn)連接已經(jīng)斷了這時同步請求就直接卡到超時。所以客戶端必須加心跳。我一般用ScheduledExecutorService每隔 10 秒發(fā)送一個心跳消息消息體統(tǒng)一成{type:PING}。服務端收到 PING 時不需要回業(yè)務數(shù)據(jù)但可以回一個 PONG或者只要服務端還在正常讀數(shù)據(jù)客戶端這條心跳就起到了保活作用。private void startHeartbeat() { ScheduledExecutorService executor Executors.newSingleThreadScheduledExecutor(); executor.scheduleAtFixedRate(() - { if (socket ! null socket.isOpen()) { JSONObject ping new JSONObject(); ping.put(type, PING); socket.send(ping.toJSONString()); } }, 10, 10, TimeUnit.SECONDS); }斷線重連方面我會在onClose里觸發(fā)重連邏輯但重連不能做成無腦循環(huán)否則服務端還沒恢復客戶端就會以極高的頻率瘋狂創(chuàng)建連接把服務端打得更慘。建議做退避重試第一次 1 秒、第二次 2 秒、第三次 4 秒指數(shù)遞增最多間隔 60 秒服務端恢復連接后把重連次數(shù)清零。重連期間所有正在等待的sendSync請求會因為超時統(tǒng)一失敗上層業(yè)務要接受這種“短暫不可用”的現(xiàn)實并在失敗后走自己的重試策略。4.5 回調(diào)協(xié)議的最佳實踐與參數(shù)建議這部分我總結(jié)了幾個真正算得上經(jīng)驗的東西。第一消息格式必須是 JSON并且在項目一開始就定好外殼字段不能今天用reqId明天用requestId這種不一致會讓整個調(diào)試過程變成災難。建議統(tǒng)一為reqId、type、payload三個字段同步響應再加一個status表示結(jié)果狀態(tài)碼。第二sendSync返回的響應建議不要直接返回原始字符串最好解析成JSONObject再返回同時把status非 200 的響應當成業(yè)務異常拋出去。這樣上層業(yè)務代碼拿到的是干凈的結(jié)果而不是還要自己判斷一堆狀態(tài)位。第三請求 ID 最好在客戶端生成。如果服務端也可以生成就會出現(xiàn)兩邊 ID 體系不一致的問題調(diào)試時很容易看花眼??蛻舳松珊笤瓨觽鹘o服務端服務端原樣回傳鏈路清晰問題也好定位。第四同步回調(diào)適合“必須有結(jié)果”的操作但不是所有消息都適合同步。像心跳、狀態(tài)上報、位置推送這類頻率高、不關(guān)心響應的消息建議走獨立的異步通道不要通過sendSync發(fā)否則線程池重負載下這些消息也會排隊等待白白增加延遲。5. 實戰(zhàn)避坑與排查實錄5.1 常見問題速查表我把實際開發(fā)中遇到過的問題整理成了一張表按現(xiàn)象排查效率很高現(xiàn)象可能原因解決辦法客戶端始終連接不上服務端缺少ServerEndpointExporter注冊檢查配置類是否注冊了該 Bean連接報 400 握手失敗路徑參數(shù)不對或請求路徑與端點不匹配核對ServerEndpoint路徑和客戶端連接地址請求 100% 超時reqId沒有配對服務端響應里丟了reqId抓包看服務端回包字段確認reqId原樣返回連接頻繁斷開沒有心跳中間設備回收空閑連接客戶端加心跳建議 10 秒一次消息出現(xiàn)錯亂或串線同一 Session 被多個線程并發(fā)寫加 Session 寫鎖或全部改成異步發(fā)送重連后消息發(fā)不出去重連成功后內(nèi)部連接狀態(tài)沒更新在 onOpen 回調(diào)里更新連接標志位長時間運行后內(nèi)存增長明顯pendingMap中超時的 Future 沒清理finally塊里執(zhí)行pendingMap.remove(reqId)5.2 幾個我踩過的深坑第一個深坑就是忘記注冊ServerEndpointExporter。項目啟動完全正常沒有任何報錯但所有 WebSocket 連接都失敗我在瀏覽器控制臺看到的是 404。排查了半天才發(fā)現(xiàn)是配置類里少了一個 Bean這種問題報錯信息不直接非常迷惑人。第二個深坑是瀏覽器里連接 503。如果你前面掛了 Nginx 之類的反向代理必須在代理配置里顯式開啟協(xié)議升級并且設置合理的超時時間。很多人本地直連正常一到測試環(huán)境就失敗基本都是這一層沒配置。生產(chǎn)環(huán)境還要注意負載均衡器的會話保持不然同一個客戶端的多次連接會被轉(zhuǎn)發(fā)到不通的節(jié)點上。第三個深坑是普通方法里調(diào)用sendSync導致線程池打滿。我當時在業(yè)務線程里直接調(diào)同步請求結(jié)果服務端回包變慢幾十個請求全部卡在future.get()上業(yè)務線程全部占滿應用開始出現(xiàn)連環(huán)超時。后來給同步請求單獨隔離了一個線程池并且嚴格限制超時時間這個問題才算解決。第四個深坑是關(guān)于 Resend 或者消息重發(fā)。WebSocket 底層不保證消息不丟有時候服務端確實不會回包客戶端超時后會重試。重試時不能直接把同一個reqId重新發(fā)一遍否則如果服務端前面已經(jīng)處理成功只是回包丟了會引起業(yè)務重復執(zhí)行。建議給每條消息加一個sequence序號重試時用新reqId但在payload里帶上原始業(yè)務冪等號服務端自己決定是否要去重。5.3 分布式多實例下的拓展思路以上所有方案在單機模式下都是沒有問題的因為會話信息都存在本地 JVM 的ConcurrentHashMap里。一旦項目從單實例擴展成多實例部署問題就出來了用戶 A 連接在實例 1用戶 B 連接在實例 2實例 1 想推送消息給 B本地 Session 里根本找不到 B 的連接。常規(guī)解法是把“在線會話表”從本地內(nèi)存挪到外部存儲比如 Redis 哈希結(jié)構(gòu)里記錄 userId 和節(jié)點 ID 的映射。推送時先查路由表找到目標節(jié)點再把消息投遞到對應的實例由該實例的本地會話表完成真正的推送。節(jié)點間通信可以用消息隊列或者 Redis 發(fā)布訂閱來做實例只需消費消息并判斷消息里的目標節(jié)點是不是自己。同步回調(diào)在這種架構(gòu)下也會變得復雜但好在請求 ID 的機制不受影響只要消息能路由到正確的節(jié)點配對邏輯依然成立。我通常建議先把單機版跑通再考慮加分布式路由層不要一上來就把復雜度拉滿。最后再分享一點個人體會整個項目跑通之后我最大的感受是同步回調(diào)這個能力真正難的不是代碼而是協(xié)議先想清楚。你只要把reqId、type、payload、status這套外殼定好后面換服務端框架、換客戶端引擎都不傷筋動骨。我后來遇到類似的需求第一件事永遠是先確認消息格式而不是先寫連接代碼。另外一個很實際的心得是同步語義雖好但不要貪只對業(yè)務上確實需要返回結(jié)果的請求用同步像心跳、狀態(tài)廣播這些應該走純事件否則你的線程池很快就會被占滿。要是后續(xù)打算跨集群擴展可以把請求 ID 和路由信息塞進消息頭再配一個消息網(wǎng)關(guān)這套客戶端組件基本不用改服務端的路由層單獨替換就行。