分库分表与扩容:分片键与平滑迁移
单库单表撑到单表 5000 万行、写入 5000 TPS 左右就会开始难受:DDL 要几小时、备份恢复按天算、Buffer Pool 命中率下滑。IM 的写入是持续且均匀的,靠调优只能延缓,最终必须分片。分片最难的从来不是"怎么分",而是分片键选错后无法回头,以及扩容时怎么不停机搬迁。
分片键:为什么 IM 常用会话维度
| 分片键 | 优点 | 缺点 | 适用 |
|---|---|---|---|
conv_id(会话) | 同一会话的消息落在同一分片:顺序性天然保证、按会话拉历史是单分片范围扫描、写扩散不放大 | 按 uid 查"我的所有会话"要跨全部分片;热点会话集中在一个分片 | 消息表、会话内序列号 |
uid(用户) | 收件箱维度写入分散、按用户查消息快 | 群消息要按成员数写 N 份(写放大)、会话内顺序需额外协调 | 收件箱/未读索引、用户维度的同步表 |
msg_id(哈希) | 分布最均匀 | 顺序查询彻底失效,任何范围扫描都变成全分片查询 | 极少使用 |
结论是按用途分开分:消息表按 conv_id 分(保证顺序与范围扫描),收件箱与未读索引按 uid 分(保证"我的消息"快速可查)。两侧通过 conv_id 关联,形成"消息按会话存、我的消息按用户索引"的双视图,这也是 11、12 章离线与多端同步能落地的前提。
分片算法:预分片而非取模
取模扩容是自找麻烦:hash % 8 改成 hash % 12 会导致几乎全量搬迁。正确做法是先定死逻辑分片数,再把它映射到物理库表:
shard_id = hash(conv_id) % 1024 # 逻辑分片号,永不改变
初期:1024 片 → 8 库 × 128 表/库 # 每片即一张表
扩容:路由表改为 16 库 × 64 表/库 # 只搬逻辑分片,hash 算法不变
再扩容:32 库 × 32 表/库
路由表(可放配置中心,进程内缓存 30s 刷新):
shard_id=0 → db0.t0
shard_id=1 → db0.t1
...
shard_id=1023 → db7.t127
好处是扩容时最多搬 1024 个逻辑分片中的一部分,且每次搬迁的粒度可控(一个分片 = 一个会话群的连续数据)。表名即分片号,排查时看到 im_message_db3_t57 就能直接定位到 shard_id。
业务侧必须补的三张索引表
分片后原本一条 JOIN 能解决的事都要显式建索引:
| 表 | 主键 | 作用 | 冗余字段 |
|---|---|---|---|
im_conv_member | (uid, conv_id) | 用户 → 会话列表,按 updated_at 排序 | conv_id、last_read_seq、mute |
im_conv_seq | conv_id | 会话当前最大 seq,发号用(也可用 Redis 递增加 MySQL 兜底) | max_seq、member_cnt |
im_inbox | (uid, conv_id, seq) | 用户维度的收件箱,支撑未读与补拉 | msg_id、send_time |
im_inbox 是写扩散(16、17 章)的产物,它的行数 ≈ 消息数 × 平均在线成员数,往往是全库最大的表,必须按 uid 分片并配合 TTL 清理(已读且超过 30 天的可归档)。
平滑迁移:双写与灰度切读
迁移的目标是"用户无感、随时可回滚"。七个步骤缺一不可:
1. 建新分片集群:空库、同版本、同参数,先跑通 DDL 与监控
2. 双写:写路径同时写旧库(主)与新库(从),新库失败只记补偿队列,
旧库成功即返回 ACK —— 绝不因为新库异常影响线上
3. 存量迁移:按 shard_id 逐片迁移,每片限速;记录 binlog 位点,迁移完用位点追增量
4. 校验:行数比对 + 抽样内容比对 + 聚合校验和(如 sum(crc32(id)) 分段比对)
5. 灰度切读:按 conv_id 白名单 1% → 10% → 50% → 100%,重点看错误率与 P99
6. 切主写:新库转为主写,旧库反向双写,保留 7 天可回滚窗口
7. 清理:确认无回滚后停双写,旧库转只读归档
顺序性风险要单独处理:搬迁期间同一会话的写入必须落同一侧,否则新旧两侧的 seq 会不一致。做法是按分片加迁移锁——迁移某分片时,该分片的写入由迁移程序串行转发,迁移完成后再放开;宁可让这个分片短暂排队,也不要让会话消息乱序。
双写与幂等(Go 示意)
// 双写必须幂等,且旧库成功与否是 ACK 的唯一判据
func (s *MsgStore) Save(ctx context.Context, m *Message) error {
if err := s.old.Insert(ctx, m); err != nil { // 旧库:权威
return err // 旧库失败要重试,不能让用户以为发出去了
}
if err := s.new.Upsert(ctx, m); err != nil { // 新库:尽力而为
s.compensate.Push(ctx, m) // 进补偿队列,由定时任务重放
metrics.ShardingDualWriteFail.Inc() // 双写失败必须告警
}
return nil
}
补偿队列要能去重(用 msg_id 作为幂等键),否则重放会把新库写成两份。
容量口径
单表容量上限:5000 万行 / 100 GB(按 SSD 主键点查估算)
消息日增 4 亿条 → 4 亿 ÷ 5000 万 = 8 张表/日(这是按月归档前的原始速度)
因此热数据不按时间分,按 conv_id 哈希分:1024 片 × 5000 万行 ≈ 51 亿行承载量
按月归档表:im_message_archive_202601、...、im_message_archive_202606
迁移速度口径:单分片 2000 行/s 限速,1024 片 × 平均 500 万行 ≈ 需 3~5 天
(限速是刻意的,迁移不该影响线上写入延迟)
扩容触发线建议写死在文档里:任一分库磁盘 > 60%、任一分片写入延迟 P99 > 50ms、单分片 QPS > 3000,三条命中任一即启动扩容流程。
常见坑
- 分片键用
uid存消息:群聊写放大是成员数倍,一个 5000 人大群一条消息写 5000 行。 - 取模扩容:
% N改% M等于全量重导,务必用预分片 + 路由表。 - 跨分片事务:分片后没有分布式事务,用本地消息表 + 最终一致(15 章),不要试图引入两阶段提交。
- 全局唯一 ID 依赖数据库自增:分片后自增只在单表内唯一,必须用雪花算法或发号器(08 章)。
- 迁移期间不做限速:全速搬迁会把线上 IOPS 抽干,低峰期限速是最低要求。
- 跳过校验直接切主:没有校验和比对的迁移等于赌运气,务必抽样 + 聚合比对。
- 配置中心缓存不刷新:路由表变更后旧进程仍打旧库,双写窗口要覆盖缓存刷新时间(至少 2 倍刷新周期)。
小结:IM 的分片策略是"消息按 conv_id、索引按 uid"的双视图,原因是会话维度天然满足顺序与范围扫描、用户维度满足收件箱查询;算法上用 1024 个逻辑分片加路由表实现预分片,扩容只搬分片不改哈希;迁移流程固定为建库、双写、限速搬存量、校验比对、灰度切读、切换主写、清理归档七步,并在搬迁期间用分片锁守住会话内顺序。