已读回执与未读数:计数模型与一致性

未读数是 IM 里最容易做错的功能:直觉方案「每来一条消息就给所有成员的未读数加一」在 2000 人群里等于写放大 2000 倍,而且一旦某个 INCR 丢了,误差永久留存、无法自愈。正确模型把未读变成一次减法——会话保存 last_seq,成员保存 last_read_seq,未读 = last_seq - last_read_seq,读时现算,写侧零放大。

为什么不用未读计数器

方案每条消息的写操作漂移风险自愈能力结论
每成员计数器 INCRO(成员数)高,丢一次即永久偏差需全量重算才能修只适合成员极少或会话极少的场景
位点差值(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 / BITCOUNT1 bit × 人数O(人数/8)精确中群(数百至数千人)
HyperLogLog每群约 12 KBO(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 与异步落库,显式回退单独区分来源,多端共享位点是默认语义。

笔记加载中…