前阵子刚带团队把我们内部的一套核心数据链路,从单表硬生生拆成了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或者更大的32?16张表,单表年数据量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条是否可能全部落在某一张表里,所以只能先截断到单表TopN(N至少要等于一页条数),再合并。如果用户翻到第100页,offset是1980,你就需要每张表取前2000条再合并排序,性能直接爆掉。这个问题的本质是:深分页在分表场景下几乎是无解的。我们最终的产品侧方案是:后台只允许翻前200页,超过200页强制用户用筛选条件缩小范围。同时在SQL层做优化,用create_time+id的联合索引支撑每张表的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 双写期间的典型数据不一致场景双写方案最怕数据不一致,而最容易出现不一致的场景就是并发更新。比如同一个用户同时有两个请求,一个请求更新status=1,另一个请求更新status=2。单表写入顺序和分表写入顺序可能不一样,最终两边数据就可能不一致。这个问题本质上是一个分布式事务问题。我们没有引入重量级分布式事务框架,而是走了一条轻量级路线:双写时,先写单表,拿到主键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、每一个意外落空的事务,都可能在上线后变成事故。我个人的建议是,做这类改造之前,一定要先想清楚三个问题:业务未来一年的数据增长到底有多快?核心查询场景是否能够稳定地带上传分片键?跨表聚合类需求能不能从在线链路里剥离出去?这三个问题没有明确答案就动手,哪怕代码写完了,后续的运维成本和业务适配成本也会让你很难受。如果你正打算做类似的分表改造,不妨先拿一张业务影响最小的辅助表做试点,把路由、双写、迁移、灰度完整的链路跑一遍,积累经验后再动核心表。这比直接拿核心链路开刀要稳妥得多。希望这轮改造的经验,能帮你少走一些我们走过的弯路。
前阵子刚带团队把我们内部的一套核心数据链路,从单表硬生生拆成了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或者更大的32?16张表,单表年数据量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条是否可能全部落在某一张表里,所以只能先截断到单表TopN(N至少要等于一页条数),再合并。如果用户翻到第100页,offset是1980,你就需要每张表取前2000条再合并排序,性能直接爆掉。这个问题的本质是:深分页在分表场景下几乎是无解的。我们最终的产品侧方案是:后台只允许翻前200页,超过200页强制用户用筛选条件缩小范围。同时在SQL层做优化,用create_time+id的联合索引支撑每张表的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 双写期间的典型数据不一致场景双写方案最怕数据不一致,而最容易出现不一致的场景就是并发更新。比如同一个用户同时有两个请求,一个请求更新status=1,另一个请求更新status=2。单表写入顺序和分表写入顺序可能不一样,最终两边数据就可能不一致。这个问题本质上是一个分布式事务问题。我们没有引入重量级分布式事务框架,而是走了一条轻量级路线:双写时,先写单表,拿到主键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、每一个意外落空的事务,都可能在上线后变成事故。我个人的建议是,做这类改造之前,一定要先想清楚三个问题:业务未来一年的数据增长到底有多快?核心查询场景是否能够稳定地带上传分片键?跨表聚合类需求能不能从在线链路里剥离出去?这三个问题没有明确答案就动手,哪怕代码写完了,后续的运维成本和业务适配成本也会让你很难受。如果你正打算做类似的分表改造,不妨先拿一张业务影响最小的辅助表做试点,把路由、双写、迁移、灰度完整的链路跑一遍,积累经验后再动核心表。这比直接拿核心链路开刀要稳妥得多。希望这轮改造的经验,能帮你少走一些我们走过的弯路。