多数据源那套代码你写了三遍——你其实一直在手动实现桥接模式

0 阅读1分钟

去年接手一个老项目,数据源切换逻辑散落在 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

一个容易忽视的并发问题

AbstractRoutingDataSourcedetermineCurrentLookupKey() 通常依赖 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 帮你把实现做了,你只需要填配置。


我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,每个模式都用漫画还原真实踩坑场景。桥接模式这章我刚画完——卡皮巴拉在多数据源迷宫里找路。感兴趣的可以微信搜一下「爪爪代码冒险记」。