跳到正文

读写分离与多数据源 ​

一句话:读写分离把只读流量路由到从库,用「一致性」换主库容量;多数据源是同一套路由能力在多个库上的推广。路由一旦由应用决定,事务边界和延迟就得由应用自己兜住。 适用版本:MySQL 8.0 / 8.4 复制;应用侧以 Spring AbstractRoutingDataSource 为例,RoutingDataSource、RoutingContext、@ReadOnly 均为示例命名。

为什么需要 ​

单主 MySQL 的读和写抢同一份缓冲池、同一组连接和同一批 CPU。读多写少时,加从库能把只读查询挪走,主库只保留写入和强一致读,这是读写分离(read-write splitting)最直接的收益。

代价有三类:复制延迟让从库读到旧数据;每个数据源一套连接池,连接总数和运维面都变大;事务一旦跨主从,应用必须自己定义「读到什么算正确」。不先想清楚这三点,读写分离只是把主库的压力换成了不一致投诉。

核心机制 ​

路由决策:默认值决定事故形态 ​

路由要回答一个问题:这次数据库访问用哪个连接。常见信号按优先级从高到低:

  1. 写操作或事务:一律主库。事务开启时连接已经绑定,之后的读也在同一事务里,走从库会破坏事务语义。
  2. 显式只读标记:方法或类上的 @ReadOnly(示例命名),或框架的只读事务标记,才允许走从库。
  3. 默认值:建议默认主库、只读显式声明。默认从库看起来省主库,但漏标的写和写后读会直接读到旧数据;默认主库的代价只是少省一点读流量。

嵌套调用以最外层为准:外层已经进入主库事务,内层再标只读也不能切走,否则同一事务出现两个连接。异步线程会丢失 ThreadLocal 上下文,InheritableThreadLocal 只对新建线程有效,线程池里的线程仍是旧值,必须显式传递或包装执行器。

主从延迟:秒数只是粗指标 ​

Seconds_Behind_Source(8.0.22+,旧名 Seconds_Behind_Master)是 SQL 线程最后回放事务的时间与当前时间的差,受时钟、大事务和线程空闲影响:SQL 线程停了它可能是 NULL,恰好追平时可能是 0,但不代表复制健康。更可靠的是看 GTID 差集:Retrieved_Gtid_Set 是已收到的事务,Executed_Gtid_Set 是已执行的事务,差集扩大就是真实缺口。业务侧还可以用心跳表(heartbeat table):主库定时更新一行时间戳,从库读出来和本地时间比,直接得到「这个从库落后多久」。指标细节见 09-监控指标与告警。

延迟超过阈值时自动切主是常见做法,但要防抖动:连续 N 次超过阈值才切,连续 M 次恢复才切回,并记录切换事件。阈值按业务容忍度定,没有通用值。

一致性策略:按查询分级 ​

不是所有读都一样:

读类型例子策略
写后立即读下单后跳详情页、支付后查状态强制主库,或写后短时间内(如 1–5 秒)粘住主库
会话内一致性用户连续操作自己的数据会话粘性(session stickiness),同一会话固定主库或固定从库
可容忍旧数据列表、报表、历史订单、商品浏览走从库,接受秒级延迟
绝对不能旧余额、库存扣减校验、权限、幂等判断主库,不参与读写分离

写后读一致性(read-after-write consistency)不要靠「从库应该追上了」的假设。要么按主键或会话记录写入位点,读到足够新的从库;要么简单地在写后一段时间内强制主库。后者实现便宜,代价是这段时间的读流量回到主库。

多数据源与事务边界 ​

多数据源用 AbstractRoutingDataSource 加 ThreadLocal 就能做:determineCurrentLookupKey() 返回当前线程的 key,容器在获取连接时选一次数据源。关键点在「一次」:连接一旦被事务持有,后面再改 ThreadLocal 不会换连接。

因此:

  • 每个数据源要有独立的连接池和独立的事务管理器;存在多个事务管理器时,@Transactional 必须显式指定用哪个。
  • @Transactional 绑定的数据源在事务开启时确定,事务内切源无效;注解只能决定「开事务时用哪个库」,不能在中途改。
  • 跨库操作不是本地事务。两阶段提交(XA)能提供较强保证,但锁持有时间长、故障恢复复杂;多数业务用「本地事务 + 消息/补偿」换可用性,并接受中间态。复制与提交的关系见 04-Redo Log 与 Binlog 两阶段提交。

连接池与容量 ​

每个数据源一套池,总连接数是各池之和。主库池按写入 QPS 和事务持有时长算,从库池按只读 QPS 和查询延迟算;maximumPoolSize 不是越大越好,超过数据库能并行处理的连接数只会增加排队。复制线程、备份和监控也会占用从库连接与 IO,容量评估不能只算业务连接。

读写比变化时要重新算:读流量涨了先看从库池等待和从库 CPU,再决定加从库还是加池;写流量涨了加从库没用,要看主库写入路径和分片。慢查询本身的定位与改写见 06-SQL 调优与 EXPLAIN。

故障切换:回退主库会放大压力 ​

从库不可用时有两条路:快速失败,只读请求直接报错或降级,保护主库;回退主库,把只读流量切回主库,业务无感,但主库要同时承担读写。

回退必须有上限:熔断(circuit breaker)在从库连续失败后打开,限制回退流量比例,主库连接池和 CPU 留出余量;否则一个从库故障会把主库拖垮,演变成全站故障。非核心读可以降级为空结果或缓存,核心读才回退。

这张图里只有一条路径能到从库:显式只读、未开强制主库、且从库延迟可接受。写操作、事务、漏标只读、延迟超阈值、强制开关,任何一个条件都会短路到主库。两个池分开画,是因为回退时压力会从 replica-pool 整体移到 master-pool,容量要按最坏情况留余量。

实战场景 ​

一、“下单成功但详情页查不到订单”,每天几百次 ​

现象:读写分离上线两周后,客服收到大量反馈:用户提交订单后跳转详情页显示“订单不存在”,刷新一次就有了。主库数据完全正常,Seconds_Behind_Source 大多数时候显示 0,DBA 一度认为复制健康。

排查:延迟指标本身不可信,先确认这次读走的是哪个库。

定位层取证据判定
路由结果在 DAO 层打日志:本次连接的 lookupKey 与实例地址出现 replica 即读走了从库
真实复制缺口从库 Retrieved_Gtid_Set 与 Executed_Gtid_Set 的差集差集扩大才是真实落后
秒数指标可信度Seconds_Behind_Source 在 SQL 线程停止时为 NULL、恰好追平时为 0不能当健康判据
心跳表主库定时写一行时间戳,从库读出与本地时间比较直接得到“这个从库落后多久”
事务边界写操作所在事务内是否有读事务开启即绑定连接,事务内的读也必须走主库
只读标记覆盖哪些查询没有显式只读标记却跑到了从库漏标是默认从库的必然结果

默认值决定事故形态:默认从库省下读流量,但漏标的写和写后读会直接读到旧数据;默认主库的代价只是少省一点读流量。

对症做法:

java
// 1. 按查询分级,而不是全局一刀切
@Transactional(readOnly = true)                        // 显式只读才允许走从库
public Page<Order> listByUser(long uid, Pageable p) { ... }   // 列表、报表、历史订单

public OrderDetail detail(long orderId) { ... }        // 无只读标记 → 默认主库
text
2. 写后读一致性用“粘主”实现:
   写操作成功后,把 (userId 或 sessionId) 记入 Redis,TTL 取 1~5 秒(经验值,按延迟分布校准);
   窗口内该用户的所有读强制走主库。
3. 绝对不能旧的四类读不参与读写分离:
   余额、库存扣减校验、权限判定、幂等判断 → 一律主库。
4. 嵌套调用以最外层为准:
   外层已进入主库事务,内层再标只读也不能切走,否则同一事务出现两个连接。
5. 异步线程会丢 ThreadLocal 上下文,InheritableThreadLocal 只对新建线程有效;
   线程池里的线程仍是旧值,必须显式传递或包装执行器(TaskDecorator)。
yaml
# 6. 把默认值改成安全的一侧
datasource:
  route:
    default-target: master
    read-only-only-use-replica: true

复盘要点:判据是“写后 1 秒内读的命中率必须落在主库”,可以直接测:写入后立刻读,断言连接的 lookupKey=master。复制延迟要有基于 GTID 或心跳表的告警,而不是 Seconds_Behind_Source;延迟超阈值的从库应被自动摘出读池,而不是继续承接流量。

二、从库故障回退主库,把主库一起拖垮 ​

现象:一台从库磁盘故障下线。读写框架按“从库不可用则回退主库”的设计自动切换,随后主库连接池打满、CPU 从 40% 冲到 95%,写请求开始超时,整条交易链路不可用。故障域原本只是一台从库,结果变成全站。

排查:回退让压力从 replica-pool 整体移到 master-pool。

定位层取证据判定
回退流量比例路由日志里 fallback=master 的占比与时间曲线接近 100% 即全量回退
主库池状态hikaricp_connections_active / pending 与 maximumPoolSizepending 持续增长即容量不足
主库 CPU 构成读请求与写请求各自占比(按 SQL 指纹)读挤占写是这类故障的机制
是否有限流回退路径有没有比例上限与熔断没有上限就是放大器
非核心读报表/导出类是否也回退到主库这些本应降级或直接失败
从库容量预算从库总数与“失去 N 台仍可承接”的评估按最坏情况留余量

对症做法:

yaml
# 1. 回退要有上限和熔断:连续失败打开熔断,只允许固定比例回退
route:
  fallback:
    enabled: true
    max-ratio: 0.3              # 最多 30% 的读回退主库(经验值,需按主库余量校准)
    breaker:
      failure-threshold: 5      # 连续 N 次失败打开
      open-duration: 30s
      half-open-probe: 3        # 恢复 M 次成功后再切回
java
// 2. 按查询分级决定回退行为:非核心读宁可降级,不占主库容量
@ReadOnly(level = Level.BEST_EFFORT)        // 列表/报表:从库不可用返回缓存或空结果 + 提示
public Page<Product> browse(...) { ... }

@ReadOnly(level = Level.MUST_SERVE)         // 详情页等核心读:允许回退主库,且有限流
public ProductDetail detail(long id) { ... }
text
3. 容量口径按“失去一台从库”设计:
   从库数量 = 承接全部读流量的 N 倍冗余,回退预算写入主库容量模型;
   主库连接池要留出“读回退 + 复制线程 + 备份 + 监控”之外的余量。
4. 慢查询回退更危险:给回退请求设更短的 socketTimeout,
   宁可失败也不让主库排队。
5. 切换要记录事件(时间、原因、比例、恢复时间),
   连续抖动时提高“切回”所需次数,避免来回震荡。

复盘要点:判据是“从库故障演练时主库 CPU 不超过预算水位(例如 70%)”,这条只能靠演练得到,不能靠配置推断。同时要说清选择:快速失败保护主库、回退保业务无感,两者都要有上限,无上限的回退等于把局部故障升级成全站故障。

三、事务里切数据源不生效,报表 SQL 打在主库 ​

现象:多数据源用 AbstractRoutingDataSource + ThreadLocal 实现。某次“从 BI 库读、往主库写”的作业里,运维把 ThreadLocal 在方法中段改掉,以为能切库;结果整段查询仍然连在主库,报表 SQL 把主库连接占满,在线交易出现排队。另一侧症状是写入落到了 BI 库。

排查:路由只在获取连接时决策一次。

定位层取证据判定
连接何时确定事务开启时刻 vs ThreadLocal 修改时刻事务内改 key 无效,连接已被持有
事务管理器归属@Transactional(transactionManager=...) 是否显式指定多个管理器时缺省会随机命中默认那个
模板与工厂配对@MapperScan(sqlSessionTemplateRef=...) 与事务管理器是否同库不配对就出现“写 A 库、事务管 B 库”
嵌套/自调用同类自调用是否绕过代理,导致事务根本没开无事务时每次取新连接,行为看起来“随机”
异步边界异步任务线程的 ThreadLocal 值线程池复用会残留上一次的数据源选择
路由日志每次获取连接时打印 determineCurrentLookupKey() 结果与实际 URL最直接的判定证据
java
// 命中的错误写法
@Transactional                       // 事务在方法入口就绑定了 master 连接
public void reportAndWrite() {
    dsKey.set("bi");                 // 已经持有连接,改了也不会换库
    biMapper.selectBig();            // 仍然打到主库
    dsKey.set("master");
    mainMapper.insert(...);
}

对症做法:

java
// 1. 数据源在方法入口之前确定,一个方法只碰一个库
@Transactional(transactionManager = "masterTxManager", rollbackFor = Exception.class)
public void writeOnly(Report r) { mainMapper.insert(r); }

@Transactional(transactionManager = "biTxManager", readOnly = true)
public List<Row> readOnlyFromBi(DateRange range) { dsKey.set("bi"); return biMapper.scan(range); }

// 2. 跨库编排放到无事务的服务层,按库拆方法调用
public void sync() {
    List<Row> rows = biQuery.readOnlyFromBi(range);    // 事务 1:BI 库
    writeService.writeOnly(convert(rows));             // 事务 2:主库
}
text
3. 每个数据源一套独立连接池与独立事务管理器,
   @Transactional 一律显式写 transactionManager,禁止依赖默认值。
4. 跨库不要伪装成本地事务:XA/2PC 锁持有时间长、恢复复杂,
   多数业务用“本地事务 + 消息/补偿”换可用性并接受中间态。
5. ThreadLocal 必须在 finally 里 remove;线程池场景用 TaskDecorator 显式传递,
   否则会读到上一个请求残留的数据源 key。

复盘要点:判据是“不存在“一个事务方法内访问多个数据源”的代码形态”,可以用静态检查约束(同一 @Transactional 方法体内不出现多个 Mapper 包)。跨库一致性方案见 分布式事务 与 S05 分布式事务;连接池容量的算法(各池之和、超过并行处理能力只增加排队)要写进容量表,而不是每次事后调参。

最小上手 ​

Spring 环境下用 AbstractRoutingDataSource 做路由(示例命名,省略 import、AOP 注册与错误处理):

java
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return RoutingContext.get();          // "master" 或 "replica"
    }
}

public final class RoutingContext {
    private static final ThreadLocal<String> KEY = new ThreadLocal<>();
    public static void set(String key) { KEY.set(key); }
    public static String get() { return KEY.get() == null ? "master" : KEY.get(); }
    public static void clear() { KEY.remove(); }
}

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly { }                 // 示例命名

// AOP:@Before 读到 @ReadOnly 后 set("replica"),@After 无条件 clear()
// 事务方法或写方法不加 @ReadOnly,默认走主库

验证「事务内走主、只读查询走从」:

sql
-- 主库和从库分别执行;general_log 开销大,只用于短时验证
SET GLOBAL log_output = 'TABLE';
SET GLOBAL general_log = ON;
SELECT event_time, command_type, argument
FROM mysql.general_log
WHERE command_type = 'Query' AND LOWER(argument) LIKE 'select%'
ORDER BY event_time DESC LIMIT 20;

先调用一个 @Transactional 方法,里面做一次查询:主库日志应出现这条 SELECT,从库没有。再在事务外调用 @ReadOnly 方法:从库日志应出现,主库没有。同时打印 determineCurrentLookupKey() 的返回值,能区分「路由错」和「路由对但从库延迟」。

生产动作 ​

动作起点/默认值判断
复制延迟Seconds_Behind_Source > 30s 持续 1 分钟;线程不是 Yes 立即告警秒数为 NULL 或两个线程任一停,先恢复复制再看延迟
GTID 缺口Retrieved_Gtid_Set 与 Executed_Gtid_Set 差集持续扩大差集扩大说明回放慢,不是网络抖动
路由命中率从库读 / 总读,按业务基线设下限突然掉零先查强制主库开关、从库健康、注解是否失效
连接池等待各池等待时长和活跃连接数等待上升而 CPU 不高,先查慢查询和池大小
复制中断Replica_IO_Running / Replica_SQL_Running 任一非 Yes立即告警,此时延迟秒数无意义
强制主库开关人工可切、自动可切,必须有审计开关打开期间主库容量按最坏情况预留

排障路径:

  • 写后读不到:查这次读是否在事务内、是否命中 @ReadOnly、从库延迟多少 → 路由错就改注解或事务边界;路由对但延迟高,写后粘主或等位点。
  • 从库 QPS 掉零、主库 QPS 上涨:查强制主库开关、从库健康检查、熔断状态 → 判断是主动切换还是故障回退,回退要限流。
  • 只读请求变慢:查从库池等待、从库 Threads_running、复制线程 → 池满加池或限流,回放慢查大事务和从库 IO。
  • 主库压力突然升高:查回退流量比例、主库池等待、慢查询 → 先关回退或降级非核心读,再扩容。

边界 ​

  • 不解决写扩展:从库只分担读;写瓶颈要靠升配、分片或改写入模型。
  • 强一致读多时收益有限:如果大部分读都要最新数据,读写分离只是多了一层路由和延迟判断,不如直接读主库或加缓存。
  • 分库分表后路由维度更多:先选库、再选表、再选主从;注解表达不了这么复杂的规则,通常要独立的中间件或路由层。
  • 不要用注解隐式改变事务语义:在事务内标只读不会切库,反而让人误以为已经走了从库;AOP 对同类内部调用、private 方法也可能不生效。
  • 复制默认是异步的:半同步只保证至少一个从库收到日志,不保证已应用,也不等于零延迟;故障切换后仍可能读到旧数据。
  • 回退不是免费:从库故障时全量回退会把主库打穿,必须有熔断、比例上限和降级预案。

讲清楚 ​

读写分离把只读流量从主库挪到从库,用一致性换容量。路由默认走主、只读显式声明,事务内一律主库,嵌套以最外层为准,异步线程要显式传递上下文。延迟不能只看复制秒数,它可能是 0 也可能是空值,GTID 差集和心跳表更接近真实。一致性按查询分级:写后读、余额库存走主,列表报表可以读旧数据。多数据源靠路由数据源加线程上下文,但连接在事务开启时就绑定,事务内切源无效,跨库也不是本地事务。从库故障回退会放大主库压力,必须有熔断和上限;读写分离不解决写扩展。

把一门技术讲透到既懂也会用