从单表到24分表:业务层分表改造的完整实战解析 前阵子刚带团队把我们内部的一套核心数据链路从单表硬生生拆成了24张分表业务层也跟着做了一轮彻底改造。项目代号就叫“24分表业务层改造”从设计到上线前后折腾了两周多。这活儿看起来简单不就是算个哈希、拼个表名嘛真做起来全是细节。今天把这轮改造的完整思路、代码要点、迁移方案和踩过的坑都捋一遍给后面要做分表改造的朋友做个参考。先说清楚这套系统的背景。业务侧的数据以用户维度为主每天写入量在千万级单表数据量到了几亿之后索引维护成本、写入锁竞争、慢查询都开始冒头。我们希望在不引入重量级中间件的前提下用成本最低的方式把这波压力拆开——最终选了应用层路由 固定24张分表的方案。为什么是24不是16也不是64后面细讲。1. 为什么是24张表分表策略设计思路1.1 选择24的由来取模基数背后的业务考量分表数量不是拍脑袋定的核心就三个考量维度数据增长预估、单表性能拐点、以及运维复杂度。我当时先估算了一下未来一年的数据总量。系统日增量大概1000万行一年就是36亿行左右。单张MySQL表在索引合理的情况下超过2亿行之后B树的层级和缓存命中率都会明显劣化写入性能也会因为页分裂和刷脏变差。所以我给自己定的目标是——单表一年内的数据量控制在1亿到1.5亿之间。36亿除以1.5亿约等于24。这就是24张表的由来不是靠什么玄学公式纯粹是容量倒推出来的。当然为什么不用更小的16或者更大的3216张表单表年数据量2.25亿已经踩到性能拐点边缘32张表单表1.125亿倒是安全但表数量越多应用层维护成本、跨表查询的聚合开销都跟着涨。24恰好是中间的甜点位。还有一个隐性好处是24能被2、3、4、6、8、12整除。这意味着后续如果还想继续拆迁移时有非常灵活的粒度选择。比如哪天单表又扛不住了我可以按3的倍数把24张扩到72张数据搬迁的逻辑会简单很多。1.2 路由键选型为什么锁定业务ID而不是时间分表必须选一个稳定的路由键。我们一开始讨论过按时间分表就是每个月一张表的方案。这种分法对写入场景非常友好按时间顺序写几乎没有跨表事务问题归档也直观。但业务查询模型把我们拉回了现实。这套系统的核心查询几乎全部围绕用户维度展开——查某个用户某段时间的记录、查用户最后的操作状态、按用户批量拉取数据。如果按时间分表任何用户维度的查询都需要扫描全部24张表然后聚合结果。时间分表适合“按批次扫全量”的分析场景但在“按用户点查”的在线业务场景里几乎是一场灾难。所以最终路由键锁定了业务用户ID。这样同一个用户的所有数据天然落在同一张表里点查一条SQL直接定位不需要跨表聚合。加上用户ID本身自带比较高的基数分布不会出现明显倾斜这个选择无论从业务侧还是数据分布侧都有足够的说服力。1.3 表结构与分片键的预处理路由键定了之后还有一个前置动作要做——检查业务用户ID的数据类型。我们系统里的用户ID是BigInt类型路由计算非常方便。如果你手里的路由键是字符串比如订单号、手机号那就涉及到字符串哈希的问题。最简单稳妥的做法是用MurmurHash或者CRC32取整但要注意两点一是要选一个吞吐性能好的哈希算法二是哈希后的分布要尽量均匀。这里强烈不建议用Java原生的hashCode一是可能出现负数二是不同字符串的哈希分布并没有那么理想。我们最后是直接用用户IDmod 24算出表后缀。公式是tableSuffix userId % 24然后统一拼成order_info_0到order_info_23这种命名格式。为什么用0到23而不是1到24因为取模结果天然是0到23命名跟计算对齐省去一次1的偏移不容易出bug。2. 业务层改造的整体架构DAO层、路由层、数据访问层的重新分工2.1 分表前后代码结构的变化分表之前业务代码里到处是select * from order_info where uid ?这样的裸SQL。分表之后所有涉及到这张表的操作都必须先经过路由层计算出物理表名再把SQL发到对应的库表上去。我们改造的第一步是把所有直接操作分表对象的代码收拢到一个独立的DAO层里。对外暴露的还是业务方法签名比如queryByUserId(userId, params)内部封装了路由计算、表名拼接、SQL执行和结果映射。上层业务代码基本不用关心底层是单表还是24张表。这一步非常关键也是很多团队分表改造失败的原因——没有做边界收敛导致路由逻辑散落在各个业务代码里维护成本直线上升。我们当时定了一条铁规矩分表字段只允许出现在DAO层业务层一律不感知表名。2.2 改造范围界定哪些SQL需要动不是所有SQL都需要改造。改造前我们做了一次全量SQL扫描把涉及分表对象的所有SQL都列出来按功能分了三类第一类是单键操作SQL包括按用户ID的插入、更新、删除、单条查询这类改造最简单只需要做路由计算SQL本身语法基本不用动。第二类是多键操作SQL比如一次查多个用户的数据这类需要应用层做拆分把“一条大SQL”拆成“多条小SQL”并发执行再聚合结果。第三类是跨全表的聚合SQL比如后台的报表统计、全局分页查询这类SQL没法直接路由到单表需要走全表扫描汇总方案。我们当时的策略是第一类优先改造保障核心交易链路第二类坚决下沉到应用层第三类业务侧能绕的绕不能绕的就用异步离线计算避免在线接口触发全表扫描。2.3 路由组件的封装设计路由组件是整个改造的核心。我们封装了一个独立的TableRouter类对外暴露一个方法String getTableName(Long shardKey)。内部实现就是一个取模运算。但真正工程化的时候有几个细节绕不开路由结果要带缓存。一个请求生命周期内同一个用户ID可能被多次查询每次都计算取模虽然不快但也浪费。用ThreadLocal或者请求上下文缓存一下能省掉重复计算。我们是在DAO层入口处缓存了表名方法返回前清掉避免内存泄漏。路由规则要支持可配置。虽然当前是24张表但代码里不能写死% 24而是通过配置中心下发分表数量后续扩缩容的时候改配置就能生效不用重新发布代码。要对路由键做非空校验。如果业务层漏传了用户ID路由组件拿到null直接取模就是空指针或者算错表名。我们在路由入口加了一道防御发现路由键为空立刻抛异常并打出告警日志方便快速定位上游调用问题。3. 核心代码改造实操从增删改查到跨表聚合3.1 单键操作的改造插入、更新、删除单键操作是三类改造里最轻松的部分但依然有值得注意的细节。以插入为例原来一段很普通的insert into order_info (...) values (...)现在要先通过TableRouter算出物理表名再拼成真正的SQL。这里最容易踩的坑是——表名拼接一定要用占位符或者白名单校验绝对不能直接拼用户输入。虽然用户ID是BigInt类型不太可能有注入风险但工程习惯要养好。我们当时用的方案是自己封装了一个SQL模板工具。业务层传入的是逻辑表名order_info和分片键用户ID路由层解析出物理表名order_info_07然后通过预编译PreparedStatement执行。整个过程中开发同学看到的还是逻辑表名代码可读性没有下降反而是路由层集中管理了所有表名拼接逻辑。更新和删除的唯一额外注意点是——更新条件里必须带上分片键。原来可能是update order_info set status 1 where id 123分表之后这条SQL根本不知道去哪张表找。必须改成where id 123 and user_id ?这种形式路由层才能根据user_id定位表。这个规则很难用框架自动保证只能在代码Review时重点审查。我们当时的办法是在DAO方法命名加约定前缀比如updateByUidAndId让开发一眼就注意到有没有带分片键。3.2 单点查询的实现细节点查是分表改造后性能收益最明显的部分。原来单表数据量几个亿的时候一条点查SQL要扫好几层B树平均耗时七八毫秒。分表之后单表数据量骤降到几千万绝大多数点查在1毫秒内就能返回。这个过程一定要实测记录前后对比数据这既是上线后的汇报材料也是判断分表是否达到预期目标的核心依据。但点查也有一个需要警惕的坑查询条件里没带分片键。比如业务方想查一个订单记录但只传了订单号没传用户ID。这种SQL在单表时代没问题分表之后就抓瞎了——不知道去哪张表找。我们的处理方案有两个第一个方案是在订单号本身里嵌入用户ID信息。比如订单号生成规则里中间几位就是用户ID取模后的值。这种设计比较优雅但改动订单号生成规则的影响面比较大需要产品侧配合。第二个方案是建立路由索引表维护订单号和用户ID的映射关系。这种方案实现成本低但多一次查询开销而且索引表本身也是一张表可能也要考虑容量问题。我们当时因为订单号规则不好动最终采用了第二种方案用Redis缓存这个映射关系热点数据全部命中在内存里性能损耗几乎可以忽略。3.3 跨表批量查询逐表查询、并发聚合还是中间表跨表批量查询是分表改造里最容易翻车的地方。我们系统里有一个典型场景运营后台按条件筛选一批用户一次性展示这些用户的最近订单记录条件可能涉及时间范围、订单状态、金额区间等。在单表时代一句SQL就搞定了分表之后要跑24张表。最初的方案非常简单粗暴——循环遍历所有表逐张查询然后应用层合并结果。这个方案在表数量少比如8张以内的时候还能接受但到了24张表循环查询的串行耗时直接膨胀到了秒级接口完全不可用。后来我们优化成并发查询方案。用固定大小的线程池调度24个查询任务每张表一个子任务Future拿到结果后统一归并排序。这里有几个参数需要调线程池大小、单个查询的超时时间、结果的归并策略。我们实测下来在数据库连接池上限允许的前提下并发度调到12到16之间效果最好再上去会被数据库端的连接数和CPU拖累反而变慢。但并发查询也不是银弹。如果条件本身非常宽泛比如“查全平台最近一小时所有退款订单”24张表并发扫一遍单表可能扫出几万行聚合后接口依然会超时。这种全局统计类需求我们已经明确不做在线查询了全部走离线数仓生成报表供运营使用。分表之后在线接口绝对不要接全表扫描类需求这是条红线。3.4 分页排序里的深坑limit offset 和 order by 的拆解分页排序在分布式分表场景下是最容易被低估的问题。运营后台的表格几乎都要分页一页20条按时间倒序排。单表时代很简单order by create_time desc limit 20 offset 0。分表之后问题来了数据分散在24张表里全平台的“最新20条”需要先把每张表最新的20条取出来然后内存里排序取前20。取每张表20条是因为你不知道全局最新20条是否可能全部落在某一张表里所以只能先截断到单表TopNN至少要等于一页条数再合并。如果用户翻到第100页offset是1980你就需要每张表取前2000条再合并排序性能直接爆掉。这个问题的本质是深分页在分表场景下几乎是无解的。我们最终的产品侧方案是后台只允许翻前200页超过200页强制用户用筛选条件缩小范围。同时在SQL层做优化用create_timeid的联合索引支撑每张表的TopN查询加上游标分页替代offset分页把性能压到可接受范围。这里能给大家的忠告是分表之后业务产品应该重新审视“全量排序分页”这个需求是否真的必要绝大部分后台系统根本不缺这200页缺的是一个“精准筛选器”。3.5 统计类SQL的迁移方案离线与近线并行统计类SQL是分表改造的另一个重灾区。我们的系统里业务后台有好几个统计指标今日订单量、今日新增用户数、七日活跃用户数等等。这些指标在单表时代全部是select count(*) from order_info where create_time ?这种实时统计。分表之后就有两条路可以走一条路是实时的“按表汇总”。最朴素的实现24张表并发跑count(*)然后把24个结果相加。这个方案在数据量不大、统计指标少的场景下是可以接受的。我们当时压测过24张表并发count每张表单次查询都能控制在100毫秒以内汇总一次总耗时不超过300毫秒——这在后台报表场景里完全够用。另一条路是异步统计。用一个定时任务每小时跑一次把统计结果写入一张独立的汇总表报表页面直接读汇总表。这是最稳妥的方案不占用在线数据库资源也不影响主链路的查询性能。综合下来我们当时是对折处理的核心看板上的数字走异步统计保证实时性要求不高的场景稳定数据校验类的临时统计走按表汇总的临时方案。两条路并行谁也别堵谁。4. 数据迁移与灰度切换从单表平滑过渡到24张分表4.1 存量数据的全量搬迁策略分表改造里代码改动只是冰山一角真正的硬骨头是存量数据怎么平滑搬到24张表里而且不能停服太久。我们的存量数据当时大概有8亿行。全量迁移的方案选了很久最后敲定的是原生SQL导出并发导入。按用户ID取模把原来的单表数据拆成24份每一份并行导入到对应的分表。全量数据迁移的耗时主要取决于磁盘IO和目标库的写入性能实测下来在目标库配置正常的条件下8亿行数据大约用了6个小时完成搬迁。这里有一个非常值得注意的细节迁移过程中源表依然在持续写入新数据。也就是说全量迁移只能保证搬到某一时刻的快照之后新增的数据会漏掉。所以光有全量迁移是不够的必须配合增量同步这个方案通常叫“全量增量”双轨迁移。4.2 增量同步与双写机制增量同步我们采用了一个非常经典的方案在应用层做双写。改造初期所有写操作同时写入单表和分表单表保证读的可用性分表积累新数据。等全量迁移完成、数据校验通过后再把读流量切到分表。具体执行步骤分三步应用发布新版本DAO层改为双写模式。业务写操作先写单表老路径再异步写分表新路径。异步写有一个隐患是丢数据所以我们还加了一张本地消息表分表写入失败就记录失败状态由补偿任务重试。全量迁移完成进入数据校验阶段。抽查若干用户ID对比单表和对应分表里的数据量、关键字段的哈希值是否一致。我们当时的抽检率是1%也就是每100条数据里抽1条出来比对发现不一致就定位原因重新搬迁。校验通过正式切读。把读流量按10%、30%、50%、100%的比例逐步灰度切到分表每一步都盯监控指标确认无异常后再继续加量。这整套方案做下来业务全程无感没有停服窗口。代价是开发同学多写了一套双写逻辑以及并发量会短暂提升一些但换来的是平滑过渡这笔账非常划算。4.3 双写期间的典型数据不一致场景双写方案最怕数据不一致而最容易出现不一致的场景就是并发更新。比如同一个用户同时有两个请求一个请求更新status1另一个请求更新status2。单表写入顺序和分表写入顺序可能不一样最终两边数据就可能不一致。这个问题本质上是一个分布式事务问题。我们没有引入重量级分布式事务框架而是走了一条轻量级路线双写时先写单表拿到主键ID后再把同一主键ID写入分表。更新的时候分表SQL里带上版本号字段两次更新如果版本号不一致说明出现了并发冲突补偿任务以单表的数据为准覆盖分表。这套机制虽然不能百分之百杜绝瞬间不一致但能把不一致窗口缩到毫秒级而且最终能通过补偿任务收敛到一致状态。对于多数业务系统来说这种“最终一致性”已经完全够用了。5. 踩坑实录分表改造里那些防不胜防的细节5.1 事务跨表分布式事务问题的简化处理分表之前一个业务操作里改多张表的数据直接用本地事务就行。分表之后数据分散在多个物理表里本地事务管不了跨表操作了。我们遇到的第一个实际问题就是用户下单的流程里要同时更新“订单表”和“用户账户余额表”。这两张表如果都分表了各自的物理表后缀可能不一样一个在表0一个在表15就不能在同一个数据库事务里完成。当时我们没有立刻上分布式事务中间件而是先做了两个层面的缓解第一层把涉及跨表事务的业务操作拆分成“先更新主表、后更新从表”的流程通过本地消息表记录状态由异步任务保证最终一致。这种方案牺牲了一点实时一致性换来了应用的极度简化。第二层尽量把相关联的数据放到同一张物理表里。比如订单表和订单明细表在分表候选键上采用相同的路由键保证一个用户的所有订单和订单明细都在同一张分表里。这样同一个业务环节内的强一致操作就可以退化为单表单事务。提示分表场景下尽量不要在业务代码里写跨表事务。绝大多数分布式事务的开销和复杂度远大于业务上那几毫秒的强一致需求。5.2 连接池被打满并发查询导致的数据库连接数爆炸分表改造上线后我们遇到过一个比较惊险的问题某次大促预热期间数据库连接池突然被打满部分接口报连接获取超时。排查下来原因其实不复杂。原来一个查询只需要1个数据库连接改造后变成了24个并发的“子查询”一个请求瞬间占用24个连接。如果这个接口的QPS是200瞬间就有4800个连接在跑连接池不炸才怪。这个问题的解法有两个维度应用侧限制并发查询的总连接数线程池的大小和数据库连接池容量必须联动设计。我们当时把连接池最大容量压到单库单表能承受的范围内然后通过信号量机制限制跨表查询的并发度宁可让请求排队也不能击穿数据库。架构侧对跨表查询场景做分流把跨24张表的AP查询放到只读从库去执行不和在线交易链路争抢主库连接。这里再强调一句分表之后数据库连接池的监控必须提上日程连接使用率、等待时长、活跃连接数都要和改造前做对比基线上线后如果出现明显波动第一时间就要看是不是并发子查询造成的。5.3 热点用户问题分表不是万能的分表解决的是“数据量大”的问题但解决不了“数据倾斜”的问题。我们上线之后就观察到某个头部大客户名下订单数据量占据了某个分表总数据量的将近15%。这张分表的查询和写入压力明显高于其他表单表性能再次成为瓶颈。这种场景在To C业务里相对少见但在To B的SaaS系统里非常普遍。我们的应对策略是对大客户的数据单独拆一张专属分表也就是“超大规模租户独立路由”方案。在路由组件里加一层判断如果用户ID属于超大客户名单直接路由到专属表不走取模逻辑。这一层逻辑要尽量前置最好在路由组件内部解决不要让业务代码感知。我们当时在配置中心维护了一份“超大客户路由映射表”路由组件启动时加载到内存每次路由时先查名单命中就走专属表。这个方案会带来额外的代码复杂度但相比让某一个分表被热点压垮这点复杂度完全是值得的。5.4 自增主键的失效分布式ID的必要性单表时代主键ID交给数据库的自增主键就行。分表之后24张表各自维护自增ID就会出现同一个ID在表0和表1里分别存在而且业务上无法通过ID唯一标识一条记录只有“ID 用户ID”的组合才能唯一定位。这个问题的本质是分布式环境下全局唯一ID不能再依赖单库自增。我们的改造方案是引入了一套全局ID生成服务基于“时间戳 机器号 自增序号”生成64位ID保证全局唯一且趋势递增写入任何一张分表都不会有冲突。如果你手头不想额外引入ID服务至少也要用雪花算法自己实现一个。因为趋势递增对于数据库索引性能非常重要如果用随机UUID做主键B树的写放大和页分裂问题会在分表场景下被放大数倍。5.5 上线后的性能监控与容量规划最后一块拼图是监控。分表改造不是上线就算完了上线后我们持续盯了一个月的性能指标重点关注几个核心数据慢查询数量和改造前做对比理想情况是下降50%以上如果没有明显下降说明索引或者SQL本身还有问题。单表QPS分布通过路由日志统计每张分表的请求量看是否存在倾斜及时发现热点。数据增长率按周统计每张分表的数据量增长提前估算下一次扩容的时间点。这种监控体系不需要很复杂的组件用现成的日志采集和监控看板就可以做到。关键是改造前后要有基线对比数据否则你没法证明改造到底带来了什么收益。最后再分享一点个人的实操体会分表这套东西听上去是高并发架构里的常见套路但真正把一个存量单表系统改造成分表结构过程远比想象中琐碎。其中最磨人的不是技术本身而是“怎么让团队里所有人都对分表保持敬畏”——每一个没带分片键的查询、每一条绕过路由层的SQL、每一个意外落空的事务都可能在上线后变成事故。我个人的建议是做这类改造之前一定要先想清楚三个问题业务未来一年的数据增长到底有多快核心查询场景是否能够稳定地带上传分片键跨表聚合类需求能不能从在线链路里剥离出去这三个问题没有明确答案就动手哪怕代码写完了后续的运维成本和业务适配成本也会让你很难受。如果你正打算做类似的分表改造不妨先拿一张业务影响最小的辅助表做试点把路由、双写、迁移、灰度完整的链路跑一遍积累经验后再动核心表。这比直接拿核心链路开刀要稳妥得多。希望这轮改造的经验能帮你少走一些我们走过的弯路。