feat: shard group messages by month
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
# IM 群消息按月分表改造方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前每个租户库使用单表 `im_group_messages` 保存全部群消息。随着群数量、消息量和保留时间增长,单表会出现索引膨胀、查询抖动、DDL 风险升高和归档困难等问题。
|
||||
|
||||
本方案将群消息正文按自然月拆分为物理表:
|
||||
|
||||
```text
|
||||
im_group_messages_202609
|
||||
im_group_messages_202610
|
||||
im_group_messages_202611
|
||||
```
|
||||
|
||||
目标:
|
||||
|
||||
1. 每个租户库独立分表,消息表不增加 `tenant_id`。
|
||||
2. Topic 的 `seqid` 继续由 Redis 原子递增,跨月不重置。
|
||||
3. Kafka 异步落库稳定路由到首次确定的月表。
|
||||
4. 客户端继续按 `topic + seq` 拉取,不感知月份。
|
||||
5. 支持跨月分页、补拉、指定消息、搜索、撤回、编辑和删除。
|
||||
6. 旧单表平滑迁移,不停服、不丢失、不重复。
|
||||
7. Redis 保留 24 小时热消息,MySQL 月表仍是最终数据源。
|
||||
|
||||
## 2. 核心原则
|
||||
|
||||
### 2.1 分表粒度
|
||||
|
||||
- 一个租户库每月一张群消息表,不按单个群建表。
|
||||
- 表名固定为 `im_group_messages_YYYYMM`。
|
||||
- 月份统一使用 UTC,但最终写入月份由 Redis 全局 `active_month` 决定,避免多节点在月初产生交叠。
|
||||
- Redis Lua 一次性返回 `seqid + shard_month`;生产端把结果固化到 Kafka,重试、补偿不得重算。
|
||||
|
||||
### 2.2 Seq 规则
|
||||
|
||||
- `seqid` 在 Topic 内全生命周期单调递增,跨月不归零。
|
||||
- Redis `topic:last_seq` 是唯一发号源。
|
||||
- 月表只保存已分配的 Seq,不通过 `MAX(seqid)` 发号。
|
||||
- 客户端游标继续使用 `before/since/seq`。
|
||||
|
||||
### 2.3 分层职责
|
||||
|
||||
| 层级 | 职责 |
|
||||
| --- | --- |
|
||||
| Redis | 原子 Seq、全局活动月份、长期 Topic 月段路由、短期发送幂等、24 小时热消息、在线状态、实时推送 |
|
||||
| Kafka | 异步落库、削峰、重试、死信、消费幂等 |
|
||||
| MySQL 月表 | 群消息正文最终存储 |
|
||||
| MySQL 路由镜像 | 月表目录、Topic 月段路由持久化镜像;Redis 丢失时用于重建,不进入正常查询热路径 |
|
||||
| `im_dellog` | 用户视角删除范围,每张月表查询时继续过滤 |
|
||||
|
||||
## 3. 表结构
|
||||
|
||||
### 3.1 月表模板
|
||||
|
||||
第一阶段保持当前字段兼容,降低 Store 层改造范围。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_messages_202609 (
|
||||
message_id BIGINT NOT NULL COMMENT 'Snowflake 全局消息 ID',
|
||||
topic VARCHAR(64) NOT NULL COMMENT '群 Topic',
|
||||
group_id BIGINT NOT NULL COMMENT '业务群 ID',
|
||||
seqid BIGINT NOT NULL COMMENT 'Topic 内递增,跨月不重置',
|
||||
from_user BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64) NULL,
|
||||
msg_type VARCHAR(32) NOT NULL DEFAULT 'text',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
content JSON NULL,
|
||||
head JSON NULL,
|
||||
mentioned_user_ids JSON NULL,
|
||||
mention_all TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reply_seqid BIGINT NULL,
|
||||
root_seqid BIGINT NULL,
|
||||
file_count INT NOT NULL DEFAULT 0,
|
||||
content_size INT NOT NULL DEFAULT 0,
|
||||
has_risk TINYINT(1) NOT NULL DEFAULT 0,
|
||||
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
recalled_at DATETIME(3) NULL,
|
||||
recalled_by BIGINT NULL,
|
||||
deleted_at DATETIME(3) NULL,
|
||||
expires_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (message_id),
|
||||
UNIQUE KEY uk_topic_seq (topic, seqid),
|
||||
UNIQUE KEY uk_topic_sender_client_mid (topic, from_user, client_msg_id),
|
||||
KEY idx_group_seq (group_id, seqid),
|
||||
KEY idx_topic_created (topic, created_at),
|
||||
KEY idx_topic_from_seq (topic, from_user, seqid),
|
||||
KEY idx_audit_created (audit_status, created_at),
|
||||
KEY idx_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `message_id` 使用 Snowflake 全局唯一 ID,作为月表物理主键和跨表引用 ID。
|
||||
2. 聊天协议定位、补拉和已读水位仍统一使用 `topic + seqid`,不向客户端暴露物理主键。
|
||||
3. 单表唯一索引不能覆盖跨月重试,跨月幂等由 Redis、消费日志和幂等表共同保证。
|
||||
4. 文件二进制仍在 OSS,月表仅保存消息和文件元数据。
|
||||
|
||||
### 3.2 月表目录
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_shards (
|
||||
shard_month CHAR(6) NOT NULL COMMENT 'UTC 月份 YYYYMM',
|
||||
table_name VARCHAR(64) NOT NULL,
|
||||
shard_start DATETIME(3) NOT NULL,
|
||||
shard_end DATETIME(3) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'prepared'
|
||||
COMMENT 'prepared/writable/sealing/sealed/archived',
|
||||
row_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NULL,
|
||||
max_created_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (shard_month),
|
||||
UNIQUE KEY uk_table_name (table_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
表名只允许由服务端根据月份生成,并且必须存在于目录表,禁止使用请求参数直接拼接 SQL。
|
||||
|
||||
### 3.3 Redis Topic 月段路由
|
||||
|
||||
在线查询使用 Redis 路由,路由 Key 长期不过期:
|
||||
|
||||
```text
|
||||
# 当前可写月份
|
||||
im:{tenant_id}:groupmsg:active_month
|
||||
STRING = 202610
|
||||
|
||||
# Topic 月份段,score 为该月首条 seq
|
||||
im:{tenant_id}:groupmsg:route:{topic}
|
||||
ZSET: 202609 => 1
|
||||
202610 => 10087
|
||||
|
||||
# 月段完整范围,field 为 YYYYMM
|
||||
im:{tenant_id}:groupmsg:route_meta:{topic}
|
||||
HASH: 202609 => min_seq=1,max_seq=10086,count=10086
|
||||
202610 => min_seq=10087,max_seq=12000,count=1914
|
||||
```
|
||||
|
||||
路由规则:
|
||||
|
||||
1. `route` 和 `route_meta` 不设置 TTL,只在群物理删除或对应消息保留期结束后清理。
|
||||
2. 精确 Seq 定位使用 `ZREVRANGEBYSCORE route seq -inf LIMIT 0 1`,找到最大 `min_seq <= seq` 的月份,再校验 Hash 中的 `max_seq`。
|
||||
3. 倒序分页通过 `ZREVRANGE` 按月段倒序读取,正向补拉通过 `ZRANGE` 读取。
|
||||
4. 路由 Key 使用 Redis Cluster hash tag,保证 Lua 和 Pipeline 访问同一槽位。
|
||||
5. Redis 开启 AOF everysec、主从复制和持久化备份;同时保留 MySQL 镜像作为灾备重建来源。
|
||||
|
||||
活动月份和 Seq 必须在同一个 Lua 脚本中读取/分配,两个 Key 使用相同的租户 hash tag:
|
||||
|
||||
```lua
|
||||
local shard_month = redis.call('GET', KEYS[1])
|
||||
if not shard_month or shard_month < ARGV[1] then
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
shard_month = ARGV[1]
|
||||
end
|
||||
|
||||
local seq_id = redis.call('INCR', KEYS[2])
|
||||
return {seq_id, shard_month}
|
||||
```
|
||||
|
||||
其中 `KEYS[1]` 是租户活动月份,`KEYS[2]` 是 Topic Seq,`ARGV[1]` 来自 Redis `TIME` 转换后的 UTC 月份。业务节点不得根据本机时间计算月份;首次写入可初始化活动月份,之后 Lua 只允许月份单向推进,旧节点不能回拨。
|
||||
|
||||
### 3.4 MySQL 路由镜像
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_topic_shards (
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
shard_month CHAR(6) NOT NULL,
|
||||
min_seq BIGINT NOT NULL,
|
||||
max_seq BIGINT NOT NULL,
|
||||
message_count BIGINT NOT NULL DEFAULT 0,
|
||||
min_created_at DATETIME(3) NOT NULL,
|
||||
max_created_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (topic, shard_month),
|
||||
KEY idx_topic_max_seq (topic, max_seq),
|
||||
KEY idx_month_topic (shard_month, topic)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
该表不参与正常消息查询,只用于 Kafka 事务持久化、Redis 路由重建、历史迁移、数据核对和归档。Redis 与镜像不一致时,以月表实际数据和 MySQL 镜像修复 Redis。
|
||||
|
||||
路由一致性规则:
|
||||
|
||||
1. Kafka 消费事务同时提交消息和 MySQL 路由镜像,保证最终数据与镜像一致。
|
||||
2. MySQL 提交成功后再更新 Redis `route/route_meta`;Redis 更新失败不回滚消息,进入路由修复重试队列。
|
||||
3. 查询发现 Redis 路由 Key 不存在时,从 MySQL 镜像加载该 Topic 全部有效月段并回填 Redis,然后重试查询。
|
||||
4. Shard Manager 启动时可按租户增量重建路由,并定时抽样核对 Redis、MySQL 镜像和月表实际 Seq 范围。
|
||||
5. Redis 路由不设置 TTL;只有群被物理删除,或消息保留策略确认清除对应月份后,才同步删除相应月段。
|
||||
|
||||
### 3.5 跨月幂等表
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_group_message_dedup (
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
from_user BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64) NOT NULL,
|
||||
seqid BIGINT NOT NULL,
|
||||
shard_month CHAR(6) NOT NULL,
|
||||
message_id BIGINT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (topic, from_user, client_msg_id),
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_topic_seq (topic, seqid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
- Redis `SET NX` 是入口快速幂等。
|
||||
- Kafka 消费日志按 `event_id` 防止同一事件重复消费。
|
||||
- 该表防止月末发送、月初重试时同一 `client_msg_id` 落入两张表。
|
||||
- 建议保留 7 至 30 天后清理,不作为永久消息索引。
|
||||
|
||||
## 4. 建表和月切换
|
||||
|
||||
每个租户库运行统一 Shard Manager:
|
||||
|
||||
1. 每天巡检上月、当月和未来两个月。
|
||||
2. 缺表时根据固定模板创建,成功后登记目录。
|
||||
3. 使用 Redis 分布式锁和 Redis `TIME` 判断月界,确认新月表可写后再原子更新 `active_month`。
|
||||
4. 当月设置为 `writable`,所有节点通过 Redis 读取同一个活动月份。
|
||||
5. 上月进入 `sealing`,建议继续接受 7 天延迟 Kafka 消息。
|
||||
6. 确认没有积压和死信后改为 `sealed`。
|
||||
7. 建表失败立即告警,不切换 `active_month`,禁止在业务写入事务中临时执行 DDL。
|
||||
|
||||
月切换示例:
|
||||
|
||||
```text
|
||||
2026-09-30 23:59:59 UTC seq=10086 -> im_group_messages_202609
|
||||
2026-10-01 00:00:00 UTC seq=10087 -> im_group_messages_202610
|
||||
```
|
||||
|
||||
Kafka 事件必须固定路由信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "grp_xxx",
|
||||
"seq_id": 10087,
|
||||
"created_at": "2026-10-01T00:00:00.123Z",
|
||||
"shard_month": "202610",
|
||||
"client_msg_id": "client_mid_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 群消息落库
|
||||
|
||||
### 5.1 生产端
|
||||
|
||||
1. 校验群成员、禁言、群状态和内容策略。
|
||||
2. 使用 Redis 幂等 Key 抢占 `client_msg_id`。
|
||||
3. 使用 Redis Lua 原子获取 Topic 下一个 `seqid` 和全局 `active_month`。
|
||||
4. 生成服务端 UTC `created_at`,并把 Lua 返回的月份作为 `shard_month`。
|
||||
5. 把 `shard_month` 写入 Kafka;所有重试沿用原值。
|
||||
6. Kafka 生产成功后按现有策略向客户端返回 Seq。
|
||||
7. 写入 Redis 热消息并执行实时推送。
|
||||
|
||||
### 5.2 Kafka 消费端
|
||||
|
||||
消费端按 `tenant + shard_month` 分组批量写入:
|
||||
|
||||
1. 校验月份格式和允许范围。
|
||||
2. 从目录确认目标表已存在。
|
||||
3. 开启事务。
|
||||
4. 插入或确认 `im_group_message_dedup`。
|
||||
5. 插入目标月表。
|
||||
6. 在同一事务中 UPSERT MySQL 路由镜像的最小/最大 Seq、时间和消息数。
|
||||
7. 更新消费幂等状态并提交事务。
|
||||
8. 提交成功后更新 Redis `route/route_meta`,并把 Redis 幂等状态改为 `done`。
|
||||
|
||||
路由 UPSERT:
|
||||
|
||||
```sql
|
||||
INSERT INTO im_group_message_topic_shards
|
||||
(topic, shard_month, min_seq, max_seq, message_count,
|
||||
min_created_at, max_created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
min_seq = LEAST(min_seq, VALUES(min_seq)),
|
||||
max_seq = GREATEST(max_seq, VALUES(max_seq)),
|
||||
message_count = message_count + 1,
|
||||
min_created_at = LEAST(min_created_at, VALUES(min_created_at)),
|
||||
max_created_at = GREATEST(max_created_at, VALUES(max_created_at));
|
||||
```
|
||||
|
||||
### 5.3 异常处理
|
||||
|
||||
- 目标月表不存在:消费失败、进入重试并高优告警。
|
||||
- `sealing` 月表:允许携带原 `shard_month` 的延迟事件写入。
|
||||
- `sealed` 月表:进入补偿队列,人工确认后临时开放或补写。
|
||||
- 重复 `event_id`:消费日志直接判定成功。
|
||||
- 重复 `client_msg_id`:返回原 `seqid/shard_month`,不新增消息。
|
||||
- 超过最大重试次数:进入死信队列,不能静默丢弃。
|
||||
- Redis 热缓存失败不回滚 MySQL,客户端按 Seq 回源补拉。
|
||||
- Redis 路由更新失败:进入独立修复重试;查询侧可从 MySQL 镜像重建,不影响已提交消息。
|
||||
|
||||
## 6. 消息查询
|
||||
|
||||
### 6.1 总流程
|
||||
|
||||
```text
|
||||
客户端 Topic Seq 请求
|
||||
-> ACL/成员身份校验
|
||||
-> Redis 24 小时热消息
|
||||
-> Redis 是否完整覆盖请求范围
|
||||
-> Redis Topic 月段路由
|
||||
-> 月表逐张查询
|
||||
-> 按 seqid 合并、去重、排序、截断
|
||||
-> 应用 im_dellog 用户视角过滤
|
||||
-> 返回客户端
|
||||
```
|
||||
|
||||
Redis 只有完整覆盖整页时才能直接返回;有缺口必须回源 MySQL。
|
||||
|
||||
### 6.2 最新消息倒序分页
|
||||
|
||||
请求 `topic=grp_xxx&before=10088&limit=50`:
|
||||
|
||||
1. 从 Redis Topic 月段路由找出 `min_seq < before` 的月份,按月倒序。
|
||||
2. 先查最新候选月:
|
||||
|
||||
```sql
|
||||
SELECT ...
|
||||
FROM im_group_messages_202610 AS m
|
||||
LEFT JOIN im_dellog AS d
|
||||
ON d.topic = m.topic
|
||||
AND m.seqid BETWEEN d.low AND d.hi - 1
|
||||
AND d.deletedfor = ?
|
||||
WHERE m.topic = ?
|
||||
AND m.seqid < ?
|
||||
AND m.status != 'deleted'
|
||||
AND d.deletedfor IS NULL
|
||||
ORDER BY m.seqid DESC
|
||||
LIMIT ?;
|
||||
```
|
||||
|
||||
3. 数量不足时继续查前一个月。
|
||||
4. 服务层按 `seqid DESC` 合并并截取 `limit`。
|
||||
5. 下一页游标仍是本页最小 `seqid`,客户端不感知月份。
|
||||
|
||||
禁止对所有历史表执行无上限 `UNION ALL`。
|
||||
|
||||
### 6.3 Since 正向补拉
|
||||
|
||||
1. 从 Redis Topic 月段路由查找 `max_seq > since_seq` 的月份。
|
||||
2. 按月升序执行 `seqid > since_seq ORDER BY seqid ASC LIMIT N`。
|
||||
3. 合并后按 Seq 升序返回。
|
||||
4. 达到上限后返回最后一条 Seq,客户端继续请求。
|
||||
|
||||
### 6.4 指定消息
|
||||
|
||||
撤回、回复预览、举报取证按 `topic + seqid` 定位:
|
||||
|
||||
```text
|
||||
ZREVRANGEBYSCORE im:{tenant_id}:groupmsg:route:{topic} {seqid} -inf LIMIT 0 1
|
||||
HGET im:{tenant_id}:groupmsg:route_meta:{topic} {shard_month}
|
||||
```
|
||||
|
||||
先找到最大 `min_seq <= seqid` 的月份,再校验该月段 `max_seq >= seqid`,随后执行 `WHERE topic=? AND seqid=? LIMIT 1`。Redis 路由 Key 缺失时读取 MySQL 镜像并回填 Redis;镜像也缺失时,仅降级扫描当前月和最近若干月并记录修复任务,不允许扫描全部历史表。
|
||||
|
||||
### 6.5 上下文和范围
|
||||
|
||||
查询目标消息前后各 20 条时:
|
||||
|
||||
1. 定位目标所在月。
|
||||
2. 在本月分别查询目标前后消息。
|
||||
3. 任一方向不足时继续查询相邻月份。
|
||||
4. 合并、去重后按 Seq 正序返回。
|
||||
|
||||
### 6.6 搜索和统计
|
||||
|
||||
- 短期搜索必须带起止时间,最多覆盖 3 个月,逐月查询后归并。
|
||||
- 中长期通过 Kafka 建独立搜索索引;结果只返回 `topic + seqid`,服务端回源月表并重新校验 ACL。
|
||||
- Admin 统计使用按日/月汇总表,不实时扫描所有月表执行 `COUNT(*)`。
|
||||
|
||||
## 7. 撤回、编辑和删除
|
||||
|
||||
撤回、编辑流程:
|
||||
|
||||
1. 根据 `topic + seqid` 定位目标月表。
|
||||
2. 在目标月表事务中 `SELECT ... FOR UPDATE`。
|
||||
3. 校验版本、权限和状态。
|
||||
4. 更新目标月表并写修订记录、业务事件。
|
||||
5. 提交后更新或删除 Redis 热消息。
|
||||
|
||||
修订记录建议增加月份:
|
||||
|
||||
```sql
|
||||
ALTER TABLE im_message_revisions
|
||||
ADD COLUMN shard_month CHAR(6) NULL AFTER topic;
|
||||
```
|
||||
|
||||
用户侧删除继续使用 `im_dellog` 的 Topic Seq 范围,每张月表查询都应用相同过滤。全局删除或合规处置先定位月表,再更新 `status/deleted_at/content/head`;OSS 附件仍走文件生命周期任务。
|
||||
|
||||
## 8. Store 层设计
|
||||
|
||||
所有动态表访问统一经过路由组件:
|
||||
|
||||
```go
|
||||
type GroupMessageShardRouter interface {
|
||||
WriteTable(createdAt time.Time, shardMonth string) (string, error)
|
||||
CandidateTablesBySeq(topic string, seq int64) ([]string, error)
|
||||
TablesBefore(topic string, before int64) ([]string, error)
|
||||
TablesSince(topic string, since int64) ([]string, error)
|
||||
}
|
||||
```
|
||||
|
||||
改造范围:
|
||||
|
||||
- `TenantGroupMessageSave`:按事件 `shard_month` 写入。
|
||||
- `TenantGroupMessageGetOne`:路由后查询目标月。
|
||||
- `TenantGroupMessageGetAll`:逐月查询并归并。
|
||||
- `TenantGroupMessageGetByClientID`:先查 Redis/幂等表,再查询目标月。
|
||||
- `TenantGroupMessageRecall/Edit`:定位目标月后执行事务。
|
||||
- `TenantMessageSearch`:按时间选月,或转独立搜索。
|
||||
- Admin 内部消息查询、举报、收藏、回复和群文件引用同步改造。
|
||||
|
||||
动态表名必须满足 `^im_group_messages_[0-9]{6}$` 且月份已登记。SQL 参数不能绑定表名,必须严格白名单。
|
||||
|
||||
## 9. 旧表迁移
|
||||
|
||||
原 `im_group_messages` 先保留,不删除。
|
||||
|
||||
推荐步骤:
|
||||
|
||||
1. 上线路由表、幂等表和 Shard Manager。
|
||||
2. 预建历史月份、当月和未来两个月表。
|
||||
3. 先发布支持“旧表 + 月表”双读的代码,写入仍走旧表。
|
||||
4. 记录切换水位 `cutover_seq/cutover_time`。
|
||||
5. 切换新消息只写月表,Kafka 开始携带 `shard_month`。
|
||||
6. 按 `created_at` 分批把旧表数据迁入对应月表。
|
||||
7. 每批使用 `INSERT IGNORE`,校验 `topic + seqid`、数量和内容哈希。
|
||||
8. 回填 MySQL Topic 月段路由镜像,并由重建任务写入 Redis 路由。
|
||||
9. 双读期间按 `topic + seqid` 去重。
|
||||
10. 全量校验通过后停止读取旧表。
|
||||
11. 旧表改名 `im_group_messages_legacy`,只读保留。
|
||||
12. 观察至少一个完整保留周期后再单独审批清理。
|
||||
|
||||
不建议长期双写旧表和月表,否则两个写目标部分成功会扩大一致性问题。
|
||||
|
||||
迁移批次要求:
|
||||
|
||||
- 使用主键或 `created_at + id` 游标,禁止 OFFSET 深分页。
|
||||
- 单批 1,000 至 5,000 条,根据线上延迟调整。
|
||||
- 每批独立事务,记录开始/结束游标和成功/失败数。
|
||||
- 支持暂停、续跑和重复执行。
|
||||
|
||||
## 10. 归档与保留
|
||||
|
||||
状态流转:
|
||||
|
||||
```text
|
||||
prepared -> writable -> sealing -> sealed -> archived
|
||||
```
|
||||
|
||||
- `writable`:当月正常写。
|
||||
- `sealing`:月末后接受延迟消息,默认 7 天。
|
||||
- `sealed`:在线 MySQL 只读。
|
||||
- `archived`:转对象存储或低成本库,在线查询走归档服务。
|
||||
|
||||
归档前确认 Kafka 无积压/死信、路由完整、消息数量和 Seq 范围一致,并确认举报、审计、收藏和群文件引用仍可定位。
|
||||
|
||||
## 11. 监控与告警
|
||||
|
||||
| 指标 | 说明 |
|
||||
| --- | --- |
|
||||
| `group_message_persist_total{month,status}` | 各月落库成功/失败 |
|
||||
| `group_message_persist_latency` | Kafka 到 MySQL 提交耗时 |
|
||||
| `group_message_shard_missing_total` | 目标月表不存在 |
|
||||
| `group_message_query_tables` | 单次请求扫描表数 |
|
||||
| `group_message_query_latency{source}` | Redis、单月、跨月耗时 |
|
||||
| `group_message_dedup_hit_total` | 幂等命中 |
|
||||
| `group_message_route_miss_total` | Redis 路由缺失 |
|
||||
| `group_message_route_rebuild_total{status}` | 从 MySQL 镜像重建 Redis 路由 |
|
||||
| `group_message_route_repair_backlog` | Redis 路由修复积压 |
|
||||
| `group_message_dead_letter_total` | 落库死信 |
|
||||
|
||||
未来月表未创建、当月不可写、`active_month` 缺失、Kafka 延迟超限、单次扫描超过 3 张表、Redis 路由缺失或修复积压增长时必须告警。
|
||||
|
||||
## 12. 验收测试
|
||||
|
||||
1. 月末和月初消息落入正确表,Seq 连续。
|
||||
2. Kafka 次月才消费时仍写事件指定月份。
|
||||
3. 同一 `client_msg_id` 跨月重试只保留一条。
|
||||
4. 最新 50 条跨两个月时数量、顺序、游标正确。
|
||||
5. `since_seq` 跨月补拉无遗漏、无重复。
|
||||
6. `topic + seq` 可定位历史消息。
|
||||
7. 回复上下文可跨月读取。
|
||||
8. 撤回、编辑、举报、收藏、群文件引用可定位旧月。
|
||||
9. 用户删除范围跨月时过滤正确。
|
||||
10. Redis 热消息不完整时正确回源。
|
||||
11. 月表缺失时进入重试/死信并告警。
|
||||
12. 迁移双读无重复,迁移任务可暂停、续跑。
|
||||
13. 万人群高并发时 Seq 唯一且落库稳定。
|
||||
14. 非法月份和非法动态表名被拒绝。
|
||||
15. 多节点跨月并发时 Lua 返回同一活动月份,Seq 不重号且月段不交叠。
|
||||
16. 删除 Redis 路由后可从 MySQL 镜像自动重建,查询结果不缺失。
|
||||
|
||||
## 13. 实施阶段
|
||||
|
||||
### 第一阶段:基础设施
|
||||
|
||||
- 建月表模板、目录、MySQL 路由镜像和跨月幂等表。
|
||||
- 实现 Shard Manager,预建未来两个月。
|
||||
- 建立长期不过期的 Redis Topic 月段路由和原子 `seqid + active_month` Lua。
|
||||
- Kafka 群消息事件增加 `shard_month`。
|
||||
|
||||
### 第二阶段:写入切换
|
||||
|
||||
- 消费者按月落库。
|
||||
- 完成幂等、重试、死信、MySQL 镜像和 Redis 路由更新。
|
||||
- Redis 热消息与实时推送保持不变。
|
||||
|
||||
### 第三阶段:查询切换
|
||||
|
||||
- 完成精确查询、倒序分页、Since 补拉和上下文跨月查询。
|
||||
- 改造撤回、编辑、删除、举报及 Admin 内部查询。
|
||||
- 增加跨月查询监控。
|
||||
|
||||
### 第四阶段:历史迁移
|
||||
|
||||
- 上线旧表/月表双读。
|
||||
- 回填历史消息与 MySQL 路由镜像,重建 Redis Topic 路由。
|
||||
- 校验后把旧表改为只读保留。
|
||||
|
||||
### 第五阶段:搜索和归档
|
||||
|
||||
- 消息搜索切换独立索引或限制时间范围。
|
||||
- 建立 `sealed/archived` 生命周期和历史恢复机制。
|
||||
|
||||
## 14. 结论
|
||||
|
||||
群消息按月分表不能只改写入表名,必须一起实现固定 `shard_month`、Redis Topic 月段路由、MySQL 路由镜像、跨月幂等、逐月游标查询、历史消息定位和旧表迁移。
|
||||
|
||||
最终链路:
|
||||
|
||||
```text
|
||||
发送:Redis Lua 原子分配 Seq 和 active_month -> Kafka 固定 shard_month -> MySQL 月表
|
||||
实时:Redis Pub/Sub/Stream -> WebSocket
|
||||
热读:Redis 24 小时消息
|
||||
冷读:Redis Topic 月段路由 -> MySQL 月表逐月查询
|
||||
容灾:MySQL 路由镜像 -> 重建长期 Redis 路由
|
||||
补偿:客户端按 Topic Seq 拉取
|
||||
```
|
||||
|
||||
客户端协议无需增加月份字段,月表路由完全由服务端处理。
|
||||
Reference in New Issue
Block a user