
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 的吞吐量明显降低写入和查询数据都明显减慢。排查后发现数据量最大的几张表大部分在 7000W8000W 左右少数已经突破一亿。通过业务层面分析发现这些数据大多是用户产生的日志型数据业务相关性不强甚至两三个月前的数据已经不需要实时查询。由于接近年底团队希望尽可能不动应用先在运维层面缓解压力核心目标只有一个把单表的数据量降下来。想迁移数据却发现没有可排序索引最初的设想是把两个月之前的数据直接迁移到备份表中但在准备实施时发现了一个大坑表中没有一个可以排序的索引导致无法快速筛选出一部分数据。即便是加索引也需要花几个小时具体多久没敢在生产测试。如果强行按照时间筛选可能查询出 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-jdbcShardingSphere在做分表之前团队调研过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 需要具备全局唯一和趋势递增两个特性。数据库自增方案强依赖 DBDB 挂了就容易出问题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.mdREADME 中将其定位为「一次分表踩坑实践的探讨」。分表相关的理论基础可继续阅读数据库水平垂直拆分水平拆分、垂直拆分的概念与拆分后的事务两阶段提交、最终一致性问题分布式 ID 生成器分表后主键 ID 的几种生成方案及优劣对比MultipleThreadCountDownKit.java多线程并行任务计数汇总的实现参考可用于报表类跨表统计BloomFilters.java 与 LRUAbstractMap.java仓库中「哈希取模定位」思想的另外两处实现与分表路由逻辑同源。【免费下载链接】JCSprout Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考