9.1 多数据源与读写分离

R2DBC 多数据源配置:读写分离的 AbstractRoutingConnectionFactory 实现、主从路由策略、TransactionSynchronization 与响应式事务的兼容性限制,以及多数据源的连接池隔离与健康检查。

为什么需要读写分离

数据库的读操作通常远多于写操作(典型比例 10:1 甚至更高)。把所有流量打到主库会导致:

  • 主库 CPU/IO 饱和
  • 写操作被读查询阻塞
  • 无法水平扩展

读写分离把写操作路由到主库(Master),读操作路由到从库(Slave/Replica):

写请求 → 主库(PostgreSQL Primary)
         ↓ 流复制
读请求 → 从库 1(Hot Standby)
读请求 → 从库 2(Hot Standby)

PostgreSQL 的流复制(Streaming Replication)把主库的 WAL 日志传给从库,从库以只读模式运行。默认是异步复制:主库提交后不等从库确认,所以从库天然有延迟;只有配置 synchronous_standby_names 并等待从库确认,才叫同步复制,代价是写延迟上升。

多数据源配置

Spring Boot 的 spring.r2dbc.* 只自动配置一个 ConnectionFactory,没有 spring.r2dbc.datasources.* 这种多数据源列表。多数据源要自己声明 Bean:

# application.yml
eventd:
  r2dbc:
    master-url: r2dbc:postgresql://db-master:5432/eventd
    slave-url: r2dbc:postgresql://db-slave:5432/eventd
    username: eventd
    password: eventd
import org.springframework.boot.r2dbc.ConnectionFactoryBuilder;
import io.r2dbc.spi.ConnectionFactory;

@Configuration
public class MultiDataSourceConfig {

    @Bean("masterConnectionFactory")
    public ConnectionFactory masterConnectionFactory(
            @Value("${eventd.r2dbc.master-url}") String url,
            @Value("${eventd.r2dbc.username}") String username,
            @Value("${eventd.r2dbc.password}") String password) {
        return ConnectionFactoryBuilder.withUrl(url)
            .username(username)
            .password(password)
            .build();
    }

    @Bean("slaveConnectionFactory")
    public ConnectionFactory slaveConnectionFactory(
            @Value("${eventd.r2dbc.slave-url}") String url,
            @Value("${eventd.r2dbc.username}") String username,
            @Value("${eventd.r2dbc.password}") String password) {
        return ConnectionFactoryBuilder.withUrl(url)
            .username(username)
            .password(password)
            .build();
    }
}

@ConfigurationProperties 不能直接绑到 ConnectionFactory 上:它没有对应的 setter,绑定不会生效;要么用上面这种显式构造,要么写一个 @ConfigurationProperties 的配置类再手动转换。

AbstractRoutingConnectionFactory

Spring 提供 AbstractRoutingConnectionFactory 实现动态路由:

import org.springframework.r2dbc.connection.lookup.AbstractRoutingConnectionFactory;
import org.springframework.transaction.reactive.TransactionSynchronizationManager;
import reactor.core.publisher.Mono;

public class RoutingConnectionFactory extends AbstractRoutingConnectionFactory {

    @Override
    protected Mono<Object> determineCurrentLookupKey() {
        return TransactionSynchronizationManager.forCurrentTransaction()
            .map(tx -> tx.isActualTransactionActive() ? "master" : "slave")
            .switchIfEmpty(Mono.just("slave"));  // 无事务,读从库
    }
}

这里用的是 org.springframework.transaction.reactive 包下的版本,它从 Reactor Context 读取事务状态,不是 ThreadLocal;forCurrentTransaction() 返回 Mono<TransactionSynchronizationManager>,拿不到事务时是空 Mono。同名类在 org.springframework.transaction.support 包下才是 ThreadLocal 版,那两个不要混用。

这种「有事务就走主库」的规则只解决了写事务:只读事务也会被路由到主库,想区分需要额外的提示(例如事务定义里的 name 或 Context 标记)。

响应式环境下的路由策略

由于 ThreadLocal 不可用,需要手动传递路由意图:

方案 A:Reactor Context

public class DatabaseContext {
    private static final String KEY = "db.route";

    public static Mono<String> getRoute() {
        return Mono.deferContextual(ctx ->
            Mono.justOrEmpty(ctx.getOrDefault(KEY, "slave")));
    }

    public static <T> Mono<T> withMaster(Mono<T> mono) {
        return mono.contextWrite(Context.of(KEY, "master"));
    }
}

使用:

// 写操作:显式标记路由到主库
public Mono<Order> createOrder(Order order) {
    return DatabaseContext.withMaster(
        orderRepo.save(order)
    );
}

// 读操作:默认从库
public Mono<Order> findOrder(Long id) {
    return orderRepo.findById(id);  // route = slave
}

自定义路由工厂:

public class ContextRoutingConnectionFactory extends AbstractRoutingConnectionFactory {
    @Override
    protected Mono<Object> determineCurrentLookupKey() {
        return DatabaseContext.getRoute();
    }
}

方案 B:Repository 层显式路由

更简单的做法是在 Repository 中直接区分主从:

@Repository
public class OrderRepository {
    private final DatabaseClient masterDb;
    private final DatabaseClient slaveDb;

    public OrderRepository(
            @Qualifier("masterConnectionFactory") ConnectionFactory master,
            @Qualifier("slaveConnectionFactory") ConnectionFactory slave) {
        this.masterDb = DatabaseClient.create(master);
        this.slaveDb = DatabaseClient.create(slave);
    }

    // 写操作 → 主库
    public Mono<Long> insert(Order order) {
        return masterDb.sql("INSERT INTO orders ...").fetch().rowsUpdated();
    }

    // 读操作 → 从库
    public Mono<Order> findById(Long id) {
        return slaveDb.sql("SELECT * FROM orders WHERE id = $1")
            .bind("$1", id)
            .map((row, meta) -> new Order(...))
            .one();
    }
}

这个方案不涉及动态路由,代码直观,但 Repository 层需要感知主从结构。

eventd v2 采用方案 B:显式 masterDb / slaveDb。因为:

  • 读写操作在代码中已经清晰分离(Repository 方法级别)
  • 避免了 Reactor Context 的隐式传递,降低心智负担
  • 方便单元测试(注入 mock 的 DatabaseClient)

从库延迟

主从复制有延迟(通常毫秒级,但可能秒级甚至分钟级)。如果写入后立即读取:

orderRepo.insert(order)    // 写入主库
    .then(orderRepo.findById(order.id()))  // 从从库读取
    .subscribe();  // 可能得到空 Mono

解决:写入后需要强一致读时,强制走主库:

public Mono<Order> createAndGet(Order order) {
    return masterDb.sql("INSERT INTO orders ... RETURNING *")
        .map((row, meta) -> mapOrder(row))
        .one();  // INSERT + RETURNING 走主库,立即返回
}

或者引入**读取偏好(read preference)**策略:

public Mono<Order> findById(Long id, boolean forceMaster) {
    DatabaseClient db = forceMaster ? masterDb : slaveDb;
    return db.sql("SELECT * FROM orders WHERE id = $1")
        .bind("$1", id)
        .map(this::mapOrder)
        .one();
}

连接池隔离

主从各自独立连接池:

@Bean("masterPool")
public ConnectionPool masterPool(@Qualifier("masterConnectionFactory") ConnectionFactory cf) {
    return new ConnectionPool(ConnectionPoolConfiguration.builder(cf)
        .maxSize(20)   // 主库写操作少,池可小
        .build());
}

@Bean("slavePool")
public ConnectionPool slavePool(@Qualifier("slaveConnectionFactory") ConnectionFactory cf) {
    return new ConnectionPool(ConnectionPoolConfiguration.builder(cf)
        .maxSize(50)   // 从库读操作多,池更大
        .build());
}

健康检查

多数据源需要多个健康检查:

@Component
public class MasterHealthIndicator implements ReactiveHealthIndicator {
    private final ConnectionPool masterPool;
    // ... 检查主库连通性
}

@Component
public class SlaveHealthIndicator implements ReactiveHealthIndicator {
    private final ConnectionPool slavePool;
    // ... 检查从库连通性 + 复制延迟
}

从库额外检查复制延迟:

-- PostgreSQL 检查复制延迟
SELECT
    CASE
        WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
        THEN 0
        ELSE EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))
    END AS lag_seconds;

可以把延迟阈值(例如 5 秒)接入健康检查,但「自动切主库」不会因为健康状态变化而自己发生:方案 B 的 masterDb / slaveDb 是显式注入的,必须在查询层判断健康状态或复制延迟后再选 DatabaseClient。如果希望自动切换,就需要把路由集中到 AbstractRoutingConnectionFactory 或自定义选择器,并在那里读取延迟指标。

小结

方案路由方式复杂度适用场景
ThreadLocal(MVC)support.TransactionSynchronizationManager低只适用于阻塞代码
Reactor Contextreactive.TransactionSynchronizationManager / deferContextual中纯响应式,但隐式传递
Repository 显式masterDb / slaveDb低eventd v2 选择,直观可控
问题解决
主从延迟INSERT + RETURNING 走主库;读操作接受短暂不一致
从库故障健康检查发现延迟 → 标记 DOWN → 读切主库
连接池主从独立配置,从库池更大

下一节 9.2 缓存策略 :Caffeine Async、Redis Reactive、cache-aside 与 write-through 模式。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南