1. 为什么“消息不丢”是IM系统真正的生死线
做IM系统的人,最怕的不是功能少、界面丑、并发低,而是用户发了一条“我到了”,对方没收到;或者团队在紧急协作时,关键指令石沉大海。这种“消息丢了”的问题,表面看只是技术细节,实操中却直接触发客诉、信任崩塌、甚至商务合作终止。我参与过三个不同规模的IM项目——从几十人内部工具到日活百万的行业平台,所有上线后第一轮压测和灰度反馈里,“消息没送达”永远排在问题清单前三。它不像UI卡顿能靠优化渲染掩盖,也不像登录慢可以加loading提示缓冲,它是通信链路的“信用凭证”,一旦失效,整个系统就失去了存在基础。
标题里说的“QoS机制”,不是什么高大上的学术概念,就是一套可验证、可兜底、可回溯的消息交付保障体系。它解决的不是“能不能发出去”,而是“发出去之后,对方到底有没有真正看到、处理、确认”。很多新手会误以为TCP可靠传输=消息可靠投递,这是最大的认知陷阱。TCP只保证字节流在网络层不丢包、不乱序,但IM的完整链路远不止于此:消息要经过网关路由、服务端存储、离线队列、多端同步、客户端解析、UI渲染、用户点击确认……中间任何一个环节出错,TCP都无能为力。比如服务端写入数据库失败但没抛异常、客户端收到消息后因内存不足崩溃未持久化、用户切换网络时ACK包丢失……这些场景TCP完全不感知。
所以QoS的本质,是在应用层主动构建冗余、引入状态机、设计超时重传、建立双向确认闭环。它不依赖底层协议的“尽力而为”,而是用确定性的逻辑覆盖不确定的网络与设备环境。本文聚焦的,正是这套机制如何在真实项目中落地——不是理论推演,而是我把三年来踩过的坑、调过的参数、压测时崩溃又重建的代码逻辑,全部摊开讲透。核心代码我会逐行注释原理,告诉你为什么选这个重试策略、为什么ACK要分两级、为什么离线消息必须带时间戳而非序列号。如果你正在搭建IM、正在优化现有系统、或者只是想搞懂“微信/钉钉凭什么敢承诺消息必达”,这篇就是为你写的。
2. QoS机制的整体设计思路与方案选型逻辑
2.1 不是所有QoS都叫QoS:三类等级的真实代价
业内常提QoS 0/1/2三级,但很多文档只讲定义,不讲代价。我在某跨平台IM项目里做过对比测试,同一套硬件资源下,三种模式对系统吞吐量、延迟、存储成本的影响差异巨大:
QoS 0(最多一次):发完即忘,不存、不等ACK、不重传。实测单机QPS可达12万+,平均延迟<15ms,但消息丢失率在弱网环境下高达8.3%(模拟30%丢包率)。适合通知类消息(如天气推送),不适合IM主聊。
QoS 1(至少一次):服务端持久化+客户端ACK+服务端重传。这是当前主流IM的选择。我们压测时发现,当重试次数设为3次、超时阈值15s时,99.992%的消息在60s内完成投递,但峰值QPS跌至4.2万,存储压力增加3.7倍(每条消息需存3份元数据)。
QoS 2(恰好一次):在QoS 1基础上增加服务端去重ID和客户端幂等处理。理论上完美,但实测发现:客户端去重逻辑复杂度陡增,iOS后台进程被杀后ID丢失导致重复消息;服务端去重表查询成为性能瓶颈,QPS再降40%。最终我们放弃纯QoS 2,改用“QoS 1 + 客户端业务层去重”折中方案。
提示:别迷信QoS 2。真实世界里,网络抖动、设备休眠、App被杀比理论模型复杂得多。我们最终采用的方案是:对文本/语音等核心消息走QoS 1强保障,对表情包/已读回执等非关键消息走QoS 0轻量通道,用不同通道隔离资源消耗。
2.2 架构分层:为什么必须拆成“发送链路”和“接收链路”两套状态机
早期我们尝试用一个全局状态机管理所有消息,结果在高并发下频繁死锁。后来重构为双状态机架构,才真正稳定下来:
发送链路状态机(Sender FSM):运行在发送方客户端和服务端。负责消息生成、本地缓存、发送、等待ACK、超时重传、最终标记为“已送达”或“已失败”。关键点在于:所有状态变更必须原子写入本地数据库(SQLite/Realm),且每次写入附带唯一trace_id,便于全链路追踪。
接收链路状态机(Receiver FSM):运行在接收方客户端和服务端。负责消息接收、解密、存储、UI展示、用户操作(阅读/回复)、生成ACK。这里有个反直觉的设计:ACK不是“收到即发”,而是“用户真正看到并停留超过1秒后才触发”。我们通过监听页面可见性API和消息列表滚动位置实现,避免了“消息刚进列表但用户根本没看到就发ACK”的假确认。
双状态机解耦后,发送方崩溃不影响接收方状态更新,接收方离线时发送方仍可按计划重传。更重要的是,故障排查时能清晰定位问题在“发不出去”还是“收不到确认”,而不是在一团乱麻的状态里大海捞针。
2.3 关键决策:为什么选择“服务端主导重传”而非“客户端自主重传”
有团队主张让客户端自己管重传——毕竟客户端最清楚网络状况。但我们在线上跑了半年后彻底否定了这个方案。原因很现实:
- 客户端重传不可控:iOS后台限制、Android省电策略、用户手动杀进程,都会导致重传任务中断且无法恢复;
- 状态同步灾难:客户端重传时若服务端已成功投递,就会产生重复消息;而服务端无法判断“这是新消息还是重传”,只能靠客户端带重传标识,但这个标识本身可能丢失;
- 运维黑洞:重传日志分散在数百万台设备上,无法集中监控和分析。
所以我们强制所有重传由服务端发起。客户端只做两件事:1)发送消息时附带msg_id和timestamp;2)收到消息后生成带msg_id的ACK。服务端维护一个“待确认消息表”,每30秒扫描一次超时未ACK的消息,触发重传。这样所有状态、日志、监控都在服务端闭环,运维同学半夜接到告警时,能直接查数据库看到哪条消息卡在哪个环节。
3. 核心细节解析:从消息生成到最终确认的7个关键环节
3.1 消息ID生成:为什么UUIDv4不够用,必须自定义结构化ID
很多项目直接用UUIDv4作为消息ID,看似唯一,实则埋雷。我们在某教育IM项目中遇到过惨痛教训:两个老师同时给班级群发“下课了”,UUIDv4生成的ID完全随机,服务端无法区分这是两条独立消息还是同一操作的重复提交。结果家长端收到两条一模一样的消息,引发大量投诉。
解决方案是设计结构化消息ID,格式为:{app_id}_{timestamp}_{seq}_{device_id_hash}
app_id:应用标识,多租户场景下隔离;timestamp:毫秒级时间戳,保证大致有序,便于按时间范围查询;seq:客户端本地单调递增序列号,同一设备同一毫秒内多次发送可排序;device_id_hash:设备ID哈希值(非明文),用于溯源但保护隐私。
这样生成的ID既全局唯一,又携带业务语义。服务端收到消息时,先解析ID中的timestamp和seq,若发现同一device_id_hash在100ms内提交了seq=5和seq=3,立刻判定seq=3为旧消息丢弃,避免乱序和重复。
注意:
device_id_hash必须用不可逆哈希(如SHA256),且哈希前拼接服务端密钥,防止客户端伪造。我们曾因密钥硬编码在前端被逆向,导致恶意用户批量刷ID,最终在服务端加了二次校验——ID中的timestamp必须在当前时间±5分钟内,否则拒绝。
3.2 发送方本地缓存:SQLite事务的正确打开方式
客户端发送消息后,不能等网络返回才存库,必须“先落库,再发送”。否则网络请求中App崩溃,消息就彻底消失。但直接INSERT会阻塞UI线程,我们采用以下方案:
// Android示例:使用Room数据库+异步事务 @Dao public interface MessageDao { @Insert(onConflict = OnConflictStrategy.REPLACE) long insertMessage(Message message); @Query("UPDATE messages SET status = :status, updated_at = :time WHERE msg_id = :id") void updateStatus(String id, int status, long time); } // 发送前:原子写入待发送状态 Completable.fromAction(() -> { Message msg = new Message(); msg.setMsgId(generateStructuredId()); msg.setStatus(Message.STATUS_PENDING); // 待发送 msg.setCreatedAt(System.currentTimeMillis()); messageDao.insertMessage(msg); // 此处是异步,不阻塞 }).andThen( // 再执行网络发送 sendToServer(msg) .doOnSuccess(response -> { // 发送成功,更新状态为已发送 messageDao.updateStatus(msg.getMsgId(), Message.STATUS_SENT, System.currentTimeMillis()); }) .doOnError(error -> { // 发送失败,更新状态为发送失败,触发重试 messageDao.updateStatus(msg.getMsgId(), Message.STATUS_FAILED, System.currentTimeMillis()); }) );关键点在于:状态更新必须用UPDATE而非INSERT新记录,避免消息ID重复导致数据混乱。我们曾因用INSERT导致同一条消息在数据库里存了5条不同状态的记录,同步时客户端取最新一条,结果把“发送失败”的记录当成了最终状态。
3.3 服务端消息存储:为什么用“双写”而非“单写+缓存”
服务端接收到消息后,不能只写Redis缓存就返回成功。我们吃过亏:某次Redis集群故障,缓存全丢,但服务端已向客户端返回“发送成功”,结果消息实际没存,用户以为发出去了,对方根本没收到。
现在强制执行双写策略:消息到达网关后,同步写入MySQL(主库)和Redis(缓存),只有两者都成功才返回ACK。MySQL表结构关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
msg_id | VARCHAR(64) | 结构化ID,主键 |
from_uid | BIGINT | 发送者ID |
to_uid | BIGINT | 接收者ID(单聊)或group_id(群聊) |
content | TEXT | 加密后的内容(AES-256) |
status | TINYINT | 0=待投递, 1=已投递, 2=已读, 3=已撤回 |
created_at | BIGINT | 毫秒时间戳,用于排序和过期清理 |
ack_timeout | BIGINT | ACK超时时间戳(created_at + 60s) |
Redis中只存轻量级元数据:msg_id → {to_uid, status, created_at},用于快速查询投递状态。MySQL是唯一可信源,Redis故障时可全量重建。
3.4 离线消息队列:Kafka分区策略与消费者组设计
当接收方不在线时,消息必须暂存在离线队列。我们用Kafka,但分区策略很关键。最初按to_uid哈希分区,结果发现热门用户(如客服账号)消息堆积,而冷门用户分区空转,负载严重不均。
改为复合分区键:{to_uid}_{shard_id},其中shard_id是to_uid % 16。这样每个用户固定落在16个分区中的一个,但整体流量被均匀打散。消费者组配置也做了调整:
- 每个消费者实例订阅所有分区,但通过
assign()手动分配分区,避免Kafka自动rebalance导致消息重复消费; - 消费者处理消息时,先更新MySQL中该消息的
status=1(已投递),再推送至客户端。若推送失败,不回滚offset,而是将消息写入“死信队列”人工介入——因为MySQL状态已变,重复推送不会造成业务错误,但回滚offset会导致消息丢失。
3.5 客户端ACK生成:不只是“收到”,而是“真正被看到”
前面提到,ACK不是收到消息就发,而是用户真正看到。具体实现:
- 消息进入聊天列表后,监听
RecyclerView的onViewAttachedToWindow事件; - 当消息Item完全显示在屏幕内,且用户停止滚动超过1秒,触发
markAsRead(); markAsRead()方法生成ACK包,包含:msg_id、read_timestamp(当前毫秒时间)、device_id_hash;- ACK包走独立轻量通道(HTTP短连接,非长连接),避免阻塞主消息流。
这个设计大幅降低无效ACK。我们统计过,旧版“收到即ACK”模式下,32%的ACK对应的消息用户根本没看到(消息在列表底部,用户没滑动过去);新版后,ACK有效率提升至99.1%,服务端重传率下降67%。
3.6 服务端重传引擎:基于时间轮的超时调度
重传不能靠定时任务轮询,否则10万条待ACK消息,每秒扫一遍数据库,IO直接打爆。我们用分层时间轮(Hierarchical Timing Wheel):
- 第一层:秒级时间轮,60个槽,每个槽存未来0-59秒超时的消息ID;
- 第二层:分钟级时间轮,60个槽,存未来1-59分钟超时的消息;
- 第三层:小时级时间轮,24个槽,存未来1-23小时超时的消息。
消息入库时,根据ack_timeout计算应放入哪一层哪个槽。每秒只检查第一层当前槽位,取出所有ID批量处理。这样O(1)时间复杂度搞定超时检测,实测单节点可支撑50万+待重传消息。
重传逻辑本身也很克制:首次超时,立即重传;第二次超时,延迟3秒再传;第三次超时,延迟10秒并降级为短信通知(如果用户绑定了手机号)。避免雪崩式重传压垮下游。
3.7 消息撤回与状态同步:为什么撤回指令也要走QoS链路
很多人忽略撤回消息本身也需要可靠投递。我们曾发生过:用户A撤回一条消息,服务端成功处理,但撤回指令没推送给用户B,导致B一直看到那条已被撤回的消息,还截图发群里,造成误会。
所以撤回指令被视为一条特殊消息,同样走完整QoS流程:生成结构化ID、服务端双写、离线队列、客户端ACK。唯一区别是,撤回消息的content字段为空,只含original_msg_id和revoke_timestamp。客户端收到后,本地查找对应ID的消息,若存在则UI上替换为“该消息已撤回”。
更关键的是,撤回状态必须同步到所有端。用户A在手机撤回,iPad端也要实时更新。我们通过“设备同步通道”实现:服务端处理撤回后,向用户A的所有在线设备推送一条同步指令,强制刷新该消息状态。这条同步指令同样带重试和ACK,确保最终一致性。
4. 实操过程:从零搭建QoS核心模块的完整步骤
4.1 环境准备与依赖配置
我们以Spring Boot 2.7 + MySQL 8.0 + Kafka 3.0 + Redis 7.0为基准环境。所有组件均部署在Docker中,便于本地调试和生产一致。
关键依赖(pom.xml):
<!-- 消息队列 --> <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> </dependency> <!-- 数据库 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- 缓存 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 时间轮 --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-common</artifactId> <version>4.1.94.Final</version> </dependency>注意:Netty的时间轮组件比自己手写更稳定。我们试过Quartz,但其调度精度在毫秒级不够,且集群模式下需要额外的数据库锁,最终换为Netty的
HashedWheelTimer,精度可控,且支持优雅关闭。
4.2 消息实体与状态枚举定义
消息实体(Message.java):
@Entity @Table(name = "messages") public class Message { @Id @Column(name = "msg_id", length = 64) private String msgId; @Column(name = "from_uid") private Long fromUid; @Column(name = "to_uid") private Long toUid; @Column(name = "content") private String content; // AES加密后base64字符串 @Column(name = "status") private Integer status; // 对应MessageStatus枚举 @Column(name = "created_at") private Long createdAt; @Column(name = "ack_timeout") private Long ackTimeout; // getter/setter省略 }状态枚举(MessageStatus.java):
public enum MessageStatus { PENDING(0, "待发送"), // 客户端本地状态 SENT(1, "已发送"), // 服务端接收成功 DELIVERED(2, "已投递"), // 已推送给接收方(在线)或入离线队列(离线) READ(3, "已读"), // 接收方ACK确认 REVOKED(4, "已撤回"), // 撤回指令生效 FAILED(5, "发送失败"); // 客户端重试3次后仍失败 private final int code; private final String desc; MessageStatus(int code, String desc) { this.code = code; this.desc = desc; } // 静态方法:code转枚举,避免null }4.3 服务端消息接收与存储核心代码
Controller层(MessageController.java):
@RestController @RequestMapping("/api/v1/messages") public class MessageController { @Autowired private MessageService messageService; @PostMapping public ResponseEntity<?> sendMessage(@RequestBody SendMessageRequest request, @RequestHeader("X-Device-ID") String deviceId) { try { // 1. 校验必要字段 if (StringUtils.isBlank(request.getContent()) || request.getToUid() == null || request.getFromUid() == null) { return ResponseEntity.badRequest().body("参数缺失"); } // 2. 生成结构化msg_id String msgId = IdGenerator.generate(request.getFromUid(), deviceId); // 3. 加密内容(AES-256-GCM) String encryptedContent = AesUtil.encrypt(request.getContent(), AppSecrets.getMessageKey()); // 4. 构建消息实体 Message message = new Message(); message.setMsgId(msgId); message.setFromUid(request.getFromUid()); message.setToUid(request.getToUid()); message.setContent(encryptedContent); message.setStatus(MessageStatus.SENT.getCode()); message.setCreatedAt(System.currentTimeMillis()); message.setAckTimeout(message.getCreatedAt() + 60_000L); // 60秒ACK超时 // 5. 双写:MySQL + Redis boolean saved = messageService.saveMessageWithCache(message); if (!saved) { return ResponseEntity.status(500).body("存储失败,请重试"); } // 6. 异步投递:在线则直推,离线则入Kafka messageService.asyncDeliver(message); // 7. 返回成功,含msg_id供客户端更新本地状态 return ResponseEntity.ok(Map.of("msg_id", msgId)); } catch (Exception e) { log.error("发送消息异常", e); return ResponseEntity.status(500).body("系统繁忙"); } } }Service层核心逻辑(MessageService.java):
@Service public class MessageService { @Autowired private MessageRepository messageRepository; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private KafkaTemplate<String, String> kafkaTemplate; @Autowired private OnlineStatusService onlineStatusService; // 在线状态服务 @Transactional // MySQL事务 public boolean saveMessageWithCache(Message message) { try { // 1. 写MySQL messageRepository.save(message); // 2. 写Redis缓存(设置过期时间,避免缓存雪崩) String cacheKey = "msg:" + message.getMsgId(); Map<String, Object> cacheData = new HashMap<>(); cacheData.put("to_uid", message.getToUid()); cacheData.put("status", message.getStatus()); cacheData.put("created_at", message.getCreatedAt()); redisTemplate.opsForHash().putAll(cacheKey, cacheData); redisTemplate.expire(cacheKey, 7, TimeUnit.DAYS); // 缓存7天 return true; } catch (Exception e) { log.error("双写失败", e); return false; } } @Async // 异步执行,避免阻塞HTTP线程 public void asyncDeliver(Message message) { try { // 1. 查询接收方是否在线 boolean isOnline = onlineStatusService.isOnline(message.getToUid()); if (isOnline) { // 在线:直接推送(WebSocket或HTTP Push) pushToOnlineUser(message); } else { // 离线:发往Kafka离线队列 kafkaTemplate.send("offline-messages", String.valueOf(message.getToUid()), JSON.toJSONString(message)); } } catch (Exception e) { log.error("投递失败,入死信队列", e); // 记录死信,后续人工处理 deadLetterQueue.add(message.getMsgId(), e.getMessage()); } } private void pushToOnlineUser(Message message) { // 实际推送逻辑,此处简化为伪代码 // 1. 获取接收方所有在线设备token // 2. 调用推送服务(如APNs/FCM) // 3. 若推送成功,更新消息状态为DELIVERED // 4. 若推送失败,记录错误,不重试(由重传引擎兜底) } }4.4 客户端ACK发送与服务端处理
客户端(Android Kotlin):
fun sendReadAck(msgId: String) { val ackBody = mapOf( "msg_id" to msgId, "read_timestamp" to System.currentTimeMillis(), "device_id_hash" to DeviceIdUtil.getHash() ) // 走独立轻量通道,避免占用主消息连接 apiService.sendAck(ackBody) .enqueue(object : Callback<Unit> { override fun onResponse(call: Call<Unit>, response: Response<Unit>) { if (response.isSuccessful) { // ACK成功,本地更新消息状态 messageDao.updateStatus(msgId, MessageStatus.READ.code) } else { // ACK失败,加入本地重试队列(最多3次) retryAckQueue.add(msgId, 3) } } override fun onFailure(call: Call<Unit>, t: Throwable) { retryAckQueue.add(msgId, 3) } }) }服务端ACK Controller(AckController.java):
@PostMapping("/api/v1/acks") public ResponseEntity<?> handleAck(@RequestBody AckRequest request) { try { // 1. 校验msg_id存在且未过期 Message message = messageRepository.findByMsgId(request.getMsgId()); if (message == null) { return ResponseEntity.badRequest().body("消息不存在"); } // 2. 校验ACK时间戳合理性(不能早于消息创建,不能晚于超时时间+5秒) if (request.getReadTimestamp() < message.getCreatedAt() || request.getReadTimestamp() > message.getAckTimeout() + 5000) { return ResponseEntity.badRequest().body("ACK时间戳非法"); } // 3. 更新消息状态为READ message.setStatus(MessageStatus.READ.getCode()); message.setUpdatedAt(request.getReadTimestamp()); messageRepository.save(message); // 4. 清除Redis缓存 redisTemplate.delete("msg:" + request.getMsgId()); return ResponseEntity.ok().build(); } catch (Exception e) { log.error("处理ACK异常", e); return ResponseEntity.status(500).build(); } }4.5 时间轮重传引擎初始化与调度
重传引擎(RetryEngine.java):
@Component public class RetryEngine { @Autowired private MessageRepository messageRepository; @Autowired private MessageService messageService; private HashedWheelTimer timer; @PostConstruct public void init() { // 创建时间轮,精度100ms,总容量约1小时(600个槽) this.timer = new HashedWheelTimer( Executors.defaultThreadFactory(), 100, TimeUnit.MILLISECONDS, 600 ); // 启动定时扫描任务,将待重传消息加入时间轮 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(this::scanAndSchedule, 0, 1, TimeUnit.SECONDS); } private void scanAndSchedule() { try { // 扫描超时未ACK的消息(status=SENT or DELIVERED,且ack_timeout < now) long now = System.currentTimeMillis(); List<Message> timeoutMessages = messageRepository.findTimeoutMessages(now); for (Message msg : timeoutMessages) { // 计算重试延迟:第1次0ms,第2次3s,第3次10s long delay = getRetryDelay(msg.getRetryCount()); if (delay >= 0) { // 加入时间轮 timer.newTimeout(timeout -> { try { // 执行重传 messageService.retryDeliver(msg.getMsgId()); } catch (Exception e) { log.error("重传失败", e); } }, delay, TimeUnit.MILLISECONDS); // 更新重试次数 msg.setRetryCount(msg.getRetryCount() + 1); messageRepository.save(msg); } } } catch (Exception e) { log.error("扫描超时消息异常", e); } } private long getRetryDelay(int retryCount) { switch (retryCount) { case 0: return 0; // 首次超时,立即重试 case 1: return 3000; // 第二次,延迟3秒 case 2: return 10000; // 第三次,延迟10秒 default: return -1; // 超过3次,不再重试 } } @PreDestroy public void destroy() { if (timer != null) { timer.stop(); // 优雅关闭 } } }5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 快速定位方法 | 解决方案 |
|---|---|---|---|
| 消息发送成功,但对方始终收不到 | 1. 接收方设备离线,且离线队列Kafka消费者挂掉 2. MySQL写入成功但Redis缓存失败,导致状态不一致 3. 推送服务(APNs/FCM)配置错误 | 1. 查Kafkaoffline-messagestopic是否有积压2. 查Redis中 msg:{msg_id}是否存在3. 查推送服务日志,看token是否失效 | 1. 重启消费者,检查消费者组offset 2. 修复Redis连接,补全缓存 3. 更新推送证书或token |
| 消息重复出现 | 1. 客户端重传未去重,服务端又收到原始消息 2. Kafka消费者重复消费(offset未提交) 3. 撤回指令未同步到所有设备 | 1. 查MySQL中同一msg_id是否有多条记录2. 查Kafka消费者组 current-offset与log-end-offset差值3. 查其他设备是否收到撤回指令 | 1. 强制客户端ID去重,服务端校验msg_id唯一性2. 消费者处理完再提交offset 3. 撤回指令走设备同步通道,强制刷新 |
| ACK一直不返回,服务端不断重传 | 1. 客户端网络异常,ACK包发不出 2. 客户端ACK逻辑bug(如未触发 markAsRead)3. 服务端ACK接口超时或500 | 1. 查客户端日志,看sendReadAck是否调用2. 查服务端 /api/v1/acks接口QPS和错误率3. 查Redis中 msg:{msg_id}的status是否仍为SENT | 1. 客户端增加ACK失败本地重试 2. 修复 markAsRead触发条件3. 优化ACK接口,增加熔断降级 |
| 撤回消息对方仍能看到 | 1. 撤回指令未推送到对方所有设备 2. 客户端未监听撤回指令或UI未刷新 3. 撤回指令本身丢失 | 1. 查撤回指令是否写入Kafkarevoke-commandstopic2. 查对方设备是否收到撤回消息 3. 查MySQL中撤回消息的 status是否为REVOKED | 1. 撤回指令走设备同步通道,广播到所有在线设备 2. 客户端增加撤回消息处理器,强制刷新UI 3. 撤回指令同样走QoS链路,带重试 |
5.2 我踩过的3个深坑与独家避坑技巧
坑1:MySQL主从延迟导致状态不一致
现象:用户A发消息,服务端写主库成功,但从库延迟2秒,此时用户B查消息状态(读从库)显示“未发送”,实际已发出。
避坑技巧:所有状态查询必须读主库。我们为此专门封装了MasterOnlyJpaRepository,在@Query上强制指定@Transactional(readOnly = true, propagation = Propagation.REQUIRED),并配置HikariCP连接池的slaveThresholdMs=0,确保读操作不走从库。虽然牺牲了部分读性能,但换来状态绝对一致。
坑2:Kafka消息顺序性被破坏
现象:用户A连续发“你好”、“在吗”、“忙吗”,但接收方收到顺序是“在吗”、“你好”、“忙吗”。
原因:Kafka按Partition保证顺序,但我们的分区键是to_uid,当用户A在不同设备发消息时,to_uid相同但device_id_hash不同,导致分到不同Partition。
避坑技巧:改用{to_uid}_{from_uid}作为分区键。这样同一对话的所有消息必然进同一Partition,严格保序。代价是分区数减少,但对IM场景影响不大,因为单个用户对话的QPS远低于集群总能力。
坑3:时间轮内存泄漏
现象:重传引擎运行一周后,JVM堆内存持续增长,Full GC频繁。
原因:HashedWheelTimer创建的Timeout对象未被及时回收,尤其当重试次数多时,大量Timeout堆积。
避坑技巧:重写Timeout的cancel()方法,在取消时显式清除引用。我们继承HashedWheelTimer,在newTimeout()返回前,用WeakReference包装回调,确保GC可回收。同时设置timer.stop()在应用关闭时调用,避免线程泄漏。
5.3 压测与线上监控关键指标
光有代码不够,必须建立监控闭环。我们定义了5个黄金指标,全部接入Prometheus+Grafana:
| 指标 | 监控点 | 健康阈值 | 异常处理 |
|---|---|---|---|
| 消息端到端投递成功率 | 从SENT到READ状态变更比例 | ≥99.99% | <99.9%时自动告警,触发重传引擎诊断 |
| ACK平均延迟 | read_timestamp - created_at | ≤1.5s | >3s时检查客户端网络和markAsRead逻辑 |
| 重传率 | 重传消息数 / 总发送消息数 | ≤0.5% | >1%时检查Kafka消费者和推送服务 |
| 离线队列积压 | Kafkaoffline-messagestopic lag | ≤1000 | >5000时扩容消费者或检查接收方在线状态服务 |
| QoS链路P99延迟 | 从发送到READ状态的99分位耗时 | ≤60s | >90s时检查MySQL慢查询和Redis响应 |
实操心得:监控不是摆设。我们设置了一个“QoS健康看板”,每天晨会第一件事就是看这5个指标。一旦某个指标飘红,SRE同学立刻拉起专项会议,不是等故障发生,而是提前干预。比如重传率突然升到0.8%,我们查