你刚写进去的数据怎么就读不出来了:主从延迟的七种死法

2026-10-29 6 0

你刚写进去的数据怎么就读不出来了:主从延迟的七种死法

你在后台点「提交订单」,弹出「订单已创建」。立刻刷新列表——空的。再刷新,还是空的。两秒后,订单自己长出来了。

工程师查了一圈:代码没 bug,事务没回滚,缓存也没问题。最后在某个犄角旮旯的日志里看到一行:Seconds_Behind_Master: 3。

恭喜,你踩到了分布式数据库里最经典也最容易被误诊的坑:读写分离下读不到自己刚写的值。这坑在压测环境几乎不出,在生产的某个下午突然就出。

咱们把它拆开:延迟怎么产生、怎么坑你、怎么在架构层面接住。顺便吐槽那些「主从延迟是玄学」的说法——它一点都不玄学,只是被一半人当玄学处理了。


一、先搞清楚:复制不是同步,是「异步搬运工」

主库执行完 COMMIT、把变更写进 binlog,就直接返回客户端「成功」,根本不等从库收到。所以「写成功」的实际语义是:我的 binlog 落地了,而不是我的数据全网可见了。

主库: 执行事务 → 写 binlog → COMMIT → 回复客户端 OK
                                     ↓ (网络传输, 可能几毫秒, 也可能几秒)
从库: 拉 binlog → relay log → 回放 → 应用完成为最新数据

这段空档就是你看到的「延迟」。关键假设是:主从应用的是同一份有序的变更序列。只要顺序不乱、变更不丢,从库最终一定追上来。所以延迟该度量的是「从库位点落后主库多少」,而不是笼统的「差几秒」。


二、延迟的七种死法(按我排查时的出场顺序排)

死法一:大事务,一条 UPDATE 干掉两百万行

最常见也最粗暴的一种。binlog 是事务级记录的:事务没提交,这几万行的变更一行都进不了 binlog。从库只能眼睁睁看着主库改完的两百万行。

更阴的是,这类大 UPDATE 在主库上往往很快,但从库回放时如果没有主键,就得全表扫描定位每一行——主库几秒的活儿,从库跑几分钟。

铁律是:UPDATE/DELETE 一定要带 WHERE,且 WHERE 命中的列要有索引。别问「我只是偶尔清一下数据」,生产上一张两千万行的日志表删一次,够你喝一壶。

死法二:无主键表

ROW 格式 binlog 对每行变更都记录「改前镜像 + 改后镜像」,回放时靠主键定位行。没有主键的行,从库只能执行 UPDATE table SET ... WHERE 全部字段等于旧值——全表扫描。

-- 危险:id 不是主键
CREATE TABLE audit_log (
  id BIGINT,
  action VARCHAR(32),
  payload TEXT
);
ALTER TABLE audit_log ADD PRIMARY KEY (id);

还有更狠的:表里一整行都是 NULL。回放时 MySQL 认为无法定位,直接报 Error_code: 1032,复制断掉,从库从此原地踏步。

死法三:DDL 和锁

DDL 在 binlog 里同样是语句,先于后续 DML 到达从库。一个没加 ALGORITHM=INPLACE 的加字段,主从两端各做一次全表重建,从库的回放线程只能排队等着。

更隐蔽的是 pt-osc / gh-ost:它们本身要写 binlog,从库回放时又老老实实做一遍 DDL,主从的表结构状态可能在中间态上打架。

死法四:从库回放是单线程的

老版本 MySQL 的从库只有一个 SQL Thread 回放 binlog。主库并发写入十万 QPS,从库单线程重放,CPU 打满,延迟飘到分钟级。

MySQL 5.7 引入并行复制,8.0 配合 WRITESET 依赖跟踪更猛。但这个参数在云厂商的 MySQL 里常被保守设置,你得自己确认,而不是相信控制台上的「已开启高可用」。

死法五:脑裂切换时丢更新(最贵的一种)

场景:主库 A 已把「转账成功」写进 binlog,但还没传到从库 B。此时 A 宕机,切换到 B——这笔钱根本不存在,但客户端已经收到了「成功」响应。

这就是「半同步复制」被反复念叨的原因:主库等至少一个从库确认收到 binlog 才返回客户端。代价是延迟和可用性两头都要付。

半同步只是把概率调低。真正的兜底是切换时做数据校验:MHA / Orchestrator 在提升新主库前用 pt-table-checksum 比对差异,不一致就拒绝切换——宁可让集群停住,也不让它带着丢数据往前走。

死法六:缓存帮你把问题藏得更深

经典双写:写库 + 删缓存;读缓存 miss 后查从库。用户第一次读不到是正常的(缓存刚删还没回填),第二次就读到了。测试用例只跑一遍,这 bug 就活过了上线。

还有更恶的:为了「读自己的写」,有人把对象直接塞进写接口的返回值,前端展示一切正常,但列表页、详情页、导出报表全都对不上。用户截图给你,你看着那个「孤儿订单」开始怀疑人生。

死法七:监控的盲区

Seconds_Behind_Master 有个众所周知的坑:它是两个时间戳的差,从库 IO 线程一拉到 binlog 就会更新,哪怕 SQL 线程还在排队回放。IO 线程一追上,指标就显示 0,但回放还堵着。

它可能骗你(显示 0 实际在积压),也可能吓你(显示 300 实际是一笔五分钟的大事务刚提交完)。真正该盯的是 GTID 集合的差集。

SHOW SLAVE STATUS\G
-- 关注:Replica_IO_Running / Replica_SQL_Running
--        Retrieved_Gtid_Set / Executed_Gtid_Set
-- 它们的差集,就是还没应用的主库事务

三、怎么接住它:五层防御

核心只有一句:写完立刻要读的那一次,必须读到主库。围绕这一句能堆五层防御。

第 1 层:写完读主(最简单,也最常被跳过)

写操作返回后,紧接着的读取请求路由到主库。代价是一次跨机房往返,收益是彻底消灭「读不到自己写」。多数业务只需要这一层。

难点在于「哪些请求算写后读」很难自动判断。粗暴做法是写后 2 秒内该用户的读全打主库;聪明的做法是带一个 hint 让中间件识别:

-- 在 SQL 里显式标注,走 hint 中间件(如 ShardingSphere / Vitess)
SELECT /* SHARDING_SPHERE_HINT: WRITE_ROUTE */ * FROM orders WHERE id = 10086;

用「写后读」这个语义标签做路由,比用时间窗口拍脑袋准得多。

第 2 层:会话级绑定

用户登录后把会话固定在主库一段时间(比如 30 秒),期间读写都走主库。实现上在连接上打标记:

-- 会话变量标记,中间件识别后路由到主库
SET @read_consistency = 'LEADER';

-- 或者用 GTID 等待法:读之前先等从库追上指定 GTID
SELECT WAIT_FOR_EXECUTED_GTID_SET('uuid:1-12345', 1);

GTID 等待法是我偏好的方案:它把「等一下」变成精确可验证的条件,而不是拍脑袋的 sleep。代价是从库必须支持 WAIT_FOR_EXECUTED_GTID_SET。

第 3 层:单调读与「把读挪出数据库」

如果业务允许「读到旧一点,但不能比刚才更旧」,就给每个会话记一个「已读到的最新 GTID」,后续读只允许路由到已应用该 GTID 的节点。本质是版本戳路由,Facebook 的论文里最早系统化描述过这套方案。

更激进的做法是别让读路径依赖数据库做强一致。库存扣减、优惠券核销这类场景,把「读-判断-写」挪进一个原子单元:

-- 用乐观锁把判断和写入合成一次原子操作,而不是先读后写
UPDATE inventory
SET available = available - 1, version = version + 1
WHERE sku_id = 1001 AND available >= 1 AND version = ?;
-- 影响行数为 0 → 条件不满足,重试即可

这样根本不需要读操作去判断,写路径自己就能保证一致性。

第 5 层:把主从切换从「自动」改成「半自动」

自动 failover 是可用性的来源,也是丢数据的来源。可以做的是:切换触发后先让新主库进入「只读恢复态」,跑校验和补写,通过了再放开写入。切换期间业务返回明确的「系统维护中」,而不是基于残缺数据的成功回执。

用户宁可看到「稍后重试」,也看不到一个绿色的「成功」然后三分钟后订单凭空消失。后者被投诉十次,前者一次就完了。


四、排查现场:我的标准动作

遇到「写完读不到」,按这个顺序查:

第一步,确认是延迟而不是缓存/事务问题。主从两边各跑一次,比对 GTID 集合:

SELECT VARIABLE_VALUE AS gtid_executed
FROM performance_schema.global_variables
WHERE VARIABLE_NAME = 'gtid_executed';
-- 两边都跑,从库里缺的那一段,就是还没到的变更

第二步,找延迟的「那一个事务」。GTID 连续、只差一点点是正常滞后;差了一整段,就去主库 binlog 里定位这段变更,看是不是一笔大事务或 DDL。

# 统计大事务:按事务行数排序
mysqlbinlog binlog.000123 | awk '/^### (INSERT|UPDATE|DELETE)/{c++} /COMMIT/{print c, NR; c=0}' \
           | sort -rn | head

第三步,看从库在回放什么。

-- 从库工作线程状态
SELECT thread_id, name, state
FROM performance_schema.threads
WHERE name LIKE '%replica%' OR name LIKE '%slave%';

-- 实时延迟(Percona 工具集)
pt-slave-delay --host=slave --master-host=master --interval=1

第四步,看从库是不是被别的东西压住了。很多团队主从延迟的原因特别朴素:从库上还跑着报表查询、备份、或一个没关的定时任务把 IO 打满。回放线程和业务查询抢 IO,从库永远输,它优先级低。所以硬规则是:从库上不允许跑非复制类的重查询。


五、几条不讨喜但很实用的结论

1. 「读写分离提升性能」的收益被高估了。大部分系统的瓶颈在写或业务逻辑本身,把 30% 的读挪到从库,可能连 CPU 都省不出来,却换来一整套一致性难题。

2. 慢查询是延迟的放大器。从库回放慢,除了大事务和不带索引,还有一半原因是统计信息过期导致优化器选错计划:回放一个本该走索引的 UPDATE,在从库上变成全表扫。

3. 「我加了监控」不等于「我能定位」。延迟类问题需要三个量:主库 GTID 集、从库 GTID 集、回放线程状态。少一个都只能靠猜。

4. 灰度永远比架构更管用。在彻底改造读写路由前,一个成本极低的兜底是:给「写后立即读」这条路径加个显式标记,灰度放量观察错误率。它可能只覆盖 5% 的流量,却覆盖 90% 的投诉。


最后

分布式系统里最贵的一类 bug,不是「服务挂了」,而是「服务好好地返回了一个错误的答案」。前者有报警,后者只有用户知道。

而这类 bug 的根因朴素得让人想笑:数据还没从 A 搬到 B,你就跑过去读了。所以下次看到 Seconds_Behind_Master 飙红,别急着加机器,先去看看是不是又有人写了一笔没带 WHERE 的 UPDATE,或者在从库上跑了 SELECT COUNT(*)。

架构上,多加一次跨机房往返,通常比多修一个「自以为有强一致」的 bug 划算得多。

相关文章

限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞
别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账
生成一个 ID 而已,怎么就变成了事故现场:分布式 ID 的五层坑
epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?
Rust 三大痛点,其实只有一个:所有权。读完你会想删掉所有 GC
NULL 不是空,是”未知”:一次凌晨两点的订单失踪,揪出 SQL 三值逻辑的真凶

发布评论