数据库读写分离:ShardingSphere-JDBC的路由规则与强制路由
大约 7 分钟
数据库读写分离:ShardingSphere-JDBC的路由规则与强制路由
适读人群:Java后端开发、架构师、对读写分离实现感兴趣的工程师 | 阅读时长:约23分钟
开篇故事
本系列的最后一篇,用读写分离这个话题收尾。
2020年,我们的订单查询把主库打垮了。
背景:下单高峰期,数百个线程在主库执行复杂的报表查询(统计每个品类的销量),占用大量CPU和IO,导致下单的写操作被严重阻塞。
解决方案:读写分离,把所有SELECT路由到从库,主库只处理写操作。
听起来简单,但实现起来有很多细节:
- 什么查询该走主库?(刚写完立即查,必须走主库)
- 什么查询可以走从库?(对延迟不敏感的查询)
- 事务内的查询怎么路由?(必须走主库)
- 如何处理主从延迟?(延迟过大时降级到主库)
今天把ShardingSphere-JDBC的读写分离路由规则和强制路由完整讲透。
一、读写分离的路由规则
ShardingSphere-JDBC的默认路由策略:
写操作 → 主库(永远):
INSERT, UPDATE, DELETE, DDL
不管是否在事务中
读操作(默认规则):
事务内的SELECT → 主库(保证事务一致性)
非事务SELECT → 从库(轮询或随机选择一个从库)
特殊情况 → 主库:
使用了 Hint(强制路由)
事务内的任何操作
LAST_INSERT_ID()二、底层原理:ShardingSphere的路由决策树
SQL路由决策树:
接收到SQL
↓
判断SQL类型
├── 写SQL(INSERT/UPDATE/DELETE/DDL)→ 主库
└── 读SQL(SELECT)
↓
是否在显式事务中?
├── 是 → 主库(保证读到事务内的修改)
└── 否
↓
是否有Hint强制指定?
├── HintManager.setMasterRouteOnly() → 主库
└── 否 → 从库(轮询/随机)三、完整解决方案与代码
3.1 ShardingSphere-JDBC读写分离配置
# application.yml(ShardingSphere 5.x)
spring:
shardingsphere:
datasource:
names: master, slave0, slave1
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://mysql-master:3306/mydb
username: root
password: password
hikari:
maximum-pool-size: 20
slave0:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://mysql-slave0:3306/mydb
username: readonly
password: password
hikari:
maximum-pool-size: 20
slave1:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://mysql-slave1:3306/mydb
username: readonly
password: password
hikari:
maximum-pool-size: 20
rules:
readwrite-splitting:
data-sources:
mydb_rw: # 逻辑数据源名称
static-strategy:
write-data-source-name: master
read-data-source-names:
- slave0
- slave1
load-balancer-name: round_robin # 从库负载均衡策略
load-balancers:
round_robin:
type: ROUND_ROBIN # 轮询(也可以用RANDOM)
props:
sql-show: true # 开发环境打印路由信息3.2 强制路由:Hint API
/**
* 使用Hint API强制将读操作路由到主库
*/
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 场景1:下单后立即查询(必须走主库,因为从库有延迟)
*/
@Transactional
public OrderVO createAndQuery(CreateOrderRequest request) {
// INSERT走主库(默认)
Order order = createOrder(request);
// 事务内的SELECT自动走主库(ShardingSphere的默认行为)
// 不需要额外配置
return buildVO(orderMapper.selectById(order.getId()));
}
/**
* 场景2:非事务场景,强制读主库
*/
public Order getOrderForceMaster(Long orderId) {
try (HintManager hintManager = HintManager.getInstance()) {
// 强制此次查询走主库
hintManager.setReadwriteSplittingAuto(); // 重置为自动(默认)
// 或者:
hintManager.setWriteRouteOnly(); // 强制走主(写)库
return orderMapper.selectById(orderId);
} // try-with-resources:自动关闭HintManager,恢复默认路由
}
/**
* 场景3:使用AOP注解封装强制主库读
*/
@ForceMasterRead // 自定义注解
public Order getOrderAfterPayment(Long orderId) {
// 支付完成后立即查询订单,需要最新数据
return orderMapper.selectById(orderId);
}
/**
* 场景4:批量操作,强制所有操作走主库
*/
public void batchProcess(List<Long> orderIds) {
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setWriteRouteOnly(); // 整个批量操作都走主库
for (Long orderId : orderIds) {
Order order = orderMapper.selectById(orderId);
if (needUpdate(order)) {
orderMapper.update(buildUpdate(order));
}
}
}
}
}
/**
* @ForceMasterRead AOP实现
*/
@Aspect
@Component
public class ForceMasterReadAspect {
@Around("@annotation(com.example.annotation.ForceMasterRead)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setWriteRouteOnly();
return pjp.proceed();
}
}
}3.3 主从延迟感知的动态路由
/**
* 主从延迟监控 + 动态路由:延迟过大时强制走主库
*/
@Component
public class AdaptiveReadRouteInterceptor {
@Autowired
private SlaveDataSource slaveDataSource;
// 允许的最大主从延迟(秒)
private static final int MAX_LAG_SECONDS = 3;
// 延迟状态缓存(避免每次请求都查询延迟)
private volatile boolean slaveLagExceeded = false;
private long lastCheckTime = 0;
private static final long CHECK_INTERVAL = 5000; // 5秒检查一次
/**
* 获取当前从库延迟(带缓存)
*/
public boolean isSlaveLagAcceptable() {
long now = System.currentTimeMillis();
if (now - lastCheckTime < CHECK_INTERVAL) {
return !slaveLagExceeded; // 返回缓存值
}
try (Connection conn = slaveDataSource.getConnection();
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery("SHOW SLAVE STATUS");
if (rs.next()) {
int lag = rs.getInt("Seconds_Behind_Master");
slaveLagExceeded = (lag > MAX_LAG_SECONDS);
lastCheckTime = now;
if (slaveLagExceeded) {
log.warn("从库延迟{}秒超过阈值{}秒,自动降级到主库读", lag, MAX_LAG_SECONDS);
}
}
} catch (SQLException e) {
// 从库连接失败,降级到主库
slaveLagExceeded = true;
log.error("从库状态检查失败,降级到主库", e);
}
return !slaveLagExceeded;
}
}
/**
* 结合延迟感知的查询服务
*/
@Service
public class SmartQueryService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private AdaptiveReadRouteInterceptor routeInterceptor;
public List<Order> listOrders(Long userId) {
if (!routeInterceptor.isSlaveLagAcceptable()) {
// 从库延迟过大,强制走主库
try (HintManager hint = HintManager.getInstance()) {
hint.setWriteRouteOnly();
return orderMapper.selectByUserId(userId);
}
}
// 从库延迟可接受,正常走从库
return orderMapper.selectByUserId(userId);
}
}3.4 读写分离的监控指标
/**
* 读写分离路由监控
* 统计读主库 vs 读从库的比例
*/
@Component
public class ReadWriteSplitMetrics {
private final Counter masterReads = Counter.builder("db.master.reads")
.description("主库读次数").register(Metrics.globalRegistry);
private final Counter slaveReads = Counter.builder("db.slave.reads")
.description("从库读次数").register(Metrics.globalRegistry);
private final Counter writes = Counter.builder("db.writes")
.description("写操作次数").register(Metrics.globalRegistry);
@EventListener(SQLExecutionEvent.class)
public void onSQLExecution(SQLExecutionEvent event) {
if (event.isMasterRoute()) {
if (event.isWriteSQL()) {
writes.increment();
} else {
masterReads.increment(); // 读操作走主库(可能是强制路由或事务内)
}
} else {
slaveReads.increment(); // 读操作走从库
}
}
}四、踩坑实录
坑1:事务内的查询被意外路由到从库
// 错误场景:Spring AOP代理导致@Transactional失效,事务未正确传播
@Service
public class OrderService {
@Autowired
private OrderService self; // 注入自身(解决AOP代理问题)
@Transactional
public void processOrder(Long orderId) {
// 内部调用:this.updateStatus(orderId) 会绕过AOP代理!
// 导致内部方法不在同一个事务中
// ShardingSphere认为不在事务中 → 路由到从库
this.updateStatus(orderId); // 错误!
self.updateStatus(orderId); // 正确(通过代理调用,事务传播)
}
@Transactional(propagation = Propagation.REQUIRED)
public void updateStatus(Long orderId) {
Order order = orderMapper.selectById(orderId); // 可能走从库!
// 如果走从库,可能读到旧数据
}
}坑2:@Transactional(readOnly=true)会路由到主库还是从库?
// 疑问:Spring的readOnly=true事务,ShardingSphere会路由到哪里?
@Transactional(readOnly = true)
public Order queryOrder(Long orderId) {
return orderMapper.selectById(orderId);
}验证结果(ShardingSphere 5.x):
开启 sql-show: true 观察日志:
Logic SQL: SELECT * FROM orders WHERE id = ?
Actual SQL: slave0 ::: SELECT * FROM orders WHERE id = ?
↑ readOnly=true 的事务内查询,ShardingSphere仍然路由到从库!
(这是ShardingSphere 5.x的行为,不同版本可能不同)如果你的readOnly事务需要强一致性(刚写完就读),要显式强制路由:
@Transactional(readOnly = true)
public Order getOrderConsistent(Long orderId) {
try (HintManager hint = HintManager.getInstance()) {
hint.setWriteRouteOnly(); // 强制主库
return orderMapper.selectById(orderId);
}
}坑3:多数据源场景,读写分离配置冲突
// 场景:项目有两个DataSource,一个用于业务库(读写分离),一个用于日志库(单机)
// 错误:日志库的DataSource被ShardingSphere代理,导致路由混乱
@Bean
@Primary
public DataSource businessDataSource() {
// ShardingSphere管理的读写分离数据源
return shardingSphereDataSource;
}
@Bean
@Qualifier("logDataSource")
public DataSource logDataSource() {
// 独立的数据源,不走ShardingSphere
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://log-db:3306/logs");
return new HikariDataSource(config);
}
// 使用@Qualifier明确指定数据源,防止混用
@Autowired
@Qualifier("logDataSource")
private DataSource logDataSource;五、总结与延伸
读写分离实战总结,也是本系列「数据库深度剖析」的收尾:
读写分离的核心价值:将查询负载从主库转移到从库,主库专注写操作,提升系统整体吞吐量。
ShardingSphere-JDBC的路由规则:事务内→主库,事务外的SELECT→从库(默认),Hint强制→指定库。
三种必须走主库的场景:
- 事务内的读操作(系统自动处理)
- 写操作后立即读(手动加Hint)
- 主从延迟过大时(自动降级)
读写分离的局限:解决了读压力,但写压力和数据量的问题需要分库分表来解决。当数据量超过单机,或写TPS超过主库上限时,就到了需要分库分表的时候了。
数据库深度剖析系列(409-430期)就此收尾。
这22篇从MVCC机制到分布式事务,从缓存设计到连接池调优,把Java后端工程师在数据库方向最高频踩的坑,按照「生产事故→根因分析→完整方案→踩坑实录」的结构,完整地过了一遍。
写这个系列的动力,是因为我自己年轻的时候在这些地方都踩过。希望大家能少踩一些坑,把时间花在更有价值的事情上。
