# 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 拉取 ``` 客户端协议无需增加月份字段,月表路由完全由服务端处理。