为什么我们的消息不丢不乱序
这不是一篇营销文章。每一层保证都对应仓库里可审计的代码、测试和文档引用。 你可以 clone 后自行验证——文末给了压测复现命令。
“消息会不会丢?会不会乱序?”——这是每一个技术决策者在选 IM 方案时的第一个问题,也是最难得到诚实回答的问题。多数厂商的回答是“不丢不乱”四个字,然后就没有了。
本文把 IMCore 的答案逐层拆开:每一层保证什么、怎么实现、怎么验证。
一句话答案
消息在服务端确认之前就已经持久化到数据库;同一会话内的消息顺序由数据库锁保证;断线后客户端用游标精确补拉到缺的每一条。
下面展开每一层。
第一层:消息不丢——先写盘,再确认
最常见的丢法
大多数 IM 系统的发送路径是这样的:
客户端发送 → 服务端收到 → 推送给接收方 → 返回"成功"
问题:如果推送给接收方之后、返回“成功”之前,服务端崩溃了呢?客户端会认为发送失败而重发,但如果推送给接收方已经完成了——接收方收到了一条“幽灵消息”。反过来,如果服务端在推送之前崩溃,客户端收到超时错误,但服务端可能已经把消息丢进了内存队列——消息就丢了。
IMCore 的做法:同步幂等插入
客户端发送
↓
服务端生成 UUIDv7 消息 ID
↓
同步 INSERT 到 PostgreSQL(幂等:重复发送不产生第二行)
↓ ←—— 数据已落盘,此刻断电也不丢
推送 给在线接收方
↓
返回 "成功" 给发送方
关键点:INSERT 在 ACK 之前。客户端收到“成功”的那一刻,消息已经在数据库里了。断电、重启、进程被杀——消息都在。
幂等靠的是数据库唯一键 (conversation_id, sender_id, client_dedup_id)——同一个客户端重发同一消息(网络重试、断线重连后重发),INSERT IGNORE 不产生第二行,返回原始 message_id。
-- PostgreSQL
INSERT INTO dm_messages (conversation_id, message_id, sender_id, client_dedup_id, payload, ...)
VALUES (?, ?, ?, ?, ?, ...)
ON CONFLICT (conversation_id, sender_id, client_dedup_id) DO NOTHING;
-- MySQL
INSERT IGNORE INTO dm_messages (conversation_id, message_id, sender_id, client_dedup_id, payload, ...)
VALUES (?, ?, ?, ?, ?, ?);
重发时 RowsAffected = 0,服务端查回原始 message_id 并返回 inserted=false——客户端知道这是一次重发,不需要重新展示。
第二层:不乱序——同会话内的顺序由数据库锁保证
乱序的经典场景
Alice 和 Bob 同时在同一个群里发消息。两条消息几乎同时到达服务端:
时刻 T1: Alice 的 INSERT 分配到 id=100,但事务还未提交
时刻 T2: Bob 的 INSERT 分配到 id=101,事务先提交了
时刻 T3: 接收方拉取 id > 99 的消息 → 看到了 101,看不到 100
时刻 T4: 接收方游标推进到 101
时刻 T5: Alice 的事务提交,id=100 变为可见
结果:接收方下一次拉取 id > 101 → 空的。消息 100 被永久跳过。
这不是假设——我们在自己的代码里找到并修复了这个 bug。
IMCore 的修复:会话写锁
// lib/dblib/conversation_write_lock.go
func withConversationWriteLock(ctx context.Context, key string, fn func(conn *sql.Conn) error) error {
// PG: pg_try_advisory_lock(hashtextextended(key, 0))
// MySQL: GET_LOCK(key, 0)
//
// 尝试-退避-重试循环,等待者不占数据库连接
// 持锁者在同一条连接上完成 INSERT,锁在 COMMIT 后释放
// → 同一会话内 "分配 id" 与 "提交" 严格串行
}
效果:同一会话内,id 分配顺序 == 提交顺序。上面场景中 Bob 的 INSERT 会等 Alice 的 COMMIT 完成后才分配 id,接收方不可能看到 101 而看不到 100。
这不是约定——是数据库层面的咨询锁保证的。我们写了确定性回归测试(lib/dblib/conversation_write_lock_test.go)钉住这个行为:外部持锁时 DAO 插入必须阻塞,释放后必须完成。
为什么不用集中序列号?
集中序列号(每会话一个 Redis INCR)在高并发下引入额外延迟和单点依赖。我们的方案是:
- 消息 ID = UUIDv7(客户端游标用,字典序≈时间序)
- 数据库自增 id = 服务端全序(历史翻页用,天然唯一)
- 会话写锁 = 保证自增 id 的分配序 == 提交序(不需要额外序列号)
零额外基础设施,纯数据库原生能力。
第三层:断线恢复——游标精确补拉
在线推送可能丢(客户端闪断、网络抖动),但只要消息在数据库里,客户端就能补回来。
机制
客户端上次看到的最后一条消息 ID: "msg-019abc..."
↓
重连后发送 historyPage({ afterID: "msg-019abc..." })
↓
服务端: 把 UUIDv7 映射为数据库 id → SELECT ... WHERE id > ? ORDER BY id ASC
↓
返回缺失的每一条(精确到行,不多不少)
关键设计:afterID 是客户端亲眼见过的消息 ID——不是服务端猜的“应该到哪了”。客户端只信任自己见过的,服务端只从那之后补——没有模糊地带。
SDK 层的配合
所有 10+ 平台的 SDK 在 reconnected 事件后自动执行:
- 冲离线队列(登录确认后才 flush)
- 按会话游标增量补拉
- 对比
lastMessageSeq单调守卫(旧事件不覆盖新快照)
第四层:验证——不是“信我们”,是“自己跑”
零丢失裁决
# 持续负载下杀掉一个实例 → 幸存者接管 → 重启被杀的 → 对比发送量 == 落库量
scripts/chaos/graceful_drain.sh
scripts/chaos/verify_no_loss.sh --report <loadtest-report> --room <room-id>
Ground truth 是数据库行数,不是投递计数器——sent == COUNT(*) 一个比较出结论。
会话恢复矩阵
9 个自动化场景(SR-01 到 SR-09)覆盖:初始拉取与实时推送的竞态、断线重连补拉、多设备并发标记已读、被踢出群后的会话清理、并发发送者交错提交(我们最近加的)——每个场景在 CI 中持续运行。
压测复现
# 4C8G 云端 ECS,10,000 并发连接,200 msg/s,50 房间
# 全档 0 错误 0 丢弃 0 慢关,p99 < 200ms
# 饱和压测工具会自动起 ECS、跑完销毁
ALICLOUD_REGION=cn-hangzhou LT_SAT_STEPS=5000,6000,8000,10000 \
bash scripts/loadtest_saturation.sh
诚实边界
以下场景不在保证范围内:
| 场景 | 原因 | 缓解 |
|---|---|---|
| 接收方在线但应用被杀 | 推送到达操作系统但未到应用 | 离线推送(APNs/FCM)+ 重连补拉 |
| 极端情况下的消息延迟 | 数据库或 Redis 不可用 | 降级路径(Redis 不可用时直写 DB) |
| 跨会话的全局顺序 | 设计上只保证会话内有序 | 各会话独立有序(IM 场景的正确语义) |
排查故事:我们是怎么发现乱序 bug 的
2026 年 10 月,我们在对比新旧代码的云端压测数据时发现 10,000 连接档 p99 从 139ms 涨到 262ms。用自动化二分在 561 个提交中定位到罪魁祸首:一个“优化”提交把会话状态的逐成员流改成了整房间批量管道——本地基准测试大胜,但在生产拓扑下巨型管道阻塞了 Redis 事件循环。
这个故事告诉我们(也告诉你):性能回归不可怕,可怕的是没有仪器去发现它。我们花了一整天建好了从 A/B 对照到自动化二分的完整排查链路——这个链路本身现在也是产品的一部分。
总结
| 保证 | 实现 | 验证 |
|---|---|---|
| 消息不丢 | 同步幂等插入(ACK 前落盘) | 零丢失裁决脚本 |
| 消息不重 | 数据库唯一键 + INSERT IGNORE |
幂等重发测试 |
| 会话内有序 | 会话写锁(id 序 == 提交序) | 互斥回归门禁 |
| 断线不漏 | 游标精确补拉(afterID) | 9 场景恢复矩阵 |
| 端到端可信 | 全链路混沌 + 压测 + CI 门禁 | 可复现命令 |
IMCore 默认以二进制交付,源码可按授权获取。如果你对本文讲到的某一层有疑问,联系我们——我们会用确切的代码路径和钉住该行为的测试来回答,而不是营销话术。