7AA7 博客

读写分离与主从延迟:一致性的工程处理

江眠 UID:120
2026-10-04 16:28
67 0 1

读写分离通常是数据库扩展的第一步:写走主库,读分摊到多个从库。收益立竿见影,问题也很隐蔽——主从之间的数据不是同步到达的,业务读到的可能是几毫秒前的旧数据。多少业务能容忍这个”旧”,是设计阶段必须回答的问题。

复制链路的基本模型

以 binlog 复制为例,一条写操作从主库到从库可见,需要经过三段:

主库 ──1──> binlog 落盘 ──2──> Dump 线程推送 ──3──> 从库 IO 线程写 relay log
                                                        │
                                          ┌─────────────┘
                                          v
                              从库 SQL 线程读 relay log 并回放 ──> 数据可见

三段对应的时间:

  1. 主库 binlog 提交:受 sync_binlog 与磁盘策略影响。
  2. 传输网络:跨机房通常是亚毫秒到毫秒量级。
  3. 从库回放:这部分往往是延迟的主要来源。

早期版本从库只有一个 SQL 线程串行回放,主库并行执行的事务到从库要排队。后来引入并行复制,粒度从”按库并行”演进到”按事务组并行”:

SHOW VARIABLES LIKE 'replica_parallel_workers';   -- MySQL 8.0 之后
SHOW VARIABLES LIKE 'slave_parallel_%';
SHOW VARIABLES LIKE 'binlog_transaction_compression';

并行能否生效,取决于主库上这批事务在 prepare 阶段是否同时通过了 COMMIT ORDER——同一时间窗口内提交的事务可以并行回放。这意味着主库本身的并发度决定了从库能并行到什么程度,单笔大事务不会因此加速。

延迟从哪里来

  • 大事务。一次性更新几十万行的语句,主库上耗时可能只有几秒,但这条语句传到从库后要按同样的行级变更重放,总时长相近甚至更长。批量删除、批量更新、ALTER TABLE 都属于这一类。
  • DDL 操作。主库上 online DDL 不阻塞写,但这条 DDL 语句传到从库后仍然要执行完,期间从库这条语句之后的所有变更都在排队。
  • 从库自身压力。从库承担读流量,如果读查询本身消耗大量 CPU 或 IO,回放线程抢不到资源,延迟会随读流量起伏。
  • 从库表缺少索引。这是最容易被忽略的一条:主库上的 UPDATE t SET x=1 WHERE id=5 按主键更新很快,但从库回放时如果没有主键或合适的索引,同样的语句会退化成全表扫描,慢几十倍甚至上百倍。
  • 长事务阻塞。从库回放时的行锁等待、MDL 等待会让后续事务全部卡住。
  • 硬件差异。从库配置低于主库时,即便没有特殊负载,回放速度也可能天然落后。

Seconds_Behind_Master 的局限

SHOW REPLICA STATUSG
-- Seconds_Behind_Master: 0

这个字段来自”从库当前正在回放的事件时间戳”减去”从库当前系统时间”。它有几个明确的失效场景:

值为 0 不等于没有延迟。 计算的是事件时间戳差。如果主库上没有新的写入,从库也不会有新事件,这个值自然回落到 0,可能实际积压了没算进去。

值为 NULL。 通常意味着回放线程已经停止或异常。

大事务期间数值跳变。 一个大事务回放过程中,Seconds_Behind_Master 会突然跳到几十甚至几百,但业务上感知的可能只是”某张表的某个读变慢了”。

更可靠的做法是基于 binlog 位点判断。思路是:主库定期写入一个带时间戳的心跳表,从库读取这条记录并计算差值。

-- 主库由定时任务执行
REPLACE INTO heartbeat (id, ts) VALUES (1, NOW(6));
-- 从库读取并计算
SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(6)) / 1000 AS lag_ms FROM heartbeat;

配合监控脚本采集,可以得到一条真正有意义的延迟曲线。另一个方向是直接比较位点差:

SELECT @@gtid_executed;                 -- 从库已执行的 GTID 集合
-- 与 @@gtid_subtract 结合使用计算主库与从库的差集
SELECT GTID_SUBTRACT('主库UUID:1-5000', @@gtid_executed) AS missing;

GTID 差集为空说明追平了。这种方式不受时钟同步影响,但需要额外判断剩下多少个事务。

写后读不一致的典型场景

最经典的是下单流程:

1. INSERT INTO orders (...)            -- 写主库
2. SELECT * FROM orders WHERE id=?     -- 读从库,此时可能还没追上
3. 返回给用户"订单不存在"

用户点了提交却提示没有订单,刷新一下又有了。这种问题客服很难复现。

类似的还有:修改个人资料后立刻查看个人中心、支付状态更新后立即轮询订单状态、后台审批通过后马上刷新列表、发放权益后立即校验是否可用。

这些场景的共同点是同一个用户刚完成写操作,紧接着就要读到自己的写入结果。限定词是”自己的”——用户对自己数据的修改有天然强一致预期,对别人的数据没有。

工程上的应对手段

按场景强制走主库

最直接的办法。在 DAO 层或者中间件层提供”强制主库”的标记:

// 用注解或 ThreadLocal 标记后续查询走主库
@DataSourceRoute(Mode.MASTER)
public Order detail(Long orderId) {
    return orderMapper.selectById(orderId);
}

粒度要控制在必要的方法上。把所有读都打回主库,读写分离就没有意义了。实践中通常是:新增/修改对象后紧接着的读取、金额与状态等关键字段的读取走主库,列表、详情、统计类页面容忍延迟。

会话级或时间窗粘滞

在一次会话里,只要发生过写操作,之后某个时间窗内(比如 5 到 10 秒,取值应大于常态延迟的 P99)所有读都路由到主库。这需要中间件支持会话跟踪,或者用 ThreadLocal + 请求上下文实现。窗口结束后自动恢复读从库。

if (System.currentTimeMillis() - lastWriteTime < STICKY_WINDOW_MS) {
    route = MASTER;
}

延迟感知路由

中间件或代理持续采集各从库的延迟,超过阈值就把该从库临时摘除或降级。这解决的是”某个从库异常慢”的问题,属于故障域的处理:

if (replica.lag_ms > threshold_ms) { markUnhealthy(replica); }

这种方式对”写后立刻读”帮助有限——延迟可能只有 5ms,从库是”健康”的,但这 5ms 恰好覆盖了该用户的读取。

缓存补偿

写入主库之后同步写缓存,读取时优先读缓存。缓存里是最新的,天然绕过复制延迟。

writeToMaster(order);
redis.set("order:" + order.getId(), order, Duration.ofMinutes(5));

代价是要维护缓存与数据库的一致性,且缓存失效后仍可能读到旧数据。适合读多写少的对象型数据。

业务层容忍与提示

把强一致从技术约束转成产品设计。”提交成功,数据将在几秒内生效”比”操作失败”更好;对状态类查询做轮询重试;对不关键的计数类数据接受旧值。这是成本最低的处理方式,在很多场景里也是唯一合理的。

半同步复制的取舍

默认的异步复制里,主库写完 binlog 就返回给客户端,不等待任何从库确认。主库宕机时,已返回成功但还没传出的事务会丢失。

半同步复制改了这个流程:主库提交后至少等待一个从库确认收到 relay log 才返回。

-- 主库侧
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 1000;   -- 单位毫秒
-- 从库侧
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;

需要理解的是,半同步保证”日志已落到至少一个从库”,不保证”数据在从库可见”。从库收到 relay log 后还要回放,这段差距依然存在。它解决的是崩溃时不丢事务,不解决读一致性。

它的代价也很直接:

  • 每条写事务多一次网络往返,写延迟增加一个 RTT 量级。
  • 超过 rpl_semi_sync_master_timeout 会自动降级为异步复制,保护消失。这个降级必须被监控,否则会误以为自己仍在被保护。
  • 网络抖动或 JVM GC 停顿会直接转化为写延迟抖动。
SHOW STATUS LIKE 'Rpl_semi_sync%';
-- Rpl_semi_sync_master_clients           当前半同步从库数量(0 说明已全部断开)
-- Rpl_semi_sync_master_no_times          超时退化为异步的次数,必须告警
-- Rpl_semi_sync_master_yes_tx / _no_tx   成功/失败的半同步事务计数,可算比例

等待策略设为 AFTER_SYNC(先等待再返回)可以保证提交返回前数据已到从库,不存在”其他会话看到本事务但未同步”的窗口;AFTER_COMMIT 在该窗口上存在风险。

部署与监控的几条基线

  • 从库与主库的硬件规格保持一致,特别是磁盘 IOPS。
  • 从库必须有与主库相同的索引结构,从库缺失主键是常见的延迟放大源。
  • 大批量 DML 必须在业务低峰执行,并拆小批次,控制单事务规模。
  • 在线 DDL 要评估从库侧的回放时间:先在从库执行验证耗时,再考虑是否在主库执行。
  • 监控要同时覆盖:Seconds_Behind_Master、心跳表算出的真实延迟、GTID 差集、半同步降级次数、复制线程状态。任一项单独看都不完整。
  • 为读流量设置过载保护:从库延迟超过阈值时,把读流量按比例切回主库或熔断,避免把从库压垮导致延迟无限扩大。

读写分离的价值在于把不相干的读请求从主库挪走,而不是提供一组可以随意使用的读库。哪些读能容忍延迟、哪些不能,这个清单应该在设计阶段就写出来,而不是等用户投诉之后再逐条补。

2.1k浏览量
67赞
0评论
0参与用户
12 分钟阅读时间

评论(0)

还没有评论,来抢沙发。