一、项目整体架构设计
1.1 项目背景
在大型互联网应用中,数据库读写分离是提升系统吞吐量的核心手段之一。传统的读写分离方案依赖数据库中间件(如 MyCat、ShardingSphere-Proxy),但在微服务架构下,应用层轻量级的多数据源动态路由中间件具有更高的灵活性和可维护性。
本实战项目 dynamic-mybatis 旨在深度整合 Spring 核心容器、MyBatis 插件体系、连接池管理,构建一个支持一主多从、动态度量负载均衡、SQL 级别写保护、事务内一致性保障的生产级读写分离中间件,并封装为 Spring Boot Starter 实现开箱即用。
1.2 知识体系关联
项目紧密关联课程中的以下篇章:
| 组件 | 对应篇章 | 关键知识点 |
|---|---|---|
AbstractRoutingDataSource |
Spring 核心容器系列第 8 篇(数据访问) | 模板方法模式、determineCurrentLookupKey()、targetDataSources |
@ReadOnly AOP 切面 |
Spring 核心容器系列第 3 篇(DI)、第 4 篇(AOP) | 自定义注解、@Around 切面、ThreadLocal 上下文 |
| 事务内切换失效处理 | Spring 数据访问系列第 3、4 篇 | DataSourceTransactionManager.doBegin、LazyConnectionDataSourceProxy、TransactionSynchronizationManager |
WriteOnSlaveGuardInterceptor |
MyBatis 第 4 篇(插件拦截链) | Interceptor、StatementHandler、SQL 解析 |
| 健康检查与自动切换 | 连接池管理、Spring Boot 内核系列 | 连接池状态、HealthIndicator、定时任务 |
| 自动配置 | Spring Boot 内核系列第 2、3 篇 | @Configuration、@Conditional、spring.factories |
| Actuator 端点 | Spring Boot 内核系列 | Endpoint、@ReadOperation |
| 性能调优 | MyBatis 第 6 篇 | 拦截器对性能影响、连接池参数 |
| 反模式排查 | MyBatis 第 10 篇 | 避免在从库写、缓存一致性 |
1.3 整体架构图
java
┌───────────────────────────────────────────────────────────────────┐
│ Spring Boot Application │
│ ┌───────────────────────────────────────────────────────────────┐│
│ │ @ReadOnly AOP 切面 ││
│ │ DataSourceContextHolder (ThreadLocal) ││
│ └───────────────────────────┬───────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐│
│ │ DynamicRoutingDataSource ││
│ │ extends AbstractRoutingDataSource ││
│ │ ┌───────────────────────────────────────────────────────┐ ││
│ │ │ determineCurrentLookupKey() │ ││
│ │ │ │ │ ││
│ │ │ ▼ │ ││
│ │ │ ┌─────────────────┐ │ ││
│ │ │ │ LoadBalance │ 从库选择策略 │ ││
│ │ │ │ Strategy │ 轮询/随机/权重/动态 │ ││
│ │ │ └────────┬────────┘ │ ││
│ │ │ │ │ ││
│ │ │ ┌──────────────┼──────────────┐ │ ││
│ │ │ ▼ ▼ ▼ │ ││
│ │ │ Master Slave1(Slave2 ...) │ ││
│ │ │ DataSource DataSource(带LazyConnectionProxy) │ ││
│ │ └───────────────────────────────────────────────────────┘ ││
│ └───────────────────────────┬───────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐│
│ │ MyBatis SqlSessionFactory ││
│ │ ┌───────────────────────────────────────────────────────┐ ││
│ │ │ WriteOnSlaveGuardInterceptor (Plugin) │ ││
│ │ │ - 拦截 StatementHandler.prepare │ ││
│ │ │ - 检测写 SQL + 从库标志 → 策略(抛异常/告警/审计) │ ││
│ │ │ - 白名单注解 @AllowWriteOnSlave │ ││
│ │ └───────────────────────────────────────────────────────┘ ││
│ └───────────────────────────────────────────────────────────────┘│
│ │
│ ┌───────────────────────────────────────────────────────────────┐│
│ │ Health Check ││
│ │ - 定时任务检测从库 SELECT 1 ││
│ │ - 故障剔除 / 恢复重新加入 ││
│ └───────────────────────────────────────────────────────────────┘│
│ │
│ ┌───────────────────────────────────────────────────────────────┐│
│ │ Actuator /datasources Endpoint ││
│ │ - 活跃连接数、健康状态、切换次数、拦截统计 ││
│ └───────────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────────┘
1.4 项目结构
text
dynamic-mybatis
├── dynamic-mybatis-core // 核心模块,纯 Java 逻辑
│ └── src/main/java/com/example/dynamic/mybatis/core
│ ├── annotation // 注解定义
│ │ ├── ReadOnly.java
│ │ └── AllowWriteOnSlave.java
│ ├── context // ThreadLocal 上下文
│ │ └── DataSourceContextHolder.java
│ ├── routing // 动态数据源路由核心
│ │ ├── DynamicRoutingDataSource.java
│ │ ├── LoadBalanceStrategy.java (接口)
│ │ ├── RoundRobinLoadBalanceStrategy.java
│ │ ├── RandomLoadBalanceStrategy.java
│ │ ├── WeightLoadBalanceStrategy.java
│ │ └── ConnectionPoolAwareLoadBalanceStrategy.java
│ ├── interceptor // MyBatis 拦截器
│ │ └── WriteOnSlaveGuardInterceptor.java
│ ├── health // 健康检查
│ │ ├── DataSourceHealthChecker.java
│ │ └── HealthStatus.java
│ └── util // 工具类
│ └── SqlParserUtils.java
├── dynamic-mybatis-spring-boot-starter // Starter 自动配置
│ └── src/main/java/com/example/dynamic/mybatis/autoconfigure
│ ├── DynamicMybatisProperties.java
│ ├── DynamicMybatisAutoConfiguration.java
│ ├── aop
│ │ └── ReadOnlyAspect.java
│ ├── endpoint
│ │ └── DataSourceEndpoint.java
│ └── health
│ └── DataSourceHealthIndicator.java
└── pom.xml
1.5 核心工作流程
- 应用启动 :
DynamicMybatisAutoConfiguration根据配置创建主库数据源和多个从库数据源,每个从库数据源被LazyConnectionDataSourceProxy装饰。将它们注册到DynamicRoutingDataSource的targetDataSources中,并设置默认数据源为主库。 - 请求到来 :
ReadOnlyAspect拦截标注了@ReadOnly的方法,将Read标志放入DataSourceContextHolder;对于未标注且方法名匹配写操作前缀(如insert、update、delete)的方法,自动推断为主库。 - 获取连接 :当执行 SQL 时,MyBatis 向
DataSource请求连接。请求抵达DynamicRoutingDataSource.determineCurrentLookupKey(),内部根据DataSourceContextHolder中的标志进行路由:- 若要求读:调用负载均衡策略从健康的从库列表中选择一个 Key 返回。
- 若要求写:返回主库 Key。
- 事务处理 :由于
LazyConnectionDataSourceProxy的存在,DataSourceTransactionManager.doBegin获取的只是一个代理连接,真正的物理连接延迟到第一次实际执行 SQL 时才获取。若在事务内先去从库读数据(获得从库连接),后续写操作会迫使LazyConnectionDataSourceProxy切换到主库连接,后续读操作也因此走主库,实现事务内写后读强一致。 - SQL 安全拦截 :
WriteOnSlaveGuardInterceptor在 MyBatis 构建StatementHandler时检测当前线程是否走从库,若是则解析 SQL 并判断是否为写操作,若违规则根据策略(抛异常/告警/仅审计)处理。白名单允许特定 Mapper 方法从库写。 - 健康检查 :后台定时任务周期性地对从库执行
SELECT 1,标记健康或故障;故障节点自动从负载均衡列表中摘除,恢复后自动加入。 - 监控 :Actuator 端点
/actuator/datasources提供全部数据源的状态、连接数、切换次数、拦截违规次数等信息。
二、核心接口与类设计
2.1 注解定义
@ReadOnly
java
/**
* 标记方法或类上的数据源路由为只读(从库)。
* 可结合 AOP 自动切换,也可手动设置 DataSourceContextHolder。
*
* 知识点:自定义注解(Spring 核心容器系列第 3 篇 DI、第 4 篇 AOP)
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ReadOnly {
/**
* 指定特定的从库名称,为空则使用负载均衡策略选择。
*/
String value() default "";
}
@AllowWriteOnSlave
java
/**
* 白名单注解:标记在 Mapper 方法上,允许该方法在从库上执行写操作。
* 常见场景:日志写入、审计表写入等非核心业务。
*
* 知识点:MyBatis 插件拦截链、安全策略(MyBatis 第 4 篇)
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AllowWriteOnSlave {
/**
* 说明原因,便于审计。
*/
String reason() default "";
}
2.2 上下文持有者 DataSourceContextHolder
java
/**
* 基于 ThreadLocal 的数据源上下文管理器,支持嵌套切换。
* 通过 push/pop 实现类似栈的操作,保证嵌套调用时数据源正确恢复。
*
* 知识点:ThreadLocal 原理、栈式上下文管理、避免内存泄漏(手动 remove)
*/
public class DataSourceContextHolder {
private static final ThreadLocal<Deque<String>> CONTEXT_HOLDER = ThreadLocal.withInitial(ArrayDeque::new);
// 写标志
private static final String WRITE = "WRITE";
private static final String READ = "READ";
/**
* 获取当前线程的数据源查找键。
* 若为 WRITE 则返回 null 表示主库(默认),READ 则由路由器选择从库。
*/
public static String determineLookupKey() {
Deque<String> deque = CONTEXT_HOLDER.get();
if (deque.isEmpty()) {
return null; // 默认主库
}
return deque.peek();
}
public static void pushWrite() {
CONTEXT_HOLDER.get().push(WRITE);
}
public static void pushRead() {
CONTEXT_HOLDER.get().push(READ);
}
public static void pushRead(String slaveKey) {
CONTEXT_HOLDER.get().push(slaveKey);
}
public static void pop() {
Deque<String> deque = CONTEXT_HOLDER.get();
if (!deque.isEmpty()) {
deque.pop();
}
if (deque.isEmpty()) {
CONTEXT_HOLDER.remove(); // 防止内存泄漏
}
}
/**
* 判断当前是否为读操作且未指定具体从库。
*/
public static boolean isReadRoute() {
String key = determineLookupKey();
// 如果没有压栈(null),或者压的是 WRITE,则不是读路由
return READ.equals(key) || (key != null && !WRITE.equals(key));
}
/**
* 清理 ThreadLocal,防止内存泄漏(可由 Filter 或 AOP 在请求结束后调用)。
*/
public static void clear() {
CONTEXT_HOLDER.remove();
}
}
设计要点:
- 使用
Deque支持嵌套调用,确保内层方法若切换了数据源类型,外层方法恢复时不受影响。 READ表示让路由器通过负载均衡选择具体从库;也可以 push 一个具体的从库名称(如"slave2"),实现指定从库的功能。- 在
pop后若队列为空则调用remove(),防止线程池复用时的内存泄漏。
2.3 负载均衡策略接口
java
import javax.sql.DataSource;
import java.util.List;
import java.util.Map;
/**
* 从库负载均衡策略接口。
* 支持多种实现:轮询、随机、权重、基于连接池状态的动态负载均衡。
*/
public interface LoadBalanceStrategy {
/**
* 从可用的从库键列表中选择一个。
*
* @param slaveKeys 可用从库的键列表
* @param dataSources 所有目标 DataSource 映射(可用于获取连接池指标)
* @return 选中的从库键
*/
String select(List<String> slaveKeys, Map<Object, Object> dataSources);
/**
* 策略名称,便于监控和配置。
*/
String getName();
}
各个实现类说明:
RoundRobinLoadBalanceStrategy:使用AtomicInteger轮询。RandomLoadBalanceStrategy:ThreadLocalRandom随机选择。WeightLoadBalanceStrategy:根据配置的权重值,实现加权随机。ConnectionPoolAwareLoadBalanceStrategy:(核心)通过采集连接池指标选择负载最低的从库。
2.4 动态路由数据源 DynamicRoutingDataSource
java
/**
* 核心动态数据源,继承 AbstractRoutingDataSource,实现模板方法模式。
* 内部维护主库 key 和从库 key 列表、负载均衡策略、健康检查器等。
*
* 知识点:AbstractRoutingDataSource、模板方法模式(Spring 核心容器系列第 8 篇)
*/
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
private final LoadBalanceStrategy loadBalanceStrategy;
private final String masterKey;
private final List<String> slaveKeys;
private final DataSourceHealthChecker healthChecker;
private final Map<Object, Object> allDataSources;
public DynamicRoutingDataSource(Map<Object, Object> targetDataSources,
Object defaultTargetDataSource,
String masterKey,
List<String> slaveKeys,
LoadBalanceStrategy loadBalanceStrategy,
DataSourceHealthChecker healthChecker) {
this.masterKey = masterKey;
this.slaveKeys = slaveKeys;
this.loadBalanceStrategy = loadBalanceStrategy;
this.healthChecker = healthChecker;
this.allDataSources = targetDataSources;
super.setTargetDataSources(targetDataSources);
super.setDefaultTargetDataSource(defaultTargetDataSource);
// 必须调用 afterPropertiesSet 完成初始化
super.afterPropertiesSet();
}
@Override
protected Object determineCurrentLookupKey() {
String contextKey = DataSourceContextHolder.determineLookupKey();
// 1. 上下文为空 -> 默认主库
if (contextKey == null) {
return masterKey;
}
// 2. 明确要求写主库
if ("WRITE".equals(contextKey)) {
return masterKey;
}
// 3. 上下文为指定从库名称(如直接 push("slave2"))
if (!"READ".equals(contextKey)) {
return contextKey; // 直接使用上下文中的具体从库 key
}
// 4. 上下文为 READ,需要负载均衡选择从库
List<String> healthySlaves = healthChecker.getHealthySlaveKeys(slaveKeys);
if (healthySlaves.isEmpty()) {
// 没有健康从库,降级到主库
return masterKey;
}
return loadBalanceStrategy.select(healthySlaves, allDataSources);
}
// 省略 getter 方法用于监控...
}
关键点:
- 必须调用
afterPropertiesSet()完成resolvedDataSources的初始化。 - 降级策略:当所有从库不可用时,自动回退到主库,保证可用性。
- 健康检查器
DataSourceHealthChecker负责维护健康从库列表。
2.5 基于连接池状态的动态负载均衡
java
/**
* 基于连接池活跃连接数的动态负载均衡策略。
* 通过 HikariCP 的 JMX 接口获取各从库连接池的活跃连接数,
* 选择当前负载最轻(活跃连接数最少)的从库。
* 内置定时采集与缓存机制,避免每次路由都查询 JMX。
*
* 知识点:HikariCP、JMX 监控、连接池状态、最小连接数算法
*/
public class ConnectionPoolAwareLoadBalanceStrategy implements LoadBalanceStrategy {
private static final String NAME = "CONNECTION_POOL_AWARE";
private volatile Map<String, Integer> activeConnectionsCache = Collections.emptyMap();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final Map<Object, Object> dataSources;
public ConnectionPoolAwareLoadBalanceStrategy(Map<Object, Object> dataSources,
long collectIntervalMs) {
this.dataSources = dataSources;
// 定时采集连接池指标
scheduler.scheduleAtFixedRate(this::collectMetrics, 0, collectIntervalMs, TimeUnit.MILLISECONDS);
}
@Override
public String select(List<String> slaveKeys, Map<Object, Object> dataSources) {
if (slaveKeys.isEmpty()) {
throw new IllegalStateException("No available slave");
}
// 简单选择活跃连接数最小的
return slaveKeys.stream()
.min(Comparator.comparingInt(key -> activeConnectionsCache.getOrDefault(key, 0)))
.orElse(slaveKeys.get(0));
}
@Override
public String getName() {
return NAME;
}
/**
* 定时采集指标,缓存到 Map 中。
*/
private void collectMetrics() {
Map<String, Integer> metrics = new HashMap<>();
for (Map.Entry<Object, Object> entry : dataSources.entrySet()) {
if (entry.getValue() instanceof DataSource) {
DataSource ds = (DataSource) entry.getValue();
// 尝试解包 LazyConnectionDataSourceProxy
DataSource target = unwrapLazyProxy(ds);
if (target instanceof HikariDataSource) {
HikariDataSource hds = (HikariDataSource) target;
HikariPoolMXBean poolMXBean = hds.getHikariPoolMXBean();
if (poolMXBean != null) {
metrics.put(entry.getKey().toString(), poolMXBean.getActiveConnections());
}
}
}
}
this.activeConnectionsCache = metrics;
}
private DataSource unwrapLazyProxy(DataSource ds) {
// 如果被 LazyConnectionDataSourceProxy 包装,需要取出内部的真正 DataSource
if (ds instanceof LazyConnectionDataSourceProxy) {
return ((LazyConnectionDataSourceProxy) ds).getTargetDataSource();
}
return ds;
}
public void shutdown() {
scheduler.shutdown();
}
}
设计说明:
- 采集间隔建议配置为 1~5 秒,平衡实时性与性能开销。
- 通过
unwrapLazyProxy兼容被LazyConnectionDataSourceProxy包装的从库数据源,确保能访问到真正的 HikariCP 连接池。 - 如果某些从库未使用 HikariCP,则活跃连接数默认为 0,会一直被选中,此时可配合权重策略兜底。
2.6 MyBatis 写操作拦截器 WriteOnSlaveGuardInterceptor
java
/**
* MyBatis 插件拦截器:检测在从库上执行的写操作 SQL。
* 拦截 StatementHandler.prepare 方法,此时 SQL 已构建完成,数据源路由已确定。
* 支持三种策略:抛异常(默认)、记录告警日志后放行、仅记录统计信息。
* 白名单通过 @AllowWriteOnSlave 注解或配置文件指定。
*
* 知识点:MyBatis 拦截器链、StatementHandler、SQL 解析(MyBatis 第 4 篇)
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class WriteOnSlaveGuardInterceptor implements Interceptor {
/**
* 写操作关键字
*/
private static final Set<String> WRITE_KEYWORDS = Set.of(
"INSERT", "UPDATE", "DELETE", "TRUNCATE", "MERGE", "REPLACE"
);
/**
* 违规处理策略
*/
public enum Strategy {
THROW, // 抛出异常
WARN, // 记录日志后放行
AUDIT // 仅统计,不干预
}
private final Strategy strategy;
private final Map<String, Set<String>> whiteList; // mapperId -> 方法名集合
private final AtomicLong violationCount = new AtomicLong(0);
public WriteOnSlaveGuardInterceptor(Strategy strategy, Map<String, Set<String>> whiteList) {
this.strategy = strategy;
this.whiteList = whiteList;
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 判断当前线程是否走从库
if (!DataSourceContextHolder.isReadRoute()) {
return invocation.proceed();
}
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 2. 获取 SQL
String sql = statementHandler.getBoundSql().getSql();
if (sql == null || sql.isEmpty()) {
return invocation.proceed();
}
// 3. 判断是否为写操作
if (!isWriteSql(sql)) {
return invocation.proceed();
}
// 4. 检查白名单
// 获取 MappedStatement ID(如 com.example.UserMapper.insert)
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
String mapperId = mappedStatement.getId();
String shortMethod = mapperId.substring(mapperId.lastIndexOf('.') + 1);
if (isWhiteListed(mapperId, shortMethod)) {
// 白名单方法允许通过
return invocation.proceed();
}
// 5. 根据策略处理
violationCount.incrementAndGet();
switch (strategy) {
case THROW:
throw new WriteOnSlaveException(
String.format("检测到在从库上执行写操作: [%s] SQL: %s", mapperId, sql));
case WARN:
// 记录详细告警日志
// 此处可接入日志框架,输出 WARN 级别日志
break;
case AUDIT:
break;
default:
break;
}
return invocation.proceed();
}
private boolean isWriteSql(String sql) {
String upperSql = sql.trim().toUpperCase();
return WRITE_KEYWORDS.stream().anyMatch(upperSql::startsWith);
}
private boolean isWhiteListed(String mapperId, String methodName) {
// 全限定名匹配
if (whiteList.containsKey(mapperId)) {
return true;
}
// 简单方法名匹配
for (Map.Entry<String, Set<String>> entry : whiteList.entrySet()) {
if (entry.getValue().contains(methodName)) {
return true;
}
}
return false;
}
public long getViolationCount() {
return violationCount.get();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
}
}
SQL 解析说明 :这里采用简单的字符串前缀匹配,足以应对大多数场景。生产环境可引入 JSqlParser 等库进行更精确的解析,但会增加依赖和性能开销。白名单支持 Mapper 全限定名和简单方法名两种匹配方式,也可结合 @AllowWriteOnSlave 注解,在自动配置时扫描注解建立白名单。
2.7 健康检查器 DataSourceHealthChecker
java
/**
* 从库健康检查器,使用定时任务周期性检测从库可用性。
* 通过执行 SELECT 1 并捕获异常来判断从库是否存活。
* 维护健康从库集合,并将故障从库剔除、恢复后重新加入。
*
* 知识点:连接池管理、健康检查、ScheduledExecutorService
*/
public class DataSourceHealthChecker {
private final Map<String, DataSource> slaveDataSources; // 所有从库映射
private final Set<String> healthySlaves = new CopyOnWriteArraySet<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public DataSourceHealthChecker(Map<String, DataSource> slaveDataSources,
long checkIntervalMs,
long connectionTimeoutMs) {
this.slaveDataSources = slaveDataSources;
healthySlaves.addAll(slaveDataSources.keySet());
scheduler.scheduleAtFixedRate(() -> checkHealth(connectionTimeoutMs),
0, checkIntervalMs, TimeUnit.MILLISECONDS);
}
private void checkHealth(long timeoutMs) {
for (Map.Entry<String, DataSource> entry : slaveDataSources.entrySet()) {
String key = entry.getKey();
boolean alive = isAlive(entry.getValue(), timeoutMs);
if (alive) {
healthySlaves.add(key);
} else {
healthySlaves.remove(key);
}
}
}
private boolean isAlive(DataSource dataSource, long timeoutMs) {
try (Connection conn = dataSource.getConnection()) {
// 设置超时(需要 JDBC 驱动支持)
if (timeoutMs > 0) {
conn.setNetworkTimeout(Executors.newSingleThreadExecutor(), (int) timeoutMs);
}
try (Statement stmt = conn.createStatement()) {
stmt.setQueryTimeout(1);
stmt.execute("SELECT 1");
return true;
}
} catch (Exception e) {
return false;
}
}
public List<String> getHealthySlaveKeys(List<String> slaveKeys) {
// 返回给定列表中当前健康的从库
return slaveKeys.stream().filter(healthySlaves::contains).collect(Collectors.toList());
}
public Set<String> getAllHealthySlaves() {
return Collections.unmodifiableSet(healthySlaves);
}
public void shutdown() {
scheduler.shutdown();
}
}
三、Spring Boot Starter 自动配置
3.1 配置属性 DynamicMybatisProperties
java
@ConfigurationProperties(prefix = "dynamic.mybatis")
public class DynamicMybatisProperties {
/**
* 主库配置
*/
private DataSourceConfig master;
/**
* 从库配置列表(支持多从库)
*/
private List<DataSourceConfig> slaves = new ArrayList<>();
/**
* 负载均衡策略: round-robin, random, weight, connection-pool-aware
*/
private String loadBalanceStrategy = "round-robin";
/**
* 连接池感知策略的指标采集间隔,单位毫秒
*/
private long metricsCollectIntervalMs = 3000;
/**
* 健康检查间隔,单位毫秒
*/
private long healthCheckIntervalMs = 5000;
/**
* 从库连接超时时间(用于健康检查),单位毫秒
*/
private long healthCheckConnectionTimeoutMs = 3000;
/**
* 写操作违规策略: throw, warn, audit
*/
private String writeViolationStrategy = "throw";
/**
* 写操作白名单(Mapper 全限定名)
*/
private List<String> writeWhiteList = new ArrayList<>();
/**
* 是否启用 LazyConnectionDataSourceProxy(默认 true,事务内读写分离必须)
*/
private boolean lazyConnectionProxy = true;
// getter / setter
public static class DataSourceConfig {
private String name;
private String url;
private String username;
private String password;
private String driverClassName;
private int weight = 1; // 权重
private Map<String, Object> hikari = new HashMap<>(); // HikariCP 自定义参数
// getter / setter
}
}
3.2 自动配置类 DynamicMybatisAutoConfiguration
java
@Configuration
@ConditionalOnClass({DataSource.class, SqlSessionFactory.class})
@EnableConfigurationProperties(DynamicMybatisProperties.class)
@ConditionalOnProperty(prefix = "dynamic.mybatis", name = "enabled", havingValue = "true", matchIfMissing = true)
public class DynamicMybatisAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public DataSource dynamicRoutingDataSource(DynamicMybatisProperties properties,
DataSourceHealthChecker healthChecker,
LoadBalanceStrategy loadBalanceStrategy) {
// 1. 创建主库 DataSource
DataSource masterDataSource = createHikariDataSource(properties.getMaster());
// 2. 创建从库 DataSource Map,并用 LazyConnectionDataSourceProxy 包装
Map<Object, Object> targetDataSources = new HashMap<>();
Map<String, DataSource> slaveMapForHealth = new HashMap<>();
List<String> slaveKeys = new ArrayList<>();
targetDataSources.put(properties.getMaster().getName(), masterDataSource);
for (DynamicMybatisProperties.DataSourceConfig slaveConfig : properties.getSlaves()) {
HikariDataSource slaveDs = createHikariDataSource(slaveConfig);
DataSource actualDs = properties.isLazyConnectionProxy()
? new LazyConnectionDataSourceProxy(slaveDs) : slaveDs;
targetDataSources.put(slaveConfig.getName(), actualDs);
slaveMapForHealth.put(slaveConfig.getName(), slaveDs);
slaveKeys.add(slaveConfig.getName());
}
return new DynamicRoutingDataSource(targetDataSources, masterDataSource,
properties.getMaster().getName(), slaveKeys, loadBalanceStrategy, healthChecker);
}
@Bean
@ConditionalOnMissingBean
public LoadBalanceStrategy loadBalanceStrategy(DynamicMybatisProperties properties,
@Qualifier("dynamicRoutingDataSource") DataSource routingDataSource) {
switch (properties.getLoadBalanceStrategy()) {
case "random":
return new RandomLoadBalanceStrategy();
case "weight":
// 构建权重映射
Map<String, Integer> weights = new HashMap<>();
for (DynamicMybatisProperties.DataSourceConfig slave : properties.getSlaves()) {
weights.put(slave.getName(), slave.getWeight());
}
return new WeightLoadBalanceStrategy(weights);
case "connection-pool-aware":
Map<Object, Object> dsMap = ((DynamicRoutingDataSource) routingDataSource).getAllDataSources();
return new ConnectionPoolAwareLoadBalanceStrategy(dsMap, properties.getMetricsCollectIntervalMs());
case "round-robin":
default:
return new RoundRobinLoadBalanceStrategy();
}
}
@Bean
@ConditionalOnMissingBean
public DataSourceHealthChecker dataSourceHealthChecker(
@Qualifier("slaveDataSourcesForHealth") Map<String, DataSource> slaveDataSources,
DynamicMybatisProperties properties) {
return new DataSourceHealthChecker(slaveDataSources,
properties.getHealthCheckIntervalMs(),
properties.getHealthCheckConnectionTimeoutMs());
}
@Bean
@ConditionalOnMissingBean
public Map<String, DataSource> slaveDataSourcesForHealth(DynamicMybatisProperties properties) {
// 直接保存原始 HikariDataSource,不受 LazyConnectionDataSourceProxy 影响
Map<String, DataSource> map = new HashMap<>();
for (DynamicMybatisProperties.DataSourceConfig config : properties.getSlaves()) {
map.put(config.getName(), createHikariDataSource(config));
}
return map;
}
/**
* 注册 WriteOnSlaveGuardInterceptor 到 SqlSessionFactory 的插件链中。
* 通过 SqlSessionFactoryBeanCustomizer 或直接操作 Interceptor 列表。
*/
@Bean
@ConditionalOnMissingBean
public WriteOnSlaveGuardInterceptor writeOnSlaveGuardInterceptor(DynamicMybatisProperties properties) {
WriteOnSlaveGuardInterceptor.Strategy strategy =
WriteOnSlaveGuardInterceptor.Strategy.valueOf(properties.getWriteViolationStrategy().toUpperCase());
Map<String, Set<String>> whiteList = buildWhiteList(properties.getWriteWhiteList());
return new WriteOnSlaveGuardInterceptor(strategy, whiteList);
}
/**
* 将拦截器加入 MyBatis 拦截器链。如果项目中已有 SqlSessionFactory,使用此方式介入。
*/
@Configuration
static class MyBatisPluginConfig {
@Autowired
private List<SqlSessionFactory> sqlSessionFactories;
@Autowired
private WriteOnSlaveGuardInterceptor guardInterceptor;
@PostConstruct
public void addInterceptor() {
for (SqlSessionFactory factory : sqlSessionFactories) {
Configuration configuration = factory.getConfiguration();
configuration.addInterceptor(guardInterceptor);
}
}
}
private HikariDataSource createHikariDataSource(DynamicMybatisProperties.DataSourceConfig config) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(config.getUrl());
ds.setUsername(config.getUsername());
ds.setPassword(config.getPassword());
ds.setDriverClassName(config.getDriverClassName());
// 可应用额外的 Hikari 配置
if (config.getHikari() != null) {
// ... 通过反射或手动设置常用属性
}
return ds;
}
private Map<String, Set<String>> buildWhiteList(List<String> configList) {
Map<String, Set<String>> whiteList = new HashMap<>();
for (String entry : configList) {
// 格式: "com.example.UserMapper.insert" 或 "com.example.UserMapper"
int lastDot = entry.lastIndexOf('.');
if (lastDot > 0) {
String mapper = entry.substring(0, lastDot);
String method = entry.substring(lastDot + 1);
whiteList.computeIfAbsent(mapper, k -> new HashSet<>()).add(method);
}
}
return whiteList;
}
@Bean
@ConditionalOnMissingBean
public ReadOnlyAspect readOnlyAspect() {
return new ReadOnlyAspect();
}
// ... 其他健康端点、Actuator 配置见后文
}
自动配置解析:
- 通过
@ConditionalOnClass确保在存在 MyBatis 和数据源时加载。 LazyConnectionDataSourceProxy默认开启,这是事务内读写分离的关键。如果用户显式关闭,则事务内将无法动态切换数据源(会在事务开始时绑定物理连接)。MyBatisPluginConfig在@PostConstruct中将WriteOnSlaveGuardInterceptor注册到所有SqlSessionFactory的拦截器链中。这是 MyBatis 拦截器注入的标准方式。- 健康检查使用原始 Hikari 数据源直接执行 SQL,避免 LazyConnection 代理干扰。
3.3 AOP 切面 ReadOnlyAspect
java
/**
* AOP 切面,根据 @ReadOnly 注解自动设置数据源上下文。
* 在方法执行前设置,执行后清理(pop)。
*
* 知识点:AOP 切面、@Around、@Before/@After、切入点表达式
*/
@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE + 1) // 确保在事务切面之前执行
public class ReadOnlyAspect {
/**
* 匹配标注了 @ReadOnly 注解的类或方法
*/
@Pointcut("@within(com.example.dynamic.mybatis.core.annotation.ReadOnly) || " +
"@annotation(com.example.dynamic.mybatis.core.annotation.ReadOnly)")
public void readOnlyPointcut() {
}
/**
* 匹配写操作方法名前缀(如 insert、update、delete、save)
*/
@Pointcut("execution(* com.example..*Mapper.insert*(..)) || " +
"execution(* com.example..*Mapper.update*(..)) || " +
"execution(* com.example..*Mapper.delete*(..)) || " +
"execution(* com.example..*Mapper.save*(..))")
public void writeMethodByPrefix() {
}
@Around("readOnlyPointcut()")
public Object aroundReadOnly(ProceedingJoinPoint pjp) throws Throwable {
try {
DataSourceContextHolder.pushRead();
return pjp.proceed();
} finally {
DataSourceContextHolder.pop();
}
}
@Around("writeMethodByPrefix()")
public Object aroundWriteMethod(ProceedingJoinPoint pjp) throws Throwable {
try {
DataSourceContextHolder.pushWrite();
return pjp.proceed();
} finally {
DataSourceContextHolder.pop();
}
}
/**
* 如果一个方法同时匹配读写切点(既有 @ReadOnly 又有写前缀),读切点优先(由于 Order)。
* 但若发生了事务内写操作,通过 LazyConnectionDataSourceProxy 会自动切换为主库。
*/
}
说明:
- 通过切入点表达式将写前缀的方法直接标记为
WRITE,无需手动注解,减少业务代码侵入。 pushRead采用栈式管理,支持嵌套调用。@Around确保 finally 中执行pop。
四、事务内读写分离的深度处理
4.1 问题分析
直接使用 AbstractRoutingDataSource 时,若一个事务方法上既有读操作又有写操作,DataSourceTransactionManager.doBegin 会先调用 dataSource.getConnection(),而此时 determineCurrentLookupKey() 返回的 key 决定了物理连接绑定的数据库实例。一旦连接绑定,整个事务期间该连接不会改变,导致后续切换数据源 key 失效。
4.2 引入 LazyConnectionDataSourceProxy
LazyConnectionDataSourceProxy 是 Spring 提供的 DataSource 代理,它在 getConnection() 时返回一个延迟连接的代理对象。实际物理连接的获取推迟到第一次真正执行 SQL 时。这样,DataSourceTransactionManager 绑定的只是一个轻量级代理,真正的物理连接是在 MyBatis 执行 SQL 时由 determineCurrentLookupKey() 动态决定。
实践关键:
- 只将从库 DataSource 用
LazyConnectionDataSourceProxy包装,主库不需要(或也可包装,但无必要)。 - 在
DynamicMybatisAutoConfiguration中设置lazyConnectionProxy = true时启用包装。 - 事务内若先后执行了 读 → 写 → 读,则代理连接会在写操作时自动切换到主库连接,后续读也保持在主库连接,从而避免主从延迟导致读不到刚写入的数据。
知识点对应 :Spring 数据访问系列第 3、4 篇声明式事务原理,以及 TransactionSynchronizationManager 的资源绑定。
4.3 写操作后锁定主库的实现(进阶)
如果需要更严格的控制,可以在 WriteOnSlaveGuardInterceptor 或 AOP 中,当检测到写操作执行成功后,调用 DataSourceContextHolder.pushWrite(),强制后续读取走主库,最后在事务结束时 pop。但这会侵入业务逻辑。更优雅的做法依赖 LazyConnectionDataSourceProxy 的自动行为:一旦在从库代理上执行写 SQL,代理会意识到连接类型不匹配,重新获取主库连接(需要 determineCurrentLookupKey() 在此时返回主库)。要做到这一点,需要确保 determineCurrentLookupKey() 在写 SQL 执行时刻返回主库。我们可以在 ConnectionPoolAwareLoadBalanceStrategy 中配置,当写操作发生时线程标志已经变为 WRITE(由 AOP 切面提前设置),因此代理在获取真实连接时就会路由到主库。
五、基于连接池状态的动态负载均衡
5.1 设计动机与选型分析
在多从库架构中,简单的轮询(Round Robin)或随机(Random)策略无法感知各节点的实时负载。当某一从库承载过多慢查询或连接池饱和时,继续向其分配请求会加剧响应延迟,甚至引发雪崩。引入基于连接池状态的动态负载均衡,可以根据 活跃连接数 、等待线程数 、连接获取耗时 等指标实时选择最"空闲"的节点。
为什么选择连接池指标而非系统指标?
连接池(如 HikariCP)是数据库交互的咽喉,其内部指标直接反映了数据库连接的使用压力。相比 CPU/内存等系统指标,连接池指标与数据库响应能力相关性更强,且获取成本更低(本地 JMX 查询,无需额外监控组件)。
5.2 核心接口设计
java
/**
* 可感知连接池状态的负载均衡策略接口。
* 扩展基础 LoadBalanceStrategy,提供指标采集与选择逻辑的分离。
*/
public interface ConnectionPoolAwareStrategy extends LoadBalanceStrategy {
/**
* 触发一次连接池指标采集,通常由后台调度线程定时调用。
*/
void collectMetrics();
/**
* 返回当前的连接池指标快照,供 Actuator 端点等监控使用。
*/
Map<String, PoolMetrics> getCurrentMetrics();
}
/**
* 连接池指标快照(不可变)
*/
public class PoolMetrics {
private final int activeConnections;
private final int idleConnections;
private final int totalConnections;
private final int threadsAwaitingConnection;
private final long timestamp;
// 构造函数、getter 略
}
5.3 基于 HikariCP JMX 的指标采集实现
HikariCP 通过 HikariPoolMXBean 暴露了连接池的核心指标,可通过 JMX 或直接引用 HikariDataSource.getHikariPoolMXBean() 获取。下面的实现直接使用 API 方式,避免了 JMX 连接的开销,但需注意 HikariPoolMXBean 在连接池未初始化时可能返回 null。
java
public class HikariPoolMetricsCollector {
/**
* 从 DataSource 中提取 HikariPoolMXBean,兼容 LazyConnectionDataSourceProxy 包装。
*/
public static HikariPoolMXBean getPoolMXBean(DataSource dataSource) {
DataSource target = dataSource;
// 解包 LazyConnectionDataSourceProxy
if (target instanceof LazyConnectionDataSourceProxy) {
target = ((LazyConnectionDataSourceProxy) target).getTargetDataSource();
}
// 解包其他可能的 DataSource 代理(如 Spring 的 DelegatingDataSource)
while (target instanceof DelegatingDataSource) {
target = ((DelegatingDataSource) target).getTargetDataSource();
}
if (target instanceof HikariDataSource) {
return ((HikariDataSource) target).getHikariPoolMXBean();
}
return null;
}
/**
* 采集指定数据源列表的指标。
*
* @param dataSources 数据源映射
* @return Map<数据源Key, PoolMetrics>
*/
public static Map<String, PoolMetrics> collect(Map<String, DataSource> dataSources) {
Map<String, PoolMetrics> result = new HashMap<>();
for (Map.Entry<String, DataSource> entry : dataSources.entrySet()) {
HikariPoolMXBean mxBean = getPoolMXBean(entry.getValue());
if (mxBean != null) {
result.put(entry.getKey(), new PoolMetrics(
mxBean.getActiveConnections(),
mxBean.getIdleConnections(),
mxBean.getTotalConnections(),
mxBean.getThreadsAwaitingConnection(),
System.currentTimeMillis()
));
}
}
return result;
}
}
关键点说明:
- 解包代理链 :由于从库数据源可能被
LazyConnectionDataSourceProxy或其他DelegatingDataSource包装,必须递归解包才能获取到真正的 HikariCP 数据源。这是生产环境极易忽略的细节。 - 空值处理 :
HikariPoolMXBean可能在启动瞬间或连接池关闭时返回null,采集器需要妥善处理,避免 NPE。 - 采集性能 :
getActiveConnections()等方法仅从内存中读取计数器,耗时微秒级,因此可高频采集(如每隔 1 秒),不会对业务造成影响。
5.4 基于"动态加权最少连接"的选择算法
直接选择活跃连接数最少的从库(Least Connections)可能引发"惊群效应":当多个并发请求同时发现同一节点连接数最少,瞬间将其打满。改进方案是引入 动态权重:将活跃连接数映射为一个权重分数,结合指数加权移动平均(EWMA)平滑历史抖动,再使用加权随机选择。
算法步骤:
- 计算每个从库的"负载分数":
loadScore = activeConnections / maxPoolSize。 - 将负载分数转化为"可用权重":
weight = max(0, 1 - loadScore) * baseWeight,其中baseWeight为配置的静态权重。 - 使用
ThreadLocalRandom按权重进行加权随机选择。
java
public class AdaptiveWeightLoadBalancer implements ConnectionPoolAwareStrategy {
private final Map<String, Integer> baseWeights; // 静态权重
private final Map<String, Integer> maxPoolSizes; // 最大连接数(用于归一化)
private volatile Map<String, PoolMetrics> latestMetrics = Collections.emptyMap();
public AdaptiveWeightLoadBalancer(Map<String, Integer> baseWeights,
Map<String, Integer> maxPoolSizes) {
this.baseWeights = baseWeights;
this.maxPoolSizes = maxPoolSizes;
}
@Override
public String select(List<String> slaveKeys, Map<Object, Object> dataSources) {
// 计算动态权重
List<WeightedSlave> weightedSlaves = new ArrayList<>();
for (String key : slaveKeys) {
PoolMetrics metrics = latestMetrics.get(key);
int weight = baseWeights.getOrDefault(key, 1);
if (metrics != null) {
int maxPool = maxPoolSizes.getOrDefault(key, 10);
double load = (double) metrics.getActiveConnections() / Math.max(1, maxPool);
// 可用权重:静态权重 * (1 - 负载),最小保留 0.1 避免完全剔除
weight = (int) Math.max(1, weight * Math.max(0.1, 1.0 - load));
}
weightedSlaves.add(new WeightedSlave(key, weight));
}
// 加权随机选择
return WeightedRandomSelector.select(weightedSlaves);
}
@Override
public void collectMetrics() {
// 定时任务调用此方法,传入所有从库数据源进行指标采集
// latestMetrics 的更新需由外部调度器负责(见 5.5 节)
}
@Override
public Map<String, PoolMetrics> getCurrentMetrics() {
return Collections.unmodifiableMap(latestMetrics);
}
// 内部类:加权随机选择器(略)
}
选择器实现:标准加权随机,计算总权重,生成随机数,按累积权重区间命中节点。
5.5 定时采集与缓存机制
为了避免每次路由时都查询连接池指标(即使很快,但在高并发下仍有不必要的开销),采用 后台定时采集 + 内存缓存 模式。路由时直接读取缓存的指标快照,实现 O(1) 的极低延迟。
java
@Component
public class MetricsCollectorScheduler {
private final Map<String, DataSource> slaveDataSources;
private final AdaptiveWeightLoadBalancer loadBalancer;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public MetricsCollectorScheduler(Map<String, DataSource> slaveDataSources,
AdaptiveWeightLoadBalancer loadBalancer,
@Value("${dynamic.mybatis.metrics.collect-interval-ms:1000}") long intervalMs) {
this.slaveDataSources = slaveDataSources;
this.loadBalancer = loadBalancer;
scheduler.scheduleAtFixedRate(this::collect, 0, intervalMs, TimeUnit.MILLISECONDS);
}
private void collect() {
Map<String, PoolMetrics> metrics = HikariPoolMetricsCollector.collect(slaveDataSources);
loadBalancer.updateMetrics(metrics); // 需要 AdaptiveWeightLoadBalancer 提供更新方法
}
@PreDestroy
public void shutdown() {
scheduler.shutdown();
}
}
缓存一致性保证:
- 使用
volatile引用保证可见性。 - 路由读取快照,无需加锁,适合高并发场景。
- 指标短暂过期(1 秒)对负载均衡效果影响极小,因为连接池状态不会剧烈突变。
5.6 兜底与防抖策略
- 指标缺失处理:如果某从库的指标采集失败(如连接池未初始化),则给予一个中等的默认权重,避免其饿死或过热。
- 防抖机制:健康状态变化与负载指标结合,当节点从故障恢复时,应逐步增加其权重(如从 10% 开始指数递增),而非立刻满载,避免"刚恢复又被打挂"。
- 连接池最大等待时间感知 :如果
metrics.getThreadsAwaitingConnection() > 0,说明连接池已严重过载,可直接将其权重置为 0,实现"熔断"。
六、Spring Boot 自动配置的深度定制与扩展机制
6.1 自动配置类的完整生命周期
Spring Boot 的自动配置依赖 spring.factories 中的注册、类路径条件、Bean 条件等机制。我们设计的 DynamicMybatisAutoConfiguration 必须与 MyBatis 自动配置(MybatisAutoConfiguration)和平共处,并确保数据源 Bean 在 MyBatis 的 SqlSessionFactory 之前创建。
关键依赖顺序:
DataSource必须作为@Primary主数据源被注入到 MyBatis 配置中。LazyConnectionDataSourceProxy的包装必须在自动配置阶段完成。WriteOnSlaveGuardInterceptor需要在SqlSessionFactory初始化之后立即注册到拦截器链。
6.2 注册 writeOnSlaveGuardInterceptor 的多种方式
方式一:通过 Interceptor Bean 自动注入(推荐)
MyBatis-Spring 的 SqlSessionFactoryBean 会自动拾取容器中所有类型为 Interceptor 的 Bean。只需将 WriteOnSlaveGuardInterceptor 声明为 Bean 即可。
java
@Bean
@ConditionalOnMissingBean
public WriteOnSlaveGuardInterceptor writeOnSlaveGuardInterceptor(DynamicMybatisProperties props) {
// ... 构建拦截器
}
此方式简单可靠,但要求应用只使用 Spring Boot 的 MyBatis 自动配置(即只有一个 SqlSessionFactory)。如果有多个工厂,需额外处理。
方式二:SqlSessionFactoryBeanCustomizer(适用于多个工厂)
实现 SqlSessionFactoryBeanCustomizer 接口,在工厂初始化前添加拦截器。
java
@Bean
public SqlSessionFactoryBeanCustomizer interceptorCustomizer(WriteOnSlaveGuardInterceptor interceptor) {
return factoryBean -> factoryBean.setPlugins(new Interceptor[]{interceptor});
}
方式三:@PostConstruct 后期注入(兜底方案)
遍历容器中所有 SqlSessionFactory 的 Configuration 并添加拦截器,适用于动态添加拦截器的场景,但需注意线程安全。
6.3 与已有 MyBatis 配置的兼容性
用户可能在 application.yml 中已有 MyBatis 默认配置,如 mybatis.mapper-locations。我们的自动配置不应该覆盖这些设置,只需负责数据源路由和拦截器。因此,所有与 MyBatis 核心相关的配置保持不变,只新增 dynamic.mybatis 命名空间。
条件装配示例:
java
@Configuration
@ConditionalOnClass({SqlSessionFactory.class, HikariDataSource.class})
@EnableConfigurationProperties(DynamicMybatisProperties.class)
@AutoConfigureAfter(MybatisAutoConfiguration.class) // 确保在 MyBatis 之后
public class DynamicMybatisAutoConfiguration {
// ...
}
6.4 配置属性的高级校验
使用 JSR-303 校验确保配置正确:
java
@ConfigurationProperties(prefix = "dynamic.mybatis", ignoreUnknownFields = false)
@Validated
public class DynamicMybatisProperties {
@NotNull
private DataSourceConfig master;
@NotEmpty
private List<@Valid DataSourceConfig> slaves;
// ...
public static class DataSourceConfig {
@NotBlank
private String name;
@NotBlank
private String url;
@NotBlank
private String username;
// ...
}
}
启动时若配置缺失,会抛出 BindException,快速失败。
6.5 动态调整能力(进阶)
通过 Actuator 端点或配置中心(如 Nacos),实现运行时不重启切换负载均衡策略或调整权重。我们可在 LoadBalanceStrategy 接口中增加 updateConfig(...) 方法,并在自动配置中暴露一个可刷新的 @RefreshScope Bean。
java
@Configuration
@RefreshScope
public class DynamicLoadBalanceConfig {
// 绑定 dynamic.mybatis.load-balance.* 下的动态配置
}
6.6 Starter 的打包与引用
dynamic-mybatis-spring-boot-starter 模块的 pom.xml 中只需声明对 dynamic-mybatis-core 和必要的 Spring Boot Starter(如 spring-boot-starter-aop, mybatis-spring-boot-starter, HikariCP)的依赖。并在 resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件中写入自动配置类的全限定名(Spring Boot 2.7+ 新方式),或使用 spring.factories。
七、多数据源健康检查与自动切换
7.1 健康检查的并发模型
健康检查器需要在独立的线程中周期运行,并维护一个线程安全的健康从库集合。选择 CopyOnWriteArraySet 可以在高频率读取(路由选择)时无需加锁,但写操作(健康状态变更)相对较少,适合该场景。
java
public class DataSourceHealthChecker {
private final Map<String, DataSource> slaveDataSources;
private final Set<String> healthySlaves = new CopyOnWriteArraySet<>();
private final ScheduledExecutorService scheduler;
public DataSourceHealthChecker(Map<String, DataSource> slaveDataSources,
long checkIntervalMs,
long timeoutMs) {
this.slaveDataSources = slaveDataSources;
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ds-health-checker");
t.setDaemon(true);
return t;
});
// 初始全部标记为健康
healthySlaves.addAll(slaveDataSources.keySet());
scheduler.scheduleAtFixedRate(() -> check(timeoutMs),
checkIntervalMs, checkIntervalMs, TimeUnit.MILLISECONDS);
}
public Set<String> getHealthySlaves() {
return Collections.unmodifiableSet(healthySlaves);
}
public boolean isHealthy(String slaveKey) {
return healthySlaves.contains(slaveKey);
}
private void check(long timeoutMs) {
for (Map.Entry<String, DataSource> entry : slaveDataSources.entrySet()) {
boolean currentHealthy = probe(entry.getValue(), timeoutMs);
String key = entry.getKey();
if (currentHealthy) {
healthySlaves.add(key);
} else {
healthySlaves.remove(key);
}
}
}
}
7.2 探测机制:SELECT 1 的优化
直接使用 DataSource.getConnection() 并执行 SELECT 1 是最可靠的检查方式,但需要处理连接超时。为了避免探测导致从库连接池资源耗尽,应设置 短超时 和独立的 验证超时 (setNetworkTimeout)。
java
private boolean probe(DataSource dataSource, long timeoutMs) {
try {
// 直接获取连接,不经过 LazyConnectionProxy(健康检查用原始数据源)
DataSource target = unwrap(dataSource);
try (Connection conn = target.getConnection()) {
// JDBC 4.0 支持网络超时设置
conn.setNetworkTimeout(Runnable::run, (int) timeoutMs);
try (Statement stmt = conn.createStatement()) {
stmt.setQueryTimeout(1); // SQL 执行超时 1 秒
stmt.execute("SELECT 1");
return true;
}
}
} catch (Exception e) {
// 记录失败日志(含数据源 key)
return false;
}
}
注意事项:
- 健康检查应当直接使用 未被 LazyConnectionDataSourceProxy 包装的原始 HikariDataSource,避免延迟代理干扰连接获取。
- 若连接池完全耗尽(无空闲连接且等待队列满),
getConnection()会阻塞或抛异常,这正是我们想要探知的故障状态。
7.3 自动切换与故障转移流程
- 路由时判定 :
DynamicRoutingDataSource.determineCurrentLookupKey()从healthChecker获取健康从库列表,结合负载均衡策略选择。 - 全部从库故障时降级主库:若健康列表为空,则返回主库 key,并在日志中输出告警。
- 恢复通知:从库恢复健康后,路由自动将其重新加入候选池,无感知。
7.4 健康检查与 Spring Actuator 集成
除了自定义 /actuator/datasources 端点,我们还可实现 HealthIndicator 接口,将多数据源状态接入 Spring 的健康端点(/actuator/health)。
java
@Component("dynamicDatasourceHealth")
public class DynamicDatasourceHealthIndicator implements HealthIndicator {
private final DataSourceHealthChecker healthChecker;
@Override
public Health health() {
Health.Builder builder = Health.up();
Map<String, Object> details = new HashMap<>();
for (String slave : healthChecker.getAllSlaves()) {
details.put(slave, healthChecker.isHealthy(slave) ? "UP" : "DOWN");
}
builder.withDetails(details);
if (details.containsValue("DOWN")) {
builder.status("DEGRADED");
}
return builder.build();
}
}
7.5 避免"脑裂"的租约机制(进阶)
在极端情况下,从库可能只是由于网络闪断对健康检查线程不可达,而业务线程仍能访问(例如直连 IP 未变)。为防止健康检查误判,引入 连续失败次数 阈值:只有连续 N 次探测失败才标记为故障;同理,连续 N 次成功才标记为恢复。
java
private void checkWithThreshold(DataSource ds, String key) {
boolean current = probe(ds);
FailureTracker tracker = trackers.computeIfAbsent(key, k -> new FailureTracker());
if (current) {
tracker.consecutiveSuccesses++;
if (tracker.consecutiveSuccesses >= recoveryThreshold) {
healthySlaves.add(key);
tracker.consecutiveFailures = 0;
}
} else {
tracker.consecutiveFailures++;
if (tracker.consecutiveFailures >= failureThreshold) {
healthySlaves.remove(key);
tracker.consecutiveSuccesses = 0;
}
}
}
这种设计有效防止了瞬态抖动导致的频繁摘除与加入,提升系统稳定性。
7.6 监控与告警建议
- 日志输出:每次健康状态变更时打印 WARN 级别日志,包含数据源名称、故障原因。
- 指标暴露:通过 Actuator 端点提供健康从库数量、故障次数等指标,可接入 Prometheus。
- 连接池泄漏风险 :健康检查频繁获取连接,务必确保
try-with-resources关闭连接。如果怀疑连接泄漏,可监控activeConnections是否持续上升。
本文设计并实现了一个名为
dynamic-mybatis的读写分离中间件,以Spring Boot Starter形式交付。它基于AbstractRoutingDataSource模板方法实现动态数据源路由,通过AOP与ThreadLocal完成@ReadOnly自动切换,利用LazyConnectionDataSourceProxy解决事务内读写一致性问题,并内置MyBatis拦截器防止从库误写。同时,集成HikariCP连接池指标驱动动态负载均衡、健康检查自动故障转移及Actuator监控,全面体现了Spring与MyBatis深度整合的实战要点。