先说背景。我去年在公司做了一款内部办公通讯软件的 Java 服务端,负责的是其中最核心也最容易被吐槽的一块:端到端消息分发和提示。所谓端到端,不是只把消息从发送方发到接收方那么简单,从发送者点下“发送”,到接收者的未读角标 +1、桌面弹窗弹出、会话列表红点亮起,这一整条链路里的消息路由、在线状态、离线存储、并发计数、幂等去重、提醒触发,每一个环节都是坑。这篇文章把我实际踩过的坑和沉淀下来的设计思路完整拆一遍,适合做 IM、OA、协同办公系统后端的朋友参考,也适合准备 Java 方向面试、想理解真实业务里并发和消息机制的读者。
1. 端到端消息分发,到底难在哪
1.1 办公通讯场景下的核心需求
先明确我们做的是什么。办公通讯软件不是纯粹的个人 IM,除了普通一对一聊天,还有群通知、审批提醒、日程提醒、公告推送、文件传输消息。这些消息有一个共性要求:必须尽快触达用户,并且用尽可能明显的方式让用户感知到。个人 IM 里你设置个免打扰就能接受延迟,但办公场景下,一个审批超时提醒晚到十分钟,可能就意味着整个流程卡住。
从产品层面拆解,核心需求可以概括成四句话:
- 消息要准:发给人 A 的消息,不能跑到 B 那里。
- 消息要到:在线实时推,离线进存储,上线后重新拉取。
- 提示要响:未读数、红点、桌面通知、声音提醒,一个都不能缺。
- 链路要稳:服务重启、网络抖动、客户端崩溃,都不能轻易丢消息。
这四条看着简单,真正落到 Java 工程上就是一场灾难。因为消息本身是无状态的,但用户状态是有状态的,连接是有状态的,登录设备是有状态的,未读计数也是有状态的。做这个项目的第一周,我最大的感受就是:写一个 CRUD 接口容易,写一条不会丢、不会重、不会乱序的消息链路,太难了。
1.2 为什么不能用简单的 HTTP 轮询撑起全线
很多新手第一反应是:客户端每隔一秒拉一次“新消息”,不就能实现消息推送了吗?能,但撑不住。轮询的问题不是不能用,而是它和办公通讯的需求天然矛盾。
公司内部几千人同时在线,如果每人每 3 秒发起一次 HTTP 请求查询新消息,服务端的压力会非常难看。而且轮询的实时性非常差,3 秒一次意味着消息最多延迟 3 秒,如果把它缩短到 1 秒,QPS 直接暴涨好几倍。还有更隐蔽的问题:HTTP 请求是无状态的,服务端没法准确感知客户端到底还在不在线。你以为用户还挂着,实际上他的网络早就断了,只能靠超时时间猜。
所以实际选型里我们最终放弃了轮询,用了 WebSocket 长连接。WebSocket 的好处是:连接建立之后,服务端可以主动向客户端推送数据,不需要客户端一遍遍来问;同时通过心跳机制,服务端能够相对及时地感知连接是否存活。这是端到端消息分发体系里最基础的一个决策,后面的 Session 管理、消息路由、在线状态判断,全都建立在这个长连接底座上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 全链路架构与关键通信链路设计
2.1 一条消息从发送到触达的完整旅程
在动手写代码之前,我花了两天时间把消息的完整生命周期画得很清楚。一条消息从 A 发送到 B,实际上要经过六个阶段:
- A 的客户端把消息发给服务端,服务端先落地存储,保证消息不丢。
- 服务端判断 B 当前在线节点,把消息路由到对应节点。
- 节点从连接管理表里找到 B 的 WebSocket 连接,下推消息。
- B 的客户端收到消息后,回执 ack。
- 服务端收到 ack 后,把消息状态标记为已送达。
- 服务端额外推送一个“提醒事件”,告诉客户端未读消息数更新,触发弹窗、红点、声音。
这六个阶段里,最容易出错的是第 3 步和第 5 步。第 3 步要求服务端必须先知道 B 在哪台机器上。如果我们的服务是多节点部署,B 的连接在节点 2 上,消息却发到了节点 1,那这条消息就算路由失败了。所以在线连接信息必须集中存储,我们用的是 Redis 保存“userId -> nodeId”的映射,节点本地再用一个 ConcurrentHashMap 保存“userId -> WebSocketSession”的连接表。
第 5 步的 ack 机制是整个消息可靠性的核心,没有 ack,你根本不知道消息到底是发出去了,还是半路丢了。后面我会专门讲 ack 和重试的设计。
2.2 在线状态与连接管理:SessionRegistry 与心跳
这块是我认为最值得讲的工程细节之一。连接管理听起来就是一个 Map 存 WebSocketSession,但真正用起来会发现一堆问题。
首先是并发问题。一个用户可能在 PC 端和手机端同时登录,同一个 userId 对应了多个 WebSocketSession。如果直接用 ConcurrentHashMap<String, WebSocketSession>,后登录的设备会把先登录的顶掉,这就变成单端登录了。办公场景下用户基本都希望多端共存,所以我们实际用的是 ConcurrentHashMap<String, ConcurrentHashMap<String, WebSocketSession>>,外层 key 是 userId,内层 key 是 deviceId,值是最新的连接会话。推送时遍历当前用户的所有设备,全部下发,未读计数也会在服务端合并。
其次是会话替换问题。WebSocket 连接断开重连时,老 session 还没移除,新 session 又进来了。如果推送时拿到的是老 session,大概率会抛异常。我们的解决办法是:推送给定时调用 isOpen() 判断连接状态,并且把推送逻辑包了一层 try-catch,发现连接不可用就主动从 registry 里移除。
再就是心跳。WebSocket 本身有心跳机制,但实际项目中我建议再加一层业务心跳。我们约定客户端每隔 30 秒发送一个 Ping 帧,服务端如果 90 秒内没有收到任何来自该连接的数据,就判定连接已死,主动关闭并清理 registry。这个策略解决了大量“客户端后台被杀死但连接还挂着”的假在线问题。
2.3 消息队列与 Redis 在链路里各负责什么
架构里还有一个关键角色是消息队列。我们的消息队列选的 Kafka,主要用来解耦和削峰。客户端发送消息时,入口服务只做鉴权和基础校验,然后把消息写入 Kafka,真正的消息存储、路由、推送逻辑由后端的消费者异步处理。这样做有几个好处:入口服务的响应速度非常快,客户端不用等服务端把整个链路走完才看到“已发送”;高峰时消息堆积在队列里,消费者按自己的节奏消费,不会把数据库和 Redis 打挂。
Redis 在这套链路里承担了三个角色:
- 在线状态表:userId -> nodeId,用于路由。
- 未读计数的存储:利用 Redis 的原子自增做未读消息数。
- 分布式锁与去重表:保证消息重试时的幂等性。
用 Redis 做未读计数有一个天然优势:原子性。Redis 的 INCR 命令本身就是线程安全的,不需要像 Java 里的 AtomicLong 那样考虑多实例并发问题。不过 Redis 使用中也有不少坑,比如 increment 一个不是 Integer 类型的 key,会报 not integer or out of range,这个问题我在第 5 章会专门讲。
3. 提示技术:从服务端到用户感知的最后一公里
3.1 未读计数:Redis 自增与类型检疫
消息分发的终点不是“消息到达客户端”,而是“用户看到了提示”。提示的底层逻辑离不开未读计数,因为无论是红点、角标还是列表里的数字,最后读到的都是同一个未读数。
我们的未读计数存储在 Redis,key 的格式是 unread:userId:sessionId,value 是一个整数。每次有新消息到达且接收方不在当前会话页面,就执行 redisTemplate.opsForValue().increment(key)。这里有一个非常重要的经验:increment 前必须先确认 key 的 value 类型确实是整数。
为什么呢?因为这行代码有个经典报错:ERR value is not an integer or out of range。我遇到过的情况是:某个会话的未读 key 被误存成了字符串,或者以前的老代码往同一个 key 写过非数字内容,导致后来 increment 直接崩溃。排查起来特别难受,因为报错信息和业务完全没有关联。
我的解决思路是双保险。第一,用专门的 key 前缀和 Redis 库隔离未读计数,不让别的业务往这个命名空间写数据。第二,在启动时和定时任务里增加一轮“类型检疫”,扫描热点的未读 key,发现 value 不是数字就重建成 0。这个做法看起来很笨,但确实能救你于水火。
另外,未读计数的“未读”语义需要和“已读”操作配套。已读操作也是一个 increment,不过是负向操作,increment(key, -delta)。这里要小心,扣减后的结果不能为负数,我们会在扣减前查一次当前值,或者用 Lua 脚本做原子性的“取最小值”扣减。
3.2 提醒事件设计:WebSocket 帧与本地弹窗流程
未读计数是后台数据,用户感知要靠客户端 UI。在实际开发中,我会建议把“消息数据”和“提醒事件”分开推送,不要让客户端收到消息后自己猜要不要弹窗。
具体做法是:服务端推送消息时,单独封装一个提醒帧,格式类似这样:
json复制{
"type": "SESSION_UPDATE",
"sessionId": "10086",
"unreadCount": 12,
"lastMessage": "你有一条新的审批通知",
"fromUserId": "u_9527",
"timestamp": 1710000000000
}
客户端收到这种帧之后,自己决定怎么展示:如果当前就停留在这个会话里,直接把消息插入列表并清空本地未读;如果在别的页面,就更新侧边栏的红点数字并弹出系统通知。我们的客户端是 Electron 壳子,桌面通知用的 HTML5 Notification API,声音提示用的是本地音频文件,这个由前端同学处理。服务端要做的,就是保证提醒帧不能比消息帧晚太多。
这里有一个顺序问题值得强调:消息帧和提醒帧必须以固定顺序到达客户端。我们处理方式是:在同一个 WebSocketSession 上发送消息时加了一个会话级别的可重入锁,确保同一时刻只有一个线程在写这个 session,避免消息帧和提醒帧交叉错乱。实际代码里就是一个简单的 synchronized(session.getId()) 或者用一个 striped lock,不需要多复杂的锁。
3.3 消息类型与状态机:用好 Java 枚举
办公通讯的消息类型比个人 IM 复杂得多。有文本、图片、文件、语音、系统通知、审批卡片、日程提醒,每种类型的处理逻辑和处理权限都不一样。如果你在代码里用 int 类型表示消息类型,那维护到后面一定会疯掉。
我强烈建议用 Java 枚举统一管理。一个枚举值就是一个类型,可以在枚举里封装展示名称、是否需要写离线表、是否需要未读计数等行为。例如:
java复制public enum MessageType {
TEXT("文本消息", false, true),
IMAGE("图片", false, true),
FILE("文件", false, true),
SYSTEM_NOTICE("系统通知", true, true),
APPROVAL_CARD("审批卡片", false, true),
READ_RECEIPT("已读回执", true, false);
private final String desc;
private final boolean noPersist;
private final boolean countUnread;
MessageType(String desc, boolean noPersist, boolean countUnread) {
this.desc = desc;
this.noPersist = noPersist;
this.countUnread = countUnread;
}
}
枚举的价值不只是类型安全,更重要的是把散落在各个 if/else 里的逻辑约束集中到一起。比如判断“这条消息要不要参与未读计数”,直接读 messageType.isCountUnread() 就行,而不是到处写 if(type == 3 || type == 5)。而且枚举在序列化时默认是字符串,对前端友好,不会出现数字魔数。
我们还用枚举实现了简单的状态机。消息状态包括:CREATED(已创建)、SENT_TO_QUEUE(已入队)、DELIVERED(已投递)、READ(已读)。状态流转在消息投递处理器里统一做校验,非法流转直接抛异常。这个状态机在排查“消息为什么没显示已读”时特别好用,看状态就知道卡在哪个环节。
3.4 多端同步与幂等确认
办公软件里多端同步是刚需。用户在电脑上读了消息,手机上的未读角标应该立刻清零。这意味着已读状态的变更要能跨端同步。我们的方案是:客户端在读消息时上报已读事件到服务端,服务端更新 Redis 未读计数后,再向该用户的其他在线设备推送一个已读同步帧,让它们的 UI 也刷新未读状态。
多端同步里最容易出问题的是消息幂等。跨端之间可能发生网络抖动,客户端没收到服务端的 ack,就会自动重发,导致同一条消息被服务端接收多遍。如果不做幂等,接收方会看到两条一模一样的消息。
幂等方案的核心是消息 ID。客户端在生成消息时,用 UUID 生成一个全局唯一的消息 ID,服务端收到消息后先去 Redis 查这个 ID 是否存在,存在就直接返回成功,不存在才继续处理。实现时我用的是 redisTemplate.opsForValue().setIfAbsent(msgId, "1", Duration.ofDays(7)),这个方法底层是 SETNX,分布式下天然安全。记住:幂等去重必须放在业务处理前,否则并发下两条相同的消息可能同时通过判断,最终造成重复入库。
4. 核心模块的 Java 工程化实现
4.1 消息分发器的注册与路由
消息分发器是整个服务端的交通枢纽。它的职责是拿到一条待推送消息,找到目标用户的所有在线连接,然后把消息写出去。我用一个接口加一个默认实现来做这块:
java复制public interface MessageDispatcher {
void dispatch(MessageEnvelope envelope);
}
@Component
public class WebSocketMessageDispatcher implements MessageDispatcher {
private final SessionRegistry sessionRegistry;
@Override
public void dispatch(MessageEnvelope envelope) {
String targetUserId = envelope.getTargetUserId();
Map<String, WebSocketSession> sessions = sessionRegistry.getSessions(targetUserId);
if (sessions == null || sessions.isEmpty()) {
// 用户不在线,走离线消息逻辑
return;
}
for (WebSocketSession session : sessions.values()) {
sendToSession(session, envelope);
}
}
}
SessionRegistry 的底层是内存 Map 加 Redis 同步。这里我踩过一个非常典型的坑:直接遍历 concurrentHashMap 的 values 并写 session 时,如果连接刚好关闭,会抛 IOException。解决方式是先拷贝一份连接列表,再逐个发送并捕获异常。
路由这块有一个高频问题:消息发给用户 A,但 A 的连接不在当前节点上,怎么办?我们的做法是:当前节点先把消息写入 Kafka 的“投递topic”,所有节点都订阅这个 topic,消费者拿出消息后,先检查目标用户是否在本机的 SessionRegistry 里,在才真正推送。这种方式叫“广播投递”,实现简单,但每个节点都会拉取所有消息,好在办公通讯软件的消息量完全扛得住。
4.2 消息确认与重发的幂等处理
消息确认是可靠性设计的灵魂。不夸张地说,它决定了你的 IM 到底是不是“能用”。我们的确认机制分两层:
第一层,客户端到服务端的 ack。客户端收到消息帧后,立刻给服务端发一条 ack 消息,内容就是收到消息的 ID。服务端收到 ack 后更新消息状态,从 DELIVERED 变成 READ 或者在投递表里标记已送达。
第二层,服务端的超时重发。如果服务端投递消息后 30 秒内没收到 ack,就触发重发。重发不能无限进行,否则连接断开时会把服务端打爆。我们设了最大重试 5 次,超过 5 次就认定用户离线,走离线存储逻辑。
重发时还有一个关键点,就是防止接收端重复处理。所以接收端的业务处理逻辑必须做幂等:收到消息后先查消息 ID 是否已经被处理过,处理过就直接忽略。这里我强烈建议用 Redis 的 SETNX 或者数据库唯一索引兜底,而不是依赖内存里的 Set。服务端是多节点部署,如果只查本地 JVM 里的处理记录表,另一个节点投递过来的重复消息根本拦不住。
4.3 线程池、连接数与内存控制
消息推送是典型的 IO 密集型操作,不能用同步阻塞的方式一个线程跑一条消息。我们起初用 Executors.newFixedThreadPool(200) 创建了一个固定线程池,上线后立刻暴露问题:连接多了之后,线程池的等待队列无限堆积,内存飙升,最后直接 OOM。
后来我重新设计了线程池参数:
java复制ThreadPoolExecutor executor = new ThreadPoolExecutor(
50, // corePoolSize
200, // maximumPoolSize
60, TimeUnit.SECONDS, // 空闲回收时间
new ArrayBlockingQueue<>(5000), // 有界队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
有界队列和 CallerRunsPolicy 是关键。队列满了之后,新任务不会无限堆积,而是由调用线程自己执行,相当于自动降速。这在突发消息洪峰时能起到很好的保护作用,代价只是消息延迟增加,但系统不会崩。
这里还给新手一个建议:写 Java 并发代码时,遇到任何 Executors 工具类的快捷方法都要多想想。newFixedThreadPool 用无界队列,newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,生产环境里都不能直接裸用。这不是八股文里的面试题,是会真实导致线上事故的。
连接数控制方面,我们限制单节点最大 5 万条 WebSocket 连接,超过后新连接会被拒绝并引导到压力较小的节点。这个阈值不是拍脑袋定的,是根据实测每连接消耗的内存量推算出来的。每一条 WebSocket 连接除了 socket 本身,还要对应一个 NIO channel 和若干缓冲区,大约 20KB 到 50KB 不等。5 万连接就差不多是 1.5GB 内存,加上线程、队列、对象开销,再往上加风险就很高了。
5. 常见问题排查与避坑清单
5.1 消息不提示/收不到,先查这四层
消息收不到的排查思路,我总结了四层,按顺序检查基本都能定位问题:
第一层,连接层。看用户是否真的在线。很多人排查半天,结果发现用户在后台被杀进程了,服务端根本没推送对象。这里要查的是心跳监控日志,看最近一次心跳时间。
第二层,路由层。看消息有没有发到正确的节点。我们会在消息的 envelope 里加一个 routeNode 字段,生产环境排查时直接看日志里消息被哪个节点消费了,是不是和用户 session 所在的节点一致。
第三层,推送层。看有没有真正 write 到 WebSocket session。这一层最容易出问题的是 session 已经被关闭,但连接表里还没清理。推送时抛出的 IOException 一定要记录日志,不能吞掉。
第四层,客户端提示层。服务端消息是推到了,但客户端没弹提示。这个经常是提醒帧和消息帧的顺序问题,或者客户端自己的逻辑判断当前页面在活跃状态,选择了静默展示。和服务端的关系反而不大。
我把这个四层检查法写成了内部排查手册:收不到消息先看心跳,再看路由,再看推送日志,最后联系客户端排查 UI 展示逻辑。按这个顺序来,90% 的问题能在五分钟内定位。
5.2 Redis 计数类型冲突与并发扣减
未读计数是最容易出 Redis 类型冲突的地方。最常见的报错就是前文提到的 ERR value is not an integer or out of range。出现这个报错,十有八九是往同一个 key 里写了非数字内容。排查方法是:
bash复制redis-cli type unread:u_100:s_200
# 如果返回 string,再查值
redis-cli get unread:u_100:s_200
如果 value 是一段 JSON,那就说明有别的业务逻辑串用了 key。我的处理方式是用不同的 Redis db 隔离计数和缓存数据,从物理层面把业务隔开,比只在 key 上加前缀靠谱得多。
另外,已读扣减时还会遇到并发问题。用户在多端同时已读,两个请求并发执行 increment(key, -5),可能导致未读数变成负数。我们后来用 Lua 脚本解决,脚本里先比较当前值和扣减量,取较大值作为新值。Redis 的 Lua 脚本是原子执行的,不存在并发窗口。
5.3 编译期 Lombok 与序列化的坑
我们项目用了 Lombok,遇到的第一个问题就是编译报错:You aren't using a compiler supported by Lombok。这个报错通常出现在 JDK 版本升级后,Lombok 版本太老,不认识新的编译器。解决办法是升级 Lombok 到与 JDK 匹配的版本,并且在 Maven 编译插件里显式加上 annotationProcessorPaths,让 Lombok 以注解处理器的方式参与编译。
另一个和 Lombok 相关的坑是序列化。Java 的 @Data 生成的 getter/setter 用的是 JavaBean 规范,但如果你的成员变量名以大写字母开头,比如 String URL;,Lombok 生成的 getter 是 getURL(),而 JSON 序列化库在解析时可能会把字段名处理成 url,导致前后端字段不一致。这个坑我在这套 IM 系统里就踩过,消息体里的 URL 字段莫名其妙变成了 url,前端拿不到数据。后来统一约定:Java 字段名一律小写开头,避免这种魔幻问题。
5.4 OOM 与线程阻塞的排查思路
消息推送服务最怕的就是 OOM。我遇到过几次典型场景:一次是前面说的无界线程池,消息积压导致堆内存打满;另一次是往 Redis 里存了过大的对象,比如把整个 WebSocketSession 对象序列化进了 Redis,结果内存暴涨。
排查 OOM,我建议线上保留堆转储文件,出现异常后第一时间用 MAT 分析。如果你发现内存里大量堆积的是某个业务对象,先回头检查是不是存在无限增长的缓存,比如用 ConcurrentHashMap 存放每个连接的最后一条消息,但忘了移除。
线程阻塞也是高发问题。一个非常隐蔽的场景:在 WebSocket 的 handler 里同步调用远程接口,远程接口超时时间设成了 5 分钟,大量请求线程全部阻塞在远程调用上,最终线程池耗光,新请求排队排到客户端的耐心耗尽。解决方式是:不要在 IO 线程里做耗时的业务调用,一律异步丢给线程池处理;远程调用的超时时间必须合理配置,并且做熔断降级。
最后分享两个实际经验
排查这么多问题之后,我最大的体会是:端到端消息分发的核心不是“推送”这个动作,而是链路里每一环的可观测性和兜底设计。日志不好好看,连接状态没有统计,消息流转没有 trace,出了问题就只能靠猜。
另一个经验是:设计时永远要为“异常”留余地。连接会断、消息会重、计数会错、内存会爆,这些不是 bug,而是系统的常态。把幂等、重试、降级、限流全部做成默认能力,而不是出问题之后再补,这个系统的稳定性才能真正立住。最后送大家一句我写在代码注释里的话:消息不可达是必然,可达是偶然,我们要做的是让偶然尽可能接近必然。
