去年接手一个老项目,数据源切换逻辑散落在 40 多个 Service 里。每个方法开头都是同一段话:
if ("slave".equals(DataSourceContextHolder.getDataSourceType())) {
// 从库逻辑
} else {
// 主库逻辑
}
我数了一下,同样的 if-else 出现了 47 次。读写分离搞成这样,不是架构问题,是连 Spring 给你铺好的桥接模式都没看懂。
AbstractRoutingDataSource 就是个桥接模式
Spring 的 AbstractRoutingDataSource 只有 100 多行代码,但它把桥接模式讲得比任何教科书都清楚:
public abstract class AbstractRoutingDataSource extends AbstractDataSource {
private Map<Object, DataSource> targetDataSources;
private DataSource defaultTargetDataSource;
@Override
public Connection getConnection() throws SQLException {
return determineTargetDataSource().getConnection();
}
protected DataSource determineTargetDataSource() {
Object lookupKey = determineCurrentLookupKey();
DataSource ds = this.targetDataSources.get(lookupKey);
return ds != null ? ds : this.defaultTargetDataSource;
}
protected abstract Object determineCurrentLookupKey();
}
桥接模式的精髓是「把抽象和实现拆成两个独立维度」。在这里,抽象维度是「选哪个数据源」——由 determineCurrentLookupKey() 决定;实现维度是「具体的数据源连接」——由 targetDataSources 这个 Map 存储。
你只需要继承这个类,重写一个方法:
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceType();
}
}
抽象层 3 行代码,实现层全在配置里。这就是桥接模式的威力——两个维度的变化不会互相污染。你加一个新从库,只改配置不改代码;你换一种路由策略,只改 determineCurrentLookupKey() 不改数据源定义。
那 47 个 if-else 到底错在哪
回到开头那段散落在 40 多个 Service 里的代码。它的问题是:
把桥接模式的「桥」拆了,直接在调用方写死选择逻辑。
桥接模式要求调用方只依赖抽象(DataSource 接口),不知道有「主库」「从库」这回事。但 if ("slave".equals(...)) 这种写法等于在 Service 层把实现维度暴露了——每次加一个数据源角色,所有 Service 都要改。
我见过最离谱的一个案例:团队加了一个「归档库」,结果在 83 个地方加了 else if ("archive".equals(...))。Code Review 的时候没人觉得有问题,因为「大家都在这么写」。
实际上,你以为你写的是业务代码,其实你每写一次这种 if-else,都在手动实现一个蹩脚的桥接模式——而且每次实现的都比上一次更乱。
读写分离不是唯一的桥接场景
多租户数据源隔离:
public class TenantAwareDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenant();
}
}
租户 A 的请求自动路由到数据源 A,租户 B 到数据源 B。新接入一个租户,只需要在配置里加一个数据源,代码零改动。
分库分表前的灰度切换:
public class ShardingGrayDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 灰度规则:userId 尾号 0-3 走新库,4-9 走老库
long userId = UserContext.getCurrentUserId();
return userId % 10 < 4 ? "new_" + (userId % 4) : "old";
}
}
桥接模式把「怎么选」和「选什么」解耦了。灰度策略再怎么变,数据源的定义不变;数据源再怎么加,灰度策略的逻辑不受影响。这就是两个独立变化维度的正交。
真有人把桥接模式写反了
桥接模式最容易踩的坑是把两个维度的耦合反过来写。
错误写法:
public class MasterDataSource implements DataSource {
private HikariDataSource delegate;
// 主库特化的实现
}
public class SlaveDataSource implements DataSource {
private HikariDataSource delegate;
// 从库特化的实现
}
// 然后在配置里写:
@Bean("masterDataSource")
public DataSource masterDataSource() { ... }
@Bean("slaveDataSource")
public DataSource slaveDataSource() { ... }
// Service 里:
@Autowired @Qualifier("masterDataSource")
private DataSource masterDataSource;
@Autowired @Qualifier("slaveDataSource")
private DataSource slaveDataSource;
这叫什么?这叫在 Bean 级别把实现硬编码了。每个 Service 要知道所有数据源的存在,选择逻辑又回到了调用方。桥接模式被你写成了「在抽象层重新定义实现细节」。
正确的姿势是:调用方只注入一个 DataSource,桥在框架层帮你做完选择。
@Autowired
private DataSource dataSource; // 就这一个,别写 @Qualifier
一个容易忽视的并发问题
AbstractRoutingDataSource 的 determineCurrentLookupKey() 通常依赖 ThreadLocal:
public class DataSourceContextHolder {
private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static void setDataSourceType(String type) { contextHolder.set(type); }
public static String getDataSourceType() { return contextHolder.get(); }
public static void clear() { contextHolder.remove(); }
}
如果你在异步方法里忘了传递 ThreadLocal(比如用了 @Async),子线程拿到的是 null,路由到默认数据源。读操作变成了写,写操作变成了读——这种 Bug 不会抛异常,测试环境可能三个月才发现。
@Async
public void sendNotification(Long orderId) {
// ThreadLocal 丢了!这里默认走到主库,你以为是读从库
Order order = orderService.getById(orderId);
}
解法:用 InheritableThreadLocal 或者 TransmittableThreadLocal(阿里的开源库),或者明确在异步方法入口设置数据源标识。
桥接模式和策略模式的区别
很多人分不清这两个。区别很简单:
- 策略模式是「算法族,让它们可以互相替换」——比如支付策略(微信/支付宝/银行卡),调用方主动选
- 桥接模式是「抽象和实现独立变化」——比如数据源路由,调用方不感知选择逻辑
AbstractRoutingDataSource 内部确实用了策略模式的思路(Map 里存多个实现,按 key 选),但整体结构是桥接模式——因为目的不是「替换算法」,而是「让抽象维度和实现维度独立演进」。
这点区别面试不会被问到,但实际做架构的时候很重要。该用桥接的地方用了策略,会导致调用方承担了本不该承担的选择责任。
关键代码骨架
最简洁的桥接模式多数据源配置:
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource dataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
HikariDataSource master = createDataSource("jdbc:mysql://master:3306/db");
HikariDataSource slave1 = createDataSource("jdbc:mysql://slave1:3306/db");
HikariDataSource slave2 = createDataSource("jdbc:mysql://slave2:3306/db");
targetDataSources.put("master", master);
targetDataSources.put("slave1", slave1);
targetDataSources.put("slave2", slave2);
DynamicDataSource routingDS = new DynamicDataSource();
routingDS.setDefaultTargetDataSource(master);
routingDS.setTargetDataSources(targetDataSources);
return routingDS;
}
// 读写分离切面:读方法走从库,写方法走主库
@Around("@annotation(com.example.ReadOnly)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
DataSourceContextHolder.setDataSourceType("slave1");
try {
return pjp.proceed();
} finally {
DataSourceContextHolder.clear();
}
}
}
整个读写分离骨架不到 50 行代码。桥接模式帮你把抽象做了,AbstractRoutingDataSource 帮你把实现做了,你只需要填配置。
我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,每个模式都用漫画还原真实踩坑场景。桥接模式这章我刚画完——卡皮巴拉在多数据源迷宫里找路。感兴趣的可以微信搜一下「爪爪代码冒险记」。