搞过几年数据接入层的朋友应该都有同感单库单表撑到几千万行之后数据库的CPU、IO、连接数迟早有一个先扛不住。要么加机器做只读副本把查询压力卸掉要么把数据按维度拆开让每个库变小。这两个方案听着都不难但真到了业务侧代码要改连接、要拆SQL、要自己管路由规则草率一点就变成灾难现场。这篇文章要聊的就是我最近重构的一套方案基于Sharding JDBC实现读写分离加分库分表按2主4从的规模部署主库负责写、从库分担查同时按订单ID维度把数据分到两个主库下面每个主库再带两个只读副本。适合正在折腾数据增长瓶颈、想把读写分离和分库分表一次做干净的同学参考。我自己在这套方案里承担的是数据访问层改造和压测验证部分源码全部跑在模拟业务环境里没有用任何商业组件。整体做完之后最直接的感受是Sharding JDBC把“配置分片字段、决定路由、维护主子连接”这些脏活都收敛在Jar包内部业务代码几乎不用感知数据库拓扑的变化。但前提是分片键选得对、主从延迟策略想清楚、分布式事务边界划得准否则上线之后照样踩坑。1. 方案设计为什么是2主4从而不是更极端的拆法先说架构目标一个订单系统的核心库日增数据大约在两百万行高峰期写并发在8000 TPS左右读并发是写的五倍以上。单库单表已经出现慢查询、锁冲突和连接池排队所以改造目标明确第一让写流量分散到多个主库第二让读流量尽量走只读副本第三保留通过订单ID快速定位数据的能力。1.1 分库分表的粒度选择先算一笔账。如果只做分表一个库里的物理表能拆到几十张但单机的CPU和磁盘IO仍然是瓶颈如果只做分库库变小了但每张表的数据量还是大索引深度和缓冲池命中率都不理想。所以我选择分库与分表同时进行订单表按订单ID做分片键先分成2个库每个库里再分成16张物理表。这样单表的数据量大约是原先的1/32单库的数据量是原先的1/2。2个主库配合各自的2个从库相当于写能力翻倍、读能力变成原来的6份2主4从。为什么选2而不是4或8考虑到机房的物理容量和运维成本2个主库在切换、备份、扩容三个方向上都更容易操作。未来如果要扩到4主甚至8主只需要在规则中调整分片键的取模范围把2的倍数关系保持住就能平滑迁移。拆得太细管理复杂度提升的速度会超过数据库收益尤其是在跨库事务和全表聚合查询出现的时候会得不偿失。1.2 主从配比与读写分离的负载取向“2主4从”这四个字听起来很对称实际上对应的是两组“1主2从”的高可用单元。我采用每个主库挂两个从库的方式是因为这套系统读多写少读流量需要冗余。两个从库可以在读流量增长时用负载均衡分摊也可以做不同类型的查询分类一个从库服务一般业务查询另一个从库服务报表和统计查询长查询不会拖垮在线接口。Sharding JDBC的读写分离能力正好匹配这个诉求。它允许在同一个逻辑数据源下面挂一个写库和多个读库执行CUD语句时全部路由到主库执行SELECT时按照负载均衡策略分配到从库。这层路由逻辑发生在ORM框架之下、数据库连接之上业务代码拿到的还是一个普通数据源因此DAO层不用重写。2. 核心原理路由规则与主从分发的工作机制不少人在没接触Sharding JDBC前容易把它和中间件代理混为一谈。实际上Sharding JDBC是一个客户端级分库分表框架它不以独立服务的形式存在而是把分片逻辑打包成了JDBC驱动层的实现。应用启动时加载规则配置在SQL执行前解析语句根据分片键和分片策略改写SQL、决定路由然后执行并聚合结果。2.1 分片键与分片策略的决定性作用分片键是路由的核心依据。我的订单表里分片键选择的是order_id因为所有核心查询都有订单ID这个维度比如查订单详情、查用户订单列表、翻单、售后单关联查询。用order_id做分片能保证同一个订单的所有相关记录都落到同一个库、同一张物理表里这样单条数据查询不需要跨库跨表SQL改写成本最低。分片策略用了最简单的取模规则库粒度取order_id % 2表粒度取order_id % 16。取模的好处是规则明确、调试容易扩容的时候只要按倍数关系迁移部分数据即可。复杂的分片策略像Hash、Range、自定义复合规则我也调研过但考虑到当前数据增长曲线和现有运维工具链取模已经可以覆盖业务需求。如果将来订单量增长到需要平滑扩容而不想中断写服务可以再参照一致性哈希思路引入虚拟桶设计但那是后话。2.2 读写分离的负载均衡策略主从分离的负载均衡Sharding JDBC默认提供了轮询和随机两种方式。我先用轮询因为从库配置完全一致没有权重差异轮询可以让两侧流量基本持平。压测中发现一个问题连接复用率高时轮询容易把流量循环打到同一个从库后续优化为随机策略配合连接池在多个从库节点之间做更均匀的分布。单次请求可能读写并发叠加只要确保SELECT在从库、INSERT/UPDATE/DELETE在主库就能实现基本的压力卸载。3. 环境搭建与配置实操2主4从是怎么落地的架构设计说完了重点看实际操作。我分三块来拆先搭数据库层的主从环境再写Sharding JDBC配置最后在Spring Boot工程里接入验证。我不会贴一长串无解释的YAML每个关键字段都说明为什么这么配。3.1 数据库层两个主库各自带两个从库2主4从的物理环境部署在六台虚拟机上两台主库、四台从库。每台主库分别开启binlog设置server-id然后配置从库的复制源。从库上执行CHANGE MASTER语句时需要注意两个地方。-- 主库1的从库1初始化 CHANGE MASTER TO MASTER_HOST192.168.1.11, MASTER_PORT3306, MASTER_USERrepl_user, MASTER_PASSWORDrepl_passwd, MASTER_LOG_FILEmysql-bin.000014, MASTER_LOG_POS1024; START SLAVE; SHOW SLAVE STATUS\G执行完SHOW SLAVE STATUS后必须确认两个关键状态位Slave_IO_Running和Slave_SQL_Running都显示Yes。如果SQL线程没有启动多数情况是复制账号权限不足或中继日志文件损坏。从库的read_only参数我会设置成ON防止误写入数据导致主从数据不一致。另外从库还需要关闭gtid_mode相关参数的自动切换避免复制协议不兼容造成断链。这四台从库实际是两组主库1的从库A、从库B负责shard0的数据副本主库2的从库C、从库D负责shard1的数据副本。每个分片的数据不跨到另一组从库上。这一点在后续配置逻辑数据源时要严格对应否则Sharding JDBC会把订单数据路由到错误的物理节点。3.2 Sharding JDBC的规则配置拆解下面这份配置是精简过的YAML但涵盖了2主4从的核心表达方式。规则从上到下依次是逻辑数据源、分片规则、读写分离规则、分布式ID策略。spring: shardingsphere: datasource: names: ds0-primary,ds0-replica0,ds0-replica1,ds1-primary,ds1-replica0,ds1-replica1 ds0-primary: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/shard0?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: app_passwd ds0-replica0: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.12:3306/shard0?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: app_passwd ds0-replica1: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.13:3306/shard0?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: app_passwd ds1-primary: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.21:3306/shard1?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: app_passwd ds1-replica0: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.22:3306/shard1?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: app_passwd ds1-replica1: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.23:3306/shard1?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: app_passwd rules: sharding: tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..15} database-strategy: standard: sharding-column: order_id sharding-algorithm-name: order_db_hash table-strategy: standard: sharding-column: order_id sharding-algorithm-name: order_table_hash sharding-algorithms: order_db_hash: type: HASH_MOD props: sharding-count: 2 order_table_hash: type: HASH_MOD props: sharding-count: 16 readwrite-splitting: >dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.3.2/version /dependency版本选择上我看过5.4.x的新特性但5.3.2在社区使用份额大、稳定性验证充分。升级要等当前方案跑满一个业务周期后再说。引入依赖后Spring Boot会自动读取配置文件中的属性创建ShardingSphereDataSource。这意味着原来注入DataSource的地方不需要改动JPA、MyBatis-Plus这些ORM框架都能直接用。4.2 分布式主键不能让自增ID坏了路由分库分表之后数据库自增主键变得不可用不同物理表的ID主键可能重复而且ID本身不能反映数据归属于哪个节点。我采用雪花算法作为分布式IDSharding JDBC内置提供SNOWFLAKE类型。在分片规则里配置ID生成列和类型即可。key-generators: snowflake: type: SNOWFLAKE props: worker-id: 3生成策略绑定到order_id字段上。这里我踩过一个坑如果业务代码里先手动给order_id赋值Sharding JDBC会认为用户指定了分片键值不再走生成器。结果就是随手填的一个流水号可能导致分片不均匀。正确做法是插入时不给order_id赋值让框架统一生成。4.3 数据访问与事务的注意事项读写分离下的常规查询SQL会被路由到从库但事务内的读请求会统一路由到主库这是Sharding JDBC的设计约束。原因很直接从库复制存在延迟事务内读写如果分离可能出现一个事务里先写后读结果读出来的还是旧数据。所以使用Transactional时要意识到当前事务里所有操作都压在主库连接上。我处理事务的边界是只在写操作短事务上使用Transactional查询接口不开启事务。对于需要强一致读的场景比如订单支付后立即查询支付结果我允许走主库读通过一个独立Mapper方法强制路由。Sharding JDBC提供了Hint机制来强制主库路由。HintManager hintManager HintManager.getInstance(); hintManager.setWriteRouteOnly(); try { Order order orderMapper.selectById(orderId); } finally { hintManager.close(); }这里必须关掉HintManager实例否则同一线程后续的SQL也会被强制路由到主库造成不必要的压力。4.4 跨分片查询与分页结果分库分表最大限制之一就是跨分片的聚合查询。比如统计一个月内的订单总数需要路由到16张物理表分别COUNT之后再合并。Sharding JDBC可以合并结果但性能会受限于最少分片表的响应时间。业务侧要尽量避免这种大范围聚合查询必要时由离线报表引擎承担。分页查询也有坑limit大的offset会产生“先在各分片读取大量数据、合并后丢弃”的问题。比如limit 100000, 20每个分片都要读取100020条记录最后合并再裁剪这是灾难性的慢查询。我的处理方案要么限制深分页要么用上一页最大ID做游标分页。5. 压测结果与效果验证配置全部完成后我搭建了一个压测环境来验证2主4从的真实收益。压测工具采用开源的并发测试脚本模拟200个线程并发执行混合读写场景读写比例设定为8:2。5.1 只做分库分表与加上读写分离的对比数据场景写TPS读TPSP99响应时间主库CPU峰值单库单表基线22008100380ms93%分库分表2主520010100210ms71%2主4从读写分离53002350088ms46%从表格可以清楚看到分库分表对写能力的提升最明显2个主库分摊了写入压力读写分离对读能力的提升最明显4个从库分担读流量后P99响应时间从210ms降到88ms。从库CPU平均使用率压测期间维持在60%左右主库CPU峰值没有超过50%。这说明2主4从的配比对于当前读写比例是够用的。如果后续读流量再翻一倍可以在ds0-group和ds1-group下各加一台从库配置层面只需要改read-data-source-names列表应用不需要重启发布数据库连接以外的代码。5.2 路由正确性验证压测之外我还做了路由正确性验证。开启sql-showtrue后每次执行SQL日志会输出逻辑SQL和实际路由到的物理数据源。我抽查了1000条插入SQL确认order_id % 2的结果和路由到的库名一一对应再抽查SELECT确认大多数查询都落在从库上。这样能提前发现配置错位的问题。实际验证下来有一个值得注意的现象某些SELECT语句会连接到主库执行。排查后发现这些请求都处于一个事务中或者使用了HintManager强制主库路由。这和预期行为一致不算配置错误。6. 疑难问题排查与避坑指南方案跑通之后总会遇到一些奇怪的问题。我把这一轮改造中实际踩过、或者同行反馈过的典型问题整理成清单方便后面参考。6.1 主从数据延迟导致“刚写入查不到”这是读写分离的经典问题。从库通过binlog同步主库数据网络抖动或大事务执行时从库可能滞后几百毫秒甚至几秒。业务上表现为用户下单成功后立即去查订单列表看不到。我的做法分三个层次。第一层订单创建成功后的跳转详情页走主库强制读。第二层订单列表页允许短暂延迟因为用户一般不会在下单后1秒内频繁查询。第三层数据库层面监控seconds_behind_master连续超过5秒就报警。同时把写操作事务尽量压缩小避免大事务拖慢binlog同步。6.2 分片键误用引发的全路由select * from t_order where user_id 10086这样的SQL没有携带order_id分片键。Sharding JDBC会如何处理它无法判定该查询落在哪个分片所以会广播到所有16张物理表执行再合并结果。正确做法是业务查询尽量带分片键如果不带分片键的查询占比高就要考虑在代码里建立user_id到order_id的映射关系先查一次路由表再定位具体物理表。我的方案里额外建了一张order_user_route表专门存user_id和order_id的绑定关系保证大多数查询能快速定位分片。6.3 分布式事务的边界控制跨分片事务是最容易留下隐患的地方。比如修改订单状态的同时扣减库存订单表在shard0库存表可能在shard1。直接在业务方法上套一个Transactional会变成一个分布式事务Sharding JDBC默认使用本地事务方式无法保证跨库原子性。我这里的处理很简单事务边界收缩到单分片操作内跨分片的数据一致性通过业务表状态机加异步对账补偿实现。如果对强一致有硬性要求再考虑引入分布式事务方案但代价大、复杂度高不建议业务初期就引入。6.4 连接数管理与连接池耗尽2主4从共6个物理节点每个逻辑数据源又有自己的连接池如果不加限制应用侧的连接数可能翻好几倍。连接池参数必须合理设置maximumPoolSize否则多个微服务实例加起来数据库端连接数会撑到上限。我遇到过连接池耗尽的情况现象是页面请求排队日志里大量等待connectionTimeout。排查后发现是一个从库节点网络抖动连接池中的大量连接建立失败又不断重试。最终解决方案是给HikariCP配置连接存活检测connection-test-query设置为SELECT 1并调大validationTimeout。6.5 广播表与绑定表的设计细节分库分表后如果还有字典表、配置表这类小表每个分片都放一份全量数据是最稳妥的方式。Sharding JDBC把这类表称为广播表配置好后每次写操作会广播到所有分片保证每个分片都能本地关联查询。sharding: binding-tables: - t_order,t_order_item broadcast-tables: - t_dict_region还有一对多的父子表比如订单表和订单明细表分片键都是order_id。如果不配置绑定表两个表的分片策略各自独立计算同样的order_id可能落进不同物理表以后JOIN就只能在内存中完成。配置binding-tables之后父子表绑定路由同一条订单的数据必然落在同一个物理分片。7. 最后的体会与建议这套2主4从的Sharding JDBC方案上线到现在稳定性符合预期。如果让我给后来者一句最实用的建议那就是不要把分库分表当成一个“加上去就万能”的扩展方案它更像一次数据架构的重新梳理。分片键、路由策略、主从延迟容忍度、事务边界这些前置决策决定了上线后要交多少学费。从我个人的实操经验看改造前先花时间把现有SQL分类哪些必须带分片键、哪些是纯查询、哪些需要跨分片聚合比急着写YAML配置更有价值。SQL梳理清楚了配置只是水到渠成的事。另外压测不要只测平均响应时间一定要看P99和连接池趋势。平均响应时间好看但尾延迟和连接池排队才最容易暴露路由策略和连接参数的问题。多花一天时间模拟极端读写场景远比上线后救火划算。
阅读完成 · 觉得有帮助?