已读回执与未读数:计数模型与一致性
未读数是 IM 里最容易做错的功能:直觉方案「每来一条消息就给所有成员的未读数加一」在 2000 人群里等于写放大 2000 倍,而且一旦某个 INCR 丢了,误差永久留存、无法自愈。正确模型把未读变成一次减法——会话保存 last_seq,成员保存 last_read_seq,未读 = last_seq - last_read_seq,读时现算,写侧零放大。
为什么不用未读计数器
| 方案 | 每条消息的写操作 | 漂移风险 | 自愈能力 | 结论 |
|---|---|---|---|---|
每成员计数器 INCR | O(成员数) | 高,丢一次即永久偏差 | 需全量重算才能修 | 只适合成员极少或会话极少的场景 |
| 位点差值(last_seq − last_read_seq) | O(1),只更新会话 last_seq | 无状态,不存在漂移 | 天然自愈,历史可随时重算 | 推荐,默认方案 |
| 计数器 + 位点双写 | O(成员数) + O(1) | 中 | 位点可校准计数器 | 迁移期过渡用 |
漂移的例子很直观:计数器模式下少做 100 次 INCR,用户永远看到「少 100 条未读」,除非把整个群的成员未读全部重算;差值模式只要 last_read_seq 对,任意时刻算出的值都正确,与历史消息数量无关。
数据模型
-- 会话维度:每个用户每个会话一行,单聊与群聊统一
CREATE TABLE im_conversation (
owner_id BIGINT NOT NULL COMMENT '会话归属用户',
conv_id VARCHAR(64) NOT NULL COMMENT 's_{minUid}_{maxUid} 单聊,g_{gid} 群聊',
conv_type TINYINT NOT NULL COMMENT '1 单聊 2 群聊',
last_seq BIGINT NOT NULL DEFAULT 0 COMMENT '会话内最新 seq',
last_msg_id BIGINT NOT NULL DEFAULT 0,
last_read_seq BIGINT NOT NULL DEFAULT 0 COMMENT '已读位点,冗余在会话行避免回表',
at_count INT NOT NULL DEFAULT 0 COMMENT '@ 我的未读数',
pinned TINYINT NOT NULL DEFAULT 0 COMMENT '置顶',
mute TINYINT NOT NULL DEFAULT 0 COMMENT '免打扰,只影响推送不影响未读',
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (owner_id, conv_id),
KEY idx_owner_order (owner_id, pinned DESC, updated_at DESC) COMMENT '会话列表排序'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 群维度:成员已读的权威位点,写扩散模式下由会话行对齐
CREATE TABLE im_group_read (
group_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
last_read_seq BIGINT NOT NULL DEFAULT 0,
read_time DATETIME(3) NOT NULL,
PRIMARY KEY (group_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
last_read_seq 冗余进会话行是有意为之:会话列表要渲染 20 个会话的红点,若每次都 join 群已读表,就会变成 20 次额外查询。
渲染会话列表(20 个会话):
1) 一次查 im_conversation 拿 20 行的 last_seq / last_read_seq / at_count
2) 应用层做差:unread = last_seq - last_read_seq,20 次减法,零额外查询
3) 排序按 (pinned, updated_at),未读只决定红点样式,不参与排序权重
seq 连续性是差值法的前提
| 事件类型 | 是否占 seq | 对差值法的影响 |
|---|---|---|
| 普通消息 | 占 | 无 |
| 撤回事件 | 占(推荐) | 无,客户端渲染占位,未读照常扣减 |
| 系统通知(入群、改名) | 占 | 会让差值偏大,需标记不计数 |
| 编辑事件 | 占 | 同撤回,标记不计数 |
| 本地删除消息 | 不占 | 不影响服务端未读 |
| 服务端合规下架 | 不占,仅查询过滤 | 会让差值偏大,需单独校正 |
推荐让「一切会话内变更都占一个 seq」,这样增量同步只需一条序号流,但必须给消息加 countable 标记:未读精确值 = last_seq - last_read_seq - 区间内 countable=0 的消息数。系统通知占比很低时,直接用差值即可;大群或活动群系统消息密集,再开启精确校正(代价是区间内一次范围统计)。
已读回执:谁知道谁读了
- 单聊:对端位点就是回执,
last_read_seq >= msg.seq即已读,不需要额外存储。 - 小群(< 200 人):需要精确名单时用位图,每人一位,
SETBIT标记、BITCOUNT得人数、GETBIT判个体。200 人每条消息仅 25 字节,一天 1 万条约 250 KB,可接受。 - 大群(≥ 200 人):只暴露已读人数、不暴露名单,可用 HyperLogLog 做近似去重计数(误差约 0.8%),或只显示「已送达」。
| 已读名单方案 | 单条消息存储 | 查询复杂度 | 精度 | 适用 |
|---|---|---|---|---|
| 每消息一行 (group_id, seq, user_id) | 数十字节 × 人数 | 分页查询 | 精确 | 小群、有审计需求 |
| 位图 SETBIT / BITCOUNT | 1 bit × 人数 | O(人数/8) | 精确 | 中群(数百至数千人) |
| HyperLogLog | 每群约 12 KB | O(1) | 近似 ±0.8% | 超大群只报人数 |
| 位点对齐(只存 last_read_seq) | 每成员一行 | O(1) | 精确但只表达区间语义 | 只判「读到某条」,不列名单 |
上报与单调性
客户端把已读位点合并上报(打开会话、滑动到底、退到后台时去抖 500 ms ~ 1 s),服务端只做单调取大:
-- KEYS[1]=read:conv:{convId} KEYS[2]=待落库集合 ARGV[1]=上报位点 ARGV[2]=时间戳
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
local new = tonumber(ARGV[1])
if new <= cur then return cur end -- 旧端乱序上报,直接丢弃,不回退
redis.call('SET', KEYS[1], new)
redis.call('HSET', KEYS[2], KEYS[1], ARGV[2]) -- 攒批异步落库
return new
A 发消息 seq=1024 → 会话 last_seq = 1024(1 次写)
B 端未读数 = 1024 - 1010 = 14 → 读时算,B 端不产生任何写
B 打开会话 → 本地先清红点,500ms 后上报 last_read_seq = 1024
服务端 Lua 单调更新 → 异步落库 + 回执事件投递给 A 端,A 端显示「已读」
一致性取舍
- 允许短暂偏差:未读差 1 或 2 不影响资金与安全,只影响红点;「打开会话即归零」本身就是自愈手段,不值得为此上强一致协议。
- 推送与未读解耦:未读由位点决定,推送失败只影响到达提示;免打扰会话照常累计未读,只是不弹提醒、不显示数字(只显示小红点)。
- 全部已读:一次
UPDATE im_conversation SET last_read_seq = last_seq WHERE owner_id = ?批量处理,不要逐会话调用接口。 - 显式回退:产品上的「标记为未读」是位点回退,与普通上报的单调规则冲突,需要区分来源——普通上报走单调取大,显式回退走带条件的更新(
WHERE last_read_seq = 期望旧值)以避免覆盖并发的真实已读。 - 多端语义:默认以
(user, conv)为维度,一端口已读则各端同步;若产品要求每端独立未读,需(user, device, conv)位点,存储与计算成本乘以设备数,只在明确需求时采用。
极端场景
- 位点丢失(重装、换端):从服务端拉取,服务端是权威,客户端本地值只作为乐观展示。
- 乱序上报:先报 1024、后到 1010,单调取大天然丢弃旧值。
- 被踢出后重新入群:旧位点失效,入群时把
last_read_seq置为当前group.last_seq,只看新消息。 - 未读上限 99+:服务端返回真实值,客户端负责格式化;不要服务端截断,否则「排序」与「显示」会互相打架。
- @ 我的未读:单独维护
at_count与 at 位点列表,与总未读分开扣减,避免「已读全部」误清 @ 提醒。
小结:未读用「会话 last_seq − 成员 last_read_seq」的位点差值模型,读时现算、写侧 O(1),从根本上避开计数器漂移;会话行冗余已读位点以支撑列表批量渲染;让撤回、编辑等事件也占 seq 但 countable=0 保持一条序号流,必要时用区间校正;已读名单按群规模选位点、位图、HyperLogLog 三档;上报走单调取大的 Lua 与异步落库,显式回退单独区分来源,多端共享位点是默认语义。