做后端开发的人,基本都会碰到一类需求:给某个指定用户推送一条消息,或者朝全平台在线用户喊一嗓子。前者是私发,后者是广播,在Java生态里这俩经常被放到一起做,组合成一个消息推送模块。私发类比微信单聊,广播类比直播间的全量公告,看起来都是“把一段文本从服务器发到浏览器/客户端”,但真要落地成代码,连接管理、消息协议、线程安全、断线重连,任何一环偷懒都会在线上给你颜色看。
这篇文章不绕弯子,直接梳理“Java消息私发和广播”从设计到实现的完整链路。我会把核心会话管理、点对点消息、全员广播的实现思路掰开揉碎,附带可以直接抄走的Spring Boot + WebSocket Demo,最后聊聊我实际踩过的坑。适合刚接触WebSocket的Java后端,也适合正在把手写Socket升级成稳定模块的同学。
1. 这个需求本质在解决什么问题
1.1 私发和广播的场景拆解
私发和广播虽然都叫“发消息”,但背后是两套完全不同的消息分发模型。
私发要求精确投递:A用户发给B用户,服务器必须先知道B在哪,然后单独走一条通路把消息塞给B。这里的关键是“寻址”,就像寄快递必须有收件人地址,不能扔到小区门口就完事。
广播要求的是扇出:一条消息要复制N份,推给所有当前在线的人。这里的关键是“遍历与容错”,只要有一个连接出问题,不能把整批发送都卡死。
真实业务里这俩往往会混合出现。比如一个客服系统,用户发给客服的消息是私发;系统维护公告全员推送是广播;某个工单状态变更,既要通知提交人,又要通知处理人,这就是一组私发。想清楚使用场景再去设计代码,才不会把接口写拧巴。
1.2 技术选型:为什么我选了WebSocket而不是轮询
聊Java消息推送,不止一种路子。最简单的就是HTTP轮询,前端每隔几秒发一次请求问“有没有新消息”。这种方式优点是实现门槛极低,缺点是服务器压力大、消息实时性差,高峰期一群人狂点会直接把接口打崩,体验也很一般。还有一个方案是SSE(Server-Sent Events),服务器单向往浏览器推,适合公告流、通知流,但浏览器端API和长连接管理做起来总觉得不太顺手。我最终选择WebSocket,核心就三点:全双工通信、一次握手长期复用、消息实时性拉满。
WebSocket的优势不仅体现在“能发消息”,更关键的是它能同时承载上行和下行。用户在线状态、心跳保活、客户端主动上报,这些后续要加的功能都能在同一条连接上扩展。你当然可以用纯Java写一套原生的Socket + 多线程来管理连接,但Spring Boot自带的WebSocket Starter已经把端点注册、握手握手、连接生命周期这些底层逻辑封装好了,我们只需要关注业务本身。
1.3 一开始就定好消息模型,后面能少改很多代码
新手写消息推送最容易犯的错,是拿到需求直接写一个sendText(String message),把消息原样丢出去,结果没过几天就发现不对劲:没人知道这条消息到底是要私发还是广播,也没人知道消息体里该放什么字段。
我在动手写代码前,一定先把消息协议定好。哪怕只是Demo,也要考虑消息类型、目标用户、消息内容、发送时间这几个基础字段。前端收到消息后先看消息类型,再做对应处理,后端解析时也能快速判断该走私发逻辑还是广播逻辑。有了这层约定,后面接入前端、测试、线上排错都会省力得多。
2. 核心会话管理设计与原理解析
2.1 用一张线程安全的在线表管理所有连接
消息推送要解决的头号问题,是“服务器怎么知道用户当前连在哪”。HTTP是无状态的,一个请求处理完连接就断;WebSocket不一样,连接会一直留着。于是我们必须自己维护一张“在线连接表”,把用户标识和对应的WebSocket会话对应起来。
这张表在设计上有几个硬性要求:
第一,必须线程安全。WebSocket服务端是并发模型,每来一条连接就会有一个线程或异步任务在处理,多个用户同时上线、下线、收发消息,肯定会有并发读写这张表。用普通的HashMap,扩容和数据覆盖都是雷;我习惯用ConcurrentHashMap,读多写少的场景下它表现很稳。
第二,key要稳定唯一。选择用户ID作为key最直接,但要注意区分业务场景。比如同一用户开两个浏览器标签页,就有两个WebSocket连接,到底以哪个为准?这要在业务层面定策略:要么同一个用户只保留最新连接,旧连接主动关闭,避免一个用户收到重复消息;要么支持一个key对应多个session,发送时全session都发。大多数后台管理系统选前者,简单不易乱。
第三,value不能只存session对象。session里带了很多底层信息,但光有个session还不够,我们通常还会维护一个lastHeartbeat字段,用来做心跳检测和踢掉死连接。所以我更倾向于用一个简单封装对象作为value,而不是直接存裸session。
伪代码大概是这样的:
@Component public class SessionManager { private final ConcurrentHashMap<String, ClientSession> onlineUsers = new ConcurrentHashMap<>(); public void add(String userId, Session session) { ClientSession clientSession = new ClientSession(userId, session, System.currentTimeMillis()); onlineUsers.put(userId, clientSession); } public ClientSession remove(String userId) { return onlineUsers.remove(userId); } public Session getSession(String userId) { ClientSession clientSession = onlineUsers.get(userId); return clientSession == null ? null : clientSession.getSession(); } public Collection<ClientSession> getAllSessions() { return onlineUsers.values(); } public boolean isOnline(String userId) { return onlineUsers.containsKey(userId); } }这段代码看着简单,但它是整个私发和广播模块的地基。后面所有发送逻辑都要围绕这张表展开。
2.2 私发为什么必须查表定位
私发的本质就是根据接收方ID,到在线连接表里找对应的WebSocket会话,然后推送消息。
public boolean sendToUser(String userId, String message) { Session session = sessionManager.getSession(userId); if (session == null || !session.isOpen()) { return false; } session.getBasicRemote().sendText(message); return true; }这段代码里有两个容易被忽略的细节。
第一个是session.isOpen()的判断。客户端可能已经断网断电,服务端还没感知到;如果把一条消息写到一条已经关闭的连接上,会抛异常。所以在发送前必须先确认会话仍然有效。
第二个是发送方法的返回。实时消息系统里“消息没发出去”是常态,接收方离线、连接抖动、服务端重启,都会导致投递失败。私发必须给出成功或失败的结果,方便上层决定是落离线消息,还是给发送方一个“对方不在线”的提示。
如果直接把sendText包在try-catch里然后忽略结果,短期看不出问题,长期一定会积累一堆“消息发了但用户没收到”的线上反馈。
2.3 广播为什么不能直接遍历所有连接
广播看起来更简单:把在线连接表遍历一遍,挨个调用sendText就完事。但真正的广播需要考虑三个问题。
问题一:连接断开的隔离。遍历过程中如果某个sessionsendText抛出异常,我见过不少人直接catch住就继续,这是可以的,但有个隐藏前提——你必须确认这个异常的session只是被跳过,而不会影响后续其他session的发送。如果错误处理不好,一个脏连接就把整个广播批次拖垮。所以我建议每个session独立try-catch,这个原则和批量任务处理里的隔离思路是一样的。
问题二:消息风暴。如果在线用户有一万,广播一次就要调用一万次sendText,每次都是一次网络IO和底层协议封包。业务上线之后,如果广播频率高,比如每分钟广播一次,服务器CPU和带宽都会面临压力。这种场景下,广播不只是“发出去就完事”,还需要考虑是否要限制频率、是否要对大文本做压缩,这都属于生产环境必须补的课。
问题三:并发遍历与实时上下线冲突。遍历getAllSessions()时正好有人掉线,可能迭代到已关闭的session并触发异常;有人上线也可能出现拷贝迭代时拿到的是旧快照。ConcurrentHashMap在设计上弱一致,迭代时不会抛并发修改异常,但你要明白这并不代表迭代结果绝对实时。
一个稳妥的广播方法长这样:
public void broadcast(String message) { for (ClientSession clientSession : sessionManager.getAllSessions()) { Session session = clientSession.getSession(); if (session == null || !session.isOpen()) { continue; } try { session.getBasicRemote().sendText(message); } catch (Exception e) { // 记录日志,但不要中断整个广播 } } }这里的关键是,先检查连接状态,再单独发,发错了只记录日志不抛出,这样一条废连接不会拖累其他人。
2.4 并发染上线程安全踩的坑:HashMap转的教训
这个坑我是真实踩过的。早期写消息推送的时候,图省事直接用了HashMap<String, Session>,上线那天几十个人同时连上来,服务器console刷了一屏ConcurrentModificationException。问题就出在多个WebSocket线程同时put、同时遍历、同时remove,HashMap在扩容时遇到并发写,内部链表直接乱掉。
解决方式很简单:要么用ConcurrentHashMap,要么用Collections.synchronizedMap包裹再加手动同步。前者代码最干净,也是我最常用的一种。不过ConcurrentHashMap也不是万能,比如“先判断再放入”这种复合操作,仍然存在竞态条件:
// 有并发问题,因为containsKey和put不是原子的 if (!onlineUsers.containsKey(userId)) { onlineUsers.put(userId, session); }真正需要原子注册的时候,可以用putIfAbsent方法:
ClientSession old = onlineUsers.putIfAbsent(userId, clientSession); if (old != null) { // 说明用户已存在,做去重或顶号处理 }同时连接、同时心跳、同时掉线,这种多线程场景在消息系统里是常态,所以在线连接表里每一步操作都需要考虑并发语义。
3. 实操过程:用Java WebSocket跑通一个私发和广播的Demo
3.1 工程结构与依赖准备
我直接用Spring Boot来搭这个Demo,版本用Spring Boot 2.7.x或者3.x都可以,核心依赖只需要两个:spring-boot-starter-web和spring-boot-starter-websocket。前者提供HTTP接口,方便我们做一个触发消息的入口,后者提供WebSocket端点支持。
这一步有一个非常重要的配置,如果你用的是Spring Boot内置容器,需要注册ServerEndpointExporter这个bean,它负责扫描所有标注了@ServerEndpoint注解的类。有些同学照着教程写了端点却始终访问不到,八成就是漏了这个配置。
pom里加上依赖后,最重要的配置类如下:
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这个配置就一行,但是少写一行整个WebSocket都起不来,排查的时候很容易被忽略。
3.2 定义消息结构:私发和广播先做区分
消息结构我在前面强调过,这一步真的不能省。这里我定义一个简单的消息封装类,用Jackson序列化和反序列化:
public class ChatMessage { private String type; // private: 私发, broadcast: 广播 private String fromUserId; private String toUserId; private String content; private long timestamp; // 省略getter/setter }type字段是整个协议的开关。收到一条消息后,先解析JSON拿到type,再判断是走私发逻辑还是广播逻辑。这个设计的可扩展性也很好,后面想加“群组消息”,可以在type里扩展,比如group,再补充一个groupId字段,代码结构不用推倒重来。
另外我会习惯给消息加一个timestamp,虽然Demo阶段看起来没用,但真正出问题的时候对日志、排查延迟、确认顺序,都靠这个字段。
3.3 WebSocket处理器:核心的消息入口
端点类是整个模块的心脏,所有连接建立、消息到达、连接关闭都会触发这里的方法。
@Component @ServerEndpoint("/ws") public class MessageEndpoint { private Session session; private String userId; @OnOpen public void onOpen(Session session) { this.session = session; this.userId = session.getRequestParameterMap().getOrDefault("userId", "unknown").get(0); SessionManager.getInstance().add(userId, session); System.out.println("用户 " + userId + " 上线,当前在线:" + SessionManager.getInstance().getOnlineCount()); } @OnMessage public void onMessage(String message, Session session) { ChatMessage msg = JSON.parseObject(message, ChatMessage.class); if ("private".equals(msg.getType())) { // 私发 sendPrivate(msg); } else if ("broadcast".equals(msg.getType())) { // 广播 broadcastMsg(msg); } } @OnClose public void onClose(Session session) { SessionManager.getInstance().remove(userId); } @OnError public void onError(Session session, Throwable error) { SessionManager.getInstance().remove(userId); } }这里有几个设计点需要重点解释。
第一类是userId从哪来。Demo里我直接放在连接URL上,客户端连接时带上?userId=1001这种参数。实际项目中一定要换成token鉴权,让后端从令牌中解析出用户身份,而不是让客户端自己上报一个ID,否则任何一个人都可以伪造别人ID来收消息,这是Chat应用的重大安全漏洞。
第二是onClose和onError里都要清理会话。很多同学只写了OnClose,结果网络异常断开时,连接表里残留了一堆僵尸session,发消息的时候对着一个已经死掉的session一直报错。记住,onError的触发频率远比你想象的高。
第三是消息入口要try-catch。虽然框架层面不允许异常外抛,但如果你在onMessage期间抛出了未捕获异常,WebSocket连接会被直接关闭。所以解析消息、发送消息都建议包一层异常处理,只记录日志,不要让底层的IO异常把整个端点拖挂。
3.4 私发与广播的发送实现
接入层收到消息后,真正的动作落在发送逻辑上。我把这俩方法单独抽出来,方便复用:
private void sendPrivate(ChatMessage msg) { Session targetSession = SessionManager.getInstance().getSession(msg.getToUserId()); if (targetSession == null || !targetSession.isOpen()) { System.out.println("用户 " + msg.getToUserId() + " 不在线"); return; } try { targetSession.getBasicRemote().sendText(JSON.toJSONString(msg)); } catch (IOException e) { System.err.println("私发消息失败:" + e.getMessage()); } } private void broadcastMsg(ChatMessage msg) { String messageText = JSON.toJSONString(msg); for (ClientSession clientSession : SessionManager.getInstance().getAllSessions()) { if (clientSession.getSession().isOpen()) { try { clientSession.getSession().getBasicRemote().sendText(messageText); } catch (IOException e) { System.err.println("广播发送失败,用户:" + clientSession.getUserId()); } } } }如果你把代码跑起来,会发现一个有意思的问题:私发的时候,如果接收方不在线,这段代码只是打一行日志就什么都不做了。真实业务里这显然不够,所以生产环境中这里会触发离线消息存储,把消息存进数据库或者消息队列,等接收方上线后再拉取。私发和离线消息是一对孪生兄弟,别只看在线发送这一层。
3.5 加一个HTTP触发入口,验证起来就方便了
WebSocket是客户端主动连接后收消息,但开发联调时,总不可能每次都在另一个客户端里输入JSON去触发广播。我习惯在Demo里顺带提供两个HTTP接口,用来模拟“管理端发私发”和“服务端发广播”。
@RestController public class MessageController { @PostMapping("/sendPrivate") public String sendPrivate(@RequestBody ChatMessage message) { boolean success = ChatService.sendToUser(message.getToUserId(), message.getContent()); return success ? "已发送" : "目标用户不在线"; } @PostMapping("/broadcast") public String broadcast(@RequestBody ChatMessage message) { ChatService.broadcast(message.getContent()); return "广播已触发"; } }这样一个完整闭环就出来了:浏览器A连上WebSocket,浏览器B通过HTTP接口广播一条消息,A立刻收到。私发同理。实际验证时我建议用两个浏览器窗口,一个正常连ws,另一个通过Postman或curl触发发送,能直观看到消息的收发放结果。
再提供一个你能直接在前端控制台测试的连法:
let ws = new WebSocket("ws://localhost:8080/ws?userId=1001"); ws.onmessage = function(event) { console.log("收到消息:", event.data); }; ws.onopen = function() { ws.send(JSON.stringify({type: "broadcast", fromUserId: "1001", content: "大家好"})); };前端这十几行代码,够把整个链路从连接到广播验一遍。
4. 常见问题与排查技巧实录
4.1 私发消息总是发送失败?先看目标用户在线表
做私发遇到最多的问题,就是消息发不出去但代码又不报错。第一反应不用去看网络,先去看SessionManager里到底有没有目标用户的session。我自己的排查顺序是:
先打印在线用户数量和全部userId列表,确认目标用户是否上线。如果没有,就回到前端确认连接是否真的建立成功,有时候是Denied权限安全限制导致WebSocket握手失败,浏览器控制台不会报错但你实际没连上。
再看连接参数。很多同学在代码里硬编码了fromUserId和toUserId,测试时两个ID写反,消息发给了自己或者发给了并不存在的用户,排查了半天才发现是测试数据的问题。
最后看生命周期。onOpen是否执行了,onOpen里是否把session注册进SessionManager了。漏了注册这一个动作,后面所有判断都是“目标不在线”。
建议写代码的时候把SessionManager的操作日志打全,上线、掉线、收到消息、发送成功分别打印,线上排查的时候这些日志比什么都管用。
4.2 广播卡死或延迟飙升,多半是串行发送惹的祸
广播高峰期消息发不出去,问题大概率出在发送方式上。我早期写过一版广播,用的是session.getBasicRemote().sendText循环发,结果响应特别快,但到了300个在线用户之后,CPU就飙到80%。后来一分析,问题就出在getBasicRemote是阻塞发送,一个慢连接或者一个没收到TCP确认的客户端,会卡住整个发送线程。
解决方式有两个思路。
一个是用session.getAsyncRemote().sendText异步发送,调用后立即返回,底层有线程池处理真正的写入,吞吐量能提升不少。异步发送的问题是错误处理和顺序保证会复杂一些,要在sendText的回调里记录失败日志。
另一个思路是优化广播策略。比如合并消息:短时间内的多条广播先合并成一条,或者使用多线程分片,每批处理500个session,不要一个循环从1跑到10000。这都属于性能调优的经验,但Demo阶段可以先了解,不会踩太大坑。
4.3 连接假死:客户端显示在线,实际消息发不过去
WebSocket有一个经典的假死场景:客户端断网,比如电梯里手机信号断了,TCP连接没有被正常关闭,服务端完全不知道,session还留在在线表里。这时候广播会往这个假死连接上写数据,写了几次之后底层发现写不下去,才开始抛异常,但之前已经浪费了大量重传时间。
处理假死只有一个标准方案:心跳机制。客户端每隔30秒或60秒发送一个ping消息,服务端收到后更新该用户的lastHeartbeat时间;服务端每隔一段时间扫描一次,找出超过一定时间没心跳的连接,主动关闭并移出在线表。具体实现上,可以用WebSocket自带的Ping/Pong控制帧,也可以业务层自己约定一个ping消息格式。这个机制必须在设计阶段就考虑进去,否则线上出一次假死问题排查起来非常痛苦。
我在Demo里建议至少把lastHeartbeat字段加上,后面接一个定时任务扫描,不需要复杂设计,就能杜绝大部分连接残留问题。
4.4 部署在Nginx后面的连接被断掉
很多同学本地测试一切正常,一旦部署到服务器,WebSocket连接稳定不了几秒就被断开。这类问题十有八九是反向代理没配置好。
如果是Nginx,必须设置Upgrade相关的Header,把HTTP升级请求正确转发到后端。WebSocket是长连接,代理默认的60秒超时不一定够用,还需要设置较长的proxy_read_timeout。这些配置属于部署细节,但真到生产环境绕不开。建议任何时候接入WebSocket之前,先确认代理层已经支持协议升级。
5. 从Demo走向生产,还要补哪几节课
5.1 消息可靠性:从“尽力而为”到“至少一次”
我在前面写的Demo,本质是“消息发出去就算成功”,这叫尽力而为的投递。但在很多真实业务里,比如订单状态变更、支付结果通知,消息绝对不能丢。
要提升可靠性,需要引入ACK确认机制:客户端收到消息后,回一个ack给服务端;服务端发送消息时带一个唯一的消息ID,如果一段时间内没收到ack,就重发。重发可能导致客户端收到重复消息,所以客户端要按消息ID去重。这套机制听起来复杂,但它是一个可靠消息系统的地基。
5.2 集群部署下,私发先定位“用户在哪一台机器”
单机部署是极其罕见的,真实环境基本都是多台服务器负载均衡。如果只在一台机器的SessionManager里存在线连接,A连接在节点1,广播的消息发到了节点2,节点2根本没有A的session,消息自然丢失。
集群的解法主要有三种。一种是粘性会话,让同一个用户的WebSocket固定连到同一台节点,配置简单但节点重启会掉线。第二种是用中间件做会话广播,当节点1广播时,通过发布订阅通道通知节点2、节点3,让所有节点都参与发送,每个节点只发自己本地的session。这个方案需要引入外部依赖。第三种是集中式会话存储,比如把session句柄放到一个用户在线状态服务里,但session对象本身不好跨进程传送,所以实践中更多用的是第二种。
这块属于架构层面的扩展,但你应该知道:私发和广播的难点,从来都不是那几十行发送代码,而是站在集群视角如何解决“用户连接分散”的问题。
5.3 离线消息和消息补偿,别把实时系统做成消息黑洞
还有一类需求在聊天系统里特别常见:用户发了一条私发,对方下线了,消息不能直接从服务器消失,应该等对方上线后再补推。最简单的方式是发送失败时落库,接收方建立WebSocket连接后,服务端主动把离线期间未读的消息捞出来推过去。再讲究一点的,可以引入消息队列来做削峰,先推到队列再异步推送,避免大量推送任务同时抢占线程资源。
离线消息其实解决了两个问题:一是消息不能丢,二是接收方能知道“我不在的时间段里发生了什么”。加上这一步,才算是一个能闭环的消息系统。
最后一个实战体会
如果你只是为了完成课程作业或者快速验证功能,上面第3章的Demo已经完全够用。如果你正在做的是生产级项目,我建议把第2章的会话设计反复想透,再补上第4章和第5章里的心跳、确认、离线消息。从我的经验看,实现私发和广播本身不难,难的是把边界情况都处理掉,比如用户掉线了、重复连接了、消息发送一半连接断开、广播时一条坏连接拖垮整个批次。这些才是真正拉开普通代码和稳定系统差距的地方。按我现在的习惯,私发和广播从来不会在同一个方法里裸奔发送,而是统一封装一套发送服务,入口负责解析消息,出口负责投递策略。先把这条路走顺了,后续加任何新消息类型都会轻松很多。