ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ShardingSphere-JDBC读写分离实战:原理、配置与踩坑指南

ShardingSphere-JDBC读写分离实战:原理、配置与踩坑指南 1. ShardingSphere-JDBC读写分离到底解决了什么问题1.1 读写分离的适用场景与真实痛点我在接手一个电商后台项目时业务量一上来主库的CPU和IO就开始报警。明明做了缓存可订单查询、用户报表这些读多写少的接口还是把单库的瓶颈压得死死的。当时团队的第一反应是上分库分表但我看了下数据规模单表数据量其实并不大真正的问题是“读压力太集中”。这种场景如果用分库分表去解纯属拿大炮打蚊子——分片键怎么选、跨节点查询怎么做、分布式事务怎么处理全是本来不需要承受的复杂度。读写分离才是这个阶段最合适的解法把查询压力从主库剥离到从库主库专心处理写入和事务从库扛读流量。数据库层面复制数据的动作交给了MySQL主从复制应用层只需要决定哪条SQL去哪个数据源执行。这个“决定”的工作ShardingSphere-JDBC做得非常干净。它和你自己写多数据源路由的最大区别在于ShardingSphere-JDBC是工作在JDBC层的基础设施你的代码不需要感知数据源切换逻辑。配置好规则后它解析SQL识别出SELECT走从库、其他走主库整个过程对业务代码完全透明。对于团队里那些习惯了单库操作的开发来说几乎零学习成本该写Autowired还是照常写。1.2 为什么选择ShardingSphere-JDBC而不是换一个中间件之前团队也考虑过其他方案比如在应用层自己封装一个DataSourceRouter用AOP切数据库连接。这种方案初看简单后面全是坑事务内是否允许切换数据源、MyBatis一级缓存是否和连接绑定、不同插件对数据源类型的推断逻辑差异极大——这些问题会随着业务复杂度呈指数级爆炸。ShardingSphere-JDBC的思路是把读写分离、数据分片、数据脱敏这些能力全部集中到一个统一的JDBC驱动层。你告诉它“哪一个是主库、哪些是从库、按什么规则路由”它就会在SQL执行前完成解析和路由。关键是它还保留了后期平滑升级到分片的能力今天只需要读写分离配置一行readwrite-splitting规则就够了明天数据量真的大了再加分片规则框架不用换业务代码不用动。我的建议是从读写分离入手学ShardingSphere-JDBC是性价比最高的路径因为它的概念最少、配置最简单又能帮你把核心的路由机制吃透。后边理解分片、理解分布式事务时很多概念都是相通的。2. 原理先行SQL从进入到数据库之间发生了什么2.1 一条SELECT的灵魂旅程ShardingSphere-JDBC不是代理它是以jar包的形式存在于你的应用里的。Spring Boot启动时它会按照配置创建好一个经过增强的数据源。这个增强后的数据源内部有一整套SQL解析引擎。当你的Mapper执行SELECT * FROM orders WHERE user_id 123时容器拿到的是增强数据源内部的Connection。SQL不是直接发给MySQL的而是先经过SqlParser——把SQL拆成AST抽象语法树判断出这是一条查询语句再经过路由引擎——根据你配置的读写分离规则决定主从库最后通过改写引擎把逻辑SQL变成物理SQL真正去目标库上执行。整个过程听起来复杂但耗时极短。AST解析和路由判断都是内存操作我实测下来单次SQL的开销大约在微秒级别比一次网络RTT低几个数量级几乎可以忽略不计。这也是ShardingSphere-JDBC被称为“客户端中间件”的原因——它没有额外的网络转发损耗性能损耗自然比中心化代理小得多。2.2 主库与从库的判定逻辑读写分离规则的核心就一条非查询语句走主库查询语句走从库。但这个“查询语句”远没有你想的那么简单。SELECT ... FOR UPDATE是查询语句但它需要走主库因为加锁操作必须和执行写入的主库在同一个事务边界里SELECT ...如果被包裹在Spring的Transactional事务里而且事务已经开始那它也得走主库否则就会读到事务开始前的旧数据造成“幻读”级别的业务bug还有像SELECT last_insert_id()这种和写入强相关的函数必须走主库才能拿到正确值。ShardingSphere-JDBC内置了SQL解析能力来自动识别这些场景。它识别出FOR UPDATE关键字就直接强制路由到主库识别出事务上下文就绑定主库。这个自动识别的设计让我少写了很多代码——如果是我自己封装的Router这些边界条件写到我头秃也未必考虑得全。2.3 配置一个读写分离Demo需要哪些前置条件动手之前先把环境准备好。我建议你在本地用Docker起两个MySQL实例一个做主库一个做从库然后配置好主从复制。这里的主从复制不是ShardingSphere-JDBC的职责但它提供复制的数据源。简化版的操作如下docker run -d --name mysql-master -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDroot mysql:8.0 \ --server-id1 --log-binmysql-bin docker run -d --name mysql-slave -p 3307:3306 \ -e MYSQL_ROOT_PASSWORDroot mysql:8.0 \ --server-id2在Master上创建复制账号并查看位点然后在Slave上执行CHANGE MASTER TO并启动START SLAVE。这些操作网上有很多详细教程我不再赘述。需要强调的是ShardingSphere-JDBC本身不关心你的主从复制是否真的生效它只负责路由。如果你主从复制没配好从库数据是空的那读写分离后业务肯定出问题——这个坑特别容易被忽略。3. 核心配置详解与第一个读写分离Demo3.1 依赖引入与版本选型我用的是Spring Boot 2.7 ShardingSphere-JDBC 5.1.2。5.x版本的包名和4.x差异很大如果你在网上搜到的是sharding-jdbc-spring-boot-starter那是4.x的旧包名4.x的配置格式也和5.x不一样。这里强烈建议新项目直接用5.x版本。dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.1.2/version /dependency等一下如果你用的是Spring Boot 3.x需要对应的是shardingsphere-jdbc-core-spring-boot-starter的5.2.x以上版本否则会有兼容性问题。我在本地测试时Spring Boot 3.0.0配5.1.2会直接启动报错换成5.2.1就好了。版本这个东西真的不能只看最新要看和你的Spring Boot版本是否匹配。3.2 主从配置的两种姿势ShardingSphere-JDBC 5.x的配置方式支持YAML和Java API两种。YAML适合快速验证Java API适合在配置中心动态管理。给你看一下我最常用的YAML配置spring: shardingsphere: datasource: names: write-ds, read-ds-1, read-ds-2 write-ds: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/shop username: root password: root read-ds-1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3307/shop username: root password: root read-ds-2: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3308/shop username: root password: root rules: readwrite-splitting: >spring: shardingsphere: props: sql-show: true启动后控制台会打印每条SQL的实际路由结果类似actual sql: SELECT * FROM user WHERE id 1 // 路由到 read-ds-1 actual sql: INSERT INTO user(...) VALUES(...) // 路由到 write-ds看到actual sql里标明了目标数据源说明路由生效。这一步验证很重要因为有时候你以为配好了实际所有SQL都走主库日志一查就暴露了。4. 实操过程与核心环节实现4.1 多从库负载均衡算法怎么选配置里的load-balancer-algorithm-type只有三个内置选项ROUND_ROBIN、RANDOM、WEIGHT。前两个没什么好说的真正的差异在WEIGHT上。WEIGHT需要配合load-balancer-algorithms来配置每个从库的权重load-balancer-algorithms: read-balancer: type: WEIGHT props: read-ds-1: 2 read-ds-2: 1这个配置意味着read-ds-1承担的流量是read-ds-2的两倍。适合的场景是两台从库的机器配置不一致一台是8核16G一台是4核8G让高配机器多扛点流量更合理。不过实测下来它的底层实现就是普通的加权随机并没有做平滑加权。短时间内的流量分布可能有抖动——如果对平滑性有强需求自己实现一个自定义负载均衡算法会更可控后面我会讲。这里有一个重要的经验不要在主从复制的方式上玩花活。有人用多线程并行复制有人用半同步复制这些是DBA层面的事情和ShardingSphere-JDBC的路由逻辑没有直接关系。但在从库数量超过两个时注意MySQL复制的延迟监控一定要做好后面第五部分会专门讲。4.2 强制主库路由HintManager的用法读写分离最大的业务痛点是刚写完数据立刻去读但数据还没同步到从库。比如用户下单成功跳转订单页查询订单列表——如果查询走到从库而从库的复制延迟了200ms用户看到的订单列表可能是空的。这种线上bug非常难排查因为不是必现的只有主从延迟高的时候才偶发。ShardingSphere-JDBC提供了HintManager来强制路由主库public void getOrderAfterSave(Long orderId) { try (HintManager hintManager HintManager.getInstance()) { hintManager.setWriteRouteOnly(); orderMapper.selectById(orderId); } }这段代码告诉ShardingSphere-JDBC本次线程内的SQL全部走主库。HintManager的生命周期是线程级别的所以必须在业务方法的finally块里关闭用try-with-resources是最安全的写法。千万别把这个对象存到字段里复用线程之间会串数据。还有一个我后来才发现的细节HintManager一旦设置了写路由后续的所有查询都会被强制到主库直到调用close()。所以如果你的Service里出现了“写完后查一下”这种逻辑不要包太大的范围精准控制到本地事务方法级别就够了。4.3 事务内的读写分离行为现在说一个最隐蔽的坑。Spring的事务管理默认拦截Transactional注解的方法一旦事务开启ShardingSphere-JDBC会把整个事务绑定到主库连接上。这个时候事务方法里即使写的都是SELECT也全部走主库。这个设计是正确的——从库的数据可能落后于主库事务内读取从库会导致不可重复读。但问题是很多人会这么写Service public class OrderService { Transactional(readOnly true) public Order getOrder(Long id) { return orderMapper.selectById(id); } }以为加了readOnly trueSPHERE就会走从库。实际上在ShardingSphere-JDBC 5.x里Transactional注解本身不会触发读写分离的路由判断只有真正开启了事务事务内的SQL才会被强制主库。这里的readOnly true只是Spring传递给底层Connection的一个标志ShardingSphere-JDBC默认并不会因为它改变路由策略。这个认知极其重要。当时我们在线上发现很多查询接口的响应时间不对一查日志大量SELECT走的是主库。排查到最后是因为这些Service方法都加了Transactional哪怕是readOnly true也让事务开启后整个方法绑定了主库连接。优化方案很简单把只读事务注解去掉改成普通方法让路由引擎自行判断走从库。调整之后主库的CPU直接降了一半。4.4 连接池相关参数配置从库多了之后连接池的管理也要跟上。我用的是HikariCPShardingSphere-JDBC官方也推荐用它。连接池参数在数据源配置里直接配置即可write-ds: type: com.zaxxer.hikari.HikariDataSource props: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 60000注意ShardingSphere-JDBC每个数据源对象都是独立的连接池。你有两个从库就相当于跑了一个read-ds-1池和一个read-ds-2池。如果每个池设置maximum-pool-size: 20整个应用对数据库的连接数峰值就是60主库20两个从库各20。生产环境一定要根据数据库的max_connections合理规划别把MySQL的连接数打爆。我在测试环境就发生过一次三个数据源每个池配了50结果MySQL默认的151个连接直接被打满整个库都连不上了。5. 常见问题与排查技巧实录5.1 如何确认SQL真的走了从库这个问题排在最前面因为绝大多数“读写分离失效”的争议都是因为没有验证手段。除了开sql-show日志之外还有更直接的方式在从库上执行SHOW PROCESSLIST观察是否有来自应用的连接在跑SELECT。如果发现所有SELECT都去了主库优先检查两个地方第一配置里有没有把逻辑数据源设置成默认数据源而不是让MyBatis直接连了原始的write-ds第二业务代码里有没有在方法上加Transactional导致事务强制绑定主库。还有一个更容易忽略的ShardingSphere-JDBC只对通过它创建的DataSource的Connection生效。如果你的某些代码直接Autowired了原始的write-ds数据源比如做数据迁移的老代码它就绕过路由了。这个排查起来很花时间我建议团队从一开始就规定所有数据访问必须走逻辑数据源shop-rw禁止直接注入物理数据源。5.2 主从延迟导致读不到刚写入的数据这是读写分离方案最常见的问题。复制延迟可能来自大事务长时间未提交、从库硬件性能不足、binlog堆积。ShardingSphere-JDBC本身无法帮你消除延迟它提供的只是“强制走主库”这个绕过方案。我在业务里常用一个技巧对读写一致性要求高的核心链路写操作完成后把主键ID放进一个内存标记里下次查询时需要验证这个ID是否已经写入成功如果写入的是从库则先用HintManager强制主库查询。这个方案听着简陋实际很有效因为我们真正需要强一致的数据往往只占全部数据的极小比例。具体实现是private static final SetLong WAIT_SYNC_IDS ConcurrentHashMap.newKeySet(); public void createOrder(Order order) { orderMapper.insert(order); WAIT_SYNC_IDS.add(order.getId()); // 后续查这个id时先检查是否在集合中 }查询时如果ID命中集合就HintManager强制主库走一次查完再从集合移除。这个方案的缺点是要自己管理集合生命周期但相比引入分布式缓存或者强制全链路走主库成本低得多。5.3 自定义负载均衡算法内置算法不够用时ShardingSphere-JDBC允许你自己写。5.x的代码比较简洁核心是实现ReadLoadBalancer接口。我用一个简单示例来说明public class UserIPHashLoadBalancer implements ReadLoadBalancer { Override public String getDataSource(String writeDataSourceName, ListString readDataSourceNames, String sql) { int hashCode Thread.currentThread().getName().hashCode(); int index Math.abs(hashCode) % readDataSourceNames.size(); return readDataSourceNames.get(index); } }然后在配置里注册Bean public ReadLoadBalancer userIPHashLoadBalancer() { return new UserIPHashLoadBalancer(); }再把逻辑数据源配置里的load-balancer-algorithm-type改成你自己的Bean名。这种方式的好处是你可以基于用户维度做路由亲和性——同一个用户每次读同一个从库这对从库缓存的命中率有不错的提升。我在一个高并发报表场景里试过基于用户ID哈希路由后从库的查询缓存命中率提升了将近30%。5.4 经典版本差异踩坑记录最后整理一份我实际开发中踩过的版本坑这些在官方文档里不会写得特别明显4.x升级5.x的配置不兼容。4.x用的是master-slave-rules5.x换成了readwrite-splitting。网上大量教程还是4.x时代的照着写会直接启动报错。5.0.x版本的一个Bug在某些边界条件下事务内的写操作和读操作混合时连接复用会导致路由错乱官方在5.1.0后修复。建议生产直接用5.1.2以上。Spring Boot 3.x必须配5.2.x以上。原因是Spring Boot 3全面转向了Jakarta命名空间旧版的ShardingSphere集成不兼容。MyBatis-Plus的selectById等内置方法不会触发ShardingSphere的强制路由除非通过Mapper接口方法调用。这个社区里讨论很多原因是MyBatis-Plus内置方法对SQL的封装层级和ShardingSphere-JDBC5的SQL识别逻辑存在兼容差异。解决方案是对强制主库的查询不要用MyBatis-Plus的BaseMapper内置方法改用自己的Select注解写SQL。这个坑很伤人但在5.2版本后已经修复了一部分升级版本是最省心的方案。5.5 数据库侧与中间件侧配合的经验读写分离要做稳光有中间件是不够的数据库侧也得配合好。我现在的标准做法是从库开启read_only模式所有从库都配置和主库一致的字符集、排序规则对从库的监控平台加一个复制延迟报警指标阈值设成3秒。主从延迟一旦超过阈值告警出来DBA去排查大事务或者慢SQL。中间件侧的经验是连接池的初始化要足够保守。刚发布的版本不要一上来就调大连接数先在压测环境把流量模型跑一遍再逐步调整。我见过有些团队一上来就把连接池调到100结果数据库没扛住反而把读写分离的方案全盘否定了——实际上方案没问题是参数没调好。这个项目从搭建到跑稳定前后花了两周。其中真正配置的时间可能只有半天其余时间全花在排查那些“看似配置没生效”的隐性问题上。现在回头看一开始如果没有把schema级的抽象理解透遇到问题会非常容易慌。基础的SQL解析路由原理、Spring事务传播机制、MySQL主从复制原理这三块知识是排查读写分离问题的三板斧建议你先补足这三块储备再动手会顺很多。
返回列表