3.4 未读数与会话列表:Redis 加速
场景
用户打开应用第一眼看到的是角标:三个会话有新消息,其中一个群艾特了我。这一屏的数据全部来自会话列表。会话列表看起来简单——每个会话一行,最新一条预览加未读数——但它是对延迟最敏感的接口:用户盯着屏幕等你加载,超过五百毫秒就感觉应用卡。如果这个接口去查 MySQL,打开一次应用要扫几十个会话的最新消息,数据库随便抖一下首屏就破秒。所以未读数与会话列表必须放进 Redis。
难点不在放进去,在于保持正确。未读数是一个高频读写、强一致要求的状态:发消息加一、点开会话清零、多端同时操作、系统通知也要进同一个计数器。任何一步时序错了,红点就会与实际未读不符,用户点进去发现空会话——这种投诉我们头两个月每周都收到。
未读数还有个放大效应容易被忽视:它是被所有链路共同读写的热点。发送链路写、拉取链路写、会话列表接口读、应用图标角标聚合读、消息推送时端上比对读。一个结构被五条链路碰,任何一条链路的改动都可能引入不一致。所以这个模块的设计原则只有一条:状态尽量少、变更尽量原子、谁读谁负责兜底。
方案
基本的存储结构:未读数用 UNREAD:UID 哈希,field 是 ConvID,value 是整数;会话列表用 CONVS:UID 有序集合,score 是会话最新消息时间戳,member 是 ConvID。两个结构都在 写入链路 里被同步更新,读路径只需一次往返就能渲染整屏。
结构设计还有一个约束来自容量测算:日活五十万用户、人均一百个会话,会话列表最多五千万 member,按每 member 六十字节算约三个 G 内存——单实例放得下,但要考虑热 Key 的分布。我们的做法是按 UID 哈希分散到八个分片,单分片故障只影响八分之一用户的首屏,兜底是缓存未命中时临时回源查 MySQL 最新一条,接口 RT 预算放宽到一秒并给降级提示。
群消息的已读位置表也一并设计好(此处只念缓存侧):群成员打开群时,先从 Redis 读群公共的公共游标与自己的分项未读。会话列表的预览内容也需要区分单聊与群聊:单聊预览带发送者昵称前缀,群聊预览带群名。渲染层面的差异全部由客户端承担,服务端只原样回传结构化字段,避免预览逻辑改动要跟着客户端版本走。
| 结构 | 数据结构 | 典型操作 | 过期策略 | 一致性难点 |
|---|---|---|---|---|
| 未读数 | 哈希 | INCR 加一、清零 | 随会话淘汰 | 加一与清零的时序 |
| 会话列表 | 有序集合 | ZADD 更新最新一条 | 按数量裁剪 | 排序值并发覆盖 |
| 已读位置 | 字符串 | 单点读写 | 永久 | 客户端回退覆盖 |
| 红点聚合 | 字符串 | 总数缓存 | 随未读失效 | 与分项求和漂移 |
会话列表的排序有个陷阱:直接用最新消息时间戳做 score,同一秒内的多条消息排序值相同,有序集合里 member 会按字典序排,与真实顺序无关。我们的做法是 score 用毫秒时间戳加上序号低三位拼一个数值,既保持时间顺序又区分同秒消息。清零逻辑也要注意先清未读再写已读位置,顺序反了会出现已读位置写了但未读还在的中间态。
预览内容怎么存也有讲究。最初会话列表的 member 里塞 ConvID,预览内容另存一个哈希,客户端拉列表后再批量拉预览,两次往返。后来把最近一条预览直接存进另一个 Hash(field 是 ConvID,value 是 JSON 片段),一次 pipeline 全拿回来,首屏耗时的尾巴被削掉了。预览片段里不带敏感字段,锁屏通知要显示的内容单独再裁剪一层,权限相关的处理见 日志与审计:CLS 两套索引 里对通信日志脱敏同一套做法。
配置与代码
未读的加一与清零,加一在写入链路里,清零在拉取确认成功之后:
// 清零必须原子完成,避免与写入链路的 INCR 交叉
func (c *ConvCache) MarkRead(uid int64, convID string, readSeq int64) error {
key := fmt.Sprintf("unread:%d", uid)
_, err := c.rdb.HDel(ctx, key, convID).Result() // 未读归零
if err != nil {
return err
}
return c.rdb.Set(ctx, "read:"+convID+":"+strconv.FormatInt(uid, 10),
readSeq, 0).Err() // 已读位置单独存
}
会话列表的更新与裁剪放在同一个 pipeline 里:
func (c *ConvCache) TouchConv(uid int64, convID string, score float64) {
key := fmt.Sprintf("convs:%d", uid)
pipe := c.rdb.TxPipeline()
pipe.ZAdd(ctx, key, redis.Z{Score: score, Member: convID})
pipe.ZRemRangeByRank(ctx, key, 0, -101) // 只留最近一百条
_, _ = pipe.Exec(ctx)
}
总红点数单独缓存一份,客户端拉取时不必遍历未读哈希逐个求和。总红点是典型的读多写少场景,在未读数变更的链路里顺带维护即可,代价是每次写操作多一条 INCR/BY 指令,收益是千万级设备每天少几次全量聚合。
未读数的清零还要处理一种边界:会话被服务端整段删除(比如群解散)。此时未读哈希里残留的分项要一并清掉,否则总红点永远减不下去。我们的会话删除流程里有一步显式的缓存清理清单,清单本身用代码评审守住——每加一种新缓存,删除流程同步更新一条检查项,写成团队规范。
踩坑
第一个坑是多端并发清零与加一交叉。手机端正点开会话清零,同一会话新消息到达加一,操作顺序在某些时刻变成先加一后清零,用户看到未读为零但实际有新消息。修复办法是清零带上前一次拉取的 Seq,用 Lua 脚本判断当前最新 Seq 是否等于客户端确认值,相等才清零,否则保留差值。
第二个坑是系统通知与普通会话共用一个未读哈希。系统通知的清理逻辑原本是每天凌晨统一清零,某次清零脚本写错前缀,连带清掉了用户真实未读,客诉雪崩。修复方式是不同生命周期用不同 key 前缀,脚本操作限定前缀,并且任何脚本上线前先在影子实例跑一遍 dry run。
第三个坑是 Redis 与 MySQL 的对战。未读数完全放 Redis 的问题是 Redis 重启或主从切换期间数据可能丢一段。我们的对账机制是每十分钟用消息表的实际未读(最新 Seq 减已读 Seq)修正缓存,差异超过阈值就告警。主从切换那几分钟的窗口靠客户端对账补齐(心跳里报未读),这是我们在 心跳与重连 留的对账通道。
第四个坑是大群的会话列表膨胀。万人群的会话预览对每个成员都要写一份,一轮群消息就是上万次 ZADD,写放大很可观。优化有两条:预览内容只写一次到群的公共结构,成员的会话列表只记住有新消息这个状态,点进去再拉内容;非活跃成员(超过一周未登录)的会话列表更新降频。这两条把大群场景的 Redis 写入削掉了六成。另外大群的消息扩散本身就是第四篇的主题,存储层的取舍见 群消息:读扩散与写扩散怎么选。
第五个坑是排序值漂移。客户端靠会话列表排序立刻渲染,如果 score 与实际最新一条时间不符,列表顺序会跳来跳去,用户感知非常差。原因是更新 score 与写入最新一条预览不是原子的。解决方式是两者在同一个 pipeline 提交,并在客户端侧记录上次渲染时间做二级保护,乱序的一帧直接丢弃。
第六个坑是总红点与会话红点的差异。总红点缓存偶尔比实际分项求和少,排查发现是修改分项的请求成功后,更新总红点的第二步失败被静默吞掉,重试逻辑一直没有触发。修复办法是总红点不再独立维护,改为由分项未读哈希实时聚合加短时缓存兜底——宁可总红点算慢一点,也不允许两个口径漂移。监控上加了一条规则:分项求和与总数差异大于二持续五分钟即告警。
小结
未读数与会话列表的设计核心是:用 Redis 的速度换体验,用对账机制兜住一致性。记住三条规则:清零与加一要原子、不同生命周期的数据隔离 key、任何缓存都有定期对账。这一篇同时是第四篇的引子——当写入量继续上涨,缓存与存储的协同要靠消息队列缓冲,见 削峰填谷:RocketMQ 异步写入。
未读数也存在一致性兜底问题。Redis 故障或重启的瞬间未读数会丢吗?我们的写法是先落库再写 Redis:未读计数在消息库的事务里更新,成功后再异步刷新到 Redis,即使 Redis 挂了存储里的未读数依然准确,Redis 只是加速读的缓存。代价是 Redis 与存储会短暂不一致,端上看到的未读数偶尔少一两秒,对 IM 场景完全可接受。真正怕的是反着写——先加 Redis 再落库,库挂了 Redis 已经加过数,重试再落库导致多加,未读数从此再也对不上,清零只能等用户点开会话重算。
