你刚写进去的数据怎么就读不出来了:主从延迟的七种死法
你在后台点「提交订单」,弹出「订单已创建」。立刻刷新列表——空的。再刷新,还是空的。两秒后,订单自己长出来了。
工程师查了一圈:代码没 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 划算得多。