主题
读写分离与多数据源
一句话:读写分离把只读流量路由到从库,用「一致性」换主库容量;多数据源是同一套路由能力在多个库上的推广。路由一旦由应用决定,事务边界和延迟就得由应用自己兜住。 适用版本:MySQL 8.0 / 8.4 复制;应用侧以 Spring
AbstractRoutingDataSource为例,RoutingDataSource、RoutingContext、@ReadOnly均为示例命名。
为什么需要
单主 MySQL 的读和写抢同一份缓冲池、同一组连接和同一批 CPU。读多写少时,加从库能把只读查询挪走,主库只保留写入和强一致读,这是读写分离(read-write splitting)最直接的收益。
代价有三类:复制延迟让从库读到旧数据;每个数据源一套连接池,连接总数和运维面都变大;事务一旦跨主从,应用必须自己定义「读到什么算正确」。不先想清楚这三点,读写分离只是把主库的压力换成了不一致投诉。
核心机制
路由决策:默认值决定事故形态
路由要回答一个问题:这次数据库访问用哪个连接。常见信号按优先级从高到低:
- 写操作或事务:一律主库。事务开启时连接已经绑定,之后的读也在同一事务里,走从库会破坏事务语义。
- 显式只读标记:方法或类上的
@ReadOnly(示例命名),或框架的只读事务标记,才允许走从库。 - 默认值:建议默认主库、只读显式声明。默认从库看起来省主库,但漏标的写和写后读会直接读到旧数据;默认主库的代价只是少省一点读流量。
嵌套调用以最外层为准:外层已经进入主库事务,内层再标只读也不能切走,否则同一事务出现两个连接。异步线程会丢失 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 与 maximumPoolSize | pending 持续增长即容量不足 |
| 主库 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 差集和心跳表更接近真实。一致性按查询分级:写后读、余额库存走主,列表报表可以读旧数据。多数据源靠路由数据源加线程上下文,但连接在事务开启时就绑定,事务内切源无效,跨库也不是本地事务。从库故障回退会放大主库压力,必须有熔断和上限;读写分离不解决写扩展。