Spring boot + dynamic-datasource 读写分离:原理、用法与设计边界
1. 数据库主从读写分离基础配置
本文基于 MyBatis-Plus 和 dynamic-datasource-spring-boot-starter 框架实现主从读写分离。
1.1 使用动态数据源在 YML 中定义主从配置
首先在 application.yml 中配置主从两个数据源,主库负责写入操作,从库负责只读查询,并通过 HikariCP 配置全局连接池参数,保障连接效率与稳定性。
spring:
datasource:
dynamic:
hikari:
connection-timeout: 30000
minimum-idle: 2
maximum-pool-size: 10
max-lifetime: 3600000
connection-test-query: SELECT 1
# 默认数据源,未指定 @DS 时自动使用,确保事务默认从主库开启
primary: master
datasource:
master:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pg.addr}/${pg.db}
username: ${pg.username}
password: ${pg.pwd}
slave:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${pg1.addr}/${pg1.db}
username: ${pg1.username}
password: ${pg1.pwd}
以上配置定义了规范的主从两个数据源,核心说明如下:
master:主库,基于 PostgreSQL,承担所有数据写入、更新、删除操作,是事务的唯一合法启动数据源;slave:从库,基于 PostgreSQL,仅承担只读查询操作,严禁执行任何写入类 SQL;primary: master:指定默认数据源为 master,确保无显式指定数据源时,所有操作(尤其是事务操作)默认走主库,规避从库事务风险;- 全局 Hikari 配置:
dynamic.hikari下的参数为所有数据源共用的连接池配置,若需为主从库单独配置连接池参数(如不同的最大连接数),可将 hikari 配置嵌套在对应数据源内部。
1.2 使用 @DS 注解声明数据源
dynamic-datasource 组件提供了 @DS 注解,用于在类或方法级别显式指定当前操作使用的数据源,是手动切换主从的核心注解,其设计完全贴合主从读写分离的场景需求。
该注解源码如下,明确支持类级与方法级标注,作用域清晰:
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DS {
String value();
}
基本使用示例
在只读查询方法上标注 @DS("slave"),明确指定该方法走从库,符合主从分离“读从库”的核心原则:
@DS("slave")
public List<Double> getHeightAxis(Long id) {
// 只读查询逻辑,通过 @DS 注解指定走 slave 数据源
}
1.3 @DS 注解优先级规则
@DS 注解遵循“就近优先”原则,优先级从高到低依次为:方法级注解 > 类级注解 > 全局默认主数据源,该规则确保了数据源切换的灵活性与可控性,同时避免配置冲突。
以实际业务类为例,清晰展示优先级生效逻辑:
@Slf4j
@Service
@DS("master") // 类级注解,指定该类下所有无方法注解的方法走 master
public class UserServiceImpl {
@DS("slave") // 方法级注解,覆写类级注解,指定该方法走 slave
public List<Double> heightAxis(Long id) {
// 执行只读查询,走 slave 数据源
}
public Info findById(Long id) {
// 无方法注解,继承类级注解,走 master 数据源
}
}
核心生效结论:
- 执行
heightAxis(...)方法:优先使用方法上的@DS("slave"),走从库; - 执行
findById(...)方法:无方法注解,使用类上的@DS("master"),走主库。
1.4 重点声明:主从架构下的事务原则
在主从读写分离架构中,事务的使用有明确且严格的设计原则,直接关系到数据一致性、架构合理性,也是避免后续隐患的核心前提,需重点遵循:
1. 从库只读原则
主从分离的核心设计边界是:主库负责所有写入操作(INSERT/UPDATE/DELETE),从库仅负责只读查询。从库不允许执行任何写入类操作,这是架构层面的硬性约束,既是为了保障数据一致性,也是主从复制机制的基础。
2. 事务必须从主库开启
事务的本质是保障数据变更的原子性、一致性、隔离性与持久性,而数据写入操作仅能在主库执行,因此所有事务都必须以主库为数据源开启,不允许在从库上启动事务,更不允许在事务执行过程中切换到从库(会导致事务失效、数据不一致)。
这也是我们在配置中指定 primary: master 的核心原因——让所有未显式指定数据源的事务,默认从主库启动,从根源上规避事务风险。
3. 主从架构不应出现跨数据源事务
主从库本质是同一份数据的不同副本,并非两个独立的业务库,其核心价值是读写分离、性能扩容,而非业务拆分。在规范的主从模式下,不应该出现一个事务同时操作主库与从库的场景;若出现此类逻辑,通常意味着代码设计或数据源使用方式存在问题,违背了主从架构的核心定位。
简单来说:主从读写分离的架构定位,本身就不应该产生分布式事务的需求,跨数据源事务在主从场景下既不合理,也无必要。
4. 关于 @DSTransactional 的使用定位
@DSTransactional 是 dynamic-datasource 提供的同服务多数据源本地事务协调工具,全程不依赖任何第三方分布式事务中间件、无全局事务协调服务,仅由应用层自行管控多库事务生命周期。
底层执行逻辑:方法执行时,框架为每一个用到的数据源单独开启独立本地事务;所有SQL执行正常则按顺序依次提交全部事务,任意数据源异常则逆序回滚所有已开启事务,逻辑上遵循全部成功才提交、任意失败全回滚规则。
但因为缺少全局事务日志、故障重试、异常兜底与持久化协调机制,在网络抖动、服务宕机、提交中途中断等极端场景下,极易出现部分库提交成功、部分库回滚失败的数据不一致问题,没有强分布式事务保障能力,实际生产兜底效果较差。
因此该注解仅用于同一服务内多个独立业务库并行写入场景,严禁在主从读写分离架构中使用。
在纯主从读写分离场景下:
- 常规读写分离、可容忍主从同步延迟的业务,使用原生
@Transactional即可满足主库事务要求; - 写入主库后需要立即读取最新强一致数据,标准方案是强制路由主库查询;
- 主从异步同步延迟是数据库层物理特性,任何事务注解都无法消除脏读问题,滥用多源事务只会徒增性能损耗与混乱。
简单总结:
@DSTransactional 适配多独立业务库多写场景,不解决主从同步延迟问题。
主从架构强一致性读,通过路由主库实现,而非依赖多源事务注解。
1.5 架构思考:多数据源与微服务拆分的取舍
从微服务设计角度来看:主从模式面向的是单一微服务、同一业务域,目的是通过读写分离实现性能扩容,属于单个微服务内部的合理技术优化,完全符合微服务数据自治、单一职责的原则;而多数据源面向的是多个独立数据库、多个业务域,按照严格的微服务架构原则,此类场景理应在微服务层面进行拆分,让每个微服务持有自己的专属数据库,通过服务间接口协同,而非在单个服务内部维护多套数据源。
但这并不代表“多数据源”方案没有存在价值。理想的架构干净纯粹,但现实工程落地中,往往受历史系统包袱、业务耦合度、拆分成本、团队能力、项目进度等因素限制,无法立刻实现完美的微服务拆分。多数据源方案之所以依然被广泛使用,正是因为它提供了一种折中能力:在不彻底重构微服务的前提下,实现库级隔离、读写分离、权限控制,同时为后续逐步拆分、架构演进预留空间。
需要明确区分主从与多数据源的本质差异:主从属于同一数据副本的读写分离,是单个服务内部的合理优化;而多独立业务库的多数据源,更多是过渡架构或特定场景(如内部管理平台、数据聚合场景)下的妥协选择,不应作为长期架构的最优解。
架构设计从来不是非黑即白的选择,也不是越“完美”越好。优秀的架构,不会盲目追求理想中的微服务形态而忽略现实约束,也不会放任系统耦合泛滥而不顾长期维护成本。懂得在理想与现实之间做权衡,在约束下选择最合适的方案,允许不完美但控制边界,接受折中但明确演进方向——这就是取舍的艺术,也是架构设计真正的核心魅力。
1.6 其他注意事项
- 数据源名称必须严格一致:配置文件中
master、slave的名称,必须与@DS注解内的值完全匹配,否则会导致数据源切换失败或抛出异常; - 查询类方法尽量避免添加事务:查询操作(尤其是从库查询)无需事务保障,添加
@Transactional会提前绑定数据源,可能导致@DS注解切换失效,同时引发不必要的锁竞争; - 避免同一方法内混用主从操作:单个方法应明确只操作主库或只操作从库,避免混用导致数据源混乱、数据一致性风险;
- 生产环境主从架构必须使用同构数据库:MySQL 主从、PostgreSQL 主从,依靠数据库层复制保证数据一致,异构库仅适用于演示多数据源切换逻辑。
2. 手动显式方案的局限性
上一节介绍的基于 @DS 手动指定数据源的方式,逻辑清晰、控制精准,能够满足简单场景下的主从读写分离需求。但在中大型项目与团队协作场景中,这种纯手动配置方式会暴露出较为明显的局限性。
2.1 代码侵入性较强
所有需要路由至从库的查询方法,都必须显式添加 @DS("slave") 注解;所有写入操作也需要明确对应主库数据源。在方法数量较多的项目中,这类注解会大量分散在业务代码中,一定程度上影响代码简洁性与业务语义的纯粹性。
2.2 人为遗漏风险较高
在团队协作开发过程中,开发人员可能因疏忽未为查询方法配置 @DS("slave"),导致查询请求直接落到主库,无法有效分担主库查询压力。这类遗漏问题逻辑隐蔽,在代码评审阶段也较难被系统性识别。
2.3 后期维护成本偏高
当业务方法重构、数据源名称变更或路由策略调整时,需要逐一修改各处 @DS 注解配置。若后续需要从手动路由切换为自动路由模式,也会带来较大范围的代码改造工作量。
2.4 事务环境下数据源切换失效
如前文事务原则所述,事务一旦开启便会与主库数据源进行绑定。若在事务方法内部调用标注 @DS("slave") 的查询方法,由于数据源已在事务启动时固定,后续的数据源切换逻辑将无法生效,查询依然会在主库上执行。
这种场景具有较强的隐蔽性,容易造成主库压力不可控等潜在问题。
为解决上述问题,避免手动管理带来的不确定性与维护负担,需要引入一套自动化数据源路由机制,基于统一规则实现智能主写从读,让开发人员专注于业务逻辑本身。
3. 自动路由思路对比:SQL 拦截 vs Service 层切面
在明确手动注解方式的局限性之后,实现一套自动化数据源路由规则就成为解决问题的关键。自动化路由的核心,在于通过一套统一的逻辑,无侵入地识别当前操作是读请求还是写请求,并自动路由至对应数据源。
基于 Spring 及常用持久层框架提供的能力,在设计上主要有两种典型思路:基于 SQL 语法拦截,以及基于 Service 层方法规则的切面路由。二者看似都能实现目标,但在稳定性、一致性和适用场景上差异巨大。
3.1 思路一:基于 SQL 语法自动识别
最直观的实现方式,是在 SQL 执行阶段进行拦截,通过解析 SQL 语句类型实现路由。例如识别到 SELECT 语句则路由至从库,INSERT/UPDATE/DELETE 则路由至主库。
这种方式看似简单直接、无需额外约定,但在实际生产环境中存在难以规避的缺陷:
1. 分页查询容易出现数据不一致
一个标准分页操作通常包含 count 统计与列表查询两条 SQL。即使两条 SQL 被路由到同一个从库,由于主从复制的异步特性,count 查询与列表查询可能基于不同同步进度的数据,导致分页结果中的总条数与实际列表数量不匹配,出现分页异常等业务问题。
2. 无法感知事务与锁查询
对于 FOR UPDATE 等加锁查询,以及事务内部的查询操作,根据主从架构原则必须强制走主库。但 SQL 拦截处于底层执行阶段,难以安全、稳定地感知上层事务状态与加锁语义。例如,在 @Transactional 方法内执行 select ... for update,SQL 拦截器无法判断当前是否处于事务中,可能错误地路由到从库,导致锁失效。
3. 难以支撑全局路由策略
实际主从架构往往是一主多从部署,需要支持从库负载均衡、健康检查、动态路由等能力。同时主库也并非完全禁止查询,部分实时性、关键性查询仍需走主库。这类全局策略依赖上层业务上下文,SQL 拦截层缺乏足够的决策视角。
综合来看,SQL 拦截虽然实现简单,但先天存在数据一致性与场景适配性缺陷,难以作为稳定可靠的生产级方案。
3.2 思路二:基于 Service 层方法名规则的切面路由
更为稳健的方式,是将路由决策上移至 Service 层,通过 Spring AOP 对业务方法进行切面拦截,依据统一的方法命名规则识别读写意图,从而实现自动主写从读。
该方案具备明显优势:
1. 可感知事务上下文
Service 层与事务切面处于同一层次,能够清晰判断当前是否处于事务中,从根源上避免事务内路由至从库导致的切换失效问题。
2. 语义稳定,不受 SQL 实现影响
方法名直接表达业务读写意图,不会因 SQL 拼接、动态条件、复杂查询等实现细节改变路由规则,稳定性更高。
3. 规则统一,易于团队协作
通过简单的命名约定即可实现全局路由,代码审查成本低、不易出错,也便于后续策略扩展。
4. 便于实现灵活的路由控制
可以轻松支持多从库负载均衡、强制主库标记、灰度路由等高级能力,整体可控性更强。
4. 动态数据源切换核心原理
框架在执行 SQL 时,数据源的选择是动态决定的,这正是动态数据源能力的技术基础。DynamicDataSourceContextHolder 负责管理当前线程所要使用的数据源名称,其内部实现基于 ThreadLocal 与栈结构,确保了线程安全与嵌套切换的能力。
4.1 DynamicDataSourceContextHolder 源码解析
核心机制说明:
public final class DynamicDataSourceContextHolder {
private static final ThreadLocal<Deque<String>> LOOKUP_KEY_HOLDER = new NamedThreadLocal<Deque<String>>("dynamic-datasource") {
protected Deque<String> initialValue() {
return new ArrayDeque();
}
};
private DynamicDataSourceContextHolder() {
}
public static String peek() {
return (String)((Deque)LOOKUP_KEY_HOLDER.get()).peek();
}
public static String push(String ds) {
String dataSourceStr = StringUtils.isEmpty(ds) ? "" : ds;
((Deque)LOOKUP_KEY_HOLDER.get()).push(dataSourceStr);
return dataSourceStr;
}
public static void poll() {
Deque<String> deque = (Deque)LOOKUP_KEY_HOLDER.get();
deque.poll();
if (deque.isEmpty()) {
LOOKUP_KEY_HOLDER.remove();
}
}
public static void clear() {
LOOKUP_KEY_HOLDER.remove();
}
}
LOOKUP_KEY_HOLDER是一个ThreadLocal变量,内部持有Deque<String>类型的双端队列。每个线程独立拥有自己的数据源名称栈,天然线程安全。push(String ds)方法将指定的数据源名称压入栈顶,后续的数据源获取操作将优先使用栈顶值。这为显式指定当前线程使用的数据源提供了编程入口。peek()方法返回栈顶元素但不移除,框架在获取当前数据源时会调用此方法。poll()方法弹出栈顶元素,用于方法执行结束后的资源清理,支持嵌套切换场景。clear()方法直接移除整个ThreadLocal,防止内存泄漏。
使用 push 方法可以显式指定当前线程所要使用的数据源,其本质是一个后进先出的栈结构,能够优雅地支持嵌套数据源切换场景。同时由于这种特性,才会导致@DS注解定义的数据源会覆盖原先设置的数据源。这显然是合理的设计,显式声明的,应当给予最高权限。
4.2 DynamicRoutingDataSource 中的调用逻辑
public DataSource determineDataSource() {
String dsKey = DynamicDataSourceContextHolder.peek();
return this.getDataSource(dsKey);
}
在 com.baomidou.dynamic.datasource.DynamicRoutingDataSource 中,框架每次获取数据库连接时,都会通过 determineDataSource() 方法从 DynamicDataSourceContextHolder 中取出当前线程栈顶的数据源名称,然后从已注册的数据源集合中返回对应的 DataSource 实例。这一设计将数据源路由逻辑与连接获取流程完全解耦,既保证了高效性,又提供了极大的扩展灵活性。
4.3 DynamicDataSourceAnnotationInterceptor 拦截器处理 DS 注解
@DS 注解的解析与数据源切换由 DynamicDataSourceAnnotationInterceptor 拦截器完成,其实现了 Spring AOP 的 MethodInterceptor 接口。
public class DynamicDataSourceAnnotationInterceptor implements MethodInterceptor {
private static final String DYNAMIC_PREFIX = "#";
private final DataSourceClassResolver dataSourceClassResolver;
private final DsProcessor dsProcessor;
public DynamicDataSourceAnnotationInterceptor(Boolean allowedPublicOnly, DsProcessor dsProcessor) {
this.dataSourceClassResolver = new DataSourceClassResolver(allowedPublicOnly);
this.dsProcessor = dsProcessor;
}
public Object invoke(MethodInvocation invocation) throws Throwable {
String dsKey = this.determineDatasourceKey(invocation);
DynamicDataSourceContextHolder.push(dsKey);
Object var3;
try {
var3 = invocation.proceed();
} finally {
DynamicDataSourceContextHolder.poll();
}
return var3;
}
private String determineDatasourceKey(MethodInvocation invocation) {
String key = this.dataSourceClassResolver.findKey(invocation.getMethod(), invocation.getThis());
return key.startsWith("#") ? this.dsProcessor.determineDatasource(invocation, key) : key;
}
}
构造函数中的 allowedPublicOnly 参数控制是否仅解析 public 方法的 @DS 注解,默认为 true。若将注解加在 private 或 protected 方法上,因 AOP 代理限制无法生效,框架通过此参数直接跳过解析。
invoke 方法的核心流程为:
- 通过
determineDatasourceKey解析出目标数据源名称; - 调用
DynamicDataSourceContextHolder.push将数据源名称压入栈顶; - 执行目标业务方法;
- 在
finally块中调用DynamicDataSourceContextHolder.poll弹出栈顶,清理上下文。
try-finally 结构确保无论业务方法正常返回还是抛出异常,poll 方法都会被调用,防止数据源名称残留污染线程上下文。
DataSourceClassResolver 负责实际的注解查找工作。
public class DataSourceClassResolver {
public String findKey(Method method, Object targetObject) {
if (method.getDeclaringClass() == Object.class) {
return "";
} else {
Object cacheKey = new MethodClassKey(method, targetObject.getClass());
String ds = (String)this.dsCache.get(cacheKey);
if (ds == null) {
ds = this.computeDatasource(method, targetObject);
if (ds == null) {
ds = "";
}
this.dsCache.put(cacheKey, ds);
}
return ds;
}
}
private String computeDatasource(Method method, Object targetObject) {
if (this.allowedPublicOnly && !Modifier.isPublic(method.getModifiers())) {
return null;
} else {
String dsAttr = this.findDataSourceAttribute(method);
if (dsAttr != null) {
return dsAttr;
} else {
Class<?> targetClass = targetObject.getClass();
Class<?> userClass = ClassUtils.getUserClass(targetClass);
Method specificMethod = ClassUtils.getMostSpecificMethod(method, userClass);
specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
dsAttr = this.findDataSourceAttribute(specificMethod);
if (dsAttr != null) {
return dsAttr;
} else {
dsAttr = this.findDataSourceAttribute(userClass);
if (dsAttr != null && ClassUtils.isUserLevelMethod(method)) {
return dsAttr;
} else {
Iterator var7 = ClassUtils.getAllInterfacesForClassAsSet(userClass).iterator();
do {
if (!var7.hasNext()) {
if (specificMethod != method) {
dsAttr = this.findDataSourceAttribute(method);
if (dsAttr != null) {
return dsAttr;
}
dsAttr = this.findDataSourceAttribute(method.getDeclaringClass());
if (dsAttr != null && ClassUtils.isUserLevelMethod(method)) {
return dsAttr;
}
}
return this.getDefaultDataSourceAttr(targetObject);
}
Class<?> interfaceClazz = (Class)var7.next();
dsAttr = this.findDataSourceAttribute(interfaceClazz);
} while(dsAttr == null);
return dsAttr;
}
}
}
}
}
private String findDataSourceAttribute(AnnotatedElement ae) {
AnnotationAttributes attributes = AnnotatedElementUtils.getMergedAnnotationAttributes(ae, DS.class);
return attributes != null ? attributes.getString("value") : null;
}
}
其 computeDatasource 方法体现了 @DS 注解的优先级规则:优先从当前方法获取,若无则向上查找桥接方法,继而查找声明类、接口类,最终回退到全局默认数据源。这与 1.3 节所述的“方法级 > 类级 > 默认”优先级完全一致。
可见,@DS 注解的数据源切换能力同样基于 DynamicDataSourceContextHolder.push 实现,与手动编程切换走的是同一套底层机制。
- 基于 AOP 切面实现自动主写从读
- 强制读取主库
浙公网安备 33010602011771号