JCSprout 分表实战:亿级 MySQL 单表的分表选型、业务改造与上线迁移全记录
【免费下载链接】JCSprout👨🎓 Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout
本文以 JCSprout 仓库中的实战文档 一次分表踩坑实践的探讨 为主线,完整还原作者在生产环境面对单表数据量突破亿级、日均增长 200W+ 的场景下,从「临时应急方案」到「正式分表方案」再到「数据迁移上线」的完整决策链路。读完本文,你将掌握:何时选择时间分表、何时选择哈希分表,sharding 字段如何选取,分表数量为何取 2 的幂,以及在没有引入 sharding-jdbc 的情况下如何低成本完成分表改造与数据迁移。
背景:亿级单表与高增长带来的性能困局
在生产环境中,作者所在团队遇到的问题是许多业务发展到一定阶段都会面临的典型困局:随着业务量增长,生产数据库中的好几张单表数据量已经突破亿级,并且保持着每天200W+的新增数据量。
在这样的体量下,两个业务诉求让大表问题被进一步放大:
- 关联查询:部分业务需要做多表关联查询;
- 报表统计:报表类需求需要对大表进行全量聚合统计。
结果就是一个查询功能需要跑好几分钟,数据库性能成为明显的瓶颈。文档中也坦诚地解释了为何单表过亿才着手解决:并非不想提前规划,而是历史原因加上错误预估了数据增长所导致的被动局面。
这一背景也印证了 数据库水平垂直拆分 一文中的核心判断:当数据库量非常大、DB 已经成为系统瓶颈时,就应该考虑进行水平或垂直拆分了。而本篇文章正是这种拆分思路在一套真实生产系统上的落地记录。
阶段一:不动应用的临时应急方案
由于需求紧、人手缺,整个处理过程被拆成了多个阶段。第一阶段的触发信号来自运维:MySQL 所在主机内存占用很高、整体负载居高不下,导致整个 MySQL 的吞吐量明显降低,写入和查询数据都明显减慢。
排查后发现,数据量最大的几张表大部分在 7000W~8000W 左右,少数已经突破一亿。通过业务层面分析发现,这些数据大多是用户产生的日志型数据,业务相关性不强,甚至两三个月前的数据已经不需要实时查询。
由于接近年底,团队希望尽可能不动应用,先在运维层面缓解压力,核心目标只有一个:把单表的数据量降下来。
想迁移数据,却发现没有可排序索引
最初的设想是把两个月之前的数据直接迁移到备份表中,但在准备实施时发现了一个大坑:
表中没有一个可以排序的索引,导致无法快速筛选出一部分数据。即便是加索引也需要花几个小时(具体多久没敢在生产测试)。
如果强行按照时间筛选,可能查询出 4000W 条数据就得花上好几个小时,这显然是行不通的。这个坑也为后面的优化埋下了隐患——一张大表没有可用于排序的索引,代价是巨大的。
大胆取舍:日志型数据直接"不要了"
既然迁移行不通,团队产生了一个大胆的想法:这部分数据是否可以直接不要了?
这可能是最有效也最快的方式。与产品沟通后确认:这部分数据确实只是日志型数据,即便是报表暂时出不来,后续补上也是可以的。于是团队做了两件简单粗暴的事情:
- 修改原有表的表名,比如加上
_190416bak后缀; - 再新建一张和原有表名称相同的表。
这样,新的数据就写到了新表,业务上使用的也是这张数据量较小的新表。过程虽然不太优雅,但至少解决了眼前的压力,也为后续技术改造预留了时间。
阶段二:正式分表方案设计
临时方案只能缓解压力,不能从根本上解决问题。有些业务必须查询之前的数据,导致"改表名"这招行不通,于是团队借此机会正式把表分了。
文档中强调了一个核心观点:分表最重要的一点是要结合实际业务找出需要 sharding 的字段,同时上线阶段的数据迁移也非常重要。
分表策略选型:时间分表 vs 哈希分表
| 对比维度 | 时间分表 | 哈希分表 |
|---|---|---|
| 适用场景 | 不需要历史数据,业务只查询近几个月的数据 | 所有数据都有可能被查询 |
| 拆分方式 | 按月份划分,每月一张表 | hash(sharding字段) % 分表数量 |
| 查询方式 | 查询时拼接好表名 | 先算索引,再路由到对应表 |
| 历史数据迁移 | 简单,按时间归档即可 | 需按分片规则全量重算路由 |
| 数据分布 | 随时间自然分布 | 哈希分配,分布均匀 |
时间分表适合不需要历史数据的场景,比如业务上只查询近三个月的数据。这类需求完全可以采取时间分表,按照月份进行划分:改动简单,同时对历史数据也比较好迁移——只需要在查询的时候拼接好表名即可。
哈希分表则适用于一旦所有数据都有可能被查询的场景。此时按照时间分表就行不通了(也能做,只是如果不是按照时间进行查询时需要遍历所有的表)。哈希分表算是业界比较主流的方式。
两种策略的取舍逻辑与 数据库水平垂直拆分 中提到的"水平拆分"思路一脉相承:既可以通过 ID 取模分表,也可以通过时间分表(如每月生成一张表),还可以按范围分表,各有利弊。
sharding 字段的选择:为什么是 IMEI
采用哈希分表时,sharding 字段的选取至关重要。由于业务是一个物联网应用,所有数据都包含物联网设备的唯一标识IMEI,而且这个字段天然保持了唯一性,大多数业务也都是根据这个字段来查询的,所以它非常适合做 sharding 字段。
文档中还提到了一个细节:在这个场景下,节省了将 sharding 字段哈希的过程——因为每一个 IMEI 号本身就是一个唯一的整型,直接用它做 mod 运算即可。
分表路由的核心逻辑可以抽象为:
int index = hash(sharding字段) % 分表数量 ; select xx from 'busy_'+index where sharding字段 = xxx;其实就是先算出表名,然后路由过去查询即可。
这种「哈希取模路由」的思路在仓库源码中也有体现。例如 LRUAbstractMap.java 中通过int index = hash % arraySize定位元素所在的桶;BloomFilters.java 中通过array[first % arraySize]定位位图下标。它们与分表路由使用% 表数量的思想完全一致——都是把哈希值映射到有限的一组目标(桶/位/表)上。
方案调研:MyCAT 与 sharding-jdbc(ShardingSphere)
在做分表之前,团队调研过MyCAT和sharding-jdbc(现已升级为ShardingSphere),最终考虑到对开发的友好性以及不增加运维复杂度,决定在JDBC 层做 sharding。
但由于历史原因,团队并不太好集成 sharding-jdbc,于是基于 sharding 的特点自己实现了一个分表策略。实现方式非常简单:修改了所有底层查询方法,每个方法里都做了路由判断。
这与 sharding-jdbc 的实现思路不同。sharding-jdbc 通过代理数据库的查询方法,内部执行SQL解析 → SQL路由 → 执行SQL → 合并结果这一系列完整流程。如果自己重做一遍,无异于重新造了一个轮子,并且并不专业。团队的决策是在现有技术条件下选择一个快速实现、达成效果的方法。
分表数量为什么要取 2 的幂
考虑到后续业务发展,团队决定将拆分的表分为64 张,加上后续引入大数据平台,足以应对几年的数据增长。
文档中特别强调了一个细节:
分表的数量需要为
2∧N次方,因为在取模的这种分表方式下,即便是今后再需要分表,影响的数据也会尽量的小。
从实现角度看,这个建议有两层价值:
- 位运算优化:当表数量 N 为 2 的幂时,
hash % N等价于hash & (N-1),位与运算比取模运算更快,这在很多哈希容器的实现中都是常见的优化手段; - 扩容友好:例如从 64 张表扩容到 128 张表时,由于路由结果只在高位多了一位,原本落在同一张表的数据只有一部分会迁移到新表,迁移影响面尽量小,而不是全量重排。
分表后的主键 ID 生成方案
分表后不能依赖单表的字段自增了,需要一个统一的组件生成规则。文档给出了几种常见方案:
| 方案 | 说明 | 适用注意点 |
|---|---|---|
| 时间戳 + 随机数 | 简单,可满足大部分业务 | 高并发下需要注意唯一性 |
| UUID | 生成简单 | 无法排序,字符串不适合做主键 |
| 雪花算法(Snowflake) | 统一生成主键 ID | 全局唯一、趋势递增,实现相对复杂 |
这与仓库中 分布式 ID 生成器 一文的论述相互印证:分布式环境下唯一 ID 需要具备全局唯一和趋势递增两个特性。数据库自增方案强依赖 DB,DB 挂了就容易出问题;UUID 本地生成效率高但无序;雪花算法则通过划分命名空间,按机器、时间等维度生成 ID。大家可以根据自己的实际情况做选择。
阶段三:业务代码改造
因为没有使用第三方的 sharding-jdbc 组件,所以无法做到对代码的低侵入性,每个涉及到分表的业务代码都需要做底层方法的改造(即路由到正确的表)。
修改时只能将表名称进行全局搜索,然后逐一修改;同时根据修改的方法倒推到表现的业务并记录下来,方便后续回归测试。
非 sharding 字段查询:不可避免的全表扫描
分表之后无法避免的一个问题是:利用非 sharding 字段查询导致的全表扫描,这是所有分片后都会遇到的问题。
因此,团队在修改分表方法的底层查询时,同时会检查是否走分片字段;如果不是,则评估是否可以调整业务。例如:对于一个上亿的数据量,是否还有必要支持分页查询、日期查询?这样的业务是否真的具有意义?团队尽可能引导产品按照这样的方式来设计产品或者做出调整。
特殊类型数据:单独建表
文档中还提到了一个另类的场景:
比如一个千万表中,有某一特殊类型的数据只占了很小一部分,比如说几千上万条。
这时页面上需要对它进行分页查询是比较正常的(比如某种投诉消息,客户需要一条一条地单独处理),但如果按照 IMEI 号或者主键进行分片后再分页查询就非常麻烦。
所以这类型的数据建议单独新建一张表来维护,不要和其他数据混合在一起,这样不管是做分页还是 like 查询都比较简单和独立。
报表统计:多线程并行查询汇总
对于报表这类的需求确实没办法绕开全表扫描,比如统计表中某种类型的数据。针对这类场景,可以利用多线程并行查询再汇总统计的方式来提高查询效率。
这种「多线程并行 + 计数汇总」的模式在仓库中有现成的实现参考:MultipleThreadCountDownKit.java。它基于AtomicInteger计数器实现了类似CountDownLatch的语义:多个子线程各自完成查询任务后调用countDown()使计数器减一,主线程调用await()等待所有子任务完成,并通过回调接口Notify在全部完成后触发汇总逻辑。将 64 张分表的查询任务分发给多个线程并行执行,最后统一汇总,即可显著缩短报表类统计的响应时间。
阶段四:上线前的验证技巧
代码改完、开发单测完成后,如何验证分表业务是否正常也是比较麻烦的事:
- 一个是测试麻烦;
- 再一个是万一哪里改漏了还是查询的原表,但这样在测试环境并不会有异常,一旦上线产生了生产数据到新的 64 张表后,想要再修复就比较麻烦了。
团队取了个巧:直接将原表的表名修改,比如加一个后缀。这样在测试过程中观察前后台有无报错,就比较容易提前发现"还有代码在查询原表"这类问题。这个技巧成本极低,却能在上线前兜住"漏改"这类最容易犯的错误。
阶段五:上线与数据迁移
测试验收通过,只是分表这个需求的80%,剩下如何上线也是比较头疼的部分。一旦应用上线后,所有的查询、写入、删除都会先走路由然后到达新表;而老数据在原表里是不会发生改变的。
迁移程序的设计思路
所以上线前的第一步自然是将原有的数据进行迁移,迁移的目的是要把老数据按照分片规则复制到新的 64 张表中,这样才会对原有业务无影响。
团队额外准备了一个数据迁移程序,其核心逻辑是:按分片规则计算每条老数据的 sharding 字段(IMEI)应落入哪张新表,然后写入对应的目标表。结合本文前述的路由公式,迁移程序的关键步骤可以抽象为:
- 从老表分批读取数据(按可排序字段分段,避免一次性加载);
- 对每条数据计算
index = sharding字段 % 64,得到目标表名busy_+index; - 写入目标表;
- 循环直至老表数据全部迁移完成。
在作者这个场景下,生产数据有些已经上亿,这个迁移过程在测试环境模拟时发现耗时非常久。而且老表中用于筛选数据的字段(如create_time)没有索引(以前的技术债),查询起来就更慢了。
迁移窗口与业务妥协
最后没办法,团队只能与产品协商:告知用户对于之前产生的数据短期可能会查询不到,这个时间最坏可能会持续几天——因为只能在凌晨迁移,白天会影响到数据库负载。
这也是数据迁移阶段最现实的一条经验:对于上亿数据的分表迁移,必须有明确的迁移窗口规划和业务上的妥协预案,不可能做到完全无感。
总结与经验教训
这便是这次分表实践的全过程。文档坦诚地承认:不少过程都不优雅,但受限于当时的条件也只能折中处理。团队后续的计划是:修改底层的数据连接(目前是自己封装的一个 jar 包,导致集成 sharding-jdbc 比较麻烦),最终逐渐迁移到sharding-jdbc(现 ShardingSphere),以获得更完整的分片能力。
整个实践最终沉淀出了三条关键结论,对任何准备做分表的团队都值得借鉴:
- 一个好的产品规划非常有必要:可以在合理的时间对数据处理(不管是分表还是切入归档),而不是等到单表过亿、被迫在紧需求下仓促应对;
- 每张表都需要一个可以用于排序查询的字段(自增 ID、创建时间):本次实践正是因为缺少这样的字段,在数据筛选和迁移阶段耽搁了很长时间,这个"技术债"的代价远比预想中大;
- 分表字段需要谨慎:要全盘考虑业务情况,尽量避免出现查询扫表的情况——sharding 字段应尽量覆盖绝大多数查询场景,必要时通过单独建表、并行汇总等手段兜底特殊需求。
延伸阅读
本文对应仓库中的实践原文为 docs/db/sharding-db.md,README 中将其定位为「一次分表踩坑实践的探讨」。分表相关的理论基础可继续阅读:
- 数据库水平垂直拆分:水平拆分、垂直拆分的概念与拆分后的事务(两阶段提交、最终一致性)问题;
- 分布式 ID 生成器:分表后主键 ID 的几种生成方案及优劣对比;
- MultipleThreadCountDownKit.java:多线程并行任务计数汇总的实现参考,可用于报表类跨表统计;
- BloomFilters.java 与 LRUAbstractMap.java:仓库中「哈希取模定位」思想的另外两处实现,与分表路由逻辑同源。
【免费下载链接】JCSprout👨🎓 Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考