2024 年我们做了数据库读写分离之后,陆陆续续收到一类很难复现的用户投诉:用户在个人中心改了昵称,提示"修改成功",可页面一刷新,昵称又变回了老的;过几秒再刷,新昵称才出现。一开始我们以为是前端缓存,查了半天前端干干净净。后来才定位到,问题出在我们刚上的读写分离架构上——写操作打到了主库,而紧接着的那次读操作被路由到了从库,可这一刻主库的数据还没同步到从库,用户就读到了同步前的旧数据。这就是经典的"主从延迟"问题。投了几天把主从复制的原理和读写分离的边界彻底梳理了一遍,本文复盘这次实战。
问题背景
业务:用户中心,MySQL 一主两从,做了读写分离
事故现象:
- 用户改昵称,提示成功,刷新后仍是旧昵称,过几秒才生效
- 下单后跳订单列表,新订单偶尔"查不到"
- 现象随机出现,本地、测试环境都难复现
现场排查:
# 1. 前端排查 -> 干净,无缓存
# 2. 看读写分离的路由逻辑
@Transactional(readOnly = true) // readOnly -> 走从库
public User getUser(Long id) { ... }
@Transactional // 写 -> 走主库
public void updateNickname(Long id, String name) { ... }
# 改完昵称(主库),马上 getUser(从库)-> 读到旧值
# 3. 在从库上查复制延迟
mysql> SHOW SLAVE STATUS\G
Seconds_Behind_Master: 3 # 从库落后主库 3 秒!
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
# 4. 业务高峰期再查
Seconds_Behind_Master: 27 # 高峰期延迟到 27 秒
根因:
1. 读写分离后,写主库、读从库,但主从复制有延迟
2. "写完立刻读"的场景,读请求落到还没同步的从库
3. 没有对"刚写完就要读"的场景做特殊处理
4. 主从延迟既没监控,也没在延迟过大时做保护
修复 1:先搞清楚主从复制是怎么回事
=== MySQL 主从复制的三个线程 ===
主库(Master)上:
- log dump 线程:负责把主库的 binlog 发给从库
从库(Slave)上:
- IO 线程:连接主库,接收 binlog,写入本地的 relay log(中继日志)
- SQL 线程:读取 relay log,把里面的变更在从库上重放一遍
=== 一次写操作的完整旅程 ===
1. 业务在主库执行 update,主库写 binlog,事务提交
2. 主库 log dump 线程把这条 binlog 推给从库
3. 从库 IO 线程收到,落到 relay log
4. 从库 SQL 线程从 relay log 取出,在从库执行同样的 update
5. 至此从库的数据才和主库一致
=== 延迟发生在哪 ===
从"主库事务提交"到"从库 SQL 线程重放完成",
中间隔着【网络传输】和【从库重放】两段时间,
这段时间差,就是主从延迟(replication lag)。
=== 关键认知 ===
默认的主从复制是【异步】的:
主库提交事务后,根本不等从库,直接就返回成功了。
所以"主库已提交"不等于"从库已可见",
这中间必然存在一个时间窗口 —— 这就是问题的根源。
修复 2:主从延迟到底是怎么来的
=== 原因 1:从库 SQL 线程是单线程重放(老版本)===
主库可以几十个连接【并发】写入,
但老版本 MySQL 从库的 SQL 线程只有一个,
要把主库并发产生的变更【串行】地重放一遍 ——
主库并发越高,从库越追不上,延迟越大。
=== 原因 2:大事务 ===
主库执行一个耗时 10 秒的大事务(比如 update 上百万行),
这个事务要等执行完才写完整的 binlog,
从库也得完整地把它重放一遍 ——
一个大事务就能让从库瞬间延迟十几秒。
=== 原因 3:从库在执行慢查询 / 被读流量压住 ===
从库要承接大量读请求,如果读把从库的 CPU、IO 占满,
SQL 线程重放的速度就被拖慢,延迟拉大。
=== 原因 4:从库机器配置比主库差 ===
有的团队为省钱,从库用了更弱的机器,
主库轻松写入的量,从库重放起来很吃力。
=== 原因 5:网络抖动 ===
主从跨机房时,网络延迟和抖动会拖慢 binlog 传输。
=== 一句话 ===
延迟的本质是:从库"消费 binlog 的速度"
跟不上主库"产生 binlog 的速度"。
治理思路也就两条:让从库消费得更快,或让主库产生得更平稳。
修复 3:把主从延迟监控起来
-- === 看延迟最直接的指标:Seconds_Behind_Master ===
SHOW SLAVE STATUS\G
-- 重点关注这几行:
-- Seconds_Behind_Master: 0 从库落后主库的秒数
-- Slave_IO_Running: Yes IO 线程是否正常
-- Slave_SQL_Running: Yes SQL 线程是否正常
-- Master_Log_File / Read_Master_Log_Pos IO 读到主库哪了
-- Relay_Master_Log_File / Exec_Master_Log_Pos SQL 执行到哪了
-- === 注意:Seconds_Behind_Master 并不总是准 ===
-- 它在某些情况下会失真:
-- 1. 主库长时间没有写入时,它可能显示 0,但其实 IO 线程已断
-- 2. 大事务执行中,它可能突然跳变
-- 所以要【结合】IO/SQL 线程状态、以及位点差一起看。
-- === 更准的办法:对比 binlog 位点 ===
-- 主库执行:
SHOW MASTER STATUS; -- 拿到主库当前 File 和 Position
-- 从库执行:
SHOW SLAVE STATUS\G -- 对比 Exec_Master_Log_Pos 和主库 Position
-- 两者位点的差距,反映从库还差多少没追上。
# 主从延迟告警:延迟是读写分离架构的命脉指标
groups:
- name: mysql-replication
rules:
# 1. 主从延迟过大(读写分离会读到明显的旧数据)
- alert: MySQLReplicationLagHigh
expr: mysql_slave_status_seconds_behind_master > 10
for: 1m
annotations:
summary: "从库 {{ $labels.instance }} 延迟 > 10s,读写分离将读到旧数据"
# 2. 复制线程中断(从库彻底停止同步,数据会越差越多)
- alert: MySQLReplicationStopped
expr: |
mysql_slave_status_slave_io_running == 0
or mysql_slave_status_slave_sql_running == 0
for: 30s
annotations:
summary: "从库 {{ $labels.instance }} 复制线程已停止,数据停止同步"
修复 4:写完立刻要读的场景,强制走主库
// === 核心思路:不是所有读都能容忍延迟 ===
// 把读分成两类:
// - 能容忍短暂旧数据的读(列表浏览、统计) -> 走从库,没问题
// - 写完马上要读、要求读到最新值的读 -> 必须走主库
// 主从延迟问题,根上是"该走主库的读,走了从库"。
// === 方案 1:手动指定强制走主库 ===
// 给数据源路由加一个"强制主库"的开关,
// 在"写完立刻读"的方法上显式声明。
public class DataSourceContext {
private static final ThreadLocal FORCE_MASTER =
new ThreadLocal<>();
public static void forceMaster() { FORCE_MASTER.set(true); }
public static boolean isForceMaster() {
return Boolean.TRUE.equals(FORCE_MASTER.get());
}
public static void clear() { FORCE_MASTER.remove(); }
}
// 路由时:强制主库优先级最高
protected Object determineCurrentLookupKey() {
if (DataSourceContext.isForceMaster()) return "master";
return TransactionSynchronizationManager
.isCurrentTransactionReadOnly() ? "slave" : "master";
}
// 用法:改完昵称后那次读,显式走主库
public User updateAndGet(Long id, String name) {
userMapper.updateNickname(id, name); // 写,主库
try {
DataSourceContext.forceMaster(); // 接下来强制读主库
return userMapper.selectById(id); // 读到的就是刚写的值
} finally {
DataSourceContext.clear(); // 用完务必清理 ThreadLocal
}
}
// === 方案 2:同一个事务里的读,本来就走主库 ===
// 把"写 + 紧接着的读"放进同一个 @Transactional(非 readOnly)方法,
// 整个事务都在主库上,自然读得到最新值。
// 缺点:事务范围会变大,要权衡。
修复 5:从复制层面缩短延迟
# === 手段 1:开启并行复制,让从库 SQL 线程多线程重放 ===
# 老版本从库 SQL 线程单线程,是延迟的主因之一。
# MySQL 5.7+ 支持并行复制,从库可多线程重放 binlog。
# 从库 my.cnf 配置:
# 基于 writeset 的并行复制(5.7.22+,并行度最高)
slave_parallel_type = LOGICAL_CLOCK
slave_parallel_workers = 8 # 并行重放的线程数
slave_preserve_commit_order = ON # 保证从库提交顺序与主库一致
# 主库 my.cnf 配合:
binlog_transaction_dependency_tracking = WRITESET
# WRITESET:只要两个事务改的不是同一行,就能在从库并行重放
# === 手段 2:半同步复制,把"异步"变成"半同步" ===
# 默认异步复制:主库提交后不等从库,延迟期间从库是旧的。
# 半同步复制:主库提交事务后,要【等至少一个从库确认
# 收到了 binlog】(写入 relay log)才返回成功。
# 主库:
plugin_load = "rpl_semi_sync_master=semisync_master.so"
rpl_semi_sync_master_enabled = 1
rpl_semi_sync_master_timeout = 1000 # 等从库确认最多 1 秒
# 从库:
plugin_load = "rpl_semi_sync_slave=semisync_slave.so"
rpl_semi_sync_slave_enabled = 1
# 注意:半同步只保证从库【收到】binlog,
# 不保证从库已经【重放完】,所以它显著缩小、但不能
# 完全消灭主从延迟。而且它会增加主库写入的响应时间,
# 一旦超时(1s),会自动退化回异步复制。
修复 6:业务层面优雅地应对延迟
// === 应对 1:能不立刻读,就别立刻读 ===
// 很多"写完立刻读"其实是不必要的。
// 比如改昵称后,前端完全可以直接用"用户提交的那个值"
// 来刷新页面,而不必再去查一次库 ——
// 你刚提交的值,你自己最清楚,何必再问数据库。
// 这是最省事、最可靠的解法:从源头消灭"写后读"。
// === 应对 2:缓存兜底,写时同步更新缓存 ===
// 写操作在更新 DB 的同时,把新值也写进 Redis。
// 后续的读先查 Redis,缓存里就是最新值,
// 既绕开了主从延迟,也减轻了数据库压力。
public void updateNickname(Long id, String name) {
userMapper.updateNickname(id, name); // 写主库
redis.set("user:" + id, queryNewUser(id)); // 同步刷新缓存
}
// === 应对 3:延迟读 / 读重试 ===
// 对一致性要求不极致的场景:读到的疑似旧数据时,
// 短暂等待后重试一次,给从库一点追赶时间。
// 是一种"妥协式"方案,不优雅但有时管用。
// === 应对 4:判断延迟,延迟过大时降级读主库 ===
// 路由层实时感知从库延迟:
if (slaveLagSeconds > LAG_THRESHOLD) {
// 从库延迟太大,这批读临时全部降级到主库,
// 牺牲一点主库压力,换数据正确性
return "master";
}
// 延迟恢复正常后,再自动切回从库。
// === 选型原则 ===
// 没有银弹。先按业务把读分级:
// - 必须最新(账户余额、库存)-> 强制主库 / 缓存
// - 容忍秒级旧(动态列表、评论)-> 走从库即可
// 把"对一致性的要求"想清楚,方案自然就出来了。
优化效果
指标 治理前 治理后
=============================================================
写后读一致性 偶发读到旧数据 写后读强制主库,读到最新
主从延迟(高峰) 27 秒 稳定 1 秒以内
从库复制方式 单线程 SQL 重放 并行复制 8 线程
复制模式 纯异步 半同步,主库等从库确认
主从延迟监控 无 延迟/复制线程双告警
读流量分级 所有读一律走从库 按一致性要求分级路由
延迟过大时 继续读旧从库 自动降级读主库
用户投诉(改资料不生效) 每周数起 清零
治理过程:
- 定位读写分离 + 主从延迟根因:0.5 天
- 主从延迟监控告警接入:0.5 天
- 写后读强制主库 + 读分级路由:1.5 天
- 并行复制 + 半同步复制配置调优:1 天
- 缓存兜底 + 延迟降级:1 天
避坑清单
- 主从复制默认是异步的,主库提交事务后不等从库,所以主从之间必然有延迟窗口
- 主从复制靠三个线程:主库 dump 线程发 binlog,从库 IO 线程收、SQL 线程重放
- 读写分离后"写完立刻读"会读到还没同步的从库,这是主从延迟最典型的踩坑场景
- 延迟的本质是从库消费 binlog 的速度跟不上主库产生 binlog 的速度
- 延迟常见原因:从库单线程重放、大事务、从库被读压住、机器弱、网络抖动
- 用 Seconds_Behind_Master 看延迟,但它会失真,要结合 IO/SQL 线程状态和位点差
- 写后立刻读的场景必须强制走主库,或把写和读放进同一个非只读事务
- 开启并行复制(LOGICAL_CLOCK + WRITESET)让从库多线程重放,显著缩短延迟
- 半同步复制让主库等从库确认收到 binlog,缩小延迟但不能完全消灭,且会增加写延迟
- 业务层把读按一致性要求分级:必须最新的走主库或缓存,容忍秒级旧的走从库
总结
这次主从延迟的排查,最折磨人的地方在于现象的"随机性"——用户投诉改完昵称不生效,可我们自己怎么点都点不出问题,本地测试、测试环境一切正常。这种随机其实是有道理的:主从延迟在系统空闲时往往只有零点几秒,人的操作根本撞不进那个窗口,只有在业务高峰、主库写入压力大、从库追不上的时候,延迟才会拉大到几秒甚至几十秒,这时候"写完立刻刷新"才有较大概率正好落在数据还没同步的窗口里。要理解这个问题,首先得接受一个很多人没意识到的事实:MySQL 默认的主从复制是异步的。所谓异步,就是主库执行完一个事务、写完自己的 binlog 之后,它根本不会等从库,直接就向业务返回"提交成功"了。binlog 从主库传到从库、再被从库的 SQL 线程一条条重放执行,这整个过程是在主库返回成功【之后】才慢慢发生的。所以"主库提交成功"和"从库数据可见"之间,天然就隔着一个时间差,这个时间差就是主从延迟。我们做读写分离,本意是好的——把读流量分摊到从库,给主库减负;但读写分离同时也把这个延迟窗口直接暴露给了业务:一个写请求打到主库,紧接着的读请求被路由到从库,如果这两件事发生得足够快、快过了 binlog 同步的速度,那这次读就必然读到旧数据。想通了这条因果链,治理的方向也就清晰了,它其实分成两个层面。第一个层面是想办法把延迟本身变小:从库那个负责重放 binlog 的 SQL 线程,在老版本里是单线程的,主库几十个连接并发写入产生的变更,从库要一条条串行地重放,天生就追不上,所以开启并行复制、让从库用多个线程来重放,是缩短延迟最有效的手段之一;再配合半同步复制,让主库在提交后稍微等一下、确认至少有一个从库已经收到了 binlog 再返回,延迟窗口会被进一步压缩。但我必须强调,这个层面的所有手段都只能把延迟【变小】,不能把它【变成零】——半同步只保证从库"收到"了 binlog,并不保证从库"重放完"了,异步复制的本质没有改变。所以真正解决问题,还得靠第二个层面:在业务上承认延迟的存在,并对读操作做分级。不是所有的读都需要读到绝对最新的数据——浏览一个动态列表、看一眼评论数,读到一秒前的旧值毫无影响,这类读放心地走从库;但用户刚改完自己的资料、刚下完一笔订单,这种"写完立刻就要看见"的读,就必须强制走主库,因为只有主库才有那个还没来得及同步出去的最新值。而这次复盘让我体会最深的,反而是一个最朴素、最容易被工程师忽略的解法:很多"写完立刻读"的需求,其实压根就是不必要的。用户改了昵称,前端要展示的那个新昵称,正是用户自己刚刚填进表单、刚刚提交上来的那个字符串——这个值前端本来就拿在手里,直接用它去刷新界面就好了,何苦再绕一大圈去问数据库?我们太习惯于"任何数据都以数据库为准"的思维定式,以至于会下意识地在写完之后再查一次库来"确认",可正是这次多余的确认,把自己一头撞进了主从延迟的窗口里。最好的方案,常常不是用更复杂的架构去对抗延迟,而是回头看看,那个会触发延迟的操作,是不是从一开始就不需要存在。
—— 别看了 · 2026