前言
🍊缘由
你每天用的钉钉,怎么知道你"已读"了?

早上打开钉钉,群里 99+ 条消息,那个红色未读数字刺眼。你一条条点开,数字往下掉。
可你有没有想过:钉钉怎么知道你"已读"了哪条、"未读"了哪条?面试官也爱拿这道题考人——让你现场设计一个群消息已读。
🏀事情起因:
大家好,我是 JavaDog程序狗。今天聊点扎心的,你每天用的钉钉,它的群消息已读到底怎么存的。
这种事你经历过吗?不是你技术菜,是大多数人第一次碰到这需求,脑子里就这一招:每条消息存个已读列表。
今天我把"群已读"从一个初学者的写法,推到架构师的写法,拆成四步。不用背,看完你就知道自己的"已读"卡在第几层,也顺便看懂钉钉背后那套。
🎯主要目标
本文带你走完一条成长路线
- 初学者:一张表能跑,但为什么撑不住万人群
- 进阶:水位线把复杂度从 O(消息×成员) 降到 O(成员)
- 高级:Redis Hash 到 Bitmap,存储省 95%
- 架构师:按能力分层,接受最终一致
正文
🍪四层演进
一.初学者:一张表,先跑起来

刚入行,最直白的写法就是每条消息、每个成员落一行:
CREATE TABLE message_read_status (
group_id BIGINT NOT NULL,
msg_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
read_time DATETIME(3) NULL, -- 空 = 未读
PRIMARY KEY (group_id, msg_id, user_id)
);
发消息给每人插一行 read_time 为 NULL,谁读了就 UPDATE 成 NOW()。查"N 人已读"就是 COUNT(*)。
能跑、好懂、好调试,小群 demo 完全没问题。
直到有个老同事问了一句:万人群的群,你这条 SQL 一天插多少行?
你算一下:5 万人 × 2000 条/天 × 80% 阅读率,差不多 8000 万行,一天,一个群。
瞬间沉默。
👽人话解释
这方案像给每个快递都单独记一本账。一个小区的件你记得起,全城的件你一本账记到天荒地老。
它本身没错,只是解决了一个更小的子问题:在小群里把功能跑起来。错的是,有人把它原封不动搬进了生产。
二.进阶开发:用"序"代替"状态"

被数据量教做人之后,会悟到一个事实:群消息是天然有序的。
给每条消息一个群内单调递增的 seq,"读到第 1000 条"就等价于"1 到 1000 条都已读"。表可以改成每人一行水位线:
CREATE TABLE group_member_cursor (
group_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
last_read_seq BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (group_id, user_id)
);
- 未读数 = 群最大 seq - 我的 last_read_seq
- 我是否读了某条 = 我的 last_read_seq 是否大于等于那条消息的 seq
复杂度从 O(消息×成员) 直接降到 O(成员)。这一步挺关键,你从"存每一条状态"变成"存一个能推导的关系"。
但有个坑得记牢:水位线只能回答"你读到哪了",反查不了"某条具体消息谁读了"。想做消息详情里的"谁已读"列表,光靠它就不够了。
👽人话解释
水位线像你看书的书签。书签告诉你读到第几页,但问你"第 50 页谁看过",书签答不上来。
三.高级工程师:热点搬 Redis,先 Map 后 Bitmap

再往前走,撞上大群高并发,MySQL 顶不住读放大,得把"消息级已读"搬进内存。
先上 Hash,中小群够用:
key = read:{group_id}:{msg_id}
field = user_id
HSET read:1001:9527 uid_88 1
HLEN read:1001:9527 # 已读人数
读是 O(1),HLEN 看人数,用着很爽。
直到万人群一条消息就是 1 万个 field,Redis 内存直接给你干爆。
这时候才真正理解什么叫"让数据结构去匹配问题"。
再往前一步,Bitmap:
key = readbit:{group_id}:{msg_id}
SETBIT readbit:1001:9527 <map_id_of_uid88> 1 # 某人已读
BITCOUNT readbit:1001:9527 # 已读人数
前提是给成员分配连续自增的 map_id,别用雪花 ID 那种稀疏长整型,不然 bitmap 补空位能撑爆内存。200 人的群,用 readbit 加一个退群软标记的 quitbit,一条消息也就 54 字节左右,比最初那版 1.6KB 省了 95%。还能 BITOP AND 算"谁把几条公告都读了"。
👽人话解释
Hash 像给每个人发一张打卡表,人越多表越长;Bitmap 像一排格子,一个人只占一格,200 人一排也才几十字节。量级差出好几档。
四.架构师:没有银弹,只有分层和取舍

到架构师视角,结论不再是"用 Bitmap",而是"按能力分层,并且接受一定程度的不一致":
| 能力层 | 存储选型 | 一致性 | 放弃了什么 |
|---|---|---|---|
| 个人未读数 | Redis ZSET(成员→seq) | 强一致、低延迟 | 拿不到"谁读了"的明细 |
| 已读人数 | 热点消息才上 Bitmap | 最终一致(100ms 到 3s) | 省资源,代价是短暂不准 |
| 未读成员列表 | 仅管理端按需枚举 | 最终一致 | 不实时返回全量,换系统稳定 |
我自己踩下来,有三条值得记:
- msg_id 和 seq 必须分开。msg_id 管检索排障,seq 管已读计算。拿雪花 ID 当阅读位点,会出"序有大小但语义不连续"的坑。
- 客户端聚合上报,服务端取最大值。滑过 20 条再报一次,服务端 new_seq = max(old, reported),天然防重放、防多端并发、防 MQ 重复消费。
- Kafka 削峰,Redis Lua 原子更新,异步落库。已读不是主交易链路,不值得跟发消息抢资源。
钉钉的做法正好印证这条路线。他们的 DTIM 拆成消息、同步、通知三个服务,存储反而选写扩散(因为个人状态差异大),万人群红包推送暴涨 20 倍时用"拆小群并行推加降级"兜住,底层表格存储扛百万 TPS,已读做成独立维度加同步事件抽象。
回到开头那个钉钉 99+ 未读——你现在大概能猜到,它背后就是消息、同步、通知三个服务,加上写扩散和拆小群这几把刀在兜底。
前面说的四套方案没有谁过时,它们只是各自回答不同规模的问题。干了这么多年,我越来越觉得,这个角色最值钱的,就是那句大白话:知道在哪个规模换哪把刀。
🍈猜你想问
如何与狗哥联系进行探讨?

关注公众号【JavaDog程序狗】,回复【入群】或【加入】,一起聊技术、聊踩坑。