刚改完数据刷新就不见了?如果你第一次遇到这个问题,多半会怀疑缓存出 bug,其实大概率是主从延迟把“读后写”(Read Your Writes,读己之写一致性)给坑了。前一阵有同事拍桌子问我:“用户改完昵称,保存提示明明弹出来了,刷新一遍又变回旧昵称,主库上数据明明在,凭什么说人家没保存成功?”
这个问题我前后处理过好几轮,网上讲一致性的文章很多,但大多数要么停在概念层,要么只丢一个“关键读走主库”的结论就跑。这篇我想把整个链路摊开聊清楚:写入成功到底意味着什么,从库落后到底卡在哪几个环节,“读后写”陷阱在什么条件下必然出现,以及我实测过有效的四类处置方案。写这篇主要是给正在做读写分离、或者刚被这类幽灵 bug 折磨过的后端同学一个可落地的参考,读完你能带着排查思路回去。
1. 先复现现场:为什么“保存成功”和“刷新消失”能同时成立
1.1 “保存成功”只代表主库接受了
先看最典型的现场。一套常见的 Web 架构:主库负责写,若干从库分担读,应用层通过中间件或数据源路由做读写分离。用户提交修改资料请求,应用把 UPDATE 发到主库,主库返回 OK,接口告诉用户“保存成功”。紧接着用户刷新页面,浏览器发出一条新的查询请求,这条请求被路由到了一个从库上。
问题就出在这里:如果这个从库还没收到刚才那条 UPDATE 的 binlog,或者收到了 binlog 但还没来得及执行,它查出来的仍然是旧数据。用户看到的情况就是“刚改完,一刷新就没了”。
从系统的视角看,没有任何一个环节“出错”:
- 主库确实完成了提交,数据没问题;
- 从库确实只包含了它当前已执行到的事务,旧数据在它的快照里也是事实;
- 应用层只是把读请求均匀分发到了多个节点,恰好落到了一个“还没追上”的节点上。
所以这本质上不是某个组件坏了,而是异步复制架构下,数据存在多个副本,而副本之间有一个天然的时间窗口。这个窗口里,不同节点对外回答的是不同版本的事实。
1.2 为什么这次“消失”,上次却没有
很多团队第一次遇到这个问题时,会觉得它像个随机 bug:有时复现,有时不复现,用户一刷新可能又好了。因为它是否发生,取决于三个变量在某一瞬间是否同时成立:
- 读请求被路由到了从库(而不是主库);
- 从库当前存在延迟;
- 从“写入提交”到“发起读请求”的时间间隔,小于该从库追上该事务所需的时间。
三个条件缺一个都不会触发。这也是这类问题难排查的原因——正常情况下读写间隔可能只有几十毫秒,从库延迟往往也是几十毫秒到几秒之间波动,两者互相“赛跑”,时而追得上,时而追不上。
“刷新”这个动作其实加剧了问题。用户觉得“刷新一下是再看一眼”,但从系统的角度看,每次刷新都是一条全新的请求,负载均衡可能把它分给不同的从库。假设你有 3 个从库,其中一个正被大查询拖慢,用户第一次刷新命中它,看到旧数据;第二次刷新命中另外两个正常的从库,数据又“变”了。这种反复横跳,特别容易被用户理解为“系统出 bug 了”。
提示:排查这类问题时,先不要怀疑用户、不要怀疑缓存,第一步永远是确认“写入落的是哪个节点,读取打的是哪个节点”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 binlog 到 SQL 线程:主从延迟到底卡在哪几个环节
2.1 一条写入在从库上要经过三程转运
要理解延迟为什么不可能彻底消灭,得先把复制链路看清楚。以 MySQL 为例,主从复制大致是这么跑的:
- 主库上事务提交,记录 binlog(二进制日志);
- 从库的 IO 线程通过网络把 binlog 拉到本地,写入 relay log(中继日志);
- 从库的 SQL 线程读取 relay log,逐条在从库上重放执行。
这中间每一步都可能产生等待。第 1 步取决于主库刷 binlog 的频率和磁盘能力;第 2 步取决于网络带宽和延迟;第 3 步取决于从库自身的执行能力,这是延迟最主要、最不可控的一环。
默认的 MySQL 复制是异步的:主库并不关心从库是否收到、是否执行完。即使开启半同步复制(semi-sync),也仅仅保证“至少有一个从库收到了 binlog”,而不是“所有从库都执行完成”。你连接的可能恰好是这个“已收到但还没执行完”甚至可以不是“收到”的那一个从库。
2.2 延迟的六个常见来源
很多 DBA 会把延迟归因为“网络慢”,但实际生产中,从库 SQL 线程的执行能力才是大头。我把这几年见过的主从延迟来源归纳成六类:
- 大事务:一条 UPDATE 影响几百万行,主库执行 30 秒,binlog 里就会积压大量事件,从库要继续执行 30 秒甚至更久。批处理任务、定期归档、全表更新是重灾区;
- 从库串行重放:早期 MySQL 从库 SQL 线程是单线程执行的,主库再快,从库也只能一条一条来。MySQL 5.7 之后有了并行复制,但受事务依赖和主库并行度影响,效果可能打折扣;
- DDL 操作:主库执行完一条 DDL 后,从库重放 DDL 时同样需要拿元数据锁,期间后续事务全部排队;
- 从库自身负载:很多团队会拿从库跑报表、做备份、承接复杂统计查询,这些操作会和 SQL 线程抢 CPU、内存、磁盘 IO;
- 网络抖动与 relay log 写入慢:IO 线程拉取跟不上主库产生 binlog 的速度,relay log 落盘慢,都会让链条吃紧;
- 锁等待和重试:从库重放事务时遇到行锁、间隙锁冲突,需要等待,累积起来也会形成明显延迟。
理解了这些来源,你就明白一个事实:主从延迟不是一个“调优后可以归零”的指标,它是一个随时会因业务流量、后台任务发生波动的不确定量。你只能把延迟控制在一定水平,但永远不可能保证“读的瞬间它恰好为零”。
2.3 怎么判断从库落后了多少
最直觉的方法是看从库状态:
sql复制SHOW SLAVE STATUS\G
关键字段有这些:
| 字段 | 含义 | 注意点 |
|---|---|---|
| Slave_IO_Running | IO 线程是否在拉 binlog | 必须是 Yes |
| Slave_SQL_Running | SQL 线程是否在重放 | 必须是 Yes |
| Seconds_Behind_Master | 从库落后主库秒数 | 最常用,但最容易被误解 |
| Read_Master_Log_Pos | 已拉取到主库 binlog 的位置 | 说明“收到哪了” |
| Exec_Master_Log_Pos | 已执行到主库 binlog 的位置 | 说明“应用到哪了” |
Seconds_Behind_Master 的算法,是拿当前从库系统时间和正在执行的 binlog 事件里带的主库时间戳做差。它有两个明显的坑:一是主从机器时钟不一致会直接算错;二是从库 SQL 线程在“追赶”一批事件时,因为事件是连续执行的,这个值可能剧烈跳动,甚至瞬间归零,让你误以为没有延迟。
对延迟敏感的场景,我建议用 pt-heartbeat 这类工具做更精确的测量。它的原理是主库每秒更新一张心跳表,写入当前时间戳,从库去读这张表,算时间差。这样测出来的是真实的复制延迟,而不是依赖事件的估算值。
但无论用哪个工具,你都要记住一句话:延迟指标只能告诉你“大概落后多少”,它不能告诉你“用户要读的那一行,到底同步了没有”。 这正是接下来要聊的读后写问题的核心。
3. 读后写(Read Your Writes)的边界:哪些业务场景绝对不能妥协
3.1 先把一致性概念捋清楚
做分布式系统,一致性这个词被用得太滥了。落到数据库复制这个场景,我们把常见的一致性要求按“严苛程度”排一下:
- 强一致:任何时刻任何节点读到的都是同一条最新数据。读写分离架构在异步复制下做不到,只有读写都在主库、或者引入分布式事务协议才能勉强靠近;
- 最终一致:允许读旧数据,但保证在没有新写入后,经过一段时间所有副本会收敛到最新值;
- 读己之写(Read Your Writes):只要求“我自己的写入,我自己能读到”。别人读到旧数据我不关心,但同一个用户刚提交的操作,必须在后续请求里看到结果。
读后写是这几档里定位很特殊的一个:它看起来要求很低,但恰恰是读写分离架构最常打破的一条。平时大家口语叫“读后写”,准确讲应该叫“写后读”,英文术语是 Read Your Writes,严格定义是:写操作完成后,发起该写入的会话(或用户)应当能读到自己的写入,无论它从哪个副本发起读取。
注意这个定义没有要求“所有节点都一致”,只要求“你自己的后续读取必须包含你的写入”。这等于给系统提了个精准的约束:别人的读可以走从库,但刚写完的用户的读,必须被某种机制“保护”起来。
3.2 哪些业务链路必须守住这条线
不是所有读都值得付出代价去保。我一般会把业务链路按“写后是否立即被同一用户读取”分成两类。下面这些场景,一旦触发读后写失效,直接影响体验甚至资产安全:
| 业务场景 | 写操作 | 紧随其后的读 | 失效后果 |
|---|---|---|---|
| 注册登录 | 写入用户资料 | 登录后拉取资料、跳转首页 | 用户以为账号没创建成功,重复注册 |
| 下单支付 | 创建订单 | 跳转订单详情或列表 | 用户以为没下单成功,重复下单/客诉 |
| 资料编辑 | 修改昵称/头像 | 刷新个人信息页 | 用户以为保存失败 |
| 权限调整 | 修改角色/密码 | 下一次鉴权、访问受保护资源 | 权限配置“不生效”,或旧密码仍能登录 |
| 配置/开关 | 更新功能开关 | 配置中心下发后立即读取展示 | 功能状态不一致,运营判定出错 |
这些共同点是“写”和“读”在业务上强相关,并且大多是同一个用户在同一段时间内连续发起。对这种链路,最终一致是不够的,你必须主动做点什么。
3.3 陷阱的触发条件,再往深看一层
前面说了三个条件,现在把其中最隐蔽的一条单独拎出来:写与读的时间间隔小于复制延迟。这条听起来直白,但实际操作中,时间间隔很难预估。
举个例子:用户提交订单,应用层写主库用了 5 毫秒,返回给前端,前端拿到结果后渲染,用户再点“查看订单”,这中间已经过了几百毫秒甚至几秒。如果从库延迟是 100 毫秒,理论上读从库没问题,可一旦后台正在跑一个大事务,延迟被拉到 5 秒,读到的就是旧状态。也就是说,问题出现的概率背后,是业务耗时和复制延迟这两个随机量在赛跑。
另外要提醒一点:不要把“读后写”失效和“缓存不一致”“事务未提交读不到”混为一谈。缓存不一致是缓存淘汰策略的问题;而一个事务内的写入,在提交前连主库的普通 SELECT 都读不到,那是 MVCC 的隔离机制,跟主从延迟无关。这两类问题排查方向完全不同,写排查报告时尤其要分清楚。
4. 方案一:把“关键读”路由到主库
4.1 关键读的判断原则
最直接也最常用的思路:允许写走主库,但把“写后紧接着的读”也强制走主库。这需要你先定一个原则——哪些请求算关键读。
我的判断标准有三条,满足任意一条就该考虑强制主库:
- 用户刚完成了写操作,且他的下一步业务动作强依赖写结果;
- 读到的数据会影响用户的下一步写决策(比如看到“账户余额不足”才去充值);
- 涉及金额、订单状态、权限、开关等敏感字段的读取。
这里要克制。如果所有读都走主库,读写分离就白做了,主库压力上去了,延迟问题会以另一种形式回来。原则是“让 80% 的普通读继续走从库,把 20% 的关键读兜住”。
4.2 Spring 数据源级别的实现
如果你用的是 Spring + 动态数据源,最少的改动就是加一个注解,在 AOP 里切换路由。核心是继承 AbstractRoutingDataSource:
java复制public class MasterSlaveRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return ReadWriteRoute.get();
}
}
路由上下文用一个 ThreadLocal 保存:
java复制public class ReadWriteRoute {
public static final String MASTER = "master";
public static final String SLAVE = "slave";
private static final ThreadLocal<String> ROUTE = new ThreadLocal<>();
public static void setMaster() {
ROUTE.set(MASTER);
}
public static void setSlave() {
ROUTE.set(SLAVE);
}
public static String get() {
return ROUTE.get() == null ? SLAVE : ROUTE.get();
}
public static void clear() {
ROUTE.remove();
}
}
再定义注解和切面:
java复制@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface MasterRead {
}
java复制@Aspect
@Component
public class MasterReadAspect {
@Around("@annotation(MasterRead)")
public Object forceMasterRead(ProceedingJoinPoint pjp) throws Throwable {
ReadWriteRoute.setMaster();
try {
return pjp.proceed();
} finally {
ReadWriteRoute.clear();
}
}
}
业务代码里,只需要在关键读方法上打标记:
java复制@MasterRead
public OrderDetailVO getOrderDetail(Long orderId) {
return orderMapper.selectById(orderId);
}
一个容易被忽略的细节:如果你用的是 Spring 声明式事务,并且方法上已经开了 @Transactional,那么切面的顺序很关键。一定要确保路由切换发生在事务开启之前,否则连接池已经用默认路由拿了从库连接,再切就晚了。我一般会把这个切面的 @Order(Ordered.HIGHEST_PRECEDENCE) 设置成最高优先级,并且让写事务方法内部不依赖读从库。
4.3 中间件与 SQL Hint 的路线
如果你用的是 ShardingSphere 这类中间件,不需要自己写切面,它有现成的 Hint 机制:
java复制HintManager hintManager = HintManager.getInstance();
hintManager.setWriteRouteOnly();
try {
List<OrderDetailVO> list = orderMapper.listByUser(userId);
} finally {
hintManager.close();
}
如果读写分离是数据库代理(Proxy)层做的,也可以在 SQL 里加注释标记,由代理解析并路由到主库:
sql复制/* master */
SELECT * FROM orders WHERE user_id = 1024;
这类方案的好处是侵入性小,坏处是依赖中间件版本和团队规范,代码 review 时容易漏。我的建议是优先用注解方案,因为它是代码级约束,编译期和 review 都能看到。
4.4 会话级的兜底:让“刚写过的人”一段时间内都走主库
方法级注解有个局限:它只能覆盖你提前标记过的方法。但用户的浏览路径是不可穷举的——他可能改完资料后,去首页看到个人摘要,去关注列表看到头像,这些地方很难挨个加注解。所以还需要一个会话级兜底:用户写入成功后的 N 毫秒内,该用户的所有读请求都强制走主库。
实现方式很朴素:
- 写接口成功后,给响应里塞一个标记(Cookie 或者自定义 Header);
- 读请求进来时,网关或过滤器统一检查标记,命中则把路由设为 master;
- 标记带一个时间戳,过期后自动恢复走从库。
如果应用是多实例部署,标记不能只放本地内存,要放到 Redis 这类共享存储,否则会出现“写请求落在 A 实例,读请求落在 B 实例”的漏网场景。稍后第 6 章我会专门讲这个方案怎么和缓存标记配合。
5. 方案二:用 binlog 位点和 GTID 让从库先追上再读
5.1 先想清楚:等待的本质是“消费延迟”而不是“消灭延迟”
方案一解决了大部分问题,但有个副作用:强制走主库会把主库流量抬高。有些团队主库本来就很脆弱,或者读 QPS 极高,根本扛不住“写后所有读都走主库”的冲量。
于是有了方案二:不从路由上绕开从库,而是让读从库之前,先确认这个从库已经执行到“包含我这次写入”的位点。 如果还没到,就等一小会儿;等到了就正常读从库;等超时了再兜底走主库。这样关键读仍然分散在从库上,主库只承接少量超时回退请求。
5.2 基于 binlog 位点:MASTER_POS_WAIT
MySQL 提供了一个函数专门干这件事:
sql复制-- 在主库执行写操作后,拿到当前的 binlog 文件名和位点
SHOW MASTER STATUS;
-- 假设返回 File=mysql-bin.000231, Position=50234122
-- 在要读取的那个从库上执行
SELECT MASTER_POS_WAIT('mysql-bin.000231', 50234122, 5);
第三个参数是超时秒数。返回值有讲究:
- 返回
0:从库已经执行到该位点或之后,可以直接读; - 返回正数 N:从库等待了 N 秒后才到达该位点,说明确实有延迟,但现在追上了;
- 返回
-1:等待超时,从库还没追上,此时应该走主库兜底; - 返回
NULL:从库 SQL 线程没跑起来或参数错误,这种状态不适合读从库。
这个方案要求应用层在写完后马上拿到主库的 binlog 位置信息,并向从库发起一次 MASTER_POS_WAIT 调用,增加了额外的网络往返。但它精确、可靠,适合对一致性要求最高的关键链路。
5.3 基于 GTID 的等待更省心
位点方案有两个麻烦:一是要记住 binlog 文件名和数字位置,换文件后容易搞错;二是在一主多从、级联复制的复杂拓扑里,位点对应关系不那么直观。GTID 方案可以绕开这些问题。
前提是你开启了 GTID(MySQL 5.7 之后生产环境我强烈建议开)。流程变成三步:
sql复制-- 第一步:写操作完成后,在主库拿到当前已执行的事务集合
SELECT @@GLOBAL.GTID_EXECUTED;
-- 假设返回 2f0fd2e0-a11c-11ee-9c5a-fa163e9dd001:1-248975
-- 第二步:在要读取的从库上等待这个集合被执行到
SELECT WAIT_FOR_EXECUTED_GTID_SET('2f0fd2e0-a11c-11ee-9c5a-fa163e9dd001:1-248975', 5);
-- 第三步:返回 0 表示已追上,返回 NULL 表示超时(不同版本返回值可能有差异,以官方文档为准)
GTID 方案的好处是:应用层不需要关心 binlog 文件名、不需要关心从库当前追到哪个位置,只需要把主库返回的 GTID 集合传给从库,让从库自己判断“我有没有跟到这一步”。在并行复制场景下,这个判断也是准确的。
应用层的伪代码可以是这样的:
java复制public <T> T readGuarantee(String gtidSet,
Supplier<T> masterReader,
Supplier<T> slaveReader,
long timeoutMs) {
boolean synced = syncService.waitForGtidSet(gtidSet, timeoutMs);
if (synced) {
return slaveReader.get();
}
// 超时或异常,回退主库,保证读己之写
return masterReader.get();
}
5.4 超时时间怎么定
“等待多久”是个经验题。等太长,用户感知就是页面卡住;等太短,经常超时回退主库,方案二的效果就打了折扣。
我通常这样取:先看一周内主从延迟的 P99 值,把等待超时设为这个值的 2 到 3 倍,并且给一个硬上限(比如 1 秒)。为什么加硬上限?因为读请求本身对延迟敏感,超过 1 秒的等待对用户体验的伤害,已经超过了数据不一致本身的伤害。超时之后直接回退主库,是成本最低的兜底。
另外别忘了做三个监控指标:等待请求的耗时分布、超时比例、回退主库的比例。如果超时比例长期偏高,说明复制延迟比想象中大,该去治理大事务和从库负载了,而不是一味调大超时时间。
6. 方案三:版本号、缓存标记与体验层降级
6.1 用版本号自己判断“旧不旧”
方案一和方案二都是在数据库层面解决问题。有时候你并没有权限改数据源路由,或者用的中间件不支持 GTID 等待,那就回到业务侧想办法。
最实用的一招:利用业务表里的版本字段。很多表本来就有 version 或 updated_at,写操作完成时把新版本号返回给前端;前端后续读请求带上这个版本号;服务端读从库后如果发现数据的版本号小于期望值,就知道这份数据是旧的,立即改走主库重读或直接重试。
java复制// 写接口返回新版本号
Long newVersion = orderService.createOrder(order);
// 读接口,携带期望版本
OrderDetailVO vo = slaveOrderMapper.selectById(orderId);
if (vo.getVersion() < newVersion) {
// 从库数据不够新,读主库
vo = masterOrderMapper.selectById(orderId);
}
这个方案的优点是完全没有数据库函数依赖,任何版本都能用;缺点是侵入业务代码,而且列表场景比较复杂——一次读 20 条记录,可能只有 3 条不够新,你得逐条判断。所以它更适合详情页、单条数据这类场景,不适合列表聚合场景。
6.2 缓存标记:给“刚写过的人”挂一张 master 通行证
另一个思路是彻底不碰数据库路由的代码,而是用缓存做一个短期标记。写入接口成功后,往 Redis 里写一个带 TTL 的键,读接口进来先查这个键,命中就走主库,未命中走从库。
java复制// 写入接口
redis.setex("ryw:user:" + userId + ":order", 3, "1");
// 读接口
boolean forceMaster = Boolean.TRUE.equals(
redis.hasKey("ryw:user:" + userId + ":order"));
String route = forceMaster ? ReadWriteRoute.MASTER : ReadWriteRoute.SLAVE;
TTL 怎么定?我一般取一周内主从延迟 P99 的 3 倍,再给一个最低值 2 到 3 秒。比如监控显示 P99 延迟是 800ms,TTL 就设 3 秒左右。太短兜不住突发抖动,太长则让大量请求压到主库。
要特别注意 Redis 本身的可用性。如果 Redis 挂了,hasKey 会抛异常,这时你至少要有两个选择:要么 fail-open(异常时走主库,保证读己之写,代价是主库瞬时流量升高),要么 fail-closed(异常时走从库,保性能但可能复现不一致)。我的建议是,对关键业务走 master,对非关键业务走 slave,别一把梭。
6.3 体验层的降级:让用户“看见”自己的写入
有些场景下,技术上能忍,但用户不能忍:他提交了订单,结果列表里空空如也,哪怕 3 秒后数据出现,这 3 秒里他已经开始焦虑了。这种时候,技术修复之外还得配一层体验层面的保护。
常见做法是乐观 UI:前端提交成功后,先把用户写入的结果渲染到本地页面,不依赖后端返回值;同时显示“处理中”状态,等后端确认后再替换为正式数据。比如小红书式的评论、点赞,都是这个套路。
还有一种做法是跳转兜底:创建订单成功后,不要走“回到订单列表再刷新”的老路,而是直接跳到订单详情页,并且明确要求详情接口走主库。把“写后立即读”的窗口收敛到一个可控的接口上,剩下的交给时间。
要明确一点:体验层降级不能替代数据库层的保证。它只是让“不一致”在用户视角里不那么刺眼,真正的一致性还是得靠前几个方案在数据层面兜住。
6.4 推荐组合拳
以我现在的习惯,遇到需要保读己之写的模块,我会这样组合:
- 创建、改配置这类写后必有紧跟读的接口,用方案二(GTID 等待)做核心保障;
- 同时给用户加缓存标记,覆盖他没被标记到的零散读路径;
- 普通读保持从库分摊;
- 对非关键写操作(评论、点赞),配合体验层乐观 UI 接受短暂最终一致。
三个方案不是互斥的,它们的代价和粒度不同,组合着用才能在性能、一致性、运维成本之间找到平衡。
7. 一次订单“消失”事故的完整复盘:定位链路与五个次生坑
7.1 事故现场:下单成功,列表是空的
有一次线上告警:某业务的订单列表接口“查不到刚创建的订单”的报错量在半小时内持续上升,客诉也来了。我们第一反应是订单服务写失败了,但业务日志显示写入接口全部返回成功。再查主库,订单记录存在。
真正的问题在从库。当时架构是 1 主 3 从,订单列表接口固定走从库,三个从库节点通过负载均衡轮询分发。那段时间恰好有一个后台归档任务,每小时跑一次大事务 UPDATE,主库执行没问题,但 binlog 到了从库之后,三个从库的 SQL 线程都在排队重放,延迟一度冲到 60 秒。用户下单后 1 秒内刷新列表,命中的从库还停留在旧位点,订单自然“凭空消失”。
时间线大致是这样的:
| 时刻 | 事件 | 说明 |
|---|---|---|
| 21:03:12 | 主库写入订单成功 | 数据已提交,但只有主库可见 |
| 21:03:15 | 用户刷新订单列表,请求命中从库 | 该从库还停留在 21:02 左右的位点 |
| 21:03:18 | 布尔查询结果为空,前端渲染空列表 | 用户感知“订单丢了” |
| 21:03:48 | 大事务在从库重放完成 | 用户再刷新,订单“自己回来了” |
“数据自己又回来了”这个现象,是主从延迟类问题最典型的指纹。如果你在客诉里看到“过几分钟重新进又好了”,基本可以直接怀疑复制延迟,而不是真正的数据丢失。
7.2 排查链路:四步定位根因
我把当时的排查过程整理成可复用的链路:
第一步:确认主从数据不一致的形态。 拿订单 ID 在主库和从库分别查:
sql复制-- 主库查询
SELECT * FROM orders WHERE order_id = 20240531001;
-- 从库查询
SELECT * FROM orders WHERE order_id = 20240531001;
主库有、从库无,立刻确认是复制延迟导致的数据可见性问题。
第二步:量化延迟。 上从库执行 SHOW SLAVE STATUS\G,看 Seconds_Behind_Master 和 Exec_Master_Log_Pos。当时 Seconds_Behind_Master 在 30 到 60 秒之间剧烈波动,Slave_IO_Running 为 Yes,Slave_SQL_Running 为 Yes,说明不是复制中断,是纯粹的“追不上”。
第三步:顺藤摸瓜找大事务。 看从库的 SHOW PROCESSLIST,能看到 SQL 线程正在执行一条影响范围极大的 UPDATE,正是那个归档任务。再到主库看 binlog 里对应事件的大小,确认问题源头。
第四步:核对读路由配置。 看应用配置和中间件的读写分离规则,确认订单列表确实走从库且没有做任何写后保护。到这一步,根因已经明确:读路由没有考虑读己之写,复制延迟被大事务瞬间放大,窗口期被用户撞上。
7.3 修复与验证:从热修复到常态化探针
短期热修复很简单:给订单详情和列表的“刚创建”路径加 @MasterRead,同时创建接口返回后前端跳转到详情页;后台归档任务暂停,先恢复订单列表可用性。
中期修复有三件事:一是把归档大事务拆成小批次,限制单批影响行数,从源头降低 binlog 积压;二是给从库加上“复制延迟超过阈值自动摘除”的保护,让读流量不再打到严重落后的节点;三是加一个常态化探针——定时脚本写入一条特征数据,再从从库读取,记录“从写入到从库可见”的耗时,超过阈值直接告警。这个探针比单纯看 Seconds_Behind_Master 更贴近真实业务的读己之写视角。
验证环节,我们当时在测试环境用 STOP SLAVE 手动制造了 30 秒延迟,然后用压测脚本模拟下单后立刻读列表,确认热修复后所有请求都命中了主库,异常率降为零。这里提醒一句:STOP SLAVE 这种操作只能在测试环境用,而且要记得 START SLAVE,否则忘了恢复就是一次新的线上事故。
7.4 复盘里藏着的五个次生坑
在处理这次事故以及后续优化的过程中,我踩了不少“方案本身的坑”,写在这里帮你避雷:
坑一:只给“列表查询”加了注解,漏了“详情接口”。 用户从列表点进详情,如果详情仍然走从库,一样读到旧数据。给接口加保护时,要把用户写完到“下一步所有可能读到的页面”都过一遍清单,而不是只盯着当下的触发接口。
坑二:一个事务里先写后读,和主从延迟无关。 事务没提交前,连主库的普通读都读不到自己写的数据,那是隔离级别的问题。不要在主从延迟方案里试图解决它,否则方向完全跑偏。
坑三:本地内存标记在多实例下失效。 用 ConcurrentHashMap 记录“哪个用户刚写过”只在单机有效。应用多实例部署后,写请求打在 A 实例,读请求被负载均衡分到 B 实例,B 实例不知道用户刚写过,照样把请求发到从库。这类标记必须放 Redis 等共享存储。
坑四:缓存标记的 TTL 定太短。 TTL 没跑赢延迟抖动,用户看到的现象就是“时好时坏”,比一直坏更难排查。TTL 宁可定得宽松一点,也要把 P99 延迟的突发情况兜住。
坑五:把“读己之写”当成万能一致性。 它能保证“我读到自己的写”,但它不能保证“我读到别人的写”,更不能保证两个客户端看到同一个顺序。跨用户的数据一致性、唯一性约束,该上强一致还是得靠主库或者分布式事务,别指望这层机制兜底。
7.5 一点收尾思考
处理完这次事故后,我把团队的代码规范加了一条:给任何读写分离的接口做设计时,必须回答一个问题——“这个数据被写之后,同一个用户会不会在短时间内再读它?” 这个问题的答案,决定了它要不要挂上读己之写的保护。比起事后追着延迟指标打补丁,把一致性要求和业务链路绑定在一起,是我目前觉得更省力的做法。
最后再分享一个我自己的小习惯:线上出这类问题,先保留现场,不要急着重启从库或删数据。主从延迟类问题的证据链特别短,一旦节点被重启、数据被冲刷,复盘就只能靠猜了。把日志、监控、binlog 位置都留好,再动手修。
