离线消息:存储模型、拉取与拉取位点
离线消息(offline message)是"消息已确认落库、但接收方当前不可达"的那部分数据。它的本质不是"存得多",而是在客户端不在线、或在线却错过下发时,仍能按顺序、不重不漏地把消息交到对端。本章给出收件箱(inbox)表结构、序号分配、拉取 SQL 与拉取位点(sync cursor)的设计。
问题与目标
四条硬性目标,先钉下来,后面的取舍都围绕它们:
- 不丢:发送方拿到 ACK 的消息,必须在接收方上线后能取到;
- 不重:正常路径不该出现重复,异常路径允许重复但必须可去重;
- 有序:同一会话内按发送顺序呈现,不能因为补拉而乱序;
- 可控:单次拉取有上限、有超时、有降级,不能因为一次"离线一个月"的拉取把库拖死。
三种"离线"要分开处理:长连接断开(路由表无可用节点)写收件箱并触发厂商推送(21 章);在线但未进入该会话只写收件箱,等客户端同步时拉取;在线且会话可见直接下行,同时写收件箱供多端补拉。
收件箱模型:按接收人写一份
- 仅离线时写:接收方在线就不落收件箱,只在历史表存一份。省空间,但"在线时连接闪断"的那条消息彻底丢了——投递失败没人补;
- 全量收件箱:无论在线与否,每个接收方都写一条记录(写扩散,write fanout),在线走快路径下行,离线靠它补拉。
推荐全量收件箱。它把"投递"从状态问题变成数据问题:投递成功与否只影响推送快慢,不影响消息最终能否到达,第 12 章的多设备同步也直接复用这张表。
表设计
-- 收件箱:每个接收人一份索引;正文只存一份(见 13 章 msg_content)
CREATE TABLE offline_msg (
uid BIGINT UNSIGNED NOT NULL COMMENT '收件人 uid,分片键',
seq BIGINT UNSIGNED NOT NULL COMMENT '收件箱内单调递增序号,由服务端分配',
msg_id BIGINT UNSIGNED NOT NULL COMMENT '全局消息 ID,见第 08 章',
conv_id BIGINT UNSIGNED NOT NULL COMMENT '会话 ID',
conv_type TINYINT UNSIGNED NOT NULL COMMENT '1=单聊 2=群聊',
sender_uid BIGINT UNSIGNED NOT NULL COMMENT '发送方,列表页展示用',
created_at DATETIME(3) NOT NULL COMMENT '消息落库时间,毫秒精度',
PRIMARY KEY (uid, seq),
UNIQUE KEY uk_uid_msg (uid, msg_id), -- 幂等:重复写入直接冲突
KEY idx_uid_conv (uid, conv_id, seq) -- 进入某会话时的局部补拉
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='离线消息收件箱';
三处刻意设计:主键取 (uid, seq) 而不是 (uid, conv_id, seq),因为拉取位点是"用户级"的一条线,跨会话统一推进,客户端只维护一个数字;唯一键 (uid, msg_id) 让重复投递天然幂等,不必额外建去重表;正文不落这张表——群聊 500 人写 500 行,每行若带 1KB 正文就是 500 倍写放大,正文按 msg_id 单独存。
序号分配
seq 必须每个用户单调递增,且不能靠数据库自增(分库后不全局)。两种做法:用 Redis INCR im:offline:seq:{uid} 简单原子,代价是每条消息一次网络往返、故障恢复后可能回退;或用号段(segment,一次取 1000 个进程内分配)吞吐高、往返少,代价是进程重启浪费号段、留下空洞。号段必然留空洞,所以客户端不允许假设 seq 连续,只能按"大于位点"拉取——这一点直接决定了拉取 SQL 的写法。
拉取位点
位点存 MySQL(可靠),Redis 只做缓存(快):
CREATE TABLE sync_cursor (
uid BIGINT UNSIGNED NOT NULL,
device_id VARCHAR(64) NOT NULL COMMENT '设备指纹,见第 05 章',
pull_seq BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已成功拉取到的最大 seq',
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (uid, device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备拉取位点';
位点语义是**"小于等于 pull_seq 的消息本设备已确认收到"**,推进时机因此非常关键:
错误:拉到数据就推进位点 → 下发失败则消息永久丢失
正确:数据下发成功(或写入本地库)后再推进 → 最坏结果是重复拉取
位点推进的唯一规则是:允许重复,不允许跳过。
拉取 SQL
全量补拉(上线后第一次同步)按 seq 升序分页,会话内局部补拉只取该会话:
-- 全量:cursor 来自 sync_cursor.pull_seq
SELECT seq, msg_id, conv_id, conv_type, sender_uid, created_at
FROM offline_msg
WHERE uid = ? AND seq > ?
ORDER BY seq ASC
LIMIT 500;
-- 会话内:进入会话时补拉该会话的缺
SELECT seq, msg_id, sender_uid, created_at
FROM offline_msg
WHERE uid = ? AND conv_id = ? AND seq > ?
ORDER BY seq ASC
LIMIT 50;
分页时把"本页最后一条的 seq"当作下一页入参,全部分页处理完再推进 pull_seq。中途失败则位点不推进,下次重拉同一段——幂等,客户端按 msg_id 去重即可。
Redis 加速层
热数据只有最近一小段,Redis 只承担这一层:
| key | 类型 | 说明 | TTL |
|---|---|---|---|
im:offline:seq:{uid} | String | 序号分配器(也可换成号段) | 无 |
im:offline:inbox:{uid} | ZSet | score=seq,member=msg_id,仅留最近 200 条 | 7 天 |
im:offline:cursor:{uid}:{deviceID} | String | 位点缓存,读多写少 | 1 天 |
拉取先查 ZSet:ZRANGEBYSCORE im:offline:inbox:{uid} (cursor +inf LIMIT 0 500,命中即返回;若 ZSet 里最小的 seq 仍大于 cursor,说明还有更早的数据,回源 MySQL 补齐。Redis 整片丢失也不影响正确性——从 MySQL 重建即可,这是"缓存"而非"存储"的底线。
时序
发送方 逻辑层 存储/路由 接收方
│ send ────▶│─ 分配 msg_id → 写正文 → 写收件箱 ──▶│ │
│◀── ACK ───│ (ACK 只承诺 msg_content + offline_msg 落库成功) │
│ │─ 查路由(06 章):在线下行投递 / 离线厂商推送 ──────────▶│
│ │ │◀ 上线拉 seq>cursor│
│ │◀─ 返回一页消息 ──────────────────────│ │
│ │─ 下发成功后推进 pull_seq ───────────▶│ │
注意 ACK 的位置:正文与收件箱都落库成功才回 ACK,投递在它之后。这样即使逻辑层随后崩溃,接收方上线依然能拉到。
失败场景与兜底
| 场景 | 表现 | 兜底 |
|---|---|---|
| 收件箱写失败 | 发送方未收到 ACK | 客户端重发,唯一键 (uid, msg_id) 自动去重 |
| 位点推进失败 | 同一段消息重复下发 | 客户端按 msg_id 去重,允许重复 |
| 分配器回退 | seq 出现回退或空洞 | 客户端只做 seq > cursor 比较,不做连续性校验 |
| Redis 整片丢失 | 缓存全部未命中 | 回源 MySQL 重建 ZSet,只是变慢 |
| 离线太久(十万条) | 单次拉取代价大 | 分页 + 限速;超阈值只返回"最近 500 条 + 未读摘要" |
清理策略:offline_msg 按 created_at 保留 30 天,删除前先把正文与索引归入冷存储;已被所有设备拉取过的行(pull_seq >= seq)可以更早清理,用异步任务按 (uid, seq) 小批量扫描删除,避免长事务。
工程注意点
- 不要用 offset 分页:补拉期间持续有新消息写入,offset 会漂移,导致漏消息或重复,seq 游标是唯一正确解;
- 拉取必须有上限与限流:
LIMIT不超过 500,接口带has_more;按 uid 限速(如 5 次/秒),否则重装 App 的用户能把库打满; - seq 与 msg_id 各司其职:seq 是"投递顺序",msg_id 是"消息身份",不要合并——群聊里同一个 msg_id 会对应多个 seq;
- 在线也写收件箱:这是多端同步与"卸载重装还能拉到"的前提,省这点空间的代价通常是一次线上事故。
小结:离线消息的核心是"按收件人写一份收件箱 + 用户级单调 seq + 每设备拉取位点"三件套:offline_msg(uid, seq, msg_id, conv_id, created_at) 用 (uid, msg_id) 保证幂等写入,拉取走 WHERE uid=? AND seq>? ORDER BY seq LIMIT N 的游标分页而不是 offset,位点只在数据下发成功之后推进,Redis 只做热数据加速而 MySQL 才是真相;宁可重复拉取也绝不跳过,客户端按 msg_id 去重即可。