为什么需要读写分离
数据库的读操作通常远多于写操作(典型比例 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 Context | reactive.TransactionSynchronizationManager / deferContextual | 中 | 纯响应式,但隐式传递 |
| Repository 显式 | masterDb / slaveDb | 低 | eventd v2 选择,直观可控 |
| 问题 | 解决 |
|---|---|
| 主从延迟 | INSERT + RETURNING 走主库;读操作接受短暂不一致 |
| 从库故障 | 健康检查发现延迟 → 标记 DOWN → 读切主库 |
| 连接池 | 主从独立配置,从库池更大 |
下一节 9.2 缓存策略 :Caffeine Async、Redis Reactive、cache-aside 与 write-through 模式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。