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

去年接手一个老项目,数据源切换逻辑散落在 40 多个 Service 里。每个方法开头都是同一段话:

java 复制代码
if ("slave".equals(DataSourceContextHolder.getDataSourceType())) {
    // 从库逻辑
} else {
    // 主库逻辑  
}

我数了一下,同样的 if-else 出现了 47 次。读写分离搞成这样,不是架构问题,是连 Spring 给你铺好的桥接模式都没看懂。

AbstractRoutingDataSource 就是个桥接模式

Spring 的 AbstractRoutingDataSource 只有 100 多行代码,但它把桥接模式讲得比任何教科书都清楚:

java 复制代码
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 存储。

你只需要继承这个类,重写一个方法:

java 复制代码
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,都在手动实现一个蹩脚的桥接模式------而且每次实现的都比上一次更乱。

读写分离不是唯一的桥接场景

多租户数据源隔离:

java 复制代码
public class TenantAwareDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();
    }
}

租户 A 的请求自动路由到数据源 A,租户 B 到数据源 B。新接入一个租户,只需要在配置里加一个数据源,代码零改动。

分库分表前的灰度切换:

java 复制代码
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";
    }
}

桥接模式把「怎么选」和「选什么」解耦了。灰度策略再怎么变,数据源的定义不变;数据源再怎么加,灰度策略的逻辑不受影响。这就是两个独立变化维度的正交。

真有人把桥接模式写反了

桥接模式最容易踩的坑是把两个维度的耦合反过来写

错误写法:

java 复制代码
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,桥在框架层帮你做完选择

java 复制代码
@Autowired
private DataSource dataSource; // 就这一个,别写 @Qualifier

一个容易忽视的并发问题

AbstractRoutingDataSourcedetermineCurrentLookupKey() 通常依赖 ThreadLocal:

java 复制代码
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 不会抛异常,测试环境可能三个月才发现。

java 复制代码
@Async
public void sendNotification(Long orderId) {
    // ThreadLocal 丢了!这里默认走到主库,你以为是读从库
    Order order = orderService.getById(orderId);  
}

解法:用 InheritableThreadLocal 或者 TransmittableThreadLocal(阿里的开源库),或者明确在异步方法入口设置数据源标识。

桥接模式和策略模式的区别

很多人分不清这两个。区别很简单:

  • 策略模式是「算法族,让它们可以互相替换」------比如支付策略(微信/支付宝/银行卡),调用方主动选
  • 桥接模式是「抽象和实现独立变化」------比如数据源路由,调用方不感知选择逻辑

AbstractRoutingDataSource 内部确实用了策略模式的思路(Map 里存多个实现,按 key 选),但整体结构是桥接模式------因为目的不是「替换算法」,而是「让抽象维度和实现维度独立演进」。

这点区别面试不会被问到,但实际做架构的时候很重要。该用桥接的地方用了策略,会导致调用方承担了本不该承担的选择责任。

关键代码骨架

最简洁的桥接模式多数据源配置:

java 复制代码
@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 帮你把实现做了,你只需要填配置。


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

相关推荐
谭光志38 分钟前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..42 分钟前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects1 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰1 小时前
一些基础和问题解决
后端
陈随易1 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN1 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp
Imchendiana1 小时前
《狂人日记NO.9》— 前后端一把梭,我的全栈实录
前端·后端
学渣超1 小时前
记一次分布式事务数据不一致的排查之旅:从超时到索引,层层剥茧
java·后端·架构
北冥you鱼2 小时前
Go 语言读取链上数据:从基础到实战
开发语言·后端·golang
程序员cxuan2 小时前
Claude Code 为了封禁中国用户,竟然在代码里下毒
人工智能·后端·程序员