《Spring Boot 实战》11.2 读写分离

读写分离能提升读吞吐,但前提是单库已经优化到位。本节用 AbstractRoutingDataSource 实现数据源路由,靠 @Transactional(readOnly = true) 决定读写走向,解释事务内切换数据源为何无效,并给出主从延迟下「写完读不到」的三种处理与从库故障降级。

本节目标:掌握 AbstractRoutingDataSource 的路由实现与 readOnly 事务路由,理解事务内切换数据源为何无效,并为主从延迟选出一套可落地的处理策略。
适用版本:Spring Boot 4.1.x(Java 21)

11.2 读写分离

11.1 把单库的连接池调稳了。当读流量继续增长、单库 CPU 被读查询打满时,下一步常见的动作是加从库、做读写分离。但读写分离不是一个「加上就更快」的开关——它引入复制延迟、路由复杂度、事务语义变化和降级问题。本节先讲清楚什么时候才该做,再给出完整的路由实现和延迟处理方案。

11.2.1 为什么先优化单库再上读写分离

读写分离解决的只是「读流量超过单库承载」这一个问题。在它之前,还有一串成本更低、收益更高的手段:

手段解决的瓶颈复杂度代价
加索引慢查询扫描全表低
查询优化(第 6 章)N+1、内存分页、投影低
加缓存(第 8 章)重复读、热点读中
连接池调优(11.1)连接等待、连接数冲击低
读写分离单库读吞吐打满高
分库分表(11.3)单表数据量、写入吞吐极高

判断要不要上读写分离,看两个条件是否同时成立:

  1. 单库的读查询已经占用了主要 CPU,且索引、查询、缓存都已经做过一轮;
  2. 读远多于写(典型 8:2 或 9:1)。如果写占大头,加从库不解决问题——复制本身也要消耗主库资源。

如果这两条不成立,读写分离带来的只有复杂度。一个真实的 book-loan 例子:列表页慢,根因是 loan 表缺 (member_id, status) 联合索引,加了索引之后 P99 从几百毫秒掉到个位数毫秒,根本不需要从库。先证明单库优化已经到顶,再谈分离。

11.2.2 AbstractRoutingDataSource 的原理

Spring 提供了一个专门用于多数据源路由的抽象类:org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource。它的机制很简单——把多个真实 DataSource 装进一个 Map,每次被要求 getConnection() 时,回调一个方法问「这次用哪个 key」,然后返回对应数据源的连接。

public class ReplicaAwareRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContext.isPrimaryForced() ? "primary" : "replica";
    }
}

配置类里把主库、从库两个 HikariDataSource 注册进去:

@Configuration
public class RoutingDataSourceConfig {

    @Bean
    @ConfigurationProperties("app.datasource.primary")
    public HikariDataSource primaryDataSource() {
        return new HikariDataSource();
    }

    @Bean
    @ConfigurationProperties("app.datasource.replica")
    public HikariDataSource replicaDataSource() {
        return new HikariDataSource();
    }

    @Bean
    public DataSource dataSource(HikariDataSource primaryDataSource,
                                 HikariDataSource replicaDataSource) {
        ReplicaAwareRoutingDataSource routing = new ReplicaAwareRoutingDataSource();
        routing.setDefaultTargetDataSource(primaryDataSource);
        routing.setTargetDataSources(Map.of(
                "primary", primaryDataSource,
                "replica", replicaDataSource));
        routing.afterPropertiesSet();
        return routing;
    }
}

setDefaultTargetDataSource 是关键:当 determineCurrentLookupKey() 返回的 key 在 Map 里找不到时,会退回默认数据源。把默认设成主库,这样任何路由异常都会退回主库(可能更慢,但不会读到脏数据或直接失败)。

对应的配置:

app:
  datasource:
    primary:
      jdbc-url: jdbc:postgresql://db-primary:5432/bookloan
      username: bookloan
      password: ${DB_PRIMARY_PASSWORD}
      maximum-pool-size: 20
    replica:
      jdbc-url: jdbc:postgresql://db-replica:5432/bookloan
      username: bookloan_ro
      password: ${DB_REPLICA_PASSWORD}
      maximum-pool-size: 40

注意这里用的是 jdbc-url 而不是 url。HikariDataSource 的 setter 是 setJdbcUrl,所以手工绑定 HikariDataSource 时字段名是 jdbc-url;而 Spring Boot 自动配置的 spring.datasource.url 是它自己的属性,会自动映射过去。两套名字混用是配多数据源时的高频错误。

从库可以配更大的池,因为它只承担读,通常可以承受更多并发(但同样要受数据库连接上限约束,见 11.1.7)。

11.2.3 基于 readOnly 事务的路由

最自然的读写判断依据是 Spring 的事务只读标志。@Transactional(readOnly = true) 的方法进入时,Spring 会把「当前事务是只读」写进 TransactionSynchronizationManager,而路由数据源在借连接时正好能读到这个标志:

public class ReplicaAwareRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        if (DataSourceContext.isPrimaryForced()) {
            return "primary";
        }
        if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
            return "replica";
        }
        return "primary";
    }
}

于是业务代码只需要照常标注:

@Service
public class LoanQueryService {

    private final LoanRepository loanRepository;

    public LoanQueryService(LoanRepository loanRepository) {
        this.loanRepository = loanRepository;
    }

    // 只读查询,自动路由到从库
    @Transactional(readOnly = true)
    public List<Loan> listByMember(Long memberId) {
        return loanRepository.findByMemberId(memberId);
    }

    // 写操作,走主库
    @Transactional
    public Loan borrow(Long bookId, Long memberId) {
        // ...
    }
}

这里的时序很重要,也是整个方案能成立的前提:@Transactional 的拦截器在方法体执行之前就开启了事务、向数据源借连接,而 readOnly 标志在开启事务时就已经设置好了。所以 determineCurrentLookupKey() 被调用时能正确读到它。换句话说,路由发生在事务开始时,方法体里的代码改不了它。

11.2.4 事务内切换数据源为何无效

这是读写分离最容易踩的一个认知陷阱。看下面这段「想当然」的代码:

@Transactional
public void wrongSwitch(Long loanId) {
    // 事务已开启,连接已从主库借出并绑定到当前线程
    loanRepository.updateStatus(loanId, LoanStatus.RETURNED);

    DataSourceContext.setReplica(); // 想切换到从库
    loanRepository.findById(loanId); // 仍然走主库,切换无效
}

为什么无效?因为连接在事务开始时就已经借好并绑定到了线程。Spring 的事务同步管理器用 ConnectionHolder 把连接挂在当前线程上,事务期间所有 JDBC 操作复用的都是这同一条连接。determineCurrentLookupKey() 只在 getConnection() 时被调用一次,之后无论 ThreadLocal 怎么改,都不会再触发路由。

结论:数据源路由的粒度是整个事务,不是单条 SQL。 一个事务里要么全走主库,要么全走从库,不可能一半一半。想「同一事务内读写分库」,那是分布式事务要解决的问题(第 12 章会讲),代价完全不同。

这条性质还带来一个连带约束:如果事务被误标成 readOnly = true 却又执行了写操作,连接会被路由到从库,而从库通常是只读账号,直接报权限或只读错误。所以 readOnly 的标注必须和实际行为一致,不能随手加。

11.2.5 主从延迟:写完立刻读不到

复制是异步的。MySQL 默认异步复制(半同步需显式开启),PostgreSQL 的流复制默认也是异步的(synchronous_commit 可调,但同步提交会拖慢主库写入)。从库收到 binlog/WAL 并重放需要时间,这段时间就是复制延迟。

延迟的来源有:网络往返、从库重放速度(MySQL 早期版本重放是单线程)、主库上的大事务(一次提交几万行,从库要重放很久)。正常情况延迟在毫秒级,但大事务或从库压力大时会飙到秒级甚至分钟级。

对 book-loan 的影响很具体:会员借书成功后,页面跳转到「我的借阅」列表。借阅写入了主库,列表却从从库读——如果复制延迟还没追上,列表里看不到刚借的那本书,用户以为借阅失败,又点一次,造成重复借阅或幂等冲突(第 5 章讲过幂等)。这是读写分离上线后最常见的用户可见事故。

11.2.6 三种处理策略

「写完读不到」没法用技术彻底消除(除非放弃异步复制,代价太大),只能按业务重要性选择处理方式。三种策略各有适用面:

策略做法适用代价
强制走主库关键读显式指定走主写完立刻要读的流程主库读压力增加
会话粘滞写后一段时间内该会话的读都走主用户自己的操作回显需要会话状态,窗口难定
业务容忍非关键读允许短暂不一致列表、统计、报表用户可能看到旧数据

策略一:强制走主库。 给需要 read-your-writes 的方法加一个显式标记,绕过 readOnly 路由。用一个注解加 ThreadLocal 提示实现:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PrimaryRead {
}
public final class DataSourceContext {

    private static final ThreadLocal<Boolean> PRIMARY = ThreadLocal.withInitial(() -> false);

    public static void forcePrimary() {
        PRIMARY.set(true);
    }

    public static boolean isPrimaryForced() {
        return PRIMARY.get();
    }

    public static void clear() {
        PRIMARY.remove();
    }

    private DataSourceContext() {
    }
}

用 AOP 切面在方法前后设置与清理:

@Aspect
@Component
public class PrimaryReadAspect {

    @Around("@annotation(PrimaryRead)")
    public Object forcePrimary(ProceedingJoinPoint pjp) throws Throwable {
        DataSourceContext.forcePrimary();
        try {
            return pjp.proceed();
        } finally {
            DataSourceContext.clear();
        }
    }
}

AOP 需要 spring-boot-starter-aspectj(4.x 中 spring-boot-starter-aop 的替代名)。业务侧这样用:

@PrimaryRead
@Transactional(readOnly = true)
public List<Loan> listJustBorrowed(Long memberId) {
    return loanRepository.findByMemberId(memberId);
}

isPrimaryForced() 在 determineCurrentLookupKey() 里被优先判断,因此即使方法标了 readOnly,也会走主库。

策略二:会话粘滞。 写入后记录一个时间戳(放在 Session 或 Redis),在「延迟上限」这个窗口内,该用户的读全部走主库。窗口设成复制延迟的 P99 上限(比如 1 秒)。它比策略一更细粒度——不用逐个方法标,而是按用户维度兜底。代价是需要会话状态,且窗口设大了会让主库承担本可下放的读。

策略三:业务容忍。 大多数读其实不要求实时。图书列表、借阅历史、热门统计,晚几百毫秒完全可接受。这类读直接走从库,不做任何特殊处理。工程上应当默认走从库,只对确实需要 read-your-writes 的少数接口用策略一或二,而不是反过来。

选择顺序建议:先问「这个读能不能容忍旧数据」,能容忍就什么都不做;不能容忍且是「用户自己刚写的数据」,用会话粘滞;只有极少数强一致要求的关键读,才逐个方法加 @PrimaryRead。

11.2.7 从库故障的降级

读写分离还有一个必须提前设计的点:从库挂了怎么办? 如果路由到从库的读直接失败,一次从库故障会放大成全站读不可用。

标准做法是在路由数据源上加健康检查与降级:探测到从库不可用时,把路由 key 强制回退到主库。最省事的实现是让从库的 HikariDataSource 连接失败时,determineCurrentLookupKey() 返回 "primary"——但要注意连接失败的判断不能在每次借连接时都做(开销大),通常靠一个后台探针维护一个「从库健康」的原子标志。

另一种更简单的策略是从库只读账号权限受限时的静默降级:路由前先检查标志,不健康就走默认(主库)。无论哪种,核心原则是——读写分离必须能一键退回单库,否则它就从「性能优化」变成了「可用性风险」。

11.2.8 常见坑

坑一:没做单库优化就上读写分离。 缺索引、N+1、慢 SQL 才是大多数「数据库慢」的根因,这些不解决,加从库只是把问题复制了一份。

坑二:以为事务内能切换数据源。 连接在事务开始时已绑定,路由粒度是整个事务,改 ThreadLocal 无效。

坑三:写完立刻从从库读。 read-your-writes 失败,用户看到旧数据甚至触发重复操作。关键回显读用 @PrimaryRead 或会话粘滞。

坑四:从库故障没有降级。 从库一挂读全失败。必须有健康探针 + 回退主库,并保证能退回单库。

坑五:readOnly 事务里做写操作。 会被路由到只读从库,直接报错。readOnly 必须和实际行为一致。

坑六:多数据源属性名混用。 手工绑定 HikariDataSource 时字段是 jdbc-url,不是 url,写错会导致连接串为空。

坑七:异步线程里路由失效。 路由 key 存在 ThreadLocal,跨线程池的任务读不到(第 10 章讲过上下文传播)。异步里访问数据库要么显式传标志,要么让该任务直接走主库。

坑八:connection-fetch=lazy 与路由的交互。 11.1 提到的 4.1 惰性连接获取会把物理连接推迟到第一条 SQL 才取,路由也随之推迟。大多数情况没问题,但排查「为什么这次走了从库」时要记得这一点。

小结

  • 读写分离只解决「读吞吐打满」,之前应依次做索引、查询优化、缓存、连接池;两条判据(读占主要 CPU、读写比 8:2 以上)同时成立才做。
  • AbstractRoutingDataSource 通过 determineCurrentLookupKey() 在借连接时选数据源,默认数据源设为主库以兜底。
  • 用 @Transactional(readOnly = true) + TransactionSynchronizationManager.isCurrentTransactionReadOnly() 实现自动路由,路由发生在事务开启时。
  • 事务内切换数据源无效:连接在事务开始时已绑定到线程,路由粒度是整个事务而非单条 SQL。
  • 主从延迟会导致「写完读不到」,三种处理是强制走主库、会话粘滞、业务容忍;默认容忍,只有 read-your-writes 才特殊处理。
  • 从库必须能降级回主库,读写分离要保证可一键退回单库。
  • 手工绑定 HikariDataSource 用 jdbc-url 而非 url;异步线程里路由 ThreadLocal 会失效。

单库的读写能力用连接池和读写分离都处理过了,但表本身的数据量或写入吞吐如果继续增长,就轮到分库分表。11.3 会重点讲它的接入边界——大多数团队其实还没到需要它的那一步。

阅读导航:上一节:11.1 HikariCP 调优 · 下一节:11.3 分库分表的接入边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计