本文目录导读:

- 核心概念
- 方案一:朴素方案(
is_read字段) - 方案二:基于时间戳的方案(
last_read_time) - 方案三:基于 Redis 的位图方案(Bitmap)
- 方案四:混合方案(最推荐的实际方案)
- 总结与选择建议
这是一个非常经典的后端架构设计问题,站内信的已读/未读状态设计,核心在于平衡数据一致性、查询性能和存储成本。
没有一种“唯一正确”的方案,只有最适合你业务场景的方案,下面从简单到复杂,介绍几种主流的建模设计方案,并分析其优缺点。
核心概念
首先要区分两个概念:
- 收件箱(Inbox):用户收到的消息列表,每条消息对每个用户都有一个唯一的“用户-消息”关联记录。
- 会话/私信:类似于微信聊天,消息是成组出现的。
站内信的“已读”通常针对收件箱中的单条消息,或会话中某个用户对某条消息的已读记录。
朴素方案(is_read 字段)
场景: 用户量较少(百万级以下),消息量不大,系统简单。 模型:
-
消息表(
messages):存消息本体。id(PK)sender_idcontentcreated_at
-
用户消息关联表(
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)
场景: 站内信中的会话/私信,或者收件箱中可以容忍“伪未读”的场景。 思想: 不需要记录每条消息的已读状态,只记录用户最后一次查看收件箱或某个会话的时间戳,发送时间和这个时间比较。
模型:
-
用户表(
users) 增加字段inbox_last_read_time(或单独建一张用户时间戳表)。 -
消息表(
messages):idsender_idrecipient_id(接收者) 或group_id(群组)contentcreated_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开始递增),可以用一个很长的二进制位图来表示用户对某一批消息的已读状态。
模型:
-
消息表(
messages):正常记录消息内容。- 包含全局递增的
message_id。
- 包含全局递增的
-
Redis:为每个用户维护一个位图(BitMap),key 为
user:read:123(用户123)。- 位图含义:位图的第 N 位表示用户对
message_id = N的消息是否已读(1为已读,0为未读)。
- 位图含义:位图的第 N 位表示用户对
操作:
- 标记已读:
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)进行精确、低频的展示。
推荐架构:
- 离线写入(MySQL):发信时,不立即写
user_messages表,而是写一个异步队列(如 RabbitMQ, Kafka)。 - 写放大优化:消费者从队列中拉取任务,批量化地写入
user_messages表,对于超大站内信(如发100万人),可以分片、并发写入,可以对user_messages表按user_id做 sharding(分库分表)或使用TIMESTAMP分区。 - 实时缓存(Redis):用户的未读消息总数或最新消息时间戳存储在 Redis 中,用于高并发的首页未读数展示。
- 精确已读标记(Redis 或 MySQL):
- 用户点击某条消息时,异步将
user_messages.is_read更新为true(或更新 Redis Bitmap)。 - 用户打开收件箱时,查询
user_messages表时加WHERE is_read=0条件即可。
- 用户点击某条消息时,异步将
- 数据一致性:采用最终一致性,未读数可能短暂不精确(如异步更新滞后),但通常能接受。
优点:
- 平衡了性能、存储和精度,首页未读数极快,详情页能精确展示。
- 可扩展性好,能应对从几十万到上亿用户的场景。
缺点:
- 系统复杂度较高,需要引入消息队列、Redis、分库分表等组件。
- 需要处理最终一致性的边界情况(如用户重复点击,数据重放等)。
总结与选择建议
| 你的系统特点 | 推荐方案 | 原因 |
|---|---|---|
| 小型系统、博客、个人项目 (用户<10万) | 方案一 (is_read) |
实现简单,能跑即可 |
| 私信、聊天室、会话式站内信 | 方案二 (last_read_time) 或 方案四 (混合) |
精度合理,性能好,易于理解 |
| 大型系统、系统通知/营销通知 (用户>1000万,发送量极大) | 方案三 (Redis Bitmap) 或 方案四 (混合) | 解决写放大,极致性能 |
| 需要精确单条已读、用户量超大 | 方案四 (混合) | 最终一致性,兼顾性能与准确性 |
一个简单的判断方法: 去问问产品经理或老板:“用户是只想知道有没有新消息(未读数),还是必须知道哪一条特定消息点开了没(比如邮件里的已读回执)?”
- 如果是第一种(绝大多数站内信),方案二(时间戳)+ 异步写
is_read表 是最好的选择。 - 如果是第二种(少数系统,如阿里云控制台的通知,邮件系统),方案三(Redis Bitmap) 是最佳选择。