飞享IM数据库分库分表设计方案——从现状分析到亿级扩容全解析

comsince
FshareIM Team分库分表不是一个简单的配置问题——它关系到分片键选择、扩容数学性质、跨分片查询策略,以及在不停服前提下如何安全迁移数据。本文以飞享IM(FshareIM)为案例,从现有手动分表方案的瓶颈出发,完整呈现三种技术路线对比、基于 ShardingSphere-JDBC 的新建分库方案、按 2 的幂扩容的数学推导,以及 Canal + 双写的零停机迁移时序,供 IM 系统架构师和 DBA 参考。
一、现状分析
1.1 已有分片方案
项目当前在单个 MySQL 实例上做了手动分表,核心逻辑位于 MessageShardingUtil.java。
| 表 | 分片数 | 分片键 | 算法 |
|---|---|---|---|
t_messages_0~35 | 36 | 消息 ID 中内嵌的时间戳 | (year % 3) * 12 + month |
t_user_messages_0~127 | 128 | _uid | uid.hashCode() % 128 |
消息 ID 结构(Snowflake 变体):
1.2 现有方案的瓶颈
t_messages 时间分片的问题:
- 同一个月所有消息写入同一张表,单月高流量下写入热点无法消除
- 3 年后表会被复用(2018 年和 2021 年写同一张表),历史数据无法区分
- 拉取历史消息需跨多张表扫描,当前代码最多循环查 12 张表:
t_user_messages hash 分片的问题:
uid.hashCode()依赖 JVM 实现,不同语言/环境结果可能不同,不适合跨系统使用- 128 张表固定,扩容需要全量数据重新 hash
全局瓶颈:
- 所有分片在同一个 MySQL 实例,分表只解决了单表大小,未解决写吞吐
t_id_generator自增表是严重写热点(每条消息都需要先插入获取全局 ID)- 以下高频表完全没有分片保护:
二、演进路径选择
2.1 三种方案对比
| 维度 | 方案A:MySQL + ShardingSphere | 方案B:TiDB | 方案C:HBase/Cassandra |
|---|---|---|---|
| 代码改动量 | 小(改 SQL 配置) | 极小(去掉手动分片) | 大(重写消息层) |
| 运维难度 | 中 | 高 | 极高 |
| 扩展上限 | 千万 DAU | 亿级 DAU | 数十亿级 |
| 扩容难度 | 高(需停服迁移) | 低(自动) | 低(自动) |
| 资源成本 | 低 | 中高 | 高 |
| 分布式事务 | 需额外引入 Seata | 原生支持 | 不支持 |
| 适用阶段 | 当前 → 百万级 | 百万 → 亿级 | 亿级以上 |
2.2 推荐演进路径
不建议对存量系统做分片键改造(时间分片 → target hash),因为需要全量重写现有数据,迁移成本极高。
三、从零部署的分库分表设计
本章为新项目或完全重建场景的参考设计。
3.1 整体架构
分库数选 4(2 的幂),便于后续按 2 倍扩容。
3.2 各表分片键设计
用户维度(Binding Table,同 uid 落同一库,支持库内 JOIN)
| 表 | 分片键 | 分库算法 | 分表算法 |
|---|---|---|---|
t_user | _uid | CRC32(_uid) % 4 | CRC32(_uid) % 4 |
t_friend | _uid | CRC32(_uid) % 4 | CRC32(_uid) % 4 |
t_friend_request | _uid | CRC32(_uid) % 4 | CRC32(_uid) % 4 |
t_user_messages | _uid | CRC32(_uid) % 4 | CRC32(_uid) % 4 |
t_user_session | _uid | CRC32(_uid) % 4 | CRC32(_uid) % 4 |
t_user_setting | _uid | CRC32(_uid) % 4 | CRC32(_uid) % 4 |
会话维度
| 表 | 分片键 | 分库算法 | 分表算法 |
|---|---|---|---|
t_messages | _target | CRC32(_target) % 4 | CRC32(_target) % 4 |
群组维度(Binding Table,同 gid 落同一库)
| 表 | 分片键 | 分库算法 | 分表算法 |
|---|---|---|---|
t_group | _gid | CRC32(_gid) % 4 | CRC32(_gid) % 4 |
t_group_member | _gid | CRC32(_gid) % 4 | CRC32(_gid) % 4 |
t_channel | _cid | CRC32(_cid) % 4 | CRC32(_cid) % 4 |
t_channel_listener | _cid | CRC32(_cid) % 4 | CRC32(_cid) % 4 |
广播表(每库全量复制,无需路由)
3.3 ShardingSphere 配置
3.4 全局 ID 策略
废弃 t_id_generator 自增表(写热点),直接使用项目已有的 Snowflake 实现:
3.5 用户搜索的特殊处理
t_user 按 _uid 分片后,WHERE _mobile = ? 会变成全库广播查询。解法:
3.6 消息拉取的跨分片处理
t_user_messages(按 uid 分片)和 t_messages(按 target 分片)分片键不同,拉取消息需要两次路由:
四、扩容方案
4.1 按 2 的幂扩容的数学性质
使用 CRC32(key) % N 分片,从 N→2N 时:
原来在 shard i 的数据,扩容后只有两个去处:留在 shard i,或迁到 shard i+N。
每个旧分片恰好一半数据留下,一半迁出。
4.2 迁移数据的 SQL 识别
关键前提:分片 hash 函数必须使用
CRC32,不能用 Java 的String.hashCode()(不同 JVM 实现结果可能不同,且 SQL 层无法还原)。
4.3 容量规划
五、双写迁移方案
5.1 推荐:Canal 监听 Binlog(应用层零改动)
Canal 同时处理:
- 存量数据全量迁移
- 迁移期间新写入的增量同步
当 Canal lag 降至 0 后,停写数秒完成切换。
5.2 备选:应用层手动双写
当无法引入 Canal 时,在 DatabaseStore.java 中实现:
5.3 完整切换时序
5.4 风险与应对
| 风险 | 现象 | 应对 |
|---|---|---|
| 新库写失败 | 消息在旧库有、新库无 | INSERT IGNORE + 补偿队列重试;Canal 兜底 |
| 切换瞬间请求打到旧库 | 路由未完全刷新 | ShardingSphere 热更新配置;停写窗口保护 |
| 旧库数据清理过早 | 删了还有读流量的数据 | T9 延迟到切换后 24~48 小时再执行 |
| hash 函数不一致 | Java 和 SQL 计算结果不同 | 统一用 CRC32,禁用 String.hashCode() |
六、现有代码改造点
| 文件 | 改动内容 |
|---|---|
MessageShardingUtil.java | 删除 getMessageTable() 时间分片逻辑;hash 函数改为 CRC32 |
DatabaseStore.java | 删除所有手动拼表名的 SQL;改写为标准 SQL,路由由 ShardingSphere 负责 |
pom.xml | 添加 shardingsphere-jdbc 依赖 |
application.properties | 替换为 ShardingSphere 数据源配置 |
| 消息写入路径 | 废弃 t_id_generator INSERT,改用 MessageShardingUtil.generateId() |
小结
飞享IM 的分库分表演进核心结论:
- 不动存量 — 对运行中的系统做分片键改造得不偿失,优先通过缓存和只读副本扛住当前规模
- 分片键选 CRC32 — 跨语言一致,SQL 层可验证,是 hash 分片的唯一正确选择
- 4 的幂初始规划 — 4 库 × 4 表 = 16 分片,每次 2 倍扩容,数学上只需迁移一半数据
- Canal 优于双写 — 应用层双写引入主写失败风险,Canal binlog 方式零改动且可回滚
- Binding Table 消灭跨库 JOIN — 用户维度和群组维度各自绑定,确保关联查询不跨库