598 lines
30 KiB
Markdown
598 lines
30 KiB
Markdown
# IM Redis 三层数据分层改造方案
|
||
|
||
日期:2026-08-25
|
||
|
||
## 1. 背景与目标
|
||
|
||
当前 IM 服务需要接入 Redis,但不希望出现“进程内缓存一份、Redis 缓存一份、MySQL 再存一份”的重复状态,避免后续一致性维护困难。
|
||
|
||
本方案按业务数据特征拆成三层:
|
||
|
||
- 进程内:只放低频变更配置和本节点运行态。
|
||
- Redis:放小体量、高频读取、需要跨节点一致的热数据。
|
||
- MySQL:放最终持久化数据、事务数据、审计数据和大体量列表。
|
||
|
||
第一阶段不把消息正文作为 Redis 的长期存储。万人大群可以把 24 小时内热消息放入 Redis 作为读加速,私聊消息最终来源是租户库 `im_messages`,群聊消息最终来源是租户库 `im_group_messages`。
|
||
|
||
## 2. 核心原则
|
||
|
||
1. 同一类业务状态只确定一个主读取层,不在进程内和 Redis 同时维护两套可变状态。
|
||
2. 进程内缓存只承载低频配置或本机连接态,不承载跨节点业务状态。
|
||
3. 在线、离线、最近活跃等跨节点状态统一从 Redis 查询。
|
||
4. MySQL 仍是需要持久化、事务和审计的数据最终来源。
|
||
5. Redis 缓存 MySQL 数据时采用 cache-aside 模式,写入以 MySQL 事务成功为准。
|
||
6. Redis key 必须带租户标识,虽然业务库是一租户一库,但 Redis 是共享基础设施,需要避免不同租户数据串扰。
|
||
|
||
## 3. 三层数据分类
|
||
|
||
| 层级 | 数据类型 | 典型数据 | 读写特点 | 一致性要求 |
|
||
| --- | --- | --- | --- | --- |
|
||
| 进程内 | 静态或低频配置、本机运行态 | 系统配置、字典、租户配置、OSS/短信/内容安全/声网配置、本机 WebSocket 连接、topic channel | 启动加载,定时刷新;连接态只在本机维护 | 配置允许秒级延迟;连接态只代表本节点 |
|
||
| Redis | 小体量热数据、跨节点状态、短期状态 | 用户在线状态、用户基础信息、群基础信息、群成员集合、好友/黑名单集合、24 小时热消息、验证码、临时 token、限流计数 | 高频读,TTL 或主动失效 | 跨节点查询一致,允许短暂最终一致 |
|
||
| MySQL | 持久化和事务数据 | 消息、文件记录、群申请审批、举报、审核任务、操作日志、账务类数据、大分页列表 | 事务写入,分页查询,审计追溯 | 最终权威来源 |
|
||
|
||
## 4. 进程内层设计
|
||
|
||
### 4.1 适合放进程内的数据
|
||
|
||
- 系统配置:后台 hotgo 中的系统参数。
|
||
- 字典配置:`hg_sys_dict_type`、`hg_sys_dict_data` 等字典项。
|
||
- 租户配置:租户启停、基础配置、默认策略。
|
||
- 第三方配置:OSS、阿里云内容安全、短信、声网等接入参数。
|
||
- 本节点运行态:当前进程内的 WebSocket session、local topic channel、本机订阅关系、本机连接数。
|
||
|
||
### 4.2 刷新策略
|
||
|
||
- 服务启动时加载一次配置。
|
||
- 定时刷新,建议 60 秒。
|
||
- 后台配置变更后,后续可补充主动失效或广播刷新。
|
||
- 配置读取失败时保留旧值,并打印告警日志。
|
||
|
||
### 4.3 边界说明
|
||
|
||
进程内层不保存用户在线状态的全局结果。进程内只知道“本节点有哪些连接”,跨节点判断用户是否在线必须查 Redis。
|
||
|
||
## 5. Redis 层设计
|
||
|
||
### 5.1 第一阶段必须接入
|
||
|
||
#### 用户在线状态
|
||
|
||
在线状态是 Redis 第一优先级改造内容。原因是 WebSocket 长连接固定落在某个节点上,本机内存可以管理本机连接,但其他节点无法直接知道该用户是否在线。
|
||
|
||
建议 key:
|
||
|
||
```text
|
||
im:{tenant_id}:presence:user:{uid}
|
||
im:{tenant_id}:presence:user:{uid}:sessions
|
||
im:{tenant_id}:presence:node:{node_id}:users
|
||
```
|
||
|
||
`presence:user:{uid}` 建议使用 hash:
|
||
|
||
```text
|
||
online = 1
|
||
node_id = 当前节点 ID
|
||
session_count = 当前会话数
|
||
last_active_at = 最近活跃时间
|
||
last_seen_at = 最近可见时间
|
||
device = 设备类型
|
||
ua = 客户端 UA
|
||
```
|
||
|
||
生命周期:
|
||
|
||
1. WebSocket 登录成功后写入 Redis 在线状态。
|
||
2. 心跳或业务包刷新 TTL。
|
||
3. 断开连接时扣减 session 数,最后一个 session 断开后标记离线。
|
||
4. 节点异常退出时依赖 TTL 自动过期,避免永久在线。
|
||
5. 其他节点查询用户在线状态时只查 Redis。
|
||
|
||
TTL 建议:
|
||
|
||
- 在线状态 hash:60 到 120 秒。
|
||
- session 集合:60 到 120 秒,随心跳刷新。
|
||
- node 用户集合:可不强依赖 TTL,用于辅助排查和节点清理。
|
||
|
||
当前代码已经接入基础 PresenceStore:
|
||
|
||
- 登录成功后写入 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。
|
||
- 客户端每次上行业务包刷新 `last_active_at` 和 TTL。
|
||
- session 清理时移除当前 session,最后一个 session 离线后将 `online` 置为 `0`。
|
||
- 对外提供 `store.GetUserPresence` 作为后续用户在线状态查询入口。
|
||
- 群 Topic 在线成员已同步维护 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`,订阅/离开时更新,客户端心跳或任意上行业务包会刷新已订阅群的在线成员 TTL。
|
||
- 订阅列表中的用户在线状态已改为 Redis 优先读取;Redis 未启用或读取失败时,回退到原有进程内在线判断。
|
||
|
||
#### 短期状态
|
||
|
||
以下数据适合放 Redis,减少 MySQL 压力并天然支持过期:
|
||
|
||
- 注册验证码、登录验证码。
|
||
- 邀请码短期校验结果。
|
||
- 企业码登录临时 token。
|
||
- 登录失败次数、风控计数、接口限流计数。
|
||
- 幂等 key、短期操作锁。
|
||
|
||
### 5.2 第二阶段建议接入
|
||
|
||
#### 用户基础信息
|
||
|
||
适合缓存:
|
||
|
||
- 用户昵称、头像、状态签名。
|
||
- 用户公开资料。
|
||
- 用户账号到 UID 的映射。
|
||
|
||
不建议缓存:
|
||
|
||
- 密码 hash。
|
||
- 高敏认证字段。
|
||
- KYC 原始材料。
|
||
|
||
读写规则:
|
||
|
||
1. 查询先读 Redis。
|
||
2. 未命中读 MySQL。
|
||
3. MySQL 命中后写入 Redis。
|
||
4. 用户资料变更时先提交 MySQL,再删除或更新 Redis。
|
||
|
||
#### 群相关数据
|
||
|
||
群数据体量相对可控,且聊天过程中读取频繁,适合逐步放入 Redis:
|
||
|
||
- 群基础信息:群名、头像、简介、人数、群类型。
|
||
- 群配置:入群审批、禁言策略、邀请权限、公告权限。
|
||
- 群成员集合:成员 UID 集合。
|
||
- 群成员角色:群主、管理员、普通成员。
|
||
- 群成员状态:禁言、移出、黑名单。
|
||
|
||
建议 key:
|
||
|
||
```text
|
||
im:{tenant_id}:group:{group_id}:profile
|
||
im:{tenant_id}:group:{group_id}:settings
|
||
im:{tenant_id}:group:{group_id}:members
|
||
im:{tenant_id}:group:{group_id}:admins
|
||
im:{tenant_id}:group:{group_id}:muted
|
||
```
|
||
|
||
#### 关系数据
|
||
|
||
好友、黑名单、关注类关系适合用 Redis set 承载热读:
|
||
|
||
```text
|
||
im:{tenant_id}:relation:friends:{uid}
|
||
im:{tenant_id}:relation:blocklist:{uid}
|
||
```
|
||
|
||
用于快速判断:
|
||
|
||
- 是否好友。
|
||
- 是否被拉黑。
|
||
- 是否允许发起会话。
|
||
- 是否允许查看在线状态或最近在线时间。
|
||
|
||
### 5.3 第三阶段可选接入
|
||
|
||
- 首页 bootstrap 聚合结果:短 TTL 缓存,减少多表聚合查询。
|
||
- 群成员分页快照深度优化:基础 HTTP 结果快照已接入,后续大群可继续补 sorted set、稳定游标和主动失效。
|
||
- 未读数深度优化:基础总未读 Redis 热缓存已接入,后续可继续补 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。
|
||
|
||
### 5.4 万人大群实时推送专项
|
||
|
||
万人大群的实时推送需要和 Redis 改造一起考虑。核心目标是避免普通小群模式下的全量 fanout 压垮服务端,同时保证在线用户实时可达、离线用户通过极光 Push 提醒、客户端重连后可按 seq 补齐消息。
|
||
|
||
该部分作为 Redis 改造的近期实现项处理,不再仅作为未实现增强项记录。
|
||
|
||
#### 数据分层
|
||
|
||
| 层级 | 承载内容 | 说明 |
|
||
| --- | --- | --- |
|
||
| 进程内 | 本节点在线连接、本节点 topic 订阅、本节点待发送队列 | 只负责当前节点连接和发送,不保存全局群成员在线状态。 |
|
||
| Redis | 群在线成员索引、用户所在节点、群分片、原子 seq 发号器、24 小时热消息、短期去重 key | 用于判断哪些在线用户需要实时推送、原子获取群消息 seq,以及近期高频消息读取加速。 |
|
||
| MySQL 租户库 | `im_group_messages` 群消息正文、群消息 seq 结果、消息索引、离线同步游标、审计数据 | 作为群消息最终存储,保存 Redis 分配后的 seq,不负责实时发号。 |
|
||
| 极光 Push | 离线提醒 | 只做提醒,不承载消息正文最终一致性。 |
|
||
|
||
#### 建议 Redis key
|
||
|
||
```text
|
||
im:{tenant_id}:presence:user:{uid}
|
||
im:{tenant_id}:topic:{topic}:online_members
|
||
im:{tenant_id}:topic:{topic}:node:{node_id}:online_members
|
||
im:{tenant_id}:topic:{topic}:shard:{shard_id}:members
|
||
im:{tenant_id}:topic:{topic}:last_seq
|
||
im:{tenant_id}:topic:{topic}:hot_msg_seqs
|
||
im:{tenant_id}:topic:{topic}:hot_msg:{seq}
|
||
im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id}
|
||
im:{tenant_id}:push:dedup:{topic}:{seq}:{uid}
|
||
```
|
||
|
||
说明:
|
||
|
||
- `topic:online_members` 保存当前群在线成员集合,可由用户上线、订阅群、退订群、断开连接维护。
|
||
- `topic:node:{node_id}:online_members` 用于跨节点推送时按节点拆分,减少广播范围。
|
||
- `topic:shard:{shard_id}:members` 可用于万人群成员分片,后续支持分批扫描和补偿。
|
||
- `topic:last_seq` 是群消息 seq 发号器,使用 Redis `INCR` 或 Lua 脚本原子获取下一个 seq。
|
||
- `topic:hot_msg_seqs` 使用 sorted set 保存 24 小时内热消息 seq,score 为 seq 或发送时间。
|
||
- `topic:hot_msg:{seq}` 保存单条热消息正文快照,TTL 建议 24 小时。
|
||
- `topic:uniq:msg:{from_uid}:{client_msg_id}` 保存群消息发送唯一 key,用于客户端重试幂等和服务端去重。
|
||
- `push:dedup` 用于极光 Push 或实时推送去重,TTL 建议按分钟级设置。
|
||
|
||
#### 唯一 key 与幂等规则
|
||
|
||
Redis 唯一 key 用于挡住短时间内的重复提交,MySQL 唯一索引作为最终兜底。不能只依赖 Redis 做永久唯一性,因为 Redis key 会过期,也可能被运维清理。
|
||
|
||
群消息发送唯一 key:
|
||
|
||
```text
|
||
im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id}
|
||
```
|
||
|
||
建议 value:
|
||
|
||
```json
|
||
{
|
||
"status": "pending|done|failed",
|
||
"seq": 12345,
|
||
"message_id": 987654321,
|
||
"created_at": "2026-08-25T12:00:00.000+08:00"
|
||
}
|
||
```
|
||
|
||
处理规则:
|
||
|
||
1. 客户端必须传 `client_msg_id`,同一发送人、同一 Topic 内唯一。
|
||
2. 服务端收到群消息后先用 `SET key value NX EX ttl` 抢占唯一 key,初始状态为 `pending`。
|
||
3. 抢占成功才继续 Redis `INCR` 获取群 `seq`。
|
||
4. 抢占失败时读取 key:
|
||
- `done`:直接返回已生成的 `seq/message_id`。
|
||
- `pending`:返回处理中或短暂等待后重试,避免重复发号。
|
||
- `failed`:允许客户端用同一个 `client_msg_id` 重试,或要求重新生成 `client_msg_id`。
|
||
5. 消息写入 `im_group_messages` 成功后,将唯一 key 更新为 `done`,记录 `seq/message_id`。
|
||
6. 消息写入失败时,将唯一 key 更新为 `failed` 并设置较短 TTL;已经获取的 `seq` 不回退,允许空洞。
|
||
7. `im_group_messages` 仍保留 `UNIQUE(topic, from_user, client_msg_id)`,防止 Redis key 过期后重复落库。
|
||
|
||
TTL 建议:
|
||
|
||
- `pending`:30 到 120 秒。
|
||
- `done`:24 小时到 7 天,按客户端重试窗口决定。
|
||
- `failed`:5 到 30 分钟。
|
||
- Push 去重 key:5 到 30 分钟。
|
||
|
||
#### Seq 发号规则
|
||
|
||
- 群消息 seq 由 Redis 维护,不从 MySQL 查询最大 seq。
|
||
- 每个 Topic 独立一个 `topic:last_seq`,使用 `INCR im:{tenant_id}:topic:{topic}:last_seq` 原子获取。
|
||
- 只要求单 Topic 内单调递增,不要求全局连续。
|
||
- 如果取到 seq 后消息持久化失败,该 seq 可以形成空洞;客户端按 seq 补拉时允许跳过服务端确认不存在的 seq。
|
||
- Topic 创建或 Redis key 丢失时,当前代码使用 topic 内存态 `lastID` 作为 Redis 初始化基线;该值来自 topic 加载结果或当前进程已确认的最新 seq,然后再执行原子 `INCR`。
|
||
- Redis 不可用时不能用数据库临时发号继续写大群消息,应返回可重试错误,避免多源发号造成 seq 冲突。
|
||
|
||
#### 发送流程
|
||
|
||
1. 服务端收到群消息后,先完成权限校验和禁言校验。
|
||
2. 使用 Redis 唯一 key 抢占 `topic + from_uid + client_msg_id`,防止客户端重试重复发送。
|
||
3. 抢占成功后,使用 Redis `INCR` 或 Lua 脚本从 `topic:last_seq` 原子获取下一个群 `seq`。
|
||
4. 消息正文带着该 `seq` 写入租户库 `im_group_messages`。
|
||
5. 落库成功后将唯一 key 更新为 `done`,记录 `seq/message_id`。
|
||
6. 对万人大群或高热群,将消息快照写入 `topic:hot_msg:{seq}`,并把 seq 加入 `topic:hot_msg_seqs`,TTL 保持 24 小时。
|
||
7. 查询 Redis 中该群在线成员,按 `node_id` 分组。
|
||
8. 本节点在线成员直接从进程内连接表推送。
|
||
9. 其他节点在线成员通过 Redis Pub/Sub 通知目标节点推送;当前代码使用 `im:0:group_fanout` 广播精简后的 `ServerComMessage`,订阅节点收到后投递到本机 `hub.routeSrv`,只发送给本节点已加载 Topic 的在线连接。
|
||
10. 不在线成员不做服务端全量离线消息 fanout,只更新离线游标或依赖消息 seq 补拉,并触发极光 Push。
|
||
11. 客户端重连后按 Topic seq 拉取缺失消息,优先读 Redis 热消息,未命中再回源租户库 `im_group_messages`。
|
||
|
||
#### 读取流程
|
||
|
||
客户端消息读取仍以 seq 为准,Redis 只优化 24 小时内的热读:
|
||
|
||
1. 在线实时收到消息后,客户端本地落库并更新本地最大 seq。
|
||
2. 重连、切换设备或发现 seq 不连续时,调用历史消息接口按 `topic + after_seq` 补拉。
|
||
3. 服务端先按 seq 范围读取 Redis 热消息。
|
||
4. Redis 命中的消息直接返回;缺失、过期或不连续的 seq 回源租户库 `im_group_messages`。
|
||
5. 回源命中后可按策略重新写入 Redis,保持 24 小时 TTL。
|
||
6. 极光 Push 唤醒后,客户端仍走正常登录和 seq 补拉链路。
|
||
|
||
#### 热消息缓存边界
|
||
|
||
- 只缓存 24 小时内高频读取的群消息,普通群可不开启或按群规模阈值开启。
|
||
- Redis 中的消息快照不作为审计和历史同步最终来源。
|
||
- 删除、撤回、编辑消息后,必须同步删除或更新对应 `topic:hot_msg:{seq}`,避免客户端读到旧正文。
|
||
- 大消息、文件二进制、音视频内容不直接放 Redis,只缓存消息元数据和 OSS object key。
|
||
- Redis 容量按租户和群设置上限,超过阈值时优先淘汰低热群或旧 seq。
|
||
- 当前代码只在 Redis 能完整命中一页群消息时直接返回;如果 Redis 缺失、过期、不连续或读取带有用户软删除视角,则回源 MySQL,避免缓存缺口导致消息缺失或读到用户已删除内容。
|
||
|
||
#### 离线 Push 口子
|
||
|
||
极光 Push 只用于离线提醒,建议 payload 只放最小必要信息:
|
||
|
||
```json
|
||
{
|
||
"type": "group_message",
|
||
"topic": "grp_xxx",
|
||
"seq": 12345,
|
||
"tenant_id": "tenant_xxx"
|
||
}
|
||
```
|
||
|
||
不建议在 Push payload 中放完整消息正文,避免隐私泄露、厂商通道截断和多端一致性问题。
|
||
|
||
#### 一致性边界
|
||
|
||
- Redis 的在线成员集合允许短暂不准,最终由心跳 TTL 和订阅变更修正。
|
||
- Redis 唯一 key 负责短期幂等和去重,MySQL 唯一索引负责最终唯一性兜底。
|
||
- seq 是否已分配以 Redis `topic:last_seq` 为准;群消息是否真实存在,以租户库 `im_group_messages` 为准。
|
||
- Redis 发号成功但消息持久化失败时,允许 seq 空洞,不回退 `topic:last_seq`。
|
||
- 实时推送失败不影响消息持久化,通过客户端 seq 补拉恢复;Redis 热消息缺失时回源租户库 `im_group_messages`。
|
||
- 极光 Push 失败不影响消息存储,只影响离线提醒,需要记录失败并可观测。
|
||
- Redis Pub/Sub 只负责在线实时投递,不作为可靠消息队列;节点短暂断线错过实时消息时,客户端仍以 Topic seq 补拉恢复。
|
||
|
||
## 6. MySQL 层设计
|
||
|
||
### 6.1 必须保留在 MySQL 的数据
|
||
|
||
- 私聊消息正文、私聊消息索引、离线同步游标。
|
||
- 群消息正文、群消息索引、群消息审计数据。
|
||
- 文件记录、OSS object key、文件安全审核结果。
|
||
- 群邀请、进群申请、审批记录。
|
||
- 群公告、公告确认、公告历史。
|
||
- 用户认证、KYC 记录、企业注册资料。
|
||
- 举报、投诉、内容安全审核任务。
|
||
- 操作日志、审计日志、管理后台变更记录。
|
||
- 需要事务一致性的业务状态。
|
||
|
||
### 6.2 消息表拆分
|
||
|
||
当前 `im_messages` 后续只作为私聊、文件助手等非群聊消息的主存储。群聊消息新增租户库业务表 `im_group_messages`,避免所有消息挤在一张表里,也方便后续针对大群按 `topic + seq`、时间、热度做分区、归档和缓存。
|
||
|
||
建议表结构:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS im_group_messages (
|
||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||
topic VARCHAR(64) NOT NULL COMMENT '群 Topic,如 grp_xxx',
|
||
group_id BIGINT NOT NULL COMMENT '业务群 ID,便于分区、治理和统计',
|
||
seqid BIGINT NOT NULL COMMENT '群内消息序号,由 Redis 原子发号',
|
||
from_user BIGINT NOT NULL COMMENT '发送人用户 ID',
|
||
client_msg_id VARCHAR(64) NULL COMMENT '客户端幂等消息 ID',
|
||
msg_type VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等',
|
||
status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked',
|
||
content JSON NULL COMMENT '消息正文或 Drafty 内容',
|
||
head JSON NULL COMMENT '消息头,包含 @、引用、客户端能力等',
|
||
mentioned_user_ids JSON NULL COMMENT '@ 用户列表,便于提醒和检索',
|
||
mention_all TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 @ 全体',
|
||
reply_seqid BIGINT NULL COMMENT '回复的群消息 seq',
|
||
root_seqid BIGINT NULL COMMENT '线程或合并上下文根消息 seq',
|
||
file_count INT NOT NULL DEFAULT 0 COMMENT '附件数量',
|
||
content_size INT NOT NULL DEFAULT 0 COMMENT '正文 JSON 序列化后的字节数',
|
||
has_risk TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否命中风控或内容安全',
|
||
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject',
|
||
version INT NOT NULL DEFAULT 1 COMMENT '编辑版本',
|
||
recalled_at DATETIME(3) NULL,
|
||
recalled_by BIGINT NULL,
|
||
deleted_at DATETIME(3) NULL,
|
||
expires_at DATETIME(3) NULL COMMENT '可选保留期,便于过期归档或清理',
|
||
created_at DATETIME(3) NOT NULL,
|
||
updated_at DATETIME(3) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uk_im_group_messages_group_seq (group_id, seqid),
|
||
UNIQUE KEY uk_im_group_messages_topic_seq (topic, seqid),
|
||
UNIQUE KEY uk_im_group_messages_topic_sender_client_mid (topic, from_user, client_msg_id),
|
||
KEY idx_im_group_messages_group_created (group_id, created_at),
|
||
KEY idx_im_group_messages_group_status_seq (group_id, status, seqid),
|
||
KEY idx_im_group_messages_topic_created (topic, created_at),
|
||
KEY idx_im_group_messages_topic_from_seq (topic, from_user, seqid),
|
||
KEY idx_im_group_messages_audit (audit_status, created_at),
|
||
KEY idx_im_group_messages_expires (expires_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||
```
|
||
|
||
设计说明:
|
||
|
||
- `seqid` 使用 `BIGINT`,避免大群长期高频消息导致 `INT` 不够用。
|
||
- `group_id + seqid` 和 `topic + seqid` 都保持唯一,业务治理优先用 `group_id`,协议补拉仍可用 `topic`。
|
||
- `client_msg_id` 用于发送幂等,索引带上 `topic`,避免不同群复用客户端消息 ID 时互相冲突。
|
||
- `status` 标记普通、撤回、删除、拦截状态,拉历史时可以返回占位或过滤。
|
||
- `mentioned_user_ids`、`mention_all` 单独存,方便 @ 提醒、离线 Push 和后台治理,不必每次解析正文。
|
||
- `audit_status`、`has_risk` 为内容安全和风控留字段,高风险消息可先 pending 后再放行。
|
||
- `reply_seqid`、`root_seqid` 为引用回复、线程、合并转发上下文预留。
|
||
- 文件二进制不进表,表内只保存消息内容和 OSS object key 等元数据;群消息附件会同步登记到 `im_group_files`,并记录 `source_topic/source_seq` 追溯来源消息。
|
||
- 超大规模租户后续可按 `group_id` hash 分表,或按 `created_at/expires_at` 做归档分区。
|
||
|
||
读写规则:
|
||
|
||
1. 私聊消息继续写 `im_messages`。
|
||
2. 群聊消息写 `im_group_messages`。
|
||
3. 群消息发号由 Redis `topic:last_seq` 原子递增完成,`im_group_messages.seqid` 只保存结果。
|
||
4. Redis 24 小时热消息命中时直接返回;未命中按 `topic + seqid` 回源 `im_group_messages`。
|
||
5. 撤回、编辑、删除群消息时,以 `im_group_messages` 为准,并同步更新或删除 Redis 热消息。
|
||
|
||
### 6.3 消息正文说明
|
||
|
||
第一阶段不把消息正文作为 Redis 长期存储。万人大群和高热群允许缓存 24 小时内热消息,作为客户端重连补拉、最近历史翻页和多端同步的读加速。
|
||
|
||
消息正文主要读取场景:
|
||
|
||
- 新设备登录后的历史消息同步。
|
||
- 离线用户重新上线后的消息补拉。
|
||
- 客户端本地消息缺失后的重拉。
|
||
- 举报、风控、内容安全复查。
|
||
- 管理后台审计或客服处理。
|
||
|
||
这些场景的最终来源仍是 MySQL 的消息表。私聊回源 `im_messages`,群聊回源 `im_group_messages`。Redis 热消息只覆盖短期高频读取,命中则减少数据库压力,未命中必须回源。
|
||
|
||
## 7. 读写一致性规则
|
||
|
||
### 7.1 进程内配置
|
||
|
||
- 进程启动加载配置。
|
||
- 定时刷新配置。
|
||
- 配置读取失败时使用旧值。
|
||
- 配置变更允许短时间延迟生效。
|
||
|
||
### 7.2 Redis 运行态
|
||
|
||
- 在线状态以 Redis 为跨节点查询来源。
|
||
- 本机内存只用于管理本机连接。
|
||
- Redis 写入失败时,在线状态降级为未知或离线,不阻断基础连接能力。
|
||
- 心跳持续刷新 TTL,节点异常时依赖 TTL 自动回收。
|
||
|
||
### 7.3 Redis 缓存 MySQL 热数据
|
||
|
||
- 读:先 Redis,未命中再 MySQL,最后回填 Redis。
|
||
- 写:先 MySQL 事务提交,再删除或更新 Redis。
|
||
- 消息 seq:群聊先通过 Redis `INCR` 或 Lua 脚本原子获取 seq,再带 seq 写租户库 `im_group_messages`;私聊仍按现有 `im_messages` 链路处理。
|
||
- 消息热缓存:消息持久化成功后,再写 Redis 24 小时热消息;热消息写失败不影响消息发送成功。
|
||
- Redis 故障:直接回源 MySQL,记录降级日志和指标。
|
||
- 不允许因为 Redis 写失败回滚已经成功的 MySQL 业务事务,除非该业务本身强依赖 Redis 状态。
|
||
|
||
### 7.4 MySQL 持久化
|
||
|
||
- 需要审计、事务、历史追溯的数据直接写 MySQL。
|
||
- Redis 和进程内缓存只做读取加速或运行态存储。
|
||
- 私聊消息使用 `im_messages`,群聊消息使用 `im_group_messages`,避免单表无限膨胀。
|
||
|
||
## 8. 租户隔离
|
||
|
||
当前方案是一租户一库,业务表中不再保留 `tenant_id` 字段。但 Redis、进程内配置、日志指标仍属于共享运行环境,必须保留租户维度。
|
||
|
||
Redis key 统一包含租户标识:
|
||
|
||
```text
|
||
im:{tenant_id}:...
|
||
```
|
||
|
||
如果请求早期只有企业码或租户编码,应先解析到租户 ID 或租户库配置,再进入业务缓存读取。
|
||
|
||
## 9. 代码改造建议
|
||
|
||
### 9.1 基础设施
|
||
|
||
- 增加 Redis 配置:地址、密码、DB、连接池、超时、key 前缀。
|
||
- 增加 Redis client 初始化和健康检查。
|
||
- 增加统一 key builder,避免业务代码手写 key。
|
||
- 增加缓存指标和日志。
|
||
|
||
当前代码将 Redis 配置挂在 `store_config.redis` 下;不配置则不启用 Redis 群消息发号和唯一 key,群消息仍兼容旧的本机 `lastID + 1` 发号。
|
||
|
||
群消息物理表按自然月拆分时,落库路由、跨月查询、幂等和迁移方案见:[IM-群消息按月分表改造方案](./IM-群消息按月分表改造方案.md)。
|
||
|
||
配置示例:
|
||
|
||
```json
|
||
{
|
||
"store_config": {
|
||
"redis": {
|
||
"addr": "127.0.0.1:6379",
|
||
"username": "",
|
||
"password": "",
|
||
"db": 0,
|
||
"key_prefix": "im",
|
||
"dial_timeout_ms": 2000,
|
||
"read_timeout_ms": 2000,
|
||
"write_timeout_ms": 2000,
|
||
"pending_ttl_seconds": 120,
|
||
"done_ttl_seconds": 86400,
|
||
"failed_ttl_seconds": 600,
|
||
"hot_message_ttl_seconds": 86400,
|
||
"presence_ttl_seconds": 120,
|
||
"cache_ttl_seconds": 300,
|
||
"short_state_ttl_seconds": 600,
|
||
"group_fanout_enabled": true
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`group_fanout_enabled` 默认关闭。启用后,群消息本节点投递完成后会发布 Redis Pub/Sub,其他节点订阅后只向本节点已在线的同群连接推送;如果节点没有加载该 Topic,则读取 Redis 本节点群在线成员集合做兜底直投,本地会话订阅表作为兼容兜底,实时投递失败再由客户端 seq 补拉恢复。
|
||
|
||
当前第二阶段已接入:
|
||
|
||
- 用户资料单对象缓存:`store.Users.Get` 优先读 Redis,用户更新/删除后失效。
|
||
- Topic 单对象缓存:`store.Topics.Get` 优先读 Redis,Topic 更新、转让、删除后失效。
|
||
- 单条订阅关系缓存:`store.Subs.Get` 优先读 Redis,订阅更新/删除后 active/any 两种缓存同时失效。
|
||
- 群治理资料缓存:群设置 `GroupBusiness.GetSettings`、群成员 `GroupBusiness.GetMember` 优先读 Redis,更新、离群、角色变更后刷新或失效。
|
||
- 群成员分页快照:`GET /v1/im/groups/{topic}/members` 按租户、Topic、操作者权限、筛选条件、cursor 和 limit 缓存响应快照,未命中回源订阅列表。
|
||
- 用户总未读热缓存:`store.Users.GetUnreadCount` 先读 Redis,未命中回源 MySQL 并回填;在线用户 unread updater 更新内存计数时同步写 Redis。
|
||
- 好友/黑名单 Redis Set 热缓存:关系上下文判断先查 `friends:{uid}`、`blocklist:{uid}` 正向 set,命中后减少 MySQL 查询;未命中仍回源 MySQL,新增/删除好友、拉黑/解除拉黑会同步更新 set。
|
||
- 验证码短状态缓存:验证码创建、查询、验证、失败、消费会同步 Redis,`GetActive` 使用 `scene + phone_hash` 的 active key 做重发窗口热读。
|
||
- Redis 频控:验证码创建按 `tenant + scene + phone_hash` 使用 Redis 原子 `INCR + EXPIRE` 做 1 小时 5 次限制,手机号只使用 hash,不写明文。
|
||
- 动作 token 短状态缓存:创建时写 Redis,消费成功后删除缓存;最终消费仍以 MySQL 状态机为准,保留审计一致性。
|
||
- SeqId 收口:消息、Topic 游标、订阅读/收游标、消息范围、收藏/举报/群文件引用、通话消息引用、JSON/gRPC 协议结构均改为 `int64`;MySQL 新建表 schema 中对应字段改为 `BIGINT`。
|
||
- 进程内配置缓存:租户解析、注册短信开关、第三方配置读取已加本地 TTL 缓存,减少 HotGo 控制库频繁查询;缓存过期后按下一次读取刷新。
|
||
|
||
### 9.2 接口抽象
|
||
|
||
建议新增接口:
|
||
|
||
```go
|
||
type PresenceStore interface {
|
||
MarkOnline(ctx context.Context, tenantID string, uid string, sessionID string, meta PresenceMeta) error
|
||
Refresh(ctx context.Context, tenantID string, uid string, sessionID string) error
|
||
MarkOffline(ctx context.Context, tenantID string, uid string, sessionID string) error
|
||
Get(ctx context.Context, tenantID string, uid string) (Presence, error)
|
||
}
|
||
|
||
type ConfigCache interface {
|
||
GetDict(ctx context.Context, key string) (DictValue, bool)
|
||
GetTenantConfig(ctx context.Context, tenantID string) (TenantConfig, bool)
|
||
Refresh(ctx context.Context) error
|
||
}
|
||
|
||
type ReadModelCache interface {
|
||
GetUserProfile(ctx context.Context, tenantID string, uid string) (UserProfile, bool)
|
||
SetUserProfile(ctx context.Context, tenantID string, profile UserProfile) error
|
||
DeleteUserProfile(ctx context.Context, tenantID string, uid string) error
|
||
}
|
||
```
|
||
|
||
### 9.3 业务接入顺序
|
||
|
||
1. 先接入 Redis client 和配置。
|
||
2. 接入在线状态 PresenceStore。
|
||
3. 群消息接入 Redis `SET NX` 唯一 key 与 `INCR` 原子 seq 发号,群消息正文写入 `im_group_messages`。
|
||
4. WebSocket 登录、心跳、断开链路写入 PresenceStore。
|
||
5. 用户在线状态查询接口改为读 Redis。
|
||
6. 配置和字典改为进程内 TTL 缓存刷新。
|
||
7. 再逐步接入用户、群、好友关系热数据缓存。
|
||
|
||
## 10. 故障降级
|
||
|
||
| 场景 | 降级策略 |
|
||
| --- | --- |
|
||
| Redis 不可用,查询在线状态 | 返回未知或离线,记录告警 |
|
||
| Redis 不可用,读取用户/群缓存 | 回源 MySQL |
|
||
| Redis 不可用,验证码或短期 token | 涉及安全的接口应失败关闭 |
|
||
| Redis 不可用,万人大群发消息 | 无法原子获取群 seq,应返回可重试错误;不使用 MySQL 临时发号,避免 seq 冲突 |
|
||
| 配置刷新失败 | 使用进程内旧配置 |
|
||
| MySQL 不可用 | 持久化业务失败,不依赖 Redis 兜底 |
|
||
|
||
## 11. 监控指标
|
||
|
||
- Redis 连接状态。
|
||
- Redis 命令耗时。
|
||
- Redis 缓存命中率。
|
||
- 在线用户 key 数量。
|
||
- 心跳刷新失败次数。
|
||
- Redis 回源 MySQL 次数。
|
||
- 大群在线成员集合大小。
|
||
- 大群按节点分发耗时。
|
||
- 大群实时推送成功率和补拉率。
|
||
- 极光 Push 触发数、成功数和失败数。
|
||
- 配置刷新成功和失败次数。
|
||
- Presence TTL 过期导致的离线数量。
|
||
|
||
## 12. 第一阶段落地范围
|
||
|
||
第一阶段建议只做以下内容:
|
||
|
||
- Redis 基础配置和 client 初始化。
|
||
- 用户在线、离线、心跳刷新、跨节点查询。
|
||
- 验证码、临时 token、限流计数等短期状态迁移到 Redis。
|
||
- 系统配置、字典、租户配置、第三方配置改为进程内 TTL 缓存刷新。
|
||
- 万人大群实时推送纳入 Redis 改造近期实现范围,先完成 Redis key、在线成员索引、节点分片和跨节点实时分发链路。
|
||
- 明确消息正文不进入 Redis 长期存储;万人大群 24 小时热消息可进入 Redis 做读加速。
|
||
- 群成员分页快照和用户总未读 Redis 热缓存已完成基础接入。
|
||
|
||
后续再逐步推进好友/黑名单全量预热、群成员分页快照、未读数等缓存改造。
|