从0到1实现消息已读未读:从基础设计到高并发架构

前言

"已读/未读"是职场IM(钉钉、飞书、企业微信)的标配功能。它让沟通变得确定------但也让"假装没看到"变得不可能。

有趣的是,微信张小龙曾明确表示: "微信永远不会上线已读功能,因为这会给用户带来极大的社交压力。" 但这并不妨碍我们从技术视角,去解剖这个功能在单聊与群聊中的实现奥秘。

单聊和群聊的已读回执,在技术难度上有着天壤之别 。如果设计不当,一个5000人的大群,发一条消息就能让数据库瞬间写入5000条记录,并发ACK请求能把服务器打崩。

今天,我们就从基础实现 开始,一步步演进到高并发架构,看看真正的工业级IM是如何扛住流量洪峰的。


第一部分:基础篇------单聊与群聊的核心模型

1.1 功能差异速览

特性 单聊(1v1) 群聊(Group)
送达回执 ✅ 支持 ❌ 不支持(群消息只存一份)
消息已读回执 ✅ 支持 ✅ 支持(但实现极复杂)
会话级批量已读 ✅ 支持 ✅ 支持(清空红点)
存储模型 独立存储 一份消息,N份进度

1.2 单聊实现(基础版)

单聊的已读逻辑非常直观:A发消息 → B读消息 → B告诉服务器"我读了" → 服务器通知A

表结构设计(单聊消息表):

sql 复制代码
CREATE TABLE single_msgs (
    msg_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    from_uid BIGINT NOT NULL,
    to_uid BIGINT NOT NULL,
    content TEXT,
    send_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    is_read BOOLEAN DEFAULT FALSE,    -- 是否已读
    read_time TIMESTAMP NULL          -- 已读时间
);

客户端流程

  • 接收方进入聊天页,调用 ackConversationRead(to_uid) → 批量更新所有未读消息的 is_read = TRUE
  • 发送方通过长连接回调 onMessageRead(msg_ids),将UI状态从"已送达"刷新为"已读"。

单聊极其简单,真正的Boss在群聊。

1.3 群聊基础模型(低并发下的常规方案)

群聊的核心痛点是:群消息只有一份,但每个成员的阅读进度各不相同

假设群里有200人,消息ID为10086,有的人读到了10090,有的人只读到了10050。

最朴素(但致命)的设计------每条消息为每个成员存一条回执明细:

sql 复制代码
-- ⚠️ 低并发可用的基础表,高并发会崩溃!
CREATE TABLE msg_read_detail (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    msg_id BIGINT NOT NULL,          -- 群消息ID
    user_id BIGINT NOT NULL,         -- 群成员ID
    is_read BOOLEAN DEFAULT FALSE,
    read_time TIMESTAMP NULL,
    UNIQUE KEY uk_msg_user (msg_id, user_id)
);

当发送方点击"查看已读详情"时:

sql 复制代码
SELECT COUNT(*) FROM msg_read_detail WHERE msg_id = 10086 AND is_read = TRUE;
SELECT user_id FROM msg_read_detail WHERE msg_id = 10086 AND is_read = FALSE;

这个方案在100人以内的小群里跑得挺好,但一到高并发环境,三天之内必崩。 为什么?接着往下看。


第二部分:高并发下的三大"核爆"痛点

假设一个2000人的全员大群,早高峰时一条@所有人的消息发出:

💥 痛点1:写扩散爆炸(存储黑洞)

若采用基础明细表,发1条消息 = 插入 1999条 msg_read_detail 记录。一天20条@消息 = 4万条记录。一个月120万,一年1500万。MySQL很快被打爆。

💥 痛点2:热点行锁竞争(RT飙升)

当2000人同时读完消息,瞬间产生2000个并发请求去更新同一条消息的"已读计数"或更新自己的进度。数据库行锁导致平均响应时间从10ms飙升到3秒,接口超时雪崩。

💥 痛点3:ACK信令风暴(带宽耗尽)

2000人×(上报ACK请求 + 服务端向发送方推送"张三已读"通知)。若发送方也在这个大群,消息扩散系数呈指数级增长,WebSocket/长连接带宽瞬间拉满

核心结论 :高并发下,传统"写扩散+强一致性"模型必死。必须全面转向 "读扩散 + 水位线 + 最终一致性"


第三部分:高并发架构演进------工业级解决方案

3.1 存储模型革命:摒弃明细,只存"水位线"(Watermark)

核心思想不再为每条消息存储每个成员的阅读记录,而是为每个用户在每个群只存一个值------「该用户在该群已读的最大消息ID(max_read_msg_id)」

  • 消息ID单调递增,具有全序关系
  • 判断某条消息(ID=10086)是否被某用户已读:只需比较 用户.max_read_msg_id >= 10086

存储介质选型

存储层 职责 特点
Redis(主) 扛所有实时读写 内存操作,10万+ QPS
MySQL(从) 冷备/历史归档 定时异步刷盘,5秒延迟可接受

Redis数据结构(Hash)

bash 复制代码
# Key: group:read:{groupId}
# Field: {userId}, Value: {maxReadMsgId}
HSET group:read:123 456 10086   # 用户456在群123已读到10086
HGET group:read:123 456         # -> 10086

MySQL备份表(异步同步):

sql 复制代码
CREATE TABLE group_read_watermark (
    group_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    max_read_msg_id BIGINT NOT NULL DEFAULT 0,
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (group_id, user_id)  -- 每个用户在每个群只有一条!
);

效果 :写操作从 O(N)(N=群人数)降为 O(1) 。存储成本骤降99.9%!

3.2 原子更新:使用Lua脚本防止ID回退

高并发下,网络延迟可能导致旧消息的ACK比新消息的ACK后到达 。必须保证只向更大的MsgId更新:

lua 复制代码
-- Redis Lua脚本(原子执行)
local key = KEYS[1]          -- group:read:{groupId}
local user_id = ARGV[1]
local new_msg_id = tonumber(ARGV[2])

local current = redis.call('HGET', key, user_id)
if not current or new_msg_id > tonumber(current) then
    redis.call('HSET', key, user_id, new_msg_id)
    return 1  -- 更新成功
end
return 0  -- 忽略旧ID

3.3 高并发写入链路:异步削峰(MQ)

所有写请求不直连DB,走Redis → Kafka/RocketMQ → MySQL

  1. 客户端上报ACK → 更新Redis(原子操作,耗时<1ms)→ 返回200。
  2. 更新成功后,将变更日志 (groupId, userId, msgId) 投递到Kafka。
  3. 消费者(Consumer)每隔 1~5秒 批量拉取,合并去重后 REPLACE INTOINSERT ... ON DUPLICATE KEY UPDATE 写入MySQL。
  4. MySQL仅用于离线报表、历史回溯和Redis故障降级,绝不参与实时链路。

3.4 解决ACK风暴:客户端聚合 + 服务端限流

客户端聚合(Batching)

客户端不逐条上报,而是维护本地水位线:

  • 累计新增 10条 消息,或
  • 每间隔 5秒 定时器触发
    触发时只发一个包:{ groupId, maxReadMsgId: 最新ID }

服务端限流(Throttling)

对同一用户ID进行限频,例如 1秒内只允许上报2次,多余的请求直接返回200 OK(伪成功)并丢弃,因为最终一致性允许几秒钟的延迟。

3.5 查询"已读人数":用原子计数器 O(1) 获取

若要展示"X人已读",不要遍历Hash全量成员(大群会有性能灾难)。

正确姿势:在消息发出时预创建计数器

  • 消息发出时:SET read_count:{msgId} 0
  • 用户已读时(更新水位线的同时):INCR read_count:{msgId}
  • 查询时:GET read_count:{msgId}O(1)时间复杂度
java 复制代码
// 伪代码:上报已读时原子增加计数
public void reportRead(String groupId, String userId, Long msgId) {
    String watermarkKey = "group:read:" + groupId;
    // 1. 更新水位线(Lua)
    boolean updated = updateWatermark(watermarkKey, userId, msgId);
    if (updated) {
        // 2. 若更新成功,计数器+1(仅对近期消息有效,设置过期时间)
        String countKey = "read_count:" + msgId;
        redisTemplate.opsForValue().increment(countKey);
        redisTemplate.expire(countKey, 7, TimeUnit.DAYS); // 7天后自动淘汰
    }
}

3.6 群聊分级策略(产品与技术协同)

不可能一套方案打天下,必须按群人数分治

群规模 存储策略 已读列表展示 ACK策略
小群 (2~100人) 允许写明细表(成本可控) 展示全部成员详细状态 实时
中群 (100~500人) Redis水位线 + MySQL备份 仅显示"X人已读",点击详情展示前20个最近活跃用户 客户端聚合5秒
大群 (500~3000人) 纯Redis水位线 仅展示统计数字,不展示具体人头 强限流+10秒聚合
超大群/直播群 (>3000人) 彻底关闭已读回执 不展示任何已读状态 不上报ACK

钉钉/飞书的"已读未读"详情页,大群只加载前几条,正是为了规避性能瓶颈。


第四部分:最终一致性下的异常场景处理

高并发分布式系统下,异常是常态,必须优雅容错:

  1. 消息ID乱序(Old ACK覆盖New ACK)

    • 由上述Lua脚本的 Max 比较机制解决,旧ID永远无法覆盖新ID。
  2. Redis突发故障(缓存雪崩/宕机)

    • 降级方案:客户端上报ACK时,若Redis不可用,直接记录本地Log或写入临时队列。查询已读人数时,降级查MySQL(允许5~10秒延迟)。前端UI展示"加载中"或"网络繁忙",不阻塞主流程。
  3. 用户退群/重新加群

    • 用户退群时,物理删除 group:read:{groupId} 中该用户的Field(释放内存)。
    • 重新加群时,max_read_msg_id 初始化为当前群最新消息ID。历史消息全部视为"已读",不再追溯。(产品上合理,因为新成员没看过历史。)
  4. 消息过期淘汰

    • Redis中的水位线数据设置 TTL(如30天) 。超过30天的历史消息,前端直接屏蔽"查看已读"入口,提示"该消息已过期"。

第五部分:代码实战(SpringBoot + Redisson + Kafka)

1. 服务端接收ACK上报(高并发入口)

java 复制代码
@Service
@Slf4j
public class ReadReceiptService {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    // 限流器(每用户每秒最多2次)
    private final LoadingCache<String, RateLimiter> limiterCache = Caffeine.newBuilder()
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .build(key -> RateLimiter.create(2.0));

    public void reportRead(String groupId, String userId, Long msgId) {
        // 1. 限流(防刷)
        String limitKey = "ack:" + userId;
        if (!limiterCache.get(limitKey).tryAcquire()) {
            log.warn("User {} ack too frequently, dropped", userId);
            return; // 静默丢弃
        }

        // 2. 原子更新水位线(Lua)
        String key = "group:read:" + groupId;
        RScript script = redissonClient.getScript();
        String lua = "local cur=redis.call('hget', KEYS[1], ARGV[1]);" +
                     "if not cur or tonumber(ARGV[2]) > tonumber(cur) then " +
                     "redis.call('hset', KEYS[1], ARGV[1], ARGV[2]); return 1; end; return 0;";
        
        Long updated = script.eval(RScript.Mode.READ_WRITE, lua,
                RScript.ReturnType.INTEGER,
                Collections.singletonList(key),
                userId, String.valueOf(msgId));

        // 3. 更新计数器(仅当水位提升时)
        if (updated != null && updated == 1L) {
            String countKey = "read_count:" + msgId;
            redisTemplate.opsForValue().increment(countKey);
            redisTemplate.expire(countKey, 7, TimeUnit.DAYS);
            
            // 4. 异步投递Binlog给Kafka(最终持久化到MySQL)
            kafkaTemplate.send("topic_read_binlog", 
                groupId + "|" + userId + "|" + msgId);
        }
    }
}

2. 查询已读人数(高频读)

java 复制代码
public Long getReadCount(Long msgId) {
    String key = "read_count:" + msgId;
    Integer count = (Integer) redisTemplate.opsForValue().get(key);
    if (count != null) {
        return Long.valueOf(count);
    }
    // 降级:查MySQL(但仅对近期消息)
    return readCountMapper.selectCountByMsgId(msgId);
}

3. Kafka消费者(异步刷盘)

java 复制代码
@Component
@Slf4j
public class ReadBinlogConsumer {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @KafkaListener(topics = "topic_read_binlog", batch = "true")
    public void consume(List<String> records) {
        // 批量去重:同一个(groupId,userId)只取最大的msgId
        Map<String, Long> latestMap = new HashMap<>();
        for (String record : records) {
            String[] parts = record.split("\|");
            String key = parts[0] + ":" + parts[1]; // groupId:userId
            Long msgId = Long.parseLong(parts[2]);
            latestMap.merge(key, msgId, Math::max);
        }

        // 批量Replace Into MySQL (减少锁竞争)
        String sql = "INSERT INTO group_read_watermark (group_id, user_id, max_read_msg_id) " +
                     "VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE max_read_msg_id = ?";
        List<Object[]> batchArgs = new ArrayList<>();
        for (Map.Entry<String, Long> entry : latestMap.entrySet()) {
            String[] ids = entry.getKey().split(":");
            batchArgs.add(new Object[]{
                Long.parseLong(ids[0]), 
                Long.parseLong(ids[1]), 
                entry.getValue(),
                entry.getValue()
            });
        }
        jdbcTemplate.batchUpdate(sql, batchArgs);
        log.info("Flushed {} read states to MySQL", batchArgs.size());
    }
}

第六部分:总结与避坑指南

维度 低并发方案(基础) 高并发方案(进阶)
存储模型 (msg_id, user_id) 明细 (group_id, user_id) 水位线
存储介质 MySQL单库 Redis(主) + MySQL(从)
写操作复杂度 O(N) O(1)
ACK上报方式 逐条实时上报 聚合批量(10条/5秒)
已读计数查询 全表扫 COUNT 原子计数器 INCR/GET
最终一致性 强一致(实时) 秒级最终一致
大群支持 ≤100人 万人级(需分级降级)

最后,3条红线:

  1. 绝对禁止在核心链路上用MySQL抗高并发写入。把Redis用起来,把DB当作冷备。
  2. 绝对禁止为每条群消息为每个成员生成明细回执记录。水位线是你唯一的救星。
  3. 接受不完美。群聊已读回执的"实时性"在架构上是可以妥协的------用户不会在意5秒的延迟,但会在意APP卡死。

如果产品经理非要在大群里看"谁读了谁没读"的精确列表怎么办?------让他看运营后台的数据报表,别让他绑架客户端实时架构! (开个玩笑,研发要学会和产品做杠杆博弈 😄)

希望这篇从基础到进阶的硬核实战能帮你避开那些"血泪坑"。如果在落地中遇到更极端的场景(如跨境网络延迟、多机房同步),欢迎评论区一起探讨!🚀

相关推荐
guoheng18 小时前
我们用 RealVuln 测试了 AI 代码扫描工具:召回率、精确率与误报对比
安全·架构
r_oo_ki_e_18 小时前
java微服务
java·微服务
梅头脑18 小时前
一条SQL从5秒到0.05秒:我拆开了B+Tree、MVCC和EXPLAIN,找到了慢查询优化的根
后端
weixin_BYSJ198718 小时前
django在线图书销售平台---附源码16192
java·javascript·spring boot·python·django·flask·php
糖果店的幽灵19 小时前
【langgraph 从入门到精通graphApi 篇】Command 与动态流程控制
android·java·数据库·人工智能·langgraph
只会CRUD的码仔19 小时前
【踩坑记录】Thymeleaf 下拉框设置 disabled 变灰色,但依旧可以点击选择
java
云技纵横19 小时前
堆内存明明还有一半,接口为什么每隔几分钟卡死一次?
后端
减瓦19 小时前
Java 8 编译器扩展点
java
QN1幻化引擎19 小时前
认知架构调度与语言模型辅助:DalinX V8 Track 1 实验报告
人工智能·语言模型·架构