用户换了手机,聊天记录没了;在电脑上发了消息,手机上看不到;明明在线却收不到消息……这些体验问题背后,是离线消息、消息漫游、多端同步的设计问题。这篇文章帮你彻底理清这块的技术实现。
一、问题定义:三大场景
1.1 离线消息
场景: 用户不在线时,别人发来的消息,上线后要能收到。
关键问题:
· 离线消息存哪里?
· 存多少?
· 怎么拉取?
· 什么时候清理?
1.2 消息漫游
场景: 用户换设备登录,能看到历史消息。
关键问题:
· 历史消息存多久?
· 查询性能如何保证?
· 多端数据如何一致?
1.3 多端同步
场景: 用户同时在手机和电脑登录,消息要同步。
关键问题:
· 如何判断消息已读?
· 消息状态如何同步?
· 如何避免消息重复?
二、离线消息设计
2.1 存储方案
方案一:用户维度存储(推荐)
每条消息写两份:
1、发送者的发件箱
2、接收者的收件箱(如果是离线消息)
存储结构:
offline_message:{user_id}
字段:
message_id
from_user_id
content
created_at
status
方案二:消息表 + 拉取标记
消息表(message)存储所有消息
用户拉取标记:
user_sync:{user_id}
字段:
last_sync_message_id
拉取时:
SELECT * FROM message
WHERE id > {last_sync_message_id}
AND to_user_id = {user_id}
2.2 拉取策略
策略一:全量拉取
用户上线 → 拉取所有离线消息
优势:简单
劣势:消息多时性能差
策略二:增量拉取
用户上线 → 拉取最近N条 → 用户滚动时继续拉取
优势:性能好
劣势:实现复杂
策略三:按会话拉取
用户上线 → 只拉取每个会话的最新一条 + 未读数
用户点开某个会话 → 拉取该会话的离线消息
优势:性能最好,体验好
劣势:实现复杂
2.3 离线消息清理
| 策略 | 说明 |
|---|---|
| TTL过期 | Redis设置TTL,过期自动清理 |
| 拉取后删除 | 用户拉取后删除 |
| 定时清理 | 定时任务清理超过N天的消息 |
| 持久化到历史表 | 清理前先同步到历史表 |
三、消息漫游设计
3.1 存储方案
分层存储:
热数据(近7天) : Redis
温数据(近3个月): MySQL
冷数据(3个月以上): MySQL归档库 / 对象存储
3.2 查询优化
索引设计:
CREATE TABLE message_history (
id BIGINT PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL COMMENT '会话ID',
message_id BIGINT NOT NULL,
from_user_id BIGINT NOT NULL,
to_user_id BIGINT NOT NULL,
content TEXT,
created_at DATETIME,
INDEX idx_conversation_time (conversation_id, created_at),
INDEX idx_message_id (message_id)
);
会话ID生成规则:
单聊会话ID:
min(user_id_a, user_id_b) + '_' + max(user_id_a, user_id_b)
示例:1001_2002
群聊会话ID:
group_{group_id}
示例:group_5001
3.3 查询流程
用户请求历史消息
1. 先查Redis热数据
> 有 → 返回
2. Redis无数据,查MySQL温数据
> 有 → 返回 + 写入Redis缓存
3. MySQL无数据,查冷数据(异步)
3.4 漫游时间范围策略
| 用户等级 | 漫游时间 | 说明 |
|---|---|---|
| 免费用户 | 7天 | 降低存储成本 |
| 付费用户 | 30天 | 会员权益 |
| VIP用户 | 360天 | 高端服务 |
四、多端同步设计
4.1 多端登录策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 单点登录 | 同一账号只能一个设备在线 | 安全要求高 |
| 多点登录 | 允许多设备同时在线 | 用户体验优先 |
| 平台互斥 | 手机和电脑可同时在线,同平台只能一个 | 折中方案 |
4.2 设备标识
每个设备分配唯一Device ID:
手机: 设备唯一标识
电脑: MAC地址 + 浏览器指纹
Web: Session ID
4.3 消息同步机制
核心问题:如何保证多端消息一致?
方案一:服务器推送到所有设备
用户A发送消息,服务器推送到用户B的所有在线设备 (B的手机 、B的电脑 、B的Web 等)。
优势: 实时性好
劣势: 服务器压力大
方案二:设备主动拉取
用户B设备上线 ,拉取最新消息(基于last_sync_id),推送或拉取,由设备状态决定
推荐方案:推拉结合
在线设备: 实时推送
离线设备: 上线后拉取
4.4 已读状态同步
问题: 手机上读了,电脑上还显示未读。
解决方案:已读状态同步
1、设备A标记已读
2、上报服务器
3、服务器推送到其他设备
4、设备B更新已读状态
已读状态存储:
# 用户维度
user:{user_id}:read_pointer:{conversation_id} = message_id
# 示例
user:1001:read_pointer:1001_2002 = 500000123
**查询未读消息:**
SELECT count(*) FROM message
WHERE conversation_id = '1001_2002'
AND id > 500000123;
五、消息去重设计
5.1 问题场景
· 网络重试导致消息重复发送
· 多设备同步导致消息重复接收
· 消息队列重投导致重复处理
5.2 去重方案
客户端去重:
发送消息时生成唯一client_msg_id
服务端存储message_id → client_msg_id映射
重复请求返回已存在的message_id
服务端去重:
# 消息去重表
message_dedup:{from_user_id}:{client_msg_id}
# 存在则返回已有消息ID
# 不存在则创建新消息
幂等设计:
def send_message(from_user, to_user, content, client_msg_id):
# 检查是否已处理
existing = redis.get(f"message_dedup:{from_user}:{client_msg_id}")
if existing:
return existing
# 创建消息
message_id = create_message(from_user, to_user, content)
# 记录去重
redis.setex(
f"message_dedup:{from_user}:{client_msg_id}",
86400, # 24小时过期
message_id
)
return message_id
六、架构总览
七、关键指标与优化
| 指标 | 目标 | 优化手段 |
|---|---|---|
| 离线消息拉取延迟 | <500ms | 分页拉取、按会话聚合 |
| 历史消息查询延迟 | <200ms | 索引优化、分层存储 |
| 多端同步延迟 | <1s | 推拉结合 |
| 已读同步延迟 | <2s | 实时推送 |
| 消息去重率 | 100% | client_msg_id + Redis |
下篇预告: 《礼物系统/打赏系统的高并发架构拆解》——社交变现的核心功能,高并发场景下的技术挑战。
持续输出社交App开发实战经验,关注我,一起成长。