Spring Boot 动态数据源在事务中切库失效问题排查

最近在项目里遇到一个比较典型的多数据源问题:代码里明明给 Mapper 标了 @DS("db2"),但是运行时 SQL 还是打到了主库 db1,最终报错:

rust 复制代码
Table 'vnfinance.invest_target_info' doesn't exist

或者某些配置查询返回 null,导致后续空指针:

scss 复制代码
java.lang.NullPointerException
at InvestAssetServiceImpl.syncInvestAsset(...)

这个问题本质不是表不存在,也不是数据丢了,而是事务中动态数据源切换失效

背景

项目使用了动态数据源:

yaml 复制代码
spring:
  datasource:
    dynamic:
      primary: db1
      datasource:
        db1:
          url: jdbc:mysql://.../vnfinance
        db2:
          url: jdbc:mysql://.../vncashloan

其中:

  • db1:投资业务库,比如 invest_asset
  • db2:借款业务库,比如 invest_target_infosys_config

Mapper 上也加了数据源注解:

less 复制代码
@DS("db2")
public interface LoanerMapper {
    InvestAssetPOJO getInvest(@Param("investId") Long investId);
}
kotlin 复制代码
@DS("db2")
public interface SysConfigMapper extends BaseMapper<SysConfig> {
}

看起来应该没问题,但实际在某些调用链里还是查到了 db1

问题代码

原来的逻辑大概是这样:

ini 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public InvestAsset syncInvestAsset(Long investId) {
    InvestAsset investAsset = baseMapper.getByInvestId(investId);

    if (investAsset == null || investAsset.getInvestId() == null) {
        InvestAssetPOJO invest = loanerMapper.getInvest(investId);

        SysConfig config = sysConfigService.getByConfigKey(INVEST_ASSET_TITLE_PREFIX);

        String titlePrefix = config.getConfigValue("Đầu tư linh hoạt: Kỳ thứ ");
        // ...
    }

    return investAsset;
}

这段代码的问题在于:

scss 复制代码
baseMapper.getByInvestId(investId)

是查主库 db1 的。

而整个方法又加了:

python 复制代码
@Transactional(rollbackFor = Exception.class)

所以方法一开始进入事务后,第一次数据库访问就拿到了 db1 的连接,并绑定到当前线程。

后面再执行:

scss 复制代码
loanerMapper.getInvest(investId)
sysConfigService.getByConfigKey(...)

虽然 Mapper 上标了 @DS("db2"),但是当前线程已经在同一个事务里绑定了 db1 连接,动态数据源就切不过去了。

于是本来应该查:

复制代码
vncashloan.invest_target_info

结果查成了:

复制代码
vnfinance.invest_target_info

最终报错:

rust 复制代码
Table 'vnfinance.invest_target_info' doesn't exist

为什么之前没问题

这个问题很容易让人疑惑:之前数据一直正常,为什么现在突然出问题?

原因通常不是"之前真的没问题",而是之前没有触发这个隐患。

常见情况有几种。

第一,之前查询入口没有事务。

比如列表页直接调用:

scss 复制代码
loanerMapper.queryAllInvestAsset(...)

外面没有先开启 db1 事务,@DS("db2") 可以正常生效。

第二,资产包已经同步过。

如果 invest_asset 里已经有数据:

ini 复制代码
InvestAsset investAsset = baseMapper.getByInvestId(investId);

后面就不会进入同步分支,也不会查 db2invest_target_infosys_config

第三,旧代码吞掉了异常。

有些 MQ 消费代码原来是这样:

php 复制代码
try {
    // 业务处理
} catch (Exception e) {
    log.error("change invest asset status fail:", e);
}

异常只打印,不继续抛出。RocketMQ 可能认为消费成功,问题不会持续重试暴露出来。

后来改成异常继续抛出后,MQ 会不断重试,问题就变明显了。

核心原因

一句话总结:

动态数据源切换依赖当前线程上下文,但 Spring 事务一旦开启并拿到连接,连接会绑定到当前线程。后续同一个事务里再切换数据源,可能不会生效。

尤其是这种代码非常危险:

typescript 复制代码
@Transactional
public void business() {
    db1Mapper.selectById(id);   // 先绑定 db1

    db2Mapper.selectById(id);   // 期望切 db2,但可能仍然走 db1
}

Mapper 上的 @DS("db2") 并不能保证在已经存在事务连接的情况下强制切库。

解决办法

解决思路是:跨库查询不要直接混在同一个事务里,要把 db2 查询拆到独立 Service,并开启新的只读事务。

1. 新增 db2 查询 Service

kotlin 复制代码
@Service
@DS("db2")
public class LoanerDb2Service {

    private final LoanerMapper loanerMapper;

    public LoanerDb2Service(LoanerMapper loanerMapper) {
        this.loanerMapper = loanerMapper;
    }

    /**
     * 从借款库查询资产包基础信息。
     * 外层可能已有 db1 事务,这里单独开启 db2 只读事务,避免查到主库。
     */
    @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
    public InvestAssetPOJO getInvest(Long investId) {
        return loanerMapper.getInvest(investId);
    }

    /**
     * 从借款库查询资产包关联的借款人信息。
     */
    @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
    public List<InvestLoanerPOJO> findByInvestIdFromLoan(Long investId) {
        return loanerMapper.findByInvestIdFromLoan(investId);
    }
}

重点是这两个注解:

less 复制代码
@DS("db2")
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)

REQUIRES_NEW 会挂起外层事务,重新开启一个新的事务,从而重新获取 db2 连接。

2. SysConfigService 也要处理

配置表如果也在 db2,同样不能只依赖 Mapper 上的 @DS("db2")

scala 复制代码
@Service
@DS("db2")
public class SysConfigServiceImpl extends ServiceImpl<SysConfigMapper, SysConfig>
        implements SysConfigService {

    private final SysConfigMapper sysConfigMapper;

    public SysConfigServiceImpl(SysConfigMapper sysConfigMapper) {
        this.sysConfigMapper = sysConfigMapper;
    }

    @Override
    @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
    public SysConfig getByConfigKey(String configKey) {
        LambdaQueryWrapper<SysConfig> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysConfig::getConfigKey, configKey);
        return sysConfigMapper.selectOne(wrapper);
    }
}

3. 业务 Service 不再直接调 db2 Mapper

原来:

ini 复制代码
InvestAssetPOJO invest = loanerMapper.getInvest(investId);

改成:

ini 复制代码
InvestAssetPOJO invest = loanerDb2Service.getInvest(investId);

原来:

ini 复制代码
List<InvestLoanerPOJO> loaners = loanerMapper.findByInvestIdFromLoan(investId);

改成:

ini 复制代码
List<InvestLoanerPOJO> loaners = loanerDb2Service.findByInvestIdFromLoan(investId);

这样即使当前方法在 db1 事务中,db2 查询也会进入独立事务,不会被外层连接污染。

顺手补充:配置读取要兜底

这次还暴露了另一个问题:配置缺失时直接空指针。

原代码:

ini 复制代码
SysConfig config = sysConfigService.getByConfigKey(INVEST_ASSET_TITLE_PREFIX);
String value = config.getConfigValue("Đầu tư linh hoạt: Kỳ thứ ");

如果 confignull,直接 NPE。

建议封装:

arduino 复制代码
private String getConfigValue(String configKey, String defaultValue) {
    SysConfig config = sysConfigService.getByConfigKey(configKey);
    return config == null ? defaultValue : config.getConfigValue(defaultValue);
}

使用:

ini 复制代码
String titlePrefix = getConfigValue(
    INVEST_ASSET_TITLE_PREFIX,
    "Đầu tư linh hoạt: Kỳ thứ "
);

这样即使配置缺失,也不会把 MQ 消费卡死。

最佳实践总结

多数据源项目里,建议遵守几个原则:

  1. 不要在一个事务里混查多个库。
  2. @DS 尽量加在 Service 方法上,而不是只加 Mapper。
  3. 跨库查询拆成独立 Service。
  4. 外层已有事务时,跨库查询使用 Propagation.REQUIRES_NEW
  5. 只读查询加 readOnly = true
  6. MQ 消费不要吞异常,否则业务失败但消息被确认,会留下脏数据。
  7. 配置类数据读取要有默认值兜底,避免配置缺失导致核心流程失败。

结论

这个问题看起来像是"表不存在"或者"配置为空",但真正原因是:

外层 db1 事务已经绑定连接,导致后续 db2 Mapper 的动态数据源切换没有生效。

解决方式不是简单给 Mapper 加 @DS("db2"),而是把 db2 查询下沉到独立 Service,并使用:

less 复制代码
@DS("db2")
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)

这样才能保证在复杂事务链路、MQ 消费、定时任务中,数据源切换稳定可靠。

相关推荐
_遥远的救世主_6 小时前
稳定性工程:SLO 量化、降级收敛与故障兜底体系
后端
_遥远的救世主_6 小时前
多区域架构:边缘节点、核心节点与跨区域写冲突
后端
ServBay6 小时前
你跟高级 C# 工程师的区别,就是这8个开发技巧
后端·c#·.net
卷无止境6 小时前
Python CLI 应用开发最佳实践全面指南
后端
_遥远的救世主_6 小时前
租户架构与资源治理:隔离模型选择、Noisy Neighbor 治理与成本边界
后端
用户9000434815316 小时前
Python并发编程:多线程与多进程的实战指南
后端
fliter6 小时前
用 Builder Pattern 改造 Ping:让 Rust FFI 代码更干净
后端
geovindu6 小时前
go: Generators Pattern
开发语言·后端·设计模式·golang·生成器模式
程序猿阿越7 小时前
AutoMQ源码(一)读、写、Compaction
java·后端·源码