最近在项目里遇到一个比较典型的多数据源问题:代码里明明给 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_assetdb2:借款业务库,比如invest_target_info、sys_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);
后面就不会进入同步分支,也不会查 db2 的 invest_target_info 和 sys_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ứ ");
如果 config 为 null,直接 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 消费卡死。
最佳实践总结
多数据源项目里,建议遵守几个原则:
- 不要在一个事务里混查多个库。
@DS尽量加在 Service 方法上,而不是只加 Mapper。- 跨库查询拆成独立 Service。
- 外层已有事务时,跨库查询使用
Propagation.REQUIRES_NEW。 - 只读查询加
readOnly = true。 - MQ 消费不要吞异常,否则业务失败但消息被确认,会留下脏数据。
- 配置类数据读取要有默认值兜底,避免配置缺失导致核心流程失败。
结论
这个问题看起来像是"表不存在"或者"配置为空",但真正原因是:
外层 db1 事务已经绑定连接,导致后续 db2 Mapper 的动态数据源切换没有生效。
解决方式不是简单给 Mapper 加 @DS("db2"),而是把 db2 查询下沉到独立 Service,并使用:
less
@DS("db2")
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
这样才能保证在复杂事务链路、MQ 消费、定时任务中,数据源切换稳定可靠。