项目刚好推进到第10天前9天业务功能开发得还算顺畅用户、商品、订单三大模块都陆续上了线接口文档也齐了但是第9天晚上做性能摸底时问题开始冒头。单表订单数据量已经逼近千万级后台几个列表接口的响应时间从几百毫秒一路飙到三四秒慢日志里最夸张的一条查询扫了全表直接把数据库CPU打到85%。运营那边已经开始在群里问“系统是不是卡了”。这时候技术侧心里基本有数单库单表这个架构阶段该结束了下一步就是“引入分库分表路由组件”。听起来很高级但那天真正动手的时候才发现这里面有大量细节问题要梳理。这篇文章就把DAY 10这整天的推进过程、路由组件选型、配置细节和踩坑记录完整记录下来给准备做数据分片的团队一个可参考的实操样本。1. 先判断时机不是所有系统都该急着分库分表1.1 什么信号出现后才算到了引入路由组件的时候很多团队听到“分库分表”四个字就兴奋动不动想把所有表都拆一遍。我在DAY 9晚上带着大家复盘时特意强调过分库分表是数据架构里最重的改造手段之一一旦做了后续所有查询、事务、统计都要跟着适配拆出去的库表很难再合并回来。当时项目里出现的几个信号才是真正值得动手的硬指标单表行数接近千万即使加了索引深分页查询依然明显变慢写入并发升高后单库的锁竞争和IO瓶颈开始显现CPU经常被打满备份和恢复的时间窗口拉长单库全量备份已经超过业务允许的停机时间慢查询日志里开始频繁出现全表扫描且通过加索引已经压不下来。这几个现象在DAY 9全部出现了所以DAY 10的任务落在“引入分库分表路由组件”上是顺理成章的事。如果只是偶尔一次查询慢先做索引优化、读写分离可能就够了没必要直接上分片。1.2 引入路由组件前的架构演进路线那天动手前我把项目数据库架构的演进路线在文档里列了一下方便团队理解为什么是DAY 10而不是DAY 2第一层是单库单表适合业务初期数据量小开发效率最高第二层是增加缓存层把热点数据扛住适合读多写少的场景第三层是主从读写分离主库负责写、从库负责读解决读压力到第四层才是分库分表把数据按规则分散到多个库和表中同时解决读写并发和数据容量的问题。项目前面9天其实已经做了缓存和读写分离但订单表数据量增长太快单表存储和写入并发成了新的瓶颈。继续在单库上打补丁边际收益已经很有限了这时候分片就是恰当的下一步。注意分库分表不是缓存和读写分离的替代品而是前两者都做完了之后容量和并发仍然不够时的选择。顺序反了容易过度设计。2. 路由组件的选型思路与核心原理2.1 主流方案横向对比DAY 10上午的会议主题是选型。市面上的分库分表路由组件不少最终进入对比名单的有三类方案客户端路由、代理端路由、中间件集群方案。我整理了一张选型对比表直接贴进了项目wiki方案典型代表部署方式对业务侵入协议支持适用场景客户端路由ShardingSphere-JDBC嵌入应用进程低数据源替换JDBC直连不支持跨语言Java应用中小规模分片代理端路由ShardingSphere-Proxy独立服务低应用连接普通数据库任意语言MySQL协议兼容多语言团队不想改代码代理端路由MyCat独立服务低类似数据库中间件MySQL协议传统分片需求SQL相对简单组合方案ShardingSphere-JDBC Proxy两者结合最低灵活复杂分片需要架构弹性我们项目是纯Java技术栈核心诉求是尽量减少对现有MyBatis代码的改动同时分片规则可控、社区文档齐全。所以最终选择了客户端路由方案用ShardingSphere-JDBC这个组件直接嵌入业务应用数据源替换成本最低也不引入额外的代理节点运维负担。2.2 分片键路由决策的最核心变量分片键是什么简单说路由组件要把一条SQL发送到正确的库表上执行它必须根据某一列的值来计算目标分片这个列就叫分片键。DAY 10讨论过程中我们反复确认了一个原则分片键必须包含在大多数高频查询的WHERE条件里。当时订单表最热的查询是按order_id查详情、按user_id查订单列表。如果只按user_id分片那order_id怎么找到对应分片如果只按order_id分片那按user_id查列表时就需要全库扫描。这是一个经典的取舍。最终我们决定以order_id作为主分片键因为订单详情查询是最核心的链路下单、支付回调、对账都需要按order_id精确定位。而按user_id的查询我们后面再通过增加订单索引表来解决。经验分片键选错是分库分表项目里最致命的坑一旦落库后想换分片键基本等于重新迁移全量数据。选之前一定要把团队大脑里所有高频SQL过一遍。2.3 分片算法取模、范围、一致性哈希怎么选路由组件支持多种分片算法DAY 10我们把三种最常用的做了对比取模分片用分片键的值对分片总数取余比如order_id 5分片总数为4余数为1落到第1个分片。实现简单数据分布均匀适合按数字ID分片但后续扩容时会涉及大量数据迁移。范围分片按时间、按ID区间划分比如1月订单进分片02月订单进分片1。实现直观对时间序列查询友好但热点问题明显当月数据会集中在同一个分片。一致性哈希把数据按哈希环分布扩缩容时只需要迁移少量数据但实现复杂度高路由计算开销也更大。表格对比如下算法优点缺点扩缩容友好度典型场景取模分布均匀、实现简单扩容需要迁移大量数据低订单ID、用户ID范围范围查询友好热点集中中日志、时间流水一致性哈希扩容影响小实现复杂、路由开销大高数据量成长不可预估的缓存类项目里订单表数据增长比较稳定我们预计三个月内不会扩容取模算法的优点大于缺点所以DAY 10定的方案是哈希取模分片数暂时设成4。2.4 一条查询在路由组件内到底经历了什么这是团队里好几个新人当天问得最多的问题。我画了一下处理流程这里用文字描述一条SQL进入路由组件后先经过解析器把它解析成语法树然后路由引擎根据分片键的值和分片算法计算出这条SQL应该去哪些库、哪些表执行之后改写引擎会把逻辑表名改成真实的物理表名改造完成后路由引擎把SQL分发到对应的真实数据源执行最后归并引擎把多个分片返回的结果集做合并、排序、分页再返回给业务层。这个流程解释了一个重要现象对业务层来说它写的仍然是t_order这张逻辑表路由组件在底层自动把它改写成了类似t_order_1、t_order_2这样的物理表这就是所谓“逻辑表与物理表”的对应关系。Day 10踩坑时我们发现如果分片键没有出现在WHERE条件里路由组件无法裁剪分片范围就会产生全分片路由也就是把SQL发送到所有分片执行后再归并。这种情况下性能不一定差但如果你在SQL里用了非确定性函数或者复杂子查询归并时容易出错。2.5 分布式主键不能再用数据库自增ID了分库分表后如果每个分片各自保持自增ID那么所有库都是从1开始递增订单ID会在全局范围内重复。这对业务是不可接受的。DAY 10的技术方案里我们同步接入了分布式主键生成器采用的是雪花算法。雪花算法生成的ID由时间戳、机器ID、序列号三部分组成全局唯一且趋势递增。路由组件内置了雪花算法的生成策略配置里只需要指定worker-id机器标识。这里有个容易忽略的细节如果应用所在服务器发生了时钟回拨可能导致ID重复。解决方案通常是在生成器里加一个“时钟回拨判断”如果检测到时间倒退就等待几毫秒或者用备用方案生成ID。注意分片键选order_id后不要再用数据库自增主键来生成它否则多个分片之间必然重复。项目里是在业务层正式落库前先通过主键生成器获取ID再走插入逻辑。3. 完整接入过程依赖、配置与代码改造3.1 依赖引入与版本选择DAY 10上午选型结束后下午就开始落地改造。首先是引入路由组件的依赖。我们用的是ShardingSphere生态版本固定为5.x因为5.x在分片配置的标准化上比4.x清晰很多而且支持YAML驱动式配置。依赖就加一条核心依赖构建工具会自动带出配套的SPI实现。这里要特别提醒版本号一定要和项目里的Spring Boot版本匹配否则启动时会出现数据源自动装配冲突。团队里A同学上来就装了最新版结果和项目现有的连接池版本冲突数据源初始化一直报错最后把版本降了一档才正常。3.2 数据源与分片规则配置逐行拆解这是DAY 10的核心工作量。先准备两个物理库分别命名为order_db_0和order_db_1每个库里各建两个订单表分别为t_order_0和t_order_1。所谓4个分片就是2个库乘以2张表。路由组件的接入方式是配置一个逻辑数据源把所有物理数据源和分片规则收拢进去。核心配置文件长这样dataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://10.0.0.1:3306/order_db_0?serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: ****** ds_1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://10.0.0.2:3306/order_db_1?serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: ****** rules: - !SHARDING tables: t_order: actualDataNodes: ds_${0..1}.t_order_${0..1} tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_id_mod keyGenerateStrategy: column: order_id keyGeneratorName: snowflake bindingTables: - t_order,t_order_item broadcastTables: - t_region shardingAlgorithms: order_id_mod: type: mod props: sharding-count: 4 keyGenerators: snowflake: type: SNOWFLAKE props: worker-id: 1这段配置值得逐行说清楚因为团队当天在这里盯了最久。actualDataNodes定义了逻辑表和物理表的映射关系ds_${0..1}是枚举两个数据源t_order_${0..1}是枚举每个库里的两张某表两者组合起来正好是4个分片。tableStrategy.standard指定分片策略类型shardingColumn指定了按order_id来分片shardingAlgorithmName指向下面定义的取模算法。keyGenerateStrategy配置了订单主键的生成策略这里引用雪花算法生成器写入数据时如果主键没填组件会自动填充。bindingTables这一行很重要它把t_order和t_order_item声明为绑定表意思是这两张表在分片规则上保持一致关联查询时可以优先在同分片内执行。broadcastTables是广播表配置t_region这种字典类型的数据量小、几乎不更新直接复制到所有分片避免跨库查询。3.3 分片算法的计算验证配置写完后我带着团队成员手工计算了几组取模结果来验证路由是否正确。因为分了4个分片取模基数就是4order_id 101101 % 4 1数据落到ds_0.t_order_1order_id 102102 % 4 2数据落到ds_0.t_order_2order_id 103103 % 4 3数据落到ds_0.t_order_3order_id 104104 % 4 0数据落到ds_0.t_order_0。但这里有个陷阱频繁出现如果各个分片表里的ID都是从1开始的那么取模后的余数会导致ID相等的数据散落在不同物理表。比如ID104落在t_order_0而另一个库里同样有ID104的数据落在t_order_0。因为有了分布式主键生成器全局ID不会重复才绕开了这个风险。这也是为什么分布式主键必须和分片规则同步引入。3.4 绑定表与广播表的配置意义DAY 10之前订单明细查询经常要关联t_order_item表。如果t_order_item也按订单号分片但不配置绑定关系那路由组件没办法保证一次关联查询只落到一个分片内执行很可能产生笛卡尔积式的跨库查询时间直接爆炸。配置绑定表之后路由组件在处理t_order和t_order_item的关联查询时会强制使用同一个分片键t_order_id让关联查询只在同分片内执行。这个配置对查询性能的提升是肉眼可见的。广播表则解决另一类问题。t_region这种地区表总共就几百行如果只在某个分片里放一份其他分片查询时又要跨库。把这种表配置为广播表后所有分片都会复制一份完整数据关联查询就完全不需要跨库通信了。这里要控制数量广播表不宜太多否则每次写入更新都要同步到所有分片写放大也是成本。3.5 业务代码的最小侵入改造团队最担心的一点是分库分表后业务代码会不会要大改我们实际改造下来发现只要前期设计合理改动量远比想象中小。因为路由组件的客户端模式直接提供了一个逻辑数据源业务层原来的JdbcTemplate、MyBatis连接的全部换成这个逻辑数据源其余SQL基本不用动。对MyBatis项目来说改造点主要体现在三处第一数据源配置类里Bean指向逻辑数据源不再是物理库连接第二Mapper XML文件里写的表名保持逻辑表名t_order物理表名由路由组件改写第三所有插入操作的主键改为由分布式主键生成器填充代码里不能再依赖数据库的自增返回。Configuration MapperScan(basePackages com.example.project.order.mapper) public class DataSourceConfig { Resource private DataSource shardingDataSource; Bean public SqlSessionFactory sqlSessionFactory() throws Exception { MybatisSqlSessionFactoryBean sessionFactory new MybatisSqlSessionFactoryBean(); sessionFactory.setDataSource(shardingDataSource); ... return sessionFactory.getObject(); } }这段代码是压缩后的核心结构。原来的物理数据源已经全部被逻辑数据源覆盖MyBatis在创建SqlSession时连接的就是路由组件暴露的数据源。我们当天解放了一个潜在大坑千万不要在Mapper XML里写t_order_0这种物理表名哪怕只有一个分片能用一旦分片规则调整SQL就废了。4. 分库分表后的典型问题与排查实录4.1 跨分片分页排序偏移量翻倍的问题接入分片后第一个暴露的问题就是深分页。DAY 10联调时运营后台翻到第500页接口直接卡死。原因很好理解路由组件对LIMIT 10000, 10的处理方式是把偏移量加倍后下推到每个分片。4个分片意味着每个分片都要查出10010条记录再在内存里归并排序最终才返回第10000到10010条。分片数越多这种深分页的资源浪费越夸张。解决办法有几种思路。最简单的是产品规则上限制最大页码不允许用户深度翻页技术方案上用游标分页替代偏移量分页比如WHERE order_id 上一个页面最大ID ORDER BY order_id LIMIT 10还有一种是对已有列表需求提前做预聚合或者只查第一页。团队最终选择的是序号游标方案配合前端“加载更多”交互把原来第500页这种请求彻底消灭了。经验分页方案一定要在产品设计阶段就限制深度否则分库分表后的深分页查询会成为数据库性能杀手。这是写入和查询改造之外最容易埋坑的地方。4.2 跨库关联查询绑定表与冗余的取舍分库分表后原来惯用的JOIN跨表查询变得危险。如果两个表分片键不同路由组件无法在单分片内完成JOIN只能拉全量数据到内存做关联数据量一大就直接OOM。我们项目里的订单主表和订单明细表都按order_id分片已经通过绑定表解决了JOIN问题。但还有一类查询按user_id查订单列表需要把订单分片键和用户维度关联起来。最终方案是增加一张索引映射表表里存user_id和order_id的对应关系查询时先定位order_id再定位物理分片。这种冗余方式很常见。设计时要注意映射表本身也可能是大表同样需要分片只是它的分片策略和订单表解耦。4.3 分布式事务强一致还是最终一致分库分表后原来单库事务天然支持的ACID被打破了。DAY 10的代码审查环节我们有同事问插入订单主表、插入订单明细表如果主表成功了明细表失败了事务怎么回滚这里要分场景。路由引擎对绑定表的同分片写入事务可以控制在单库范围内仍然具备强一致性。但如果是跨库的写入比如同时改订单库和用户库那就需要引入分布式事务方案。当天我们没有立刻上分布式事务框架而是做了一个务实的决定核心下单链路尽量保证所有数据落在同一个分片内依靠单库事务保证原子性跨库、跨服务的非核心操作采用本地消息表加异步补偿的方式用最终一致性替代强一致。这个决定的背后逻辑是分布式事务框架的复杂度、性能损耗和维护成本都很高并不是所有业务都值得在第一天就引入。如果业务场景确实要求强一致可以再调研XA协议或者Seata但一定要明确知道代价。4.4 数据迁移与灰度发布分库分表改造上线最怕一次性全量切换失败。我们的做法是双写加灰度。先在服务端开启双写新数据同时写入老单表和新的分片表老表保持只读不查同时写一个数据同步任务把历史数据按分片规则迁移到分片表中。迁移过程中记录进度、对比数据量、抽样校验数据一致性。等分片数据和老表数据完全对齐后开始灰度切读先让1%的流量走到分片表观察接口耗时和报错率稳定后扩大到10%、50%、100%。灰度期间如果发现分片路由错误或者数据库异常立即把流量切回老表双写任务也同步暂停回滚成本很低。这套流程当天讨论了一个多小时最后定为项目上线阶段的标准操作流程。DAY 10当天只是搭好了配置框架真正的双写和迁移在DAY 11才执行但方案已经提前敲定。5. 路由组件的避坑清单与性能验证5.1 分片键选择的反面案例分片键有多重要几乎决定分库分表的成败。DAY 10讨论时我列举了几类绝对不要选的键不要选和业务关联度低的随机数比如UUID查询时基本无法携带这个条件路由组件必然全分片扫描不要选有明显冷热分布的字段比如下单时间如果按时间分片高峰时段数据全部写进同一个分片热点问题比不分片更严重不要选会被更新的字段如果分片键本身会被UPDATE改写数据就可能在物理分片之间漂移引发一致性问题。订单表最终用order_id分片是因为这个字段一旦生成就不会改变而且主查询都携带它数据分布也比较均匀。5.2 只分表不分库还是分库又分表实体方案里有个容易混淆的决定到底是“分表不分库”还是“分库又分表”。分表不分库解决的是单表数据量过大但写入并发仍然受制于单库分库又分表进一步把并发压力分散到多个实例上。DAY 10配置里走的是分库又分表两个物理库每个库两张表共4个分片。这个选择的原因是写入并发已经逼近单库上限单纯分表解决不了。如果应用规模小数据库实例资源充足可以先只分表宁得简单。但要注意后续想从只分表升级到分库分表配置改动和数据迁移的复杂度会明显上升提前想清楚总没错。5.3 容量规划与未来扩缩容配置写成sharding-count: 4之后团队里有人问以后变成8个分片怎么办取模分片最大的痛就在这里。如果分片数从4改成8原来order_id5余数是1新规则下余数是5数据要迁移到新的物理分片。数据量百万级还能停机迁移千万级以上就非常痛苦了。针对这个隐患我们讨论了两个方案一是在初期就把分片数设得比较富余比如128或者256个分片未来数据量增长后只是每个分片容量变大不需要改路由规则二是从一开始就使用一致性哈希算法牺牲一点路由性能换取未来的扩容便利。DAY 10的项目里我们保守选择了保持4分片并在架构文档中明确标记了未来2次扩容的触发条件没有过度设计。这个决策写在了wiki里避免以后成员无脑改配置导致迁移事故。5.4 压测与验证方法路由正确率比性能更关键接入路由组件的当天下午除了功能联调我们做了一轮针对性的验证。验证路由正确性的方法是通过日志开启路由解析把路由组件打印的“Actual SQL: ds_1 .... t_order_1”逐条和手工计算的预期物理表对比。用几百条订单ID的样例数据跑一遍确认每个ID都落在预期分片。压测方面重点看了三个指标单条主键查询的P99耗时、写入吞吐是否下降、跨分片查询的响应时间。实测数据表明主键查询在分片后耗时稳定比之前单表扫描差了10%左右但写入并发提升明显数据库CPU负载从85%降到了50%以下整体收益正向。经验上线分库分表后请务必在配置里开启SQL日志观察路由结果。我第一次接入时遇到过明明配置了分片键路由仍然全分片的情况后来发现是SQL里在WHERE条件的子查询中使用了分片键路由引擎无法识别最后改写SQL才解决。最后一件事DAY 10结束前我把组里的经验补了一条备注分库分表路由组件只是工具真正的核心是分片键的设计、数据分布的预估和查询模式的适配。工具选得再好分片键选错照样全库扫描。我个人在第10天踩了几个坑之后的体会是配置文件的每一行都要搞清楚它在路由链路里承担什么角色而不是从网上复制一段YAML就完事。尤其是bindingTables和broadcastTables这两个配置看似不起眼实际上决定了关联查询会不会跨库、字典表要不要重复存储。最后再分享一个小技巧项目切到分库分表后建议把数据源连接数、活跃连接数、SQL路由分布这几个指标单独做成面板。路由组件会暴露很多统计接口稍微花点时间接一下监控后面排查问题时能省出大把时间——这比任何配置项都实用。