编程 MySQL 写后读仍是旧值:Seconds_Behind_Source 判不准,改用位点/GTID 等待

2026-10-24 00:04:53

MySQL 写后读仍是旧值:Seconds_Behind_Source 判不准,改用位点/GTID 等待

写请求提交成功,紧接着的读请求打到从库,读到的还是旧值。代理层的 causal_reads、长驻 worker 的 sticky 连接解决的是另外两个问题;这里只讨论一件事:读请求自己怎么确认“这笔写已经落到这个从库”。

过期读从哪里来

异步复制的链路是三段:主库写 binlog → 从库 IO 线程拉取写入 relay log → SQL/applier 线程重放。主库并行提交、从库在未开并行复制时单线程重放,所以主库 commit 返回的瞬间,从库很可能还没执行到这笔事务。业务表现就是“写后立刻读,读到旧值”。

Seconds_Behind_Source 为什么不能当判据

Seconds_Behind_Source 是 8.0.22 起 SHOW REPLICA STATUS 的正式字段名,旧名 Seconds_Behind_Master / SHOW SLAVE STATUS 仍可用。它的算法是:

当前时间 - 正在应用的那个事件在主库的时间戳 - 主从时钟差

这个定义决定了它不能拿来判断“这笔写到了没有”:

  • 大事务期间报的是事务年龄,不是队列深度。 一个跑了 10 分钟的大事务正在应用时,指标显示的是这 10 分钟,而不是后面还堆了多少。
  • 并行复制下反映的是 coordinator 的水位。 coordinator 把事务派发给 worker 就算推进,不代表每个 worker 都应用完了。
  • IO 线程断开时变 NULL。 或者数据一直在变旧,指标看起来却“还好”。
  • 空转时读 0。 主库空闲或应用出现间断的瞬间会显示 0,即使 IO 线程其实落后;主库 sync_binlog=0 时 binlog 写入时间滞后于提交时间,也会让这个值失真。
  • 秒级精度取整。 不满 1 秒会显示成 1 秒,阿里云文档里有 00:00:00.95 → 00:00:00 的取整例子。

结论很直接:这是个观测指标,不是收敛判据。

要看真实水位,用 performance_schema 的时间戳

performance_schema 里有两个视图给出原始提交时间:

  • replication_applier_status_by_coordinator.LAST_PROCESSED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP
  • replication_applier_status_by_worker.LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP / APPLYING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP

用 now(3) 减去原始提交时间得到秒级小数延迟,例如:

SELECT NOW(3) - MIN(LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP) AS lag_seconds
FROM performance_schema.replication_applier_status_by_worker
WHERE LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP IS NOT NULL;

要点有三个:coordinator 视图的 LAST_PROCESSED 只代表“已派发给 worker”,真实水位要取各 worker 的最小值;主从时钟必须 NTP 同步,否则时间戳相减没有意义;主库空闲时水位不会自己前进,要靠心跳事件 CHANGE REPLICATION SOURCE TO ... SOURCE_HEARTBEAT_PERIOD(默认 30 秒)推动。

这套度量比 Seconds_Behind_Source 准,但它仍然是“看”,不是“等”。读请求需要的是后者。

应用层能用的判据是等待

MySQL 提供两个阻塞函数,在从库上执行,一直等到该从库执行到指定位置或 GTID 集合为止:

-- 位点:8.0.26 起的新名,旧名 MASTER_POS_WAIT
SELECT SOURCE_POS_WAIT('binlog.000123', 456789, 1);

-- GTID
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', 1);

返回值语义必须记清:

函数成功超时出错
SOURCE_POS_WAIT>= 0,等待的事件数-1NULL
WAIT_FOR_EXECUTED_GTID_SET01NULL

WAIT_UNTIL_SQL_THREAD_AFTER_GTIDS 已废弃,不要再用。函数超时返回后不会自动做任何回落,调用方必须自己决定下一步。

把主库的位点/GTID 带到读请求

判据在从库上执行,但“要等到哪里”只能从主库拿。

位点法。 写事务后执行 SHOW MASTER STATUS / SHOW BINARY LOG STATUS 取 File + Position。注意只有 FTWRL 才能保证位点与一致性快照对应;普通应用里只能取“提交后最新的位点”,会偏保守。

GTID 精确法。 会话变量 SET @@session.session_track_gtids = 'OWN_GTID',需要客户端启用 CLIENT_SESSION_TRACK 能力并解析 OK 包里的 session state 才能拿到本事务的 GTID。PDO_MySQL 与常见 Go 驱动在这一层的支持不一致,要按版本确认。

实用变通。 写提交后在同一条连接上执行 SELECT @@global.gtid_executed。这个集合在主库上必然包含刚提交的事务;GTID 自 5.7.5 起是内存态,读取开销小。代价是集合里可能混入同实例其它会话并发提交的事务,等待会更保守,但方向是安全的。

拿到集合后,随业务上下文(请求上下文 / 消息头 / job payload)传给读侧。跨进程调用(HTTP 打下游、异步队列)时,这是唯一能把“我看得见这笔写”这件事带过去的方式。

半同步不等于读得到

半同步复制由 rpl_semi_sync_source_enabled 控制(8.0.26 之前叫 rpl_semi_sync_master_enabled),rpl_semi_sync_source_timeout 默认 10000 毫秒,超时后会降级为异步继续提交。而且 ACK 只来自某一个从库,其它从库照样可能落后。所以半同步不能替代读侧的等待判据。

写侧带 GTID、读侧等待的写法

PHP 伪代码,未在真实集群压测,只表达调用顺序与返回值处理:

<?php
// 写侧:事务提交后取 GTID 集合
$pdo->beginTransaction();
$pdo->prepare('UPDATE account SET balance = balance - ? WHERE id = ?')->execute([$amount, $from]);
$pdo->prepare('UPDATE account SET balance = balance + ? WHERE id = ?')->execute([$amount, $to]);
$pdo->commit();

// 变通做法:@@global.gtid_executed 必然包含刚提交的事务
$gtidSet = $pdo->query('SELECT @@global.gtid_executed')->fetchColumn();
// 形如 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100
// 放进请求上下文 / 消息头 / job payload,随读请求传到从库侧

// 读侧:在从库连接上等待
function waitForGtid(PDO $replica, ?string $gtidSet, float $timeoutSec = 0.1): bool
{
    if ($gtidSet === null || $gtidSet === '') {
        return true; // 没有可等待的集合,直接放行
    }
    $stmt = $replica->prepare('SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)');
    $stmt->execute([$gtidSet, $timeoutSec]);
    $ret = $stmt->fetchColumn();

    if ($ret === null || $ret === false) {
        return false; // NULL:函数出错
    }
    return (int)$ret === 0; // 0 已执行,1 超时
}

if (!waitForGtid($replicaPdo, $gtidFromContext, 0.1)) {
    // 超时或出错:回落主库,并走限流
}

单条连接复用时要注意:等待期间这条连接是被占住的,不能拿它去执行别的查询。

超时、堆积与切主

超时给多大。 等待是阻塞的,会占住连接。timeout 建议 50–200ms 级别,不要给到秒级——真落后几秒时,等下去只会把读请求一起拖死。

等待者会不会拖垮从库。 会。峰值时大量请求同时卡在 WAIT_FOR_EXECUTED_GTID_SET 上,从库上会堆积等待中的连接,applier 与查询争 CPU/IO,重放反而更慢。等待连接数要有上限,不能无限堆积。

超时回落主库要限流。 否则等于把从库扛不住的压力一次性全部搬回主库,主库跟着抖动。

切主时的行为。 主从切换后,旧 GTID 集合在新主上已经执行过,WAIT_FOR_EXECUTED_GTID_SET 会立即返回 0,这是切主期间唯一“免费”的地方。但旧集合可能来自一台已经丢失的从库,业务侧需要额外的 cutover 语义来兜底。

参考

推荐文章

程序员茄子在线接单