24 分表业务层改造从单表膨胀到横向拆分的完整实战记录先交代一下背景。我所在的项目做的是一个面向C端的交易类系统核心流水表在业务高峰期每天写入量轻松突破百万级单表数据量很快到了几亿行。索引失效、慢查询频发、主从延迟越来越大DBA那边每天收到告警比业务消息还勤快。经过几轮评估我们最终决定对这张核心表做分表处理具体方案是拆成 24 张物理表。这里说的“24”不是拍脑袋定的数字而是结合业务周期、写入峰值和查询模式算出来的。单表变 24 表听着不复杂真正难的部分在业务层改造——所有原先直连单表的 SQL、事务、统计、报表、定时任务全部要适配新的分片规则。这篇文章就是把我这段时间踩过的坑、验证过的方案、最终落地的细节完整记录下来给正在做同类改造的同学一个可以直接参考的路线图。先说清楚分表的触发条件。我们的流水表主要存储用户交易订单和流水记录每条记录大概是 1.5KB 左右日均新增峰值在 120 万行左右存量数据当时已经接近 8 亿行。单表下的问题非常典型普通二级索引的 B 树高度已经撑不住高并发写入间隙锁和页分裂导致写入吞吐持续下降按时间范围查询倒是可以走主键但业务上大量查询是按用户维度进行的索引命中率低到可以忽略最要命的是线上大事务批量状态回写经常扫到几百万行一锁就是十几秒。技术上到了这个阶段归档 加索引已经解决不了根本问题分表是绕不开的选择。但单库分表不同于分库它解决的是单表数据量过大导致的索引性能和锁竞争问题对于单一数据库实例的连接数、IOPS 压力并没有直接缓解这一点在方案设计之初就要有清醒认识。确定分表后第一件事不是写代码而是定分表策略。我们最终选择了按月分片也就是每个自然月对应一张物理表一年总共 12 张不对这里容易混淆——标题里的 24 分表实际是双写双读策略下的结果。我们在原有按月分片基础上为每个月又拆分了热数据和冷数据两张表月度表 T202401 和 T202401_HOT、T202401_COLD加起来就是 24 张物理表。这个结构的好处是冷热分离可以独立控制存储引擎参数、索引策略和数据生命周期。比如 HOT 表强制使用内存占比更高的 buffer pool 配置保留全部二级索引COLD 表则可以去掉低频索引压缩存储甚至后续直接归档到归档存储引擎。24 这个数字对应的是 12 个月 × 2热/冷双表不是随意取的。接下来重点说业务层改造。这个词看着宽泛实际拆开是几个硬骨头SQL 路由、聚合查询、事务边界、数据迁移、灰度上线、持续运维。哪个环节处理不好都会在生产环境搞出大事。我按模块逐个讲。1. 整体方案设计为什么选择按月分片 冷热分离1.1 分表维度选型的对比与取舍分表维度业界常用的有三种按时间、按哈希取模、按业务分区键。我们初期讨论过用户 ID 取模 24 的方案这样每个用户的流水都固定落在某一张表按用户查询时直接路由到单表性能最优。但取模方案有一个很致命的问题流量热点用户会被锁定在固定分片一旦某个大客户产生突发流量单张分表会成为新的瓶颈而且数据倾斜问题几乎无法避免。按月分片则不同。所有写入都落到当月表热点天然分散到不同的时间片月份切换后上个月的表自动进入冷处理阶段可以批量做归档和优化。对业务查询来说绝大多数场景都带有时间范围条件按月路由非常自然。最终我们采用的是时间路由为主、兜底双查为辅的策略原因是分表方案必须服务实际业务查询模式而不是单纯追求理论上的均衡分布。冷热分离是后加的。原因是 DBA 组反馈了一个问题单月数据中当月的活跃数据可能只有全月数据的 20%但 70% 的查询都打在这 20% 上。如果不做冷热分离整个月的数据混在一张表里热数据的查询性能还是会被大量冷数据拖累。拆成 HOT 和 COLD 双表之后当月默认写 HOT月末任务把超过 30 天仍未变动的数据迁移到 COLD这样 HOT 表始终控制在一个可控的体量索引命中率和写入性能都有保障。1.2 24 张物理表如何映射业务周期24 张表的命名规则定成T_YYYYMM_HOT和T_YYYYMM_COLD。假如当前是 2025 年 6 月那么线上会有 T202501_HOT/T202501_COLD 到 T202506_HOT/T202506_COLD 一共 12 个自然月的 24 张表。业务层的路由中心维护一个映射关系根据业务日期字段落位到yyyyMM再根据访问模式决定走不带后缀的逻辑表还是带物理后缀的标表。在 MyBatis 这类持久层框架上最粗暴的做法是 XML 里写死表名。但 24 张表不可能写死所以我们项目里建了一个路由上下文组件每次请求进入 service 层之前拦截器解析请求参数中的时间字段计算目标物理表名通过 ThreadLocal 传递到 DAO 层XML 里的表名写成动态变量${tableName}。整个过程对上层业务代码透明service 层无需感知自己操作的是哪张物理表。这套方案的优点是改造工作量小、侵入性低适合存量系统快速转型。缺点也很明显所有 SQL 都要走动态表名无法使用预编译占位符存在 SQL 注入面扩大的风险。所以表名白名单校验是必须的——计算出来的表名必须能匹配预定义的正则匹配失败立刻抛异常而不是直接拼接。这个细节我在后面会再强调。2. 核心路由逻辑从单表 SQL 到多表计算的平滑过渡2.1 自动路由与手动路由的双轨机制改造初期我们没有直接全面自动化而是做了一个双轨制路由线上请求默认走自动路由但保留一个手动指定物理表的开关。开关放在配置中心可以按用户灰度、按接口灰度、按百分比灰度。这个设计让我在灰度过程中省了大心思——一旦发现某个接口的 SQL 因路由问题报错立刻切开关回退到手动指定不用紧急发版。自动路由的核心算法极简单。截取业务参数里的时间字段格式如2025-06-15 14:30:00取前 7 位得到202506拼接出表名T202506_HOT。判断走 HOT 还是 COLD需要查一个元数据表里面有每个月份表的状态当月为 HOT历史月份为 COLD。其实这个判断还可以简化——所有 SQL 默认统一走 HOT 表只有定时归档任务才写 COLD 表查询则可以通过开关控制是否在命中 COLD 月份时追加对 COLD 表的访问。我们的做法是有开关控制的默认历史月份流水查询命中 COLD在线业务高峰期关掉历史查询的 COLD 访问以保护 IO。这里放一段我们路由计算的核心代码供参考public class TableRouter { private static final Pattern TABLE_PATTERN Pattern.compile(^T\\d{6}_(HOT|COLD)$); public String route(String bizTime, boolean hotOnly) { if (bizTime null || bizTime.length() 7) { throw new IllegalArgumentException(bizTime format error: bizTime); } String ym bizTime.substring(0, 7).replace(-, ); String suffix hotOnly ? HOT : resolveSuffix(ym); String tableName T ym _ suffix; if (!TABLE_PATTERN.matcher(tableName).matches()) { throw new IllegalStateException(Illegal route table: tableName); } return tableName; } private String resolveSuffix(String ym) { // 查元数据或者走本地缓存判断该月是否已归档 return archiveCheck(ym) ? COLD : HOT; } }这段代码最关键的坑点在于bizTime的格式。我们踩过一次前端传的查询开始时间格式是2025/06/01路由解析直接失败导致该接口报错率达到 30%。后来在网关层统一做了时间格式标准化路由层只接收一种格式彻底绝了这个隐患。2.2 聚合查询场景下的跨表合并改了路由就解决了单表的定位问题但业务不是只有单条查询。我们系统里最头疼的一类 SQL 是查某用户最近 30 天的流水汇总支持分页。平移改造之前这是一条 SQL 的事SELECT * FROM t_trade_flow WHERE user_id #{uid} AND biz_time #{start} ORDER BY biz_time DESC LIMIT #{offset}, #{size}分表之后这条 SQL 变成了跨表查询。最开始的实现是应用层逐个查 24 张表然后内存合并排序后再分页。这个方案技术上可行但性能太差线上实测一个 3 页查询要跑 400ms 以上因为要等最慢的那张表返回才能开始合并。后来我们换了思路维护一个用户最近访问流水索引表。这张索引表很小只存用户最近 90 天内的流水最小和最大时间戳以及落在哪个物理表的映射关系。查询时先查索引表只路由到真正包含该用户数据的物理表而不是盲目扫全部 24 张表。这个索引表的更新是异步的容忍 5 分钟的延迟。业务高峰期快查场景基本都可以命中索引缓存合并查询次数从 24 次降到了 1~3 次。聚合函数也是类似。统计某日的成交金额原先是SELECT SUM(amount) FROM t_trade_flow WHERE ...现在需要逐表 SUM 再累加。我们做了两个优化第一尽量让统计任务跑在从库上避免影响主库写入第二对实时性要求不高的统计直接用预聚合表按小时粒度更新查询只查结果表。跨表分页还有一个深坑MySQL 的LIMIT在跨表场景下不能简单叠加。例如每张表查 20 条24 张表共 480 条但是全局真正的第 1~20 条可能全部来自其中一张表而另一张表的第 1 条数据在全局排名中可能是第 300 名。内存合并后如果简单截断前 20 条结果就是错的。正确做法是每张表查LIMIT offsetsize合并排序后再截取。也就是说用户想看第 5 页offset80, size20每张分表要查 100 条最后内存取第 80~100 条。这个逻辑必须写在文档里否则接手的人很容易按单表思路写。注意跨表分页的 offset 越大内存消耗越高单张分表取数上限必须做保护。我们设定单表最多查 500 条超过之后直接拒绝并走离线统计通道防止极端翻页拖垮应用内存。3. 数据迁移与双写方案如何在不暂停服务的前提下改表3.1 存量数据的迁移策略拆分 24 张物理表后存量 8 亿行历史数据不可能随便丢也不可能直接停机搬运。我们采用了四步迁移第一步创建目标表结构。用与原表兼容的 DDL 创建 24 张物理表但索引策略不同——HOT 表只保留必要索引COLD 表去掉全部非唯一二级索引只留主键和月份索引。第二步全量复制。通过数据同步工具把原表数据按月份拆分成 12 个批次映射到对应月份的物理表。这里的关键点是批次大小我们第一批迁移 1 月份的 8000 万行发现单批次耗时 40 分钟且对源库的 IO 压力太大导致线上主从延迟飙到 80 秒。后面调整策略拆成按天迁移每天 200 万行一个小批次主从延迟控制在 5 秒以内。第三步增量同步。全量复制完成的时间点之后原表仍然每天会有大量新写入。我们用同步工具开启增量模式持续将新数据同步到对应月份的 HOT 表。增量同步期间不停业务只是每条写操作会双写一份到消息队列由消费者应用到目标表。第四步校验与切换。迁移不是复制完就结束必须做数据校验。我们校验的口径是这张表“总数 金额 SUM 按天的 COUNT 分组”。总数和金额能快速发现漏数据按天分组能定位到具体哪天的数据有问题。全部校验通过后在流量低峰期切读流量到分表建议先切 10% 观察 30 分钟稳定后逐步放量到 100%。3.2 双写期间的业务兼容处理双写阶段的业务兼容是最容易翻车的地方。逻辑上新写入要同时进原表和 HOT 分表如果其中一边失败怎么办我们的方案是以原表为主写分表通过消息队列异步写入。主写成功后发一条消息消费者负责把数据写入分表如果消费者写失败重试 3 次还是失败就进死信队列由定时任务扫描补偿。这个方案的好处是业务入口完全不用改坏处是分表的数据会存在秒级延迟。针对这一点我们在分表查询接口上做了一个标识允许查询最近 N 分钟的双写延迟数据。具体做法是如果查询涉及的时间范围在双写延迟窗口内就同时查原表和分表合并去重后返回。窗口时间默认 5 分钟可以配置。灰度期间这个窗口时间长一些全量切换稳定后可以直接关掉。这里有一个我自己踩过的深刻教训双写初期以为异步双写一定会成功没有做持续比对。结果分表数据比原表少了 0.03%排查下来是某些特殊状态机的订单在更新阶段没有走统一写入入口而是直接由定时任务 UPDATE 原表绕过了消息队列。后来把所有对原表的写操作全部收口到唯一的数据访问层禁止业务方自行发 SQL这类遗漏才彻底解决。4. 业务层改造后的性能与稳定性优化4.1 连接管理与线程池调优24 张物理表带来的直接变化是一个业务请求可能需要同时打开多个表连接。原先单表查询只需要 1 个连接跨表合并查询需要 3~5 个连接。如果数据库连接池上限不调整高并发下会直接把连接池打爆应用报Connection is not available, request timed out。我们的连接池使用的 HikariCP原先 maximumPoolSize 设置的是 50改造后短时间内被大量跨表查询打满。调优思路是将 maximumPoolSize 适当上调到 80同时收敛每个请求的并发连接数——通过限制 service 层同时发起的并行子查询数量避免一个请求占用过多连接。并行度我们控制在 4 以内经验值是单请求跨表超过 5 张表时串行查比并行查更稳妥因为并行连接数不可控。另一个容易被忽略的是事务边界。分表后跨表事务变成分布式事务不能用本地事务解决。我们这里的选择是核心流水写入保证单表单事务涉及跨表更新的业务拆分成多步补偿式事务——先更新主记录再异步更新关联数据通过本地消息表保证最终一致。这是放弃强一致的代价但对交易流水这种追加写为主的场景最终一致的窗口控制在几百毫秒内业务完全可接受。4.2 索引策略与慢查询治理分表之后并不是索引问题自动消失相反因为每张分表的数据量只有原来的 1/24索引的行为特征完全不同。我们的 HOT 表索引设计原则是“精而不多”保留主键、用户 ID 二级索引、业务时间索引、状态位索引其他组合索引全部砍掉。因为单表数据量降到 3000 万级别之后二级索引 回表的成本已经可控保留过多索引反而拖累写入。COLD 表则激进得多只留主键和月份索引其余二级索引全部不建。原因很简单——冷数据查询场景 90% 是“捞某个月某用户有没有发生过交易”或者做归档核对这种查询要么走主键精确匹配要么走月份范围扫二级索引不仅用不上还白白占用存储空间。归档时我们统计过去掉二级索引后 COLD 表体积减少了 38%归档速度提升了接近一倍。慢查询治理这块我建议上线后立刻开启慢查询日志并盯住三类典型 SQL。第一类是带OR条件且条件跨多个时间段的查询路由会算出多张表极容易退化成全表扫。处理方式是把OR改写成UNION ALL让优化器能分别走到正确的分表索引。第二类是范围查询不带时间条件比如后台管理页按用户查全部流水但没传时间范围这种查询会路由到所有 24 张表。处理方式是业务上强制要求必须带时间范围否则默认只查最近 3 个月拒绝全表扫描。第三类是排序字段与路由字段不一致的查询例如按金额排序不带时间条件分表后每张表的数据是局部有序但全局无序必须内存合并排序。这种查询在高频翻页下性能非常差我们最终的解法是下线了这个功能改成了离线预排序导出。5. 常见故障模式与应急排查手册5.1 路由计算错误引发的一类严重事故分表改造上线后最容易出的事故就是路由计算错误。常见原因有三个一是系统时钟不一致。我们有两台应用服务器时钟偏差较大导致路由计算时用了本地时间结果本该写入 6 月表的记录写到了 5 月表。排查了很久才发现最后在路由层强制使用数据库时间或业务参数时间屏蔽本地时钟。二是时区问题。业务时间字段存储的是 UTC 时间但路由计算直接取了 UTC 的月份导致每天早上 8 点前东八区凌晨的数据被路由到前一天的表。这个问题最坑的是它不报错只是数据落在“逻辑上错误但物理上合法”的物理表里最后靠对账才暴露出来。三是参数类型混淆。POJO 里Date类型传到路由层时由于序列化配置问题变成了String月份截取逻辑从字符串前 7 位取到了前 1 位计算出诡异的表名。最终白名单正则拦下了非法表名但接口已经报错了将近 10 分钟。这个事故之后所有路由参数都强制走统一包装类不直接传递原始类型。5.2 分表后的事务回滚陷阱之前提到跨表事务改成了最终一致但单表事务内部也藏着一个陷阱。我们的流水表写入场景里有一步是“写流水 更新账户余额”这两个操作涉及时序问题。原方案是先写流水再更新余额如果余额更新失败流水通过事务回滚。分表后如果流水表跟账户表不在同一库这个回滚就失效了。我们的最终方案是调整顺序先更新账户余额并扣减预扣款然后写流水流水写失败抛异常后余额通过补偿任务回滚。这个调整依赖一个“预扣流水号”字段作为幂等键重复补偿执行时不会重复扣余额。这个坑很典型凡是分表改造涉及资金、库存、积分这类强校验数据的务必先把幂等机制建好再动表结构。5.3 分表后数据倾斜与热点问题的处理实践说到数据倾斜简单取模分表在业务高峰期很容易出现某个分片被热点用户打满的情况。虽然我们的按月分片天然避开了用户维度的倾斜但月份表本身也有热点——例如大促日的写入量是平时的 10 倍当月 HOT 表的写入压力仍然很大。针对这种突发流量我们做了一层“临时分片”机制在路由层检测到当月 HOT 表写入耗时超过阈值时自动将新增数据按小时拆到扩展表例如T202506_HOT_EXT18查询时对当日数据额外合并扩展表。大促结束后扩展表数据合并回正式 HOT 表。这个机制本质上是热点隔离而不是分表策略的替代但应对短期峰值非常有用。实际大促期间靠这个机制把核心表写入 P99 延迟从 320ms 降到了 85ms。另一个经验是分表后必须对“全表扫描类”查询做熔断。我们线上是有一个守护任务定期扫描数据库会话里是否出现目标表名的全表扫描 SQL如果发现执行计划里的 typeALL 且 rows 超过百万直接 kill 会话并告警到值班群。这个操作看起来简单暴力但确实是保护分表架构的最有效手段。分表的收益来自查询能精确定位到一张小表一旦查询退化为全扫分表的意义就完全丧失了。5.4 定时任务与批量作业的分表适配除了在线接口定时任务也必须在改造范围里。我们的批量作业以前是SELECT * FROM t_trade_flow WHERE status 0 LIMIT 1000这种全表捞取分表后变成从每张 HOT 表各捞 1000 条汇总到内存队列再处理。这里有个问题状态回写是 UPDATE 操作如果按表逐个回写会出现同一用户两条数据跨表先后更新的情况对账户余额一致性有影响。我们改造后的批量任务统一走消息队列把按逻辑主键产生的更新事件顺序投递保证同一主键的更新按序执行。批量任务另一个大坑是清理历史数据。以前删除 30 天前的数据是一条DELETE FROM t_trade_flow WHERE biz_time ?分表后这个操作天然变成了按 COLD 表逐个 TRUNCATE——因为 30 天前的月份表肯定已经全部进入 COLD 状态直接 TRUNCATE 物理表即可比 DELETE 快了几个数量级。这也算是分表带来的意外收获。6. 改造完成后的复盘与踩坑心得整个 24 分表业务层改造从方案评审到全量切换我们大概花了六周时间。其中两周做方案设计和评审三周做代码改造和测试最后一周做灰度切换和应急演练。回头复盘有几个心得值得分享。第一分表方案必须在业务层改造之前就确定“路由键”和“查询模式”的匹配关系。我们最开始定的路由键是业务日期但后来发现后台管理模块有大量不按日期查询的场景导致这些接口被迫做全表扫描或者额外适配。如果提前梳理所有核心查询 SQL标记清楚哪些必须按用户维度查、哪些必须按状态查分表维度很有可能会有不同选择。这个排查工作一定要前置不要等表拆完了再回头补代价完全不一样。第二分表改造的灰度一定要做到接口级。我们的灰度顺序是先灰度只读接口特别是详情查询类再灰度写入接口建议先灰度低频写入、后灰度高频写入最后灰度批量任务和统计报表。只读接口出问题最多损失查询可用性写入接口出问题损失的是数据正确性所以写入灰度必须格外谨慎对应接口的核心参数比如用户信息、金额要逐一做一致性校验。第三数据校验不是上线前做一次就完了。我们在全量切换后仍然保留了持续两周的双写和每日对账。对账任务每天凌晨跑一次统计分表和原表的总数差、金额差、按天的条数分布。两周后对账结果持续为零才正式关闭双写通道原表进入只读归档状态。这个持续校验机制看着笨重但其实非常有效特别是对于没有 test 环境可以完整模拟的存量系统。第四建议整个改造过程保留一键回退能力。我们的配置中心里有一个“分表路由总开关”一旦线上出现无法快速定位的问题可以直接关掉分表路由让所有查询回退到旧表旧表在切换初期仍然保持同步只是停止写入。这个开关在灰度第 3 天就救过一次——当时批量任务的一个 JOIN 查询在分表上性能退化导致接口超时我们一键切回旧表后恢复了服务然后慢慢排查。这种回退能力平时用不上但真出大事的时候就是救命的。最后再单独说说大家在改造中最容易忽略的一个环节监控指标。分表改造后原有基于单表的监控全部失效。我们上线前补齐了四类监控物理表维度的写入延迟、单表容量增长率、跨表查询的平均合并张数、以及路由失败次数。没有这些监控光靠业务告警撑不住。特别是“路由失败次数”这个指标能最直观地反映路由逻辑是否有缺陷建议做成实时计数并接入告警。这次改造让我最大的体会是分表不是数据库的事情是整个业务架构的事情。表拆掉容易业务层的适配和运维体系的跟进才是真正的工作量所在。如果只看表结构24 张表似乎没有多复杂但每一处 SQL 的改写、每一个事务的拆分、每一个定时任务的调整背后都需要想清楚“为什么这么做”。把这层想清楚了分表改造的路径就清晰了。