JavaDog程序狗
发布于 2026-08-22 / 2 阅读
0
0

【架构师修炼】钉钉的群消息"已读",你写在第几层?

前言

🍊缘由

你每天用的钉钉,怎么知道你"已读"了?

群消息已读问题全景

早上打开钉钉,群里 99+ 条消息,那个红色未读数字刺眼。你一条条点开,数字往下掉。
可你有没有想过:钉钉怎么知道你"已读"了哪条、"未读"了哪条?面试官也爱拿这道题考人——让你现场设计一个群消息已读。

🏀事情起因:
大家好,我是 JavaDog程序狗。今天聊点扎心的,你每天用的钉钉,它的群消息已读到底怎么存的。

这种事你经历过吗?不是你技术菜,是大多数人第一次碰到这需求,脑子里就这一招:每条消息存个已读列表。

今天我把"群已读"从一个初学者的写法,推到架构师的写法,拆成四步。不用背,看完你就知道自己的"已读"卡在第几层,也顺便看懂钉钉背后那套。


🎯主要目标

本文带你走完一条成长路线

  • 初学者:一张表能跑,但为什么撑不住万人群
  • 进阶:水位线把复杂度从 O(消息×成员) 降到 O(成员)
  • 高级:Redis Hash 到 Bitmap,存储省 95%
  • 架构师:按能力分层,接受最终一致

正文

🍪四层演进

一.初学者:一张表,先跑起来

第一层 MySQL 表爆炸

刚入行,最直白的写法就是每条消息、每个成员落一行:

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

第三层 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)省资源,代价是短暂不准
未读成员列表仅管理端按需枚举最终一致不实时返回全量,换系统稳定

我自己踩下来,有三条值得记:

  1. msg_id 和 seq 必须分开。msg_id 管检索排障,seq 管已读计算。拿雪花 ID 当阅读位点,会出"序有大小但语义不连续"的坑。
  2. 客户端聚合上报,服务端取最大值。滑过 20 条再报一次,服务端 new_seq = max(old, reported),天然防重放、防多端并发、防 MQ 重复消费。
  3. Kafka 削峰,Redis Lua 原子更新,异步落库。已读不是主交易链路,不值得跟发消息抢资源。

钉钉的做法正好印证这条路线。他们的 DTIM 拆成消息、同步、通知三个服务,存储反而选写扩散(因为个人状态差异大),万人群红包推送暴涨 20 倍时用"拆小群并行推加降级"兜住,底层表格存储扛百万 TPS,已读做成独立维度加同步事件抽象。

回到开头那个钉钉 99+ 未读——你现在大概能猜到,它背后就是消息、同步、通知三个服务,加上写扩散和拆小群这几把刀在兜底。

前面说的四套方案没有谁过时,它们只是各自回答不同规模的问题。干了这么多年,我越来越觉得,这个角色最值钱的,就是那句大白话:知道在哪个规模换哪把刀。

🍈猜你想问

如何与狗哥联系进行探讨?

加瓦狗联系方式

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


评论