离线消息:存储模型、拉取与拉取位点

离线消息(offline message)是"消息已确认落库、但接收方当前不可达"的那部分数据。它的本质不是"存得多",而是在客户端不在线、或在线却错过下发时,仍能按顺序、不重不漏地把消息交到对端。本章给出收件箱(inbox)表结构、序号分配、拉取 SQL 与拉取位点(sync cursor)的设计。

问题与目标

四条硬性目标,先钉下来,后面的取舍都围绕它们:

  1. 不丢:发送方拿到 ACK 的消息,必须在接收方上线后能取到;
  2. 不重:正常路径不该出现重复,异常路径允许重复但必须可去重;
  3. 有序:同一会话内按发送顺序呈现,不能因为补拉而乱序;
  4. 可控:单次拉取有上限、有超时、有降级,不能因为一次"离线一个月"的拉取把库拖死。

三种"离线"要分开处理:长连接断开(路由表无可用节点)写收件箱并触发厂商推送(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}ZSetscore=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 去重即可。

笔记加载中…