2023 年我给一个电商系统做数据库的读写分离——之前所有读写都压在一台主库上,大促时主库 CPU 直接打满。方案很标准:加一台从库,写操作发主库,读操作发从库,把主库的读压力分出去。第一版我做得很顺手:我在数据库连接层做了个路由,SQL 一看是 SELECT 就走从库,是 UPDATE/INSERT/DELETE 就走主库。本地我测了测,读写都正常,数据也对,我心里很笃定:主从复制嘛,主库一改,从库立刻同步成一模一样的副本,读主读从读到的是同一份数据,把读分流到从库纯粹是性能优化,对业务完全透明——这读写分离稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:用户在个人中心改了昵称、点保存,页面一刷新,显示的还是旧昵称,过几秒再刷,新昵称才出来。第二种最难缠:用户下完单,跳转到订单列表,列表里却没有刚下的那一单,得手动刷新一两次才看得到。第三种最头疼:偶尔在半夜的批量任务跑的时候,从库的数据会落后主库几十秒,那段时间整个站读到的全是几十秒前的旧数据。第四种最莫名其妙:有一次主库硬件故障,运维把从库提升成主库,结果发现最后几秒的几十笔订单不见了——明明用户那边显示下单成功了。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为主从复制就是主库的数据一发生变动,从库立刻就同步成一个一模一样的副本,所以无论读主库还是读从库,读到的都是同一份、同一时刻的数据;把读请求分流到从库,纯粹是一个性能优化,对业务逻辑完全透明,不需要我操心。可这个认知是错的。主从复制是异步的:主库提交一个事务,到这个事务的变更在从库上被回放完成,中间隔着一段时间——这段时间就是复制延迟(replication lag)。这段延迟正常时是毫秒级,你几乎感觉不到;可一旦遇上大事务、从库负载高、网络抖动,它会瞬间飙到秒级甚至分钟级。在这个延迟窗口里,主库已经是新数据、从库还是旧数据,两个库并不一致。我那个"写走主库、紧接着读走从库"的操作,如果发生在延迟窗口内,读到的就是还没被同步过来的旧数据——这就是"改完昵称还显示旧的""下完单看不到"的全部真相。所以读写分离,根上不是"把 SELECT 甩给从库"这一个动作,而是一整套工程:要承认复制延迟客观存在、要分清哪些读能容忍旧数据哪些不能、要让不能容忍的读走主库或等从库追上、要把延迟真实地监控起来、还要想清楚主从切换时的数据安全。本文从头梳理:为什么"写完立刻读"会读到旧数据,复制延迟到底从哪来,哪些读必须读主库,延迟怎么量化监控,以及一些把读写分离做扎实要避开的工程坑。
问题背景
先把主从复制这件事说清楚。主从复制(replication)是数据库的一个机制:你有一台主库(master)负责接收所有写操作,另有一台或多台从库(slave / replica)。主库每提交一个事务,就把这次变更记进自己的二进制日志(binlog);从库有一个 IO 线程,不断地把主库的 binlog 拉到本地存成 relay log;从库还有一个 SQL 线程,不断地回放 relay log 里的变更,把它应用到从库自己的数据上。这样,从库的数据就"追着"主库变。读写分离,就是建立在这套机制上的:写发主库,读发从库。
错误认知是:主从是一模一样的副本,读哪个都一样,分流读到从库对业务透明。真相是:复制是异步的,从库总是落后主库一段时间,这段延迟在异常时会很大;延迟窗口内主从数据不一致,把"延迟敏感的读"分流到从库,就会读到旧数据。把这一点摊开,第一版的几类问题就都能解释了:
- 改完昵称还显示旧的:写走主库、紧接着的读走从库,落在复制延迟窗口内,读到旧值。
- 下完单看不到订单:同一个"读己之写"场景——下单写主库,订单列表读从库,从库还没同步过来。
- 半夜整站读到旧数据:批量任务制造了大事务,从库 SQL 线程回放被堵,延迟飙到几十秒。
- 切换从库丢了订单:异步复制下,主库宕机时,最后没来得及同步出去的事务,就跟着主库一起丢了。
所以让读写分离真正可靠,核心不是"SELECT 一律甩给从库",而是一整套工程:正视复制延迟、区分读的延迟敏感度、给敏感读做强制读主或等待、监控延迟、用半同步收紧丢数据窗口。下面六节,就从第一版"主从是一模一样的副本"的想当然讲起。
一、为什么"写完立刻读"会读到旧数据
第一版我的读写分离,逻辑朴素到极致:SQL 一看是 SELECT 就走从库,其余走主库。
# 反面教材:写走主库、读走从库,却以为两边数据时刻一致
def update_profile(user_id, new_name):
# 写操作:发往主库
master_db.execute(
"UPDATE users SET name = %s WHERE id = %s",
(new_name, user_id))
def get_profile(user_id):
# 读操作:发往从库(为了给主库减压)
return slave_db.execute(
"SELECT name FROM users WHERE id = %s", (user_id,))
# 我以为:update_profile 一返回,主库就改好了,
# 主从是"一模一样的副本",所以紧接着 get_profile
# 从从库读,读到的一定是新名字。
# 可真相是:主库改完,到从库同步完,中间有一段延迟 ——
# 在这段延迟里去读从库,读到的还是旧名字。
问题就藏在这两个函数的配合里。update_profile 返回,只意味着主库这边的事务提交了;它完全不保证从库已经同步好了。从库要拿到这次变更,得等它的 IO 线程把对应的 binlog 拉过来、SQL 线程再把它回放完——这一切,都在 update_profile 返回之后才慢慢发生。而你的代码,在 update_profile 返回的下一行,就调了 get_profile 去读从库。代码跑得比复制快,于是你读到的,是从库那个"还没更新"的旧状态。
这一节要建立的认知是:主从复制的"主"和"从",不是同一份数据的两个等价入口,而是"原本"和"一份永远在追赶原本的拷贝"——你读从库,读到的从来不是"现在的数据",而是"从库目前追到的那个过去时刻的数据"。第一版最深的想当然,是把主库和从库看成了对称的:以为它们装着同一份数据,只是两个不同的连接地址,读哪个纯粹看负载、看心情。但它们根本不对称。主库是数据变化真正发生的地方,它永远代表"此刻";从库是一个拷贝,它靠异步地回放主库的日志来追赶,它永远代表"主库的某个过去时刻"。正常情况下这个"过去"离"此刻"很近(几毫秒),近到你以为它们就是一回事;可它再近,也是"过去"。一旦你接受"读从库 = 读一个略微过去的快照"这个事实,第一版的问题就不再奇怪了:你写完主库立刻读从库,本质上是"改了此刻、却去读一个还停留在改之前的过去"——读到旧数据是必然,不是偶然。所以读写分离的第一课,是把"主从等价"这个幻觉丢掉,换成一个清醒的认知:从库的数据,带着一个时间上的"滞后",而你要做的所有工程,都是围绕着"如何与这个滞后共处"。这个滞后具体从哪来、有多大,就是下一节的事。
二、复制延迟从哪来:binlog、回放与单线程
要和复制延迟共处,得先知道它从哪来。从库追赶主库,要走完一条链路:主库写 binlog,从库 IO 线程把它拉成 relay log,从库 SQL 线程再回放 relay log。这条链路上每一环都可能堆积,延迟就是堆积的总和。先看怎么观察它。
-- 在从库上执行,看复制的状态和延迟
SHOW SLAVE STATUS\G
-- 重点关注这几个字段:
-- Slave_IO_Running: Yes -- IO 线程在拉主库的 binlog,正常
-- Slave_SQL_Running: Yes -- SQL 线程在回放 relay log,正常
-- Seconds_Behind_Master: 0 -- 从库自报的延迟秒数(这字段有坑,见第四节)
-- Relay_Log_Space: 1073741824 -- relay log 占用的空间,持续涨说明回放跟不上
-- 如果 Seconds_Behind_Master 很大、Relay_Log_Space 一直涨,
-- 说明 binlog 拉过来了,但 SQL 线程回放不过来 —— 卡在了最后一环。
这条链路里,最容易成为瓶颈的是最后一环——SQL 线程回放。一个经典的原因是:主库上,几十个事务可以在多核上并发地提交;可从库的 SQL 线程,传统上是单线程的——它要一个一个地、串行地把这些事务回放完。主库并行写、从库串行追,延迟自然就堆起来了。解药是打开并行回放。
# 从库配置:把 SQL 回放从"单线程"改成"多线程并行"
# (MySQL 5.7+ / 8.0,缓解复制延迟的头号手段之一)
[mysqld]
# 按逻辑时钟并行:主库上能同时提交的事务,从库就能并行回放
slave_parallel_type = LOGICAL_CLOCK
# 并行回放的工作线程数(按从库 CPU 核数来调)
slave_parallel_workers = 8
# 8.0 里,这个开关让并行回放判定得更激进、并行度更高
binlog_transaction_dependency_tracking = WRITESET
# 开了并行回放,从库那个"单车道收费口"就拓宽成了多车道,
# 同样的写入洪峰,回放得更快,延迟自然降下来。
这一节的认知是:复制延迟不是一个"网络快不快"的问题,而主要是一个"从库追得上追不上"的问题——主库的写入是可以并发的,而从库的回放能力是有限的,当写入的洪峰超过回放的能力,延迟就开始累积,而且会越积越多。很多人对延迟的想象停留在"传输":以为延迟就是 binlog 从主库传到从库的网络耗时,所以网络好延迟就小。但网络传输通常是这条链路里最快的一环。真正的瓶颈在终点——从库把 relay log 回放进自己数据的速度。这里有一个根本的不对称:主库可以几十个连接并发地写,而从库的回放(在没开并行的情况下)是单线程串行的。这就像一条多车道的高速公路,最后汇进一个单车道的收费口。平时车不多,单车道也够用,延迟很低;可一旦来一波车流(并发写入升高、或者一个大事务),单车道立刻堵住,而且只要流入还大于流出,队伍就会持续变长——延迟不是稳定在某个值,而是会雪崩式地往上涨。看懂这一点,你就知道治理延迟有两个方向:一是拓宽那个收费口(并行回放,让从库也能多线程地追),二是别一次性放一大波车进来(控制大事务,这是第六节的事)。而无论延迟有多大,有些读是绝对不能容忍它的——那是下一节的事。
三、读己之写:哪些读必须读主库
延迟客观存在,那就得承认:不是所有读都能丢给从库。读可以分成两类。一类能容忍旧数据——比如看一篇别人的文章、刷一个商品列表,数据旧几百毫秒,用户根本察觉不到,这类读走从库,没问题。另一类不能容忍——典型就是"读己之写":用户自己刚刚写入的数据,他紧接着的那个读,必须能看到。改完昵称要看到新昵称,下完单要看到新订单。这类读如果走从库,就会撞上延迟窗口。处理它的核心手段,是"粘滞读主":一个用户写入之后的一小段时间,把他的读强制粘在主库上。
# 读己之写:写操作之后,这个用户的读暂时强制走主库
import time
class ReadRouter:
def __init__(self, stick_window=3.0):
# 一个用户写入后,在 stick_window 秒内,他的读都走主库
self.stick_window = stick_window
self.last_write = {} # user_id -> 最近一次写入的时间戳
def mark_written(self, user_id):
# 每次写操作后调用,记下这个用户刚写过
self.last_write[user_id] = time.monotonic()
def pick_db(self, user_id):
ts = self.last_write.get(user_id)
if ts and time.monotonic() - ts < self.stick_window:
return master_db # 还在粘滞窗口内,读主库
return slave_db # 窗口外了,读从库
# 关键:不是"所有读都走主库"(那从库就白搭了),
# 而是"刚写过的那个用户、那一小段时间"的读走主库。
有了路由器,把它接进读写操作就行——写完调一次 mark_written,读之前用 pick_db 决定走哪个库。
# 把路由器接进读写操作
router = ReadRouter(stick_window=3.0)
def update_profile(user_id, new_name):
master_db.execute(
"UPDATE users SET name = %s WHERE id = %s",
(new_name, user_id))
router.mark_written(user_id) # 写完,标记这个用户刚写过
def get_profile(user_id):
db = router.pick_db(user_id) # 让路由器决定读哪个库
return db.execute(
"SELECT name FROM users WHERE id = %s", (user_id,))
# 现在,update_profile 之后紧接着的 get_profile,
# 会因为粘滞窗口而走主库,读到的就是刚写的新值;
# 而和这个用户无关的、别人的读,照样走从库,主库压力没白省。
这一节的认知是:读写分离里真正要分类的,不是"读和写",而是"读和读"——把所有读笼统地甩给从库是第一版的错,正确的做法是先把读切成"能容忍延迟的"和"不能容忍延迟的"两类,再分别决定它们去哪。第一版的路由规则只有一个维度:SQL 是不是 SELECT。这个维度太粗了——它把"刷一个和自己无关的列表"和"确认自己刚下的订单"这两个延迟敏感度天差地别的读,当成了同一种东西,一律发往从库。可这两个读对"旧数据"的容忍度完全不同:前者旧一点无所谓,后者旧一点就是明显的 bug。所以路由的依据,不该是"这条 SQL 是读还是写",而该是"这个读,能不能接受读到一个略旧的版本"。能接受的,放心走从库,这才是读写分离省主库压力的本意;不能接受的(尤其是读己之写),要么直接读主库,要么想办法等从库追上。"粘滞读主"就是对"读己之写"这一类的标准答案:它不粗暴地"所有读都回主库"(那从库就白加了),而是精准地只把"刚写过的那个用户、那一小段时间窗口内"的读粘到主库上,既保证了这个用户看得到自己的修改,又把对主库的额外压力压到最小。把读按延迟敏感度分类,是读写分离从"能跑"到"正确"的关键一步。而要把这件事做精细,你得先能看见延迟到底有多大——那是下一节的事。
四、把复制延迟真实地量化和监控起来
前面一直在说延迟,可第一版自始至终都不知道延迟具体是多少——它对延迟是"无感"的。要把读写分离做扎实,必须先把延迟变成一个能看见的数字。最顺手的工具是从库自报的 Seconds_Behind_Master,但它有不少坑,不能全信。更可靠的办法,是自己用一张"心跳表"去测端到端的真实延迟。
-- 精确测延迟:用一张"心跳表",主库定时写时间戳,从库读差值
-- 1. 主库上,有一个定时任务每秒更新一行心跳(NOW(6) 精确到微秒)
UPDATE heartbeat SET ts = NOW(6) WHERE id = 1;
-- 2. 从库上,读这一行,和从库的当前时间相减 —— 差值就是真实复制延迟
SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(6)) / 1000 AS lag_ms
FROM heartbeat WHERE id = 1;
-- 这个延迟是端到端的:它真实地反映了"主库写下的东西,
-- 多久之后能在从库被读到",比 Seconds_Behind_Master 可信得多。
-- 前提:主从两台机器的系统时钟要用 NTP 校准好,否则差值会失真。
把这个延迟读出来,就能接进监控、告警,也能喂给后面的延迟感知路由。
# 把延迟读出来,接入监控和告警
def measure_replication_lag():
# 从库上读心跳表,算出真实延迟(毫秒)
row = slave_db.execute(
"SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(6)) / 1000 "
"AS lag_ms FROM heartbeat WHERE id = 1")
return row[0]["lag_ms"]
def check_lag_and_alert():
lag = measure_replication_lag()
if lag > 5000: # 延迟超过 5 秒
alert(f"从库复制延迟 {lag:.0f}ms,已超阈值")
return lag
# 有了实时延迟数,你才能做"延迟感知路由":
# 延迟正常,读从库;延迟飙高,自动把读切回主库兜底。
这一节的认知是:你不能管理一个你看不见的东西——第一版对复制延迟所有的"想当然",归根到底是因为它从来没有把延迟测出来过;延迟对它是一个抽象的、可大可小的概念,而不是一个屏幕上跳动的具体数字。一旦延迟只是个抽象概念,你对它的所有判断就都是猜的:你猜它"应该很小",于是把所有读甩给从库;你猜某次故障"可能和延迟有关",但说不清。而当延迟变成一个每秒都在更新的真实数字,一切就不一样了:你能给它设阈值、设告警,半夜延迟飙到几十秒时你会第一时间知道,而不是等用户投诉;你能在监控图上看到延迟和大促、和批量任务的相关性;最重要的是,你能把这个实时数字喂给路由逻辑,让路由"看着延迟做决定"。这里还有一个容易踩的坑:从库自报的 Seconds_Behind_Master 看着方便,但它在"主从之间暂时没有新写入""IO 线程本身就落后""主从机器时钟不同步"等情况下都会失真,不能当作唯一依据。自己用心跳表测——主库定时写一个时间戳进去,从库读出来和当前时间相减——得到的才是"主库写下的东西多久能在从库被读到"这个端到端的、你真正关心的延迟。把延迟从"一种感觉"变成"一个数字",你才有资格谈下一节那些"看着延迟做决定"的策略。
五、延迟敏感的读,几种处理策略
有了实时的延迟数字,处理"延迟敏感的读"就能更精细。除了第三节的"粘滞读主",还有两种更进一步的策略。第一种是"延迟感知路由":不区分用户,只盯从库的整体延迟——延迟正常就读从库,延迟一旦超标,就把读临时全切回主库兜底。
# 策略一:延迟感知路由 —— 从库延迟高了,自动把读切回主库
class LagAwareRouter:
def __init__(self, max_tolerable_lag_ms=1000):
self.max_lag = max_tolerable_lag_ms
def pick_read_db(self):
lag = measure_replication_lag()
if lag > self.max_lag:
# 从库落后太多,这段时间所有读都回主库兜底
return master_db
return slave_db
# 这是一个"全局兜底":不区分用户,只要从库整体延迟超标,
# 就临时放弃从库。代价是主库压力会上去,所以阈值要设得合理 ——
# 太小会频繁切回主库,太大又起不到兜底作用。
第二种最精确:不靠"粘滞几秒"去猜,而是写完拿到这次写入的复制位点(GTID),读之前让从库精确地等待回放到这个位点。
# 策略二:等复制追上 —— 写完拿到位点,读之前等从库回放到该位点
def update_and_get_pos(user_id, new_name):
master_db.execute(
"UPDATE users SET name = %s WHERE id = %s",
(new_name, user_id))
# 拿到这次写入完成后,主库已执行的 GTID 集合(复制位点)
row = master_db.execute("SELECT @@GLOBAL.gtid_executed AS gtid")
return row[0]["gtid"]
def read_after(gtid, user_id, timeout=2.0):
# 让从库阻塞等待:回放到 gtid 这个位点,或等到超时
ok = slave_db.execute(
"SELECT WAIT_FOR_EXECUTED_GTID_SET(%s, %s)", (gtid, timeout))
if ok[0][0] == 1: # 返回 1 表示超时,从库还没追上
return master_db.execute( # 兜底:直接读主库
"SELECT name FROM users WHERE id = %s", (user_id,))
# 从库已回放到位,这下读它,保证能读到刚写的值
return slave_db.execute(
"SELECT name FROM users WHERE id = %s", (user_id,))
# 这是最精确的做法:不靠"粘滞几秒"猜,而是精确地
# "等从库确实追上了我这次写入"再读,从库追上了就读从库、没追上才读主库。
这一节的认知是:对付复制延迟的策略,本质上是在"一致性"和"主库压力"之间选择一个权衡点——没有一个策略是免费的,粘滞读主、延迟感知路由、等待复制位点,只是把这个权衡点放在了不同的位置。理解这几个策略,不能只看"它怎么实现",要看"它各自牺牲了什么"。粘滞读主,牺牲的是精度:它用"写后固定粘 N 秒"来近似"等从库追上",窗口设长了浪费(本可以走从库的读也回了主库),设短了又可能漏(N 秒还没追上就放读走从库)。延迟感知路由,牺牲的是粒度:它是个全局开关,延迟一超标,所有人的读都回主库,不管这些读其实大多并不敏感——它换来的是简单和"兜底"的可靠。等待复制位点最精确,它不猜、不近似,精确地等"从库确实回放到了我这次写入"才读,但它的代价是实现复杂(要数据库支持 GTID 等待)、且那个"等待"本身会给读请求增加一点延迟。没有哪个策略是"最优解",只有"最适合你这个场景的解":一个普通的读己之写,粘滞读主足够;一个延迟极度敏感、绝不能读到旧值的关键读(比如支付状态确认),值得用等待复制位点;而延迟感知路由,适合作为一道全局的兜底,垫在所有策略下面。想清楚每个策略牺牲了什么,你才能在自己的业务里把那个权衡点放对位置。
把一个读请求进来后,该怎么一步步决定它读主库还是读从库,这个决策流程画出来就是下面这张图:
[mermaid]
flowchart TD
A[一个读请求进来] --> B{这个读能容忍旧数据吗}
B -->|能容忍| C[直接读从库]
B -->|不能容忍| D{这个用户刚写过吗}
D -->|刚写过 在粘滞窗口内| E[读主库 或等从库追上位点]
D -->|没写过| F{从库当前延迟超标吗}
F -->|超标| E
F -->|正常| C
六、把读写分离做扎实,要避开的工程坑
前面五节讲清了读写分离的核心:正视延迟、区分读、监控延迟、分级处理。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是最严重的:异步复制下,主库宕机会丢数据。
# 坑一:异步复制下,主库宕机会丢掉最后没同步出去的事务
# 解药:半同步复制(semi-sync)—— 主库至少等一个从库确认收到再算提交
[mysqld]
# 加载半同步插件后,在主库上开启
rpl_semi_sync_master_enabled = 1
# 主库等从库 ack 的超时(毫秒);超时则临时退化回异步
rpl_semi_sync_master_timeout = 1000
# 至少要几个从库确认收到,才算这个事务提交成功
rpl_semi_sync_master_wait_for_slave_count = 1
# 半同步不是"零丢失",但它把"丢数据的窗口"
# 从"异步下不确定的一段",收紧到了"确实已经有从库收到了"。
# 代价:写入会多等一个网络往返,写延迟略升 —— 这是安全的价钱。
第二个坑,是大事务——它是复制延迟最常见的"尖刺制造者"。
-- 坑二:一个大事务会让从库延迟瞬间飙高
-- 反面:一条 SQL 删 500 万行,主库可以分散在多核上扛住,
-- 但从库 SQL 线程要一口气把这 500 万行的变更回放完,
-- 回放期间,排在它后面的所有事务全被堵住 ——
-- 延迟从几毫秒飙到几十秒。
DELETE FROM orders WHERE created_at < '2022-01-01';
-- 正解:把大事务拆成小批,每批之间留给从库回放的喘息
-- (在应用层循环执行,每次只删一批,直到删完)
DELETE FROM orders WHERE created_at < '2022-01-01' LIMIT 5000;
还有几个坑值得点一下。其一,从库本身也会宕机、也会被运维拉去做备份而暂停复制,你的读路由必须能感知到从库不可用、自动回退到主库,而不是把读发给一个挂掉的从库。其二,有多个从库时,不同从库的延迟可能差很多,路由不能假设"从库们"延迟一致,要么按各自的延迟挑一个最新的,要么对延迟敏感的读统一特殊处理。其三,从库的硬件配置(尤其内存、磁盘)不要比主库差太多——一台羸弱的从库,回放速度长期跟不上,会成为延迟的稳定来源。下面把延迟敏感读的几种策略集中对照一下:
延迟敏感读的几种处理策略对照
策略 解决什么问题 代价 / 适用场景
----------------------------------------------------------
粘滞读主 读己之写 实现简单 主库压力略增 窗口要靠猜
延迟感知路由 从库整体延迟尖刺 需实时延迟数 全局兜底 不分敏感度
等待复制位点 精确的读己之写 最精确 需 GTID 等待支持 读略慢
半同步复制 切换时丢数据 牺牲一点写入延迟 换取更小丢失窗口
原则:主从复制是异步的,复制延迟无法消灭,只能感知和容忍;
别让延迟窗口里的"旧数据",泄漏到不能容忍它的业务里。
这一节这几个坑,串起来是同一个意思:读写分离不是给数据库"加一台从库、把 SELECT 分过去"这么一个孤立的动作,而是引入了一个"分布式"的事实——你的数据从此存在于多个节点上,而多个节点之间,一致性、延迟、故障,都成了你必须正面处理的问题。第一版把读写分离当成一个纯粹的性能优化,以为它只是"把读的流量挪个地方",数据本身、业务逻辑都不受影响。但加一台从库的那一刻,你的系统就从"单节点"变成了"多节点":单节点时代那个让你舒服的前提——"我写完了,数据就是新的,谁来读都一样"——失效了。取而代之的,是一堆分布式系统的固有问题:节点之间的数据有延迟(复制延迟)、节点会各自独立地故障(主库宕机、从库宕机)、节点之间在故障时刻的数据可能不一致(切换丢数据)。这一节的每个坑——半同步、大事务、从库故障回退、多从库延迟差异——都是这个"多节点事实"的一个具体侧面。所以决定上读写分离,你要做好的心理准备不是"配一下路由",而是"接手一个小型分布式系统";它带来的性能收益是真实的,但它要求你付出的,是认真对待延迟、故障和一致性的工程投入。把读写分离当成一个分布式问题来对待,而不是一个路由配置来对待,你才能真正用稳它。
关键概念速查
| 概念 | 说明 |
|---|---|
| 主从复制 | 主库写 binlog,从库拉取并回放,从而维护一份数据副本 |
| 复制延迟 | 主库提交事务到从库回放完成之间的时间差,异步复制下固有存在 |
| binlog | 主库记录所有数据变更的二进制日志,是复制的数据来源 |
| relay log | 从库 IO 线程拉来的 binlog 副本,等 SQL 线程回放 |
| 读己之写 | 同一用户写入后立刻读,必须能读到自己刚写的值,不能容忍延迟 |
| Seconds_Behind_Master | 从库自报的延迟估值,在多种情况下并不准确 |
| 心跳表 | 主库定时写时间戳,从库读差值,得到端到端的真实延迟 |
| 并行回放 | 从库多线程并行回放 relay log,缓解单线程回放的瓶颈 |
| 延迟感知路由 | 实时读取延迟,从库延迟超标时把读临时切回主库兜底 |
| 半同步复制 | 主库至少等一个从库确认收到再算提交,收紧切换时的丢数据窗口 |
避坑清单
- 不要以为主从是实时一致的副本:复制是异步的,从库总有延迟。
- 不要把所有读都无脑甩给从库:延迟敏感的读会读到旧数据。
- 不要忽视读己之写:用户刚写的数据,他自己的读必须能看到。
- 不要只信 Seconds_Behind_Master:它在多种情况下并不准确。
- 不要不监控复制延迟:用心跳表测端到端真实延迟并接入告警。
- 不要让从库单线程回放:打开并行回放,缓解延迟瓶颈。
- 不要在主库上跑大事务:它会把从库回放堵出延迟尖刺。
- 不要用异步复制还指望切换零丢失:要上半同步收紧丢数据窗口。
- 不要把粘滞窗口设太短:窗口要能覆盖正常延迟,否则照样读到旧值。
- 不要忘了从库也会宕机:读路由要能在从库挂掉时回退到主库。
总结
回头看第一版那个"SELECT 走从库、其余走主库"的读写分离,它的崩溃很典型。它不在某一行代码,而在一个对主从复制的根本误解:以为主库和从库是一模一样的副本,读哪个都一样,分流读到从库对业务透明。真相是,主从复制是异步的,从库靠不断回放主库的日志来追赶,它永远落后主库一段时间——这就是复制延迟。在这段延迟里,主库已是新数据、从库还是旧数据。"写完主库、立刻读从库"的操作落进这个窗口,读到的必然是旧值。
而把读写分离做对,工程量并不小。它不是"加一句 SELECT 走从库"那么简单,而是要正视复制延迟客观存在、要把读按能否容忍旧数据分类、要给读己之写这类敏感读做粘滞读主或等待复制位点、要用心跳表把延迟真实地监控起来、要在从库延迟超标时把读切回主库、还要用半同步复制收紧主从切换时的丢数据窗口、要控制大事务、要让路由能在从库故障时回退。一套真正可靠的读写分离,是这些环节一个不少地拼起来的。
这件事其实很像一间办公室里,一份不断被修改的"正本"文件,和一份给大家传阅的"副本"。正本(主库)只有主管能改,改得很频繁;副本(从库)是另一个同事拿着正本一笔一笔誊抄出来的。第一版的想法是"副本就等于正本,看哪份都一样"。可誊抄是需要时间的——主管刚在正本上划掉一行,誊抄的同事还没翻到那一页,这一刻你去看副本,看到的就是还没被划掉的旧内容,这就是复制延迟。聪明的办公室会怎么用这两份文件?第一,分清楚什么事看副本就行、什么事必须看正本:随便翻翻、了解个大概,看副本(能容忍旧数据的读);可你自己刚交代主管改的那一行,要确认改对没有,就得去看正本(读己之写)。第二,得有人随时知道"副本现在落后正本多少"——要是誊抄的同事今天特别慢、落后了一大截,那就索性让大家这段时间都去看正本(延迟感知)。第三,万一正本被咖啡泼了、毁了,你能补救的只有副本上已经誊抄到的那部分,正本上最后那几行还没抄过去的,就真的没了(切换丢数据)。两份文件要用得安全,靠的从来不是"假装副本永远等于正本",而是"始终清楚副本落后多少、什么事非看正本不可"。
这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测,主从库往往在同一台机器、同一个网络里,没有任何负载,复制延迟低到一两毫秒——你"写完立刻读从库"也照样读到新值,因为那一两毫秒里你的代码还没跑到读那一行。你会觉得"读写分离嘛,SELECT 走从库就完事"。真正会把问题撑爆的,是上线后的真实环境:主从分处不同机器、甚至不同机房,网络有实打实的延迟;线上有真实的并发写入和大事务,从库回放会堆积;大促、批量任务会把延迟从毫秒级顶到秒级。这些场景,你本地一个都模拟不到。所以如果你正在做数据库的读写分离,别等用户开始抱怨"我改的东西怎么没生效"、别等主库切换丢了单,才回头怀疑你的读路由。在写下第一行"SELECT 走从库"的路由规则时就想清楚:复制延迟客观存在、这个读能不能容忍旧数据、不能容忍的我怎么让它读主库或等从库追上、延迟我怎么监控、主库挂了我能不能接受丢几秒——把"把读分流出去"和"让分流出去的读在真实延迟下依然正确"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。
—— 别看了 · 2026