站内信的已读未读消息如何建模设计?

wen java案例 47

本文目录导读:

站内信的已读未读消息如何建模设计?

  1. 核心概念
  2. 方案一:朴素方案(is_read 字段)
  3. 方案二:基于时间戳的方案(last_read_time
  4. 方案三:基于 Redis 的位图方案(Bitmap)
  5. 方案四:混合方案(最推荐的实际方案)
  6. 总结与选择建议

这是一个非常经典的后端架构设计问题,站内信的已读/未读状态设计,核心在于平衡数据一致性、查询性能和存储成本

没有一种“唯一正确”的方案,只有最适合你业务场景的方案,下面从简单到复杂,介绍几种主流的建模设计方案,并分析其优缺点。

核心概念

首先要区分两个概念:

  • 收件箱(Inbox):用户收到的消息列表,每条消息对每个用户都有一个唯一的“用户-消息”关联记录。
  • 会话/私信:类似于微信聊天,消息是成组出现的。

站内信的“已读”通常针对收件箱中的单条消息,或会话中某个用户对某条消息的已读记录。


朴素方案(is_read 字段)

场景: 用户量较少(百万级以下),消息量不大,系统简单。 模型:

  1. 消息表(messages:存消息本体。

    • id (PK)
    • sender_id
    • content
    • created_at
  2. 用户消息关联表(user_messages:存每个人与消息的关系。

    • id (PK)
    • user_id (收件人)
    • message_id (关联消息表)
    • is_read (Boolean, 核心字段, false 为未读, true 为已读)
    • read_at (Datetime, 可选)

优点:

  • 设计简单直观,易于理解。
  • 查询某个用户未读消息数:SELECT COUNT(*) FROM user_messages WHERE user_id=? AND is_read=0
  • 查询用户消息列表时,直接 join 即可。

缺点:

  • 写放大问题严重:当发一封站内信给 100 万用户时,需要插入 100 万条 user_messages 记录,每条 is_read=false,写入负载会剧增。
  • 存储成本高:随着用户和消息增长,这张表会膨胀得非常快。
  • 读压力大:查询用户未读数或消息列表时,即使有索引,在超大表上也会变慢。

只适合小型系统或一次性通知类(如系统通知)。不推荐用于真正的站内信或私信系统。


基于时间戳的方案(last_read_time

场景: 站内信中的会话/私信,或者收件箱中可以容忍“伪未读”的场景。 思想: 不需要记录每条消息的已读状态,只记录用户最后一次查看收件箱或某个会话的时间戳,发送时间和这个时间比较。

模型:

  1. 用户表(users 增加字段 inbox_last_read_time(或单独建一张用户时间戳表)。

  2. 消息表(messages

    • id
    • sender_id
    • recipient_id (接收者) 或 group_id (群组)
    • content
    • created_at

如何判断未读?

  • 场景1(收件箱级别的未读)

    • 未读消息数 = SELECT COUNT(*) FROM messages WHERE recipient_id=? AND created_at > ? (问号为该用户的 inbox_last_read_time)
    • 当用户打开收件箱时,更新 inbox_last_read_time 为当前时间。
  • 场景2(会话级别的已读回执)

    • 在会话表中增加 last_read_time 字段。
    • 未读消息数 = 会话中 created_at > last_read_time 的消息数量。

优点:

  • 无写放大:无论一次发送多少消息,只需要更新用户的一行 last_read_time,存储成本极低。
  • 查询极快:基于索引的时间范围查询非常高效。

缺点:

  • 精度问题:无法准确知道用户对“某一条”消息是否已读,如果一个用户之前已读了消息A,后来另一人发了消息B,用户没看,那么消息A也会被标记为已读(因为时间戳更新了),这违反了“精确已读”的要求。
  • 不支持单条已读:无法实现“逐条标记已读”。

如果你的站内信是私信聊天室模式(用户关心的是“有没有新消息”,而不是“某一条特定消息看了没”),这是最佳实践,知乎私信、微博私信。


基于 Redis 的位图方案(Bitmap)

场景: 需要精确的逐条已读状态,且用户量巨大、写操作频繁。 思想: 消息系统中,消息通常是顺序递增的(如 message_id 从1开始递增),可以用一个很长的二进制位图来表示用户对某一批消息的已读状态。

模型:

  1. 消息表(messages:正常记录消息内容。

    • 包含全局递增的 message_id
  2. Redis:为每个用户维护一个位图(BitMap),key 为 user:read:123(用户123)。

    • 位图含义:位图的第 N 位表示用户对 message_id = N 的消息是否已读(1为已读,0为未读)。

操作:

  • 标记已读
      SETBIT user:read:123 1001 1  # 标记 message_id=1001 的消息为已读
  • 判断某条消息是否已读
      GETBIT user:read:123 1001
  • 统计未读消息数(需要知道用户可接收的消息ID范围):这是一个难点,常用做法是维护一个“每个用户最新收到/应收到的消息ID”,然后用位图在这个区间内统计0的数量(需要结合 BITCOUNT 或脚本),更常见的做法是只判断“是否有未读”,而不是精确计数(或者用方案二来做未读数,用位图来做详细信息展示)。

优点:

  • 极致存储和性能:一个用户1000条消息只占125字节(1000位 ≈ 125字节),亿级用户、万亿条消息的已读状态也可以轻松放入内存,写入和读取都是 O(1) 时间。
  • 完美解决写放大:标记一百万用户已读,只需要一百万次 O(1) 的 Redis 操作(通常可以 pipeline 合并成一次网络往返)。

缺点:

  • 依赖 Redis:需要保证 Redis 的高可用和持久化(否则丢失未读数)。
  • 消息ID必须是全局递增的:如果系统分库分表,ID生成方案需要支持全局有序(如雪花算法或Redis自增)。
  • 统计未读数稍复杂:不能直接 COUNT(*),需要知道用户消息ID的范围,通常需要维护一个“用户最新收到的消息ID”的计数器。

常用于高并发、海量用户的系统级通知(如:微博点赞通知、淘宝物流通知、游戏礼物通知),对于精确的“逐条已读”需求,这是最成熟、强大的方案。


混合方案(最推荐的实际方案)

场景: 几乎所有需要精细状态管理的站内信系统。 思想: 区分“未读数”和“具体未读列表”。

  • 未读数(计数):使用方案二(时间戳)或方案三(Redis Bitmap)进行近似、高性能的计数。
  • 具体未读列表(详细查询):使用方案一(精确的 is_read 表)或方案三(Redis Bitmap)进行精确、低频的展示。

推荐架构:

  1. 离线写入(MySQL):发信时,不立即写 user_messages 表,而是写一个异步队列(如 RabbitMQ, Kafka)。
  2. 写放大优化:消费者从队列中拉取任务,批量化地写入 user_messages 表,对于超大站内信(如发100万人),可以分片、并发写入,可以对 user_messages 表按 user_id 做 sharding(分库分表)或使用 TIMESTAMP 分区。
  3. 实时缓存(Redis):用户的未读消息总数最新消息时间戳存储在 Redis 中,用于高并发的首页未读数展示。
  4. 精确已读标记(Redis 或 MySQL)
    • 用户点击某条消息时,异步将 user_messages.is_read 更新为 true(或更新 Redis Bitmap)。
    • 用户打开收件箱时,查询 user_messages 表时加 WHERE is_read=0 条件即可。
  5. 数据一致性:采用最终一致性,未读数可能短暂不精确(如异步更新滞后),但通常能接受。

优点:

  • 平衡了性能、存储和精度,首页未读数极快,详情页能精确展示。
  • 可扩展性好,能应对从几十万到上亿用户的场景。

缺点:

  • 系统复杂度较高,需要引入消息队列、Redis、分库分表等组件。
  • 需要处理最终一致性的边界情况(如用户重复点击,数据重放等)。

总结与选择建议

你的系统特点 推荐方案 原因
小型系统、博客、个人项目 (用户<10万) 方案一 (is_read 实现简单,能跑即可
私信、聊天室、会话式站内信 方案二 (last_read_time方案四 (混合) 精度合理,性能好,易于理解
大型系统、系统通知/营销通知 (用户>1000万,发送量极大) 方案三 (Redis Bitmap)方案四 (混合) 解决写放大,极致性能
需要精确单条已读、用户量超大 方案四 (混合) 最终一致性,兼顾性能与准确性

一个简单的判断方法: 去问问产品经理或老板:“用户是只想知道有没有新消息(未读数),还是必须知道哪一条特定消息点开了没(比如邮件里的已读回执)?”

  • 如果是第一种(绝大多数站内信),方案二(时间戳)+ 异步写 is_read 是最好的选择。
  • 如果是第二种(少数系统,如阿里云控制台的通知,邮件系统),方案三(Redis Bitmap) 是最佳选择。

抱歉,评论功能暂时关闭!