[{"data":1,"prerenderedAt":68},["ShallowReactive",2],{"article-159":3},{"code":4,"msg":5,"data":6,"count":14},200,"查询成功",{"id":7,"title":8,"keywords":9,"description":10,"category_id":11,"content":12,"body_html":13,"thumb_up":14,"clicks":15,"sort":14,"remark":13,"status":11,"is_open":11,"is_deleted":14,"is_top":11,"is_recommend":11,"create_time":16,"update_time":16,"image_id":17,"url":13,"member_id":14,"cate_name":18,"prev":19,"next":22,"tags":23,"words":39,"read_time":40,"comments":14,"cover":41,"relevant":42},159,"百万在线 IM 已读未读怎么设计？","IM系统, 已读未读, 已读游标, 群聊回执, 高并发","IM 已读未读的难点不在状态字段，而在海量消息下的存储膨胀、高频上报和群聊统计。核心做法是用已读游标替代逐条状态，并按单聊、大群、回执群分别设计。",1,"## 先说结论\n\n面试官问“百万在线 IM 的已读未读怎么设计”，不要上来就说给每条消息加一个 `read_status` 字段。\n\n这个功能表面是在问消息状态，实际考的是三件事：\n\n1. 数据怎么存，才不会随着消息数和成员数膨胀到不可控。\n2. 未读数怎么计算，才不会每次都扫消息表。\n3. 群聊和高频上报怎么处理，才扛得住百万在线用户。\n\n更稳的回答是：已读未读不要按“每条消息一个状态”建模，而要按“用户在会话里读到哪里”建模。也就是给每个用户在每个会话中维护一个已读游标，游标通常是会话内递增的消息序号 `seq`。\n\n## 为什么不能给每条消息存状态\n\n单聊里给消息加状态字段看起来还能跑，但一到群聊就会出问题。\n\n假设一个 500 人群发出 1 条消息，如果要精确记录每个人读没读，这条消息就可能生成 500 条读状态记录。消息越多、群越多、成员越多，记录量会按“消息数乘成员数”快速膨胀。\n\n更麻烦的是，已读状态不是低频数据。用户进入会话、划过消息列表、切换设备、回到前台，都可能触发已读位置变化。如果每次都更新数据库里的逐条状态，数据库会长期承受高频小写入，写放大很明显。\n\n所以这道题的第一个关键点是识别模型错误：已读未读不是给每条消息打标签，而是记录用户阅读进度。\n\n## 单聊：一个游标解决大部分问题\n\n单聊最适合用游标模型。\n\n每个会话里的消息都有一个递增序号：\n\n- 第 98 条消息：`seq = 98`\n- 第 99 条消息：`seq = 99`\n- 第 100 条消息：`seq = 100`\n- 第 101 条消息：`seq = 101`\n\n用户 A 在这个会话里的已读游标是 `read_seq = 100`，就表示 A 已经读到第 100 条；`seq \u003C= 100` 的消息对 A 来说都是已读，`seq > 100` 的消息都是未读。\n\n未读数也不需要逐条统计：\n\n```text\nunread_count = conversation_latest_seq - user_read_seq\n```\n\n如果要判断“我发出的某条消息对方是否已读”，也只需要看对方的游标：\n\n```text\npeer_read_seq >= message.seq  => 对方已读\npeer_read_seq \u003C  message.seq  => 对方未读\n```\n\n这个设计把 N 条消息状态压缩成 1 条用户会话进度，存储和计算成本都降下来了。\n\n## 推荐的数据模型\n\n可以把数据拆成三类：\n\n```text\nmessage\n- conversation_id\n- seq\n- sender_id\n- content\n- created_at\n\nconversation_member\n- conversation_id\n- user_id\n- join_seq\n- leave_seq\n- role\u002Fstatus\n\nconversation_read_cursor\n- conversation_id\n- user_id\n- read_seq\n- updated_at\n```\n\n其中 `message.seq` 是会话内单调递增序号。`conversation_read_cursor.read_seq` 是用户在会话里的已读位置。\n\n这里要注意两个边界：\n\n第一，已读上报必须是单调前进的。客户端可能因为多端、弱网、重试而上报旧位置，服务端更新时应该使用：\n\n```text\nread_seq = max(old_read_seq, reported_read_seq)\n```\n\n第二，群成员统计不能把不该统计的人算进去。比如用户后加入群，不应该对加入前的历史消息产生未读；用户退群后，也不应该继续参与新消息统计。因此成员表里最好保留 `join_seq`、`leave_seq` 或等价的成员有效区间。\n\n## 群聊：先看产品到底要什么\n\n群聊不能只给一个技术答案，要先区分产品形态。\n\n### 大群：只同步“我读到哪”\n\n很多大群、超大群不做精确到人的已读回执。产品上只需要知道“我在这个群读到哪里”，用于多端同步、红点、未读数和继续阅读。\n\n这种情况下，每个用户在群里仍然维护一个自己的已读游标：\n\n```text\ngroup_read_cursor(group_id, user_id, read_seq)\n```\n\n但不会展示“这条消息谁读了、谁没读”。这是用产品边界规避存储和统计爆炸。对于成员数很大的群，这通常是更现实的选择。\n\n### 回执群：每个成员一个游标\n\n如果产品要求像办公 IM 一样展示“几人已读、几人未读”，也不要为每条消息给每个人存一条状态。\n\n仍然维护每个成员在群里的已读游标：\n\n```text\ngroup_member_read_cursor(group_id, user_id, read_seq)\n```\n\n要统计某条消息 `m` 的已读人数：\n\n```text\nread_count = count(member.read_seq >= m.seq)\nunread_count = valid_member_count_at(m.seq) - read_count\n```\n\n对于 500 人级别的群，当用户点开某条消息的回执详情时，临时按成员游标统计通常是可以接受的。对于更大的群，如果还要求精确回执，就可以在 Redis 里维护一个按 `read_seq` 排序的集合：\n\n```text\nZADD im:group:{group_id}:read_cursor read_seq user_id\nZCOUNT im:group:{group_id}:read_cursor message_seq +inf\n```\n\n这样统计“读到某条消息之后的人数”就不需要扫全量成员。但这个能力仍然应该按需触发，不要在每次消息列表渲染时对每条消息都实时计算。\n\n![群聊已读回执的两种产品形态](https:\u002F\u002Ftp.myong.top\u002Fstorage\u002Fmarkdown\u002F20260701\u002F58a70c93f35f551572db86abcd6d5a76.png)\n\n## 百万在线下的高频读写链路\n\n只把数据模型设计对还不够。百万在线场景下，已读上报会非常频繁，必须控制写入路径。\n\n比较稳的链路是：\n\n1. 客户端合并上报：用户快速划过一屏消息时，不要每条消息都上报。客户端只上报一段时间内最大的 `read_seq`。\n2. 接入层校验：服务端校验用户是否属于会话，`seq` 是否合法，防止伪造会话或越权上报。\n3. Redis 扛高频：最新游标先写 Redis，使用 `max` 语义保证幂等和单调前进。\n4. 异步批量落库：通过队列或定时任务把游标批量刷回数据库，降低数据库写入压力。\n5. 长连接推送：单聊可以把对方已读位置实时推给发送方；群聊则按产品规则推摘要或在打开回执详情时再拉取。\n\n这套链路可以概括成三句话：客户端合并降上报，Redis 扛高频，异步落库保持久，长连接保实时。\n\n![已读上报的高频读写链路](https:\u002F\u002Ftp.myong.top\u002Fstorage\u002Fmarkdown\u002F20260701\u002Fabde6fd9ed897374785b902c4235c567.png)\n\n## 读扩散还是写扩散\n\n已读未读设计里还有一个常见追问：到底是写扩散还是读扩散？\n\n逐条记录每个用户读没读，本质上就是强写扩散。发送一条群消息，提前写出所有成员状态，读取时简单，但写入和存储成本高。\n\n已读游标模型更接近读时计算。写入时只更新用户自己的阅读位置；展示某条消息的回执时，再根据游标统计。这会把成本从“每条消息都写很多状态”改成“真正需要展示回执时再算”。\n\n实际系统一般不会极端选择一边，而是按场景折中：\n\n- 单聊：游标足够简单，直接用。\n- 普通群：维护成员游标，回执按需统计。\n- 超大群：不做精确到人的回执，只做个人阅读位置同步。\n- 高频热点消息：可以缓存回执统计结果，设置短 TTL，避免重复计算。\n\n## 面试时可以这样答\n\n如果面试官问“百万在线 IM 已读未读怎么设计”，可以按这个顺序回答：\n\n第一，先点破核心矛盾：\n\n> 这个问题不能按每条消息存状态设计，尤其群聊会出现消息数乘成员数的存储膨胀。核心应该是记录用户在会话里的已读游标。\n\n第二，讲单聊：\n\n> 单聊里每条消息有会话内递增 `seq`，每个用户在会话里维护一个 `read_seq`。未读数等于 `latest_seq - read_seq`；判断对方是否读了我某条消息，只要比较对方 `read_seq` 是否大于等于这条消息的 `seq`。\n\n第三，讲群聊：\n\n> 群聊要分产品形态。大群可以只做“我读到哪”的多端同步，不做精确到人的回执；需要回执的群，也不要按消息和成员存状态，而是每个成员维护一个已读游标。统计某条消息的已读人数时，用消息 `seq` 和成员游标比较，必要时用 Redis sorted set 优化统计。\n\n第四，补性能落地：\n\n> 已读上报是高频操作，客户端要合并上报最新位置，服务端用 Redis 扛住高频游标更新，再异步批量落库。单聊或小群的已读变化通过长连接实时推送，群回执详情按需拉取或缓存统计结果。\n\n## 容易答错的点\n\n不要说“给消息表加一个状态字段”。消息是否已读不是消息自己的全局属性，而是某个用户相对于某个会话的阅读进度。\n\n不要忽略群成员边界。统计未读人数时，要考虑加入时间、退群时间、禁用成员、机器人账号和发送者是否计入回执。\n\n不要让客户端直接决定最终状态。客户端只能上报“我看到的位置”，服务端必须校验会话权限和消息序号，并用单调更新防止旧上报覆盖新游标。\n\n不要把 Redis 当唯一存储。Redis 适合扛高频读写，但数据库仍然要保存可恢复的游标状态，防止缓存丢失后用户未读数大面积错乱。\n\n不要对每条群消息实时计算回执。回执详情通常是低频入口，应该按需计算、缓存或限制群规模，而不是在消息列表里持续做重统计。\n\n## 最后记住这一句\n\nIM 已读未读的核心不是“状态字段”，而是“阅读位置”。用游标替代逐条状态，单聊算得快，群聊按产品形态取舍，再用客户端合并、Redis、异步落库和长连接把高频链路兜住，才是能落地的百万在线设计。\n\n## 资料说明\n\n本文基于用户提供的视频文案和内容分析整理，未引用需要实时核验的外部数据。\n",null,0,7,"2026-07-01 22:36:49",235,"技术杂谈",{"id":20,"title":21},158,"HTTP\u002F3 的 QUIC 基于 UDP，为什么仍然可靠？",[],[24,27,30,33,36],{"id":25,"name":26},163,"系统设计",{"id":28,"name":29},139,"后端架构",{"id":31,"name":32},164,"面试题",{"id":34,"name":35},76,"Redis",{"id":37,"name":38},165,"长连接",3386,8,"https:\u002F\u002Ftp.myong.top\u002Fstorage\u002Farticle\u002F20260701\u002Fdfe49400b9fbdd240fbff867d9e4aead.png",[43,48,53,58,63],{"id":44,"title":45,"create_time":46,"description":47},89,"获取腾讯“邮我”的链接","2020-06-16 10:47:34","腾讯QQ邮箱提供了“邮我”组件，可以放在自己的网站上，让别人点击提供的图片或者链接就可以发Email过来，该文章分享的是如何获取到其中的链接。",{"id":49,"title":50,"create_time":51,"description":52},98,"本站点JavaScript相关特效使用方法整理","2021-04-22 18:38:53","本站的页面用的特效有，粒子线canvas-nest，动态彩带canvas-ribbon，鼠标点击特效以及音乐播放器，本文整理了特效的使用方法",{"id":54,"title":55,"create_time":56,"description":57},106,"阿里开源数据同步组件Canal","2023-10-26 22:43:32","最开始听说canal是从mysql与redis双写一致性解决方案，当时并没有太在意，最近由于需要实时同步数据，如果在代码对insert\u002Fupdate\u002Fdelete做拦截也可以实现，但对代码侵入性太大了，并且后期更改时容易有遗漏，风险太高，这时就又想到了canal，canal的好处在于对业务代码没有侵入，因为是基于监听binlog日志去进行同步数据，这个真的是太爽爽爽了。并且实时性也能做到准实时，这也是canal为什么这么流行，因为确实很多企业会用来做数据同步的方案。",{"id":59,"title":60,"create_time":61,"description":62},62,"内网使用Composer","2019-10-08 23:29:23","最近本地部署Laravel开发环境时，遇到公司的内网无法正常使用Composer下载Laravel，后来发现是要设置公司内网代理",{"id":64,"title":65,"create_time":66,"description":67},75,"Hexo-SEO优化开启静态文件压缩功能","2019-12-30 21:34:32","个人对HEXO搭建博客的SEO优化方案进行总结，从本地的文章结构到定期推送，再到SEO关键词优化做一个全面体系的汇总，如果有更好的方法可以私聊我。\n\n",1783431644307]