本文目录导读:

设计一个扩展性强的站内信系统,核心挑战在于平衡实时性、存储成本和海量读写,以下是一套经过实战验证的架构设计方案,重点考虑了水平扩展和业务隔离。
核心架构原则:读写分离与数据分片
- 读写分离:将“发送”和“查询”操作彻底分开,使用不同的数据表甚至不同的数据库。
- 数据分片:按照用户ID或会话ID进行水平分片(Sharding),避免单表数据量过大。
- 冷热分离:近期活跃数据(如最近30天)与历史归档数据分开存储。
数据结构设计
传统的“收件箱+发件箱”架构在扩展时容易遇到瓶颈,推荐采用 “消息-关系-会话” 三层模型,这样更有利于扩展。
消息层(Message Store)
- 表结构:
message_id (bigint, 自增/分布式ID),sender_id,content,msg_type,created_at,metadata (JSON) - 特点:只存储一份,所有收件人都指向同一条message_id,内容不冗余,节省大量存储。
关系层(User-Message Relation)
- 表结构:
relation_id (bigint),user_id (分区键),message_id,box_type (0:收件箱, 1:发件箱),status (0:未读, 1:已读, 2:已删除, 3:已撤回),is_starred (bit),batch_id (用于群发标记) - 分区键:必须用user_id作为分区键(Sharding Key),这样单用户的所有信件都落在同一节点,方便分页查询。
- 索引设计:需要
(user_id, status, created_at)作为联合索引,覆盖站内信最常见的“查未读消息”场景。
会话层(可选,用于聚合)
- 如果需要类似“对话”的效果,可以增加
conversation_id字段,在关系层或专门建立会话表,将同一主题的消息聚合展示。
关键扩展机制
群发与系统通知:写扩散 vs 读扩散
- 写扩散:给100万用户群发,会生成100万条relation记录,但用户的“消息已读”状态可以单独更新,不会影响其他用户。
- 读扩散:不生成relation记录,用户登录时实时按条件查询(如
sender_id in (system)),适合全量群发,但查询压力大。 - 最佳实践:混合策略:
- 对于普通群发(<5000人),采用写扩散。
- 对于全量系统公告(如版本更新),采用读扩散 + 时间戳过滤,例如
user_message_view表记录用户最后阅读系统公告的时间,前端只展示晚于该时间的公告。
消息已读/未读查询优化
- 痛点:群发消息要查“多少人已读”,如果用
count(*)会非常慢。 - 解决方案:使用Redis Bitmap或Bloom Filter记录已读状态,例如对每一条群发消息,用一个Redis key
msg_<msg_id>_read,用bit位标记用户ID(需要建立用户ID到bit位的映射函数),查询已读数时直接bitcount。
分页优化
- 传统方案:
SELECT * FROM relation WHERE user_id=? ORDER BY created_at DESC LIMIT 20 OFFSET 1000 - 问题:深分页导致性能急剧下降。
- 优化方案:基于游标(Cursor)的分页,例如
WHERE user_id=? AND created_at < last_created_at ORDER BY created_at DESC LIMIT 20,前端传回当前列表最后一条消息的ID或时间戳,后端直接用索引定位,无论翻到第几页,性能都恒定。
存储层扩展方案
分库分表策略
- 水平分表:按
user_id分16/64/256张表(或更多),可以使用user_id % shard_count。 - 数据一致性:基于
user_id的查询都在同一分片,天然避免跨分片事务。 - 跨分片查询:全局搜索功能(如管理员查所有用户的某条消息)需要走ES(Elasticsearch)或OLAP(联机分析处理)引擎,不直接在关系库做。
冷热分离
- 热数据:最近30天活跃消息,放在高性能磁盘(如NVMe SSD)或TiDB等HTAP(混合事务/分析处理)数据库。
- 冷数据:超过30天的消息,自动迁移到低成本存储,如AWS S3、阿里云OSS,或性价比高的HDD机器,迁移策略可以使用定时任务
DELETE + INSERT INTO cold_table或直接用分区表PARTITION BY RANGE (created_at)。
实时性与推送
- 在线推送:用户在线时,通过WebSocket直接推送到客户端,并更新小红点。
- 离线积累:用户不在线时,消息存储在DB中,用户下次登录时,前端轮询
GET /api/inbox/unread_count?last_poll_time=xxx接口。 - 消息计数器:用Redis维护每个用户的未读消息计数器,key为
unread:user_id,值用int(整型),有消息入站时incr,用户查看后decr或直接重新set。
扩展性提升技巧
| 关注点 | 具体做法 | 好处 |
|---|---|---|
| ID生成 | 使用雪花算法(Snowflake)或Leaf ID,保证全局唯一且有序 | 利于分页和索引 |
| 异步处理 | 群发消息先入MQ(消息队列,如Kafka),消费端批量写入关系表 | 削峰填谷,保护数据库 |
| ES搜索 | 同步至ES | 支持全文检索和高级过滤 |
| 多租户隔离 | 在relation表中加入tenant_id(租户ID)字段 |
支持同一套代码服务多个业务线 |
| 限流与降级 | 针对发消息接口做令牌桶限流;当DB压力过大时,降级为“消息发送成功后异步落库” | 防止意外流量冲垮系统 |
一个典型的写入流程(群发1万用户)
- API接收:
POST /api/message/send、收件人列表、发送者。 - 写入消息表:生成
message_id,写入message。 - 发送MQ:封装
{message_id, user_ids: [1,2,...,10000]}到Kafka Topic “inbox_write”。 - 消费端处理:
- 按用户ID%分片数,将relation数据写入不同的Shard表。
- 同时更新Redis计数器
unread:user_id(对每个用户做INCR操作,批量合并时用INCRBY)。
- WebSocket推送:如果用户在线,通知其更新小红点数量。
推荐技术栈
- 数据库:TiDB(HTAP自动水平扩展)或 ShardingSphere + MySQL(分库分表)。
- 缓存:Redis Cluster(存未读计数、会话热点数据)。
- 消息队列:Pulsar / Kafka(处理群发、同步ES)。
- 搜索引擎:Elasticsearch(全文检索)。
- 存储:对象存储(腾讯云COS、OSS) + CDN(内容分发网络)(存储图片、附件、历史归档)。
需要避开的坑
- *不要用`SELECT COUNT()`频繁查未读数**:用Redis实时计数代替。
- 不要直接删除消息:逻辑删除(
status=2),物理删除用定时任务清理。 - 不要一次性查询全部收件人:即使只有100个收件人,也要分100次缓存写入或批量INSERT(插入)。
- 避免大事务:一次群发写入如果用
INSERT ... VALUES (10000 rows),会锁资源,应拆分成批量(每次500-1000条)提交。
这套设计能支撑千万级用户、每天上亿条消息的站内信系统,且关键操作(发、收、翻页)都可以通过增加节点线性扩展,你需要根据实际业务流量修正分片数和缓存策略。