数据库读写分离完全指南:从一次"用户发完评论刷新就不见了"看懂主从复制延迟为什么坑人

2023 年我做一个内容社区的后端上线一段时间后数据库开始扛不住了用户刷信息流看详情翻评论海量的读请求把单台数据库压得喘不过气怎么让数据库扛住这件事我没多想就有了方案读写分离搭一台从库让它复制主库的数据然后把所有读请求打到从库写请求打到主库压力不就分摊了第一版我做得很顺手配好主从复制在代码里写了个路由读走从库写走主库本地和测试环境一跑读写各走各的主库的压力肉眼可见地降了下来我心里很笃定读写分离嘛不就是读路由到从库写路由到主库可等真实流量一上来一串问题冒了出来第一种最先把我打懵用户发了一条评论页面刷新自己发的评论却没了过两秒再刷又出来了第二种最难缠用户改了个人资料跳回资料页显示的还是改之前的旧信息第三种最头疼有一段业务逻辑先更新订单状态紧接着把订单查出来做判断结果查出来的还是更新前的状态第四种最莫名其妙有天从库出了故障我整个站点的读请求全部失败明明主库还好好的我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为读写分离就是个简单的路由规则读的语句发给从库写的语句发给主库可这个方案藏着一个我完全没考虑的前提主库和从库之间的数据同步不是瞬间完成的主从复制是异步的这段时间差叫复制延迟本文从头梳理为什么读从库写主库不等于读写分离做对了复制延迟从哪来读自己写为什么会读到旧数据怎么应对延迟事务里的读为什么必须走主库以及一些把它做扎实要避开的工程坑

2023 年我做一个内容社区的后端,上线一段时间后,数据库开始扛不住了——用户刷信息流、看详情、翻评论,海量的读请求把单台数据库压得喘不过气。怎么让数据库扛住?这件事我没多想,就有了方案:读写分离。搭一台从库,让它复制主库的数据,然后把所有读请求打到从库、写请求打到主库,压力不就分摊了。第一版我做得很顺手——配好主从复制,在代码里写了个路由:SELECT 走从库,INSERTUPDATEDELETE 走主库。本地和测试环境一跑,读写各走各的,主库的压力肉眼可见地降了下来,我心里很笃定:读写分离嘛,不就是读路由到从库、写路由到主库,这数据库扩展性稳了。可等真实流量一上来,一串问题冒了出来。第一种最先把我打懵:用户发了一条评论,页面刷新,自己发的评论却没了——过两秒再刷,又出来了。第二种最难缠:用户改了个人资料,跳回资料页,显示的还是改之前的旧信息,像是没保存成功。第三种最头疼:有一段业务逻辑,先更新订单状态、紧接着把订单查出来做下一步判断,结果查出来的还是更新前的状态,整个逻辑都错了。第四种最莫名其妙:有天从库出了故障,我的整个站点的读请求全部失败,等于直接挂了——明明主库还好好的。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为读写分离就是个简单的路由规则——读的语句发给从库,写的语句发给主库,数据库压力就这么分摊掉了。可这个方案藏着一个我完全没考虑的前提:主库和从库之间的数据同步,不是瞬间完成的。主从复制是异步的——你往主库写了一条数据,这条数据要经过一段时间(几毫秒到几秒不等),才会被复制到从库。这段时间差,叫复制延迟。在这段延迟里,主库已经有了新数据,从库还是旧的。你"写完主库、立刻读从库",读到的就是那个还没追上的旧数据。要把读写分离做扎实,根上要明白:它不是一条无害的路由规则,它引入了"主从数据不一致"这个新问题,你必须正视复制延迟,并针对性地处理那些"不能容忍读到旧数据"的场景。本文从头梳理:为什么"读从库写主库"不等于读写分离做对了,复制延迟从哪来,"读自己写"为什么会读到旧数据,怎么应对延迟,事务里的读为什么必须走主库,以及一些把它做扎实要避开的工程坑。

问题背景

先把读写分离的机制说清楚。它的基础是数据库的主从复制:一台主库(master)负责处理所有写操作;一台或多台从库(replica)通过复制机制,持续地把主库上发生的变更同步过来,从而拥有一份和主库(几乎)一样的数据。读写分离,就是在这个基础上,把读请求分流到从库,让主库专心处理写,从库分担读。它确实能扩展数据库的读能力。

错误认知是:读写分离就是一条路由规则,读走从库、写走主库,主从数据反正是一样的,随便读哪个都行。真相是:主从复制是异步的,主库写入和从库同步之间存在时间差,这段时间内主从数据是不一致的。读写分离不是"无损"的,它用"可能读到旧数据"换"读能力的扩展"。把这一点摊开,第一版的几类问题就都能解释了:

  • 发完评论刷新没了:写评论到主库,刷新页面的读请求走了从库,而从库还没同步到这条评论,于是读不到。
  • 改完资料显示旧值:同理,资料更新写进主库,资料页的读走从库,读到的是复制延迟期间的旧数据。
  • 事务里读到旧状态:更新走主库,紧接着的查询被路由到从库,从库还没追上,读到更新前的状态。
  • 从库挂了站点全瘫:所有读都依赖从库,从库成了一个单点,它一故障,读请求无处可去。

所以让读写分离做对,核心不是把路由规则写得更细,而是承认复制延迟这个事实,识别出哪些读"输不起"旧数据,并为它们设计对策。下面六节,就从第一版"读从库写主库"的想当然讲起。

一、为什么"读从库写主库"不等于读写分离做对了

第一版的路由规则,逻辑上简洁得无可挑剔:语句是读,就发从库;语句是写,就发主库。这个规则本身没错,它是读写分离的骨架。第一版的错,不在这条规则,而在它背后一个没说出口、却被默认成立的假设——它假设"主库和从库的数据,在任何时刻都是一致的",所以"读哪个都一样"。正是这个假设,是错的。

# 反面教材:只按"读/写"路由,默认主从数据总是一致

def route(sql: str):
    sql_head = sql.strip().split()[0].upper()
    if sql_head in ("INSERT", "UPDATE", "DELETE"):
        return master_conn        # 写,走主库
    return replica_conn           # 读,一律走从库

# 这条规则本身没错,错的是它隐含的假设:
# "从库的数据 == 主库的数据,所以读从库 == 读主库"
# 一旦主库刚写完、从库还没同步,这个假设就崩了

# 用户的真实操作序列:
#   1. 发表评论     -> 写,走主库,主库有了这条评论
#   2. 刷新看评论   -> 读,走从库,从库还没同步,读不到
# 用户看到的:我的评论"丢了"

要看清这个假设为什么靠不住,得理解主从之间的数据是怎么"对齐"的。从库不是和主库共享同一份数据,它有自己独立的一份数据副本。主库每发生一次写,会把这次变更记录下来,从库再把这些变更"重放"一遍,从而让自己的副本追上主库。这个"记录、传输、重放"的过程,需要时间。所以在任意一个瞬间,从库的数据,代表的其实是主库"过去某个时刻"的状态——它总是慢主库一点点。

这一节要建立的认知是:读写分离把一份数据,变成了主库和从库上的两份副本,而这两份副本之间,存在一个无法消除的、时刻在变动的"时间差"。第一版的根本错误,是脑子里还停留在"只有一个数据库"的模型里——在那个模型里,写完立刻读,天经地义读到的就是新值。可一旦做了读写分离,这个模型就不再成立了:你写的地方(主库)和你读的地方(从库),是两个东西,它们之间靠一条有延迟的"传送带"连接。承认这一点,是做对读写分离的起点。它意味着,你不能再笼统地说"读数据库",你必须分情况想清楚:这次读,我读的是主库还是从库?如果是从库,我能不能接受它可能比主库旧几秒?后面几节,就是围绕这个问题展开的。

二、主从复制是异步的:复制延迟从哪来

"复制延迟"这个词,不能只当成一个抽象的概念,得知道它具体是怎么产生的,才能判断它在你的系统里会有多大、什么时候会变大。主从复制的大致流程是:主库把每一次数据变更,写进一个叫"binlog"(二进制日志)的文件;从库有一个线程,负责把主库的 binlog 拉取过来,存成自己的"中继日志";从库再有一个(或多个)线程,负责读取中继日志,把里面的变更在自己的数据上重新执行一遍。延迟,就藏在这几个环节里。

-- 在从库上查看复制状态和延迟(MySQL)

SHOW REPLICA STATUS;

-- 重点关注这几个字段:
--   Replica_IO_Running    拉取 binlog 的线程是否在跑,应为 Yes
--   Replica_SQL_Running   重放变更的线程是否在跑,应为 Yes
--   Seconds_Behind_Source 从库落后主库的秒数,这就是复制延迟
--                         理想情况接近 0,变大就说明从库追不上了

正常情况下,这个延迟可能只有几毫秒,小到几乎无感。但它会在某些时刻显著变大:主库突然来了一大批写操作,从库重放的速度跟不上生产的速度,延迟就堆积起来;从库自己负载很高、或者它在执行一个很慢的大事务,重放就被拖慢;主从之间网络抖动,binlog 传输变慢。也就是说,复制延迟不是一个固定值,它是一个会随系统负载剧烈波动的量——平时几毫秒,大促或批量任务时,可能涨到几秒甚至几十秒。

# 主动监控复制延迟,延迟过大时及时告警

def check_replication_lag(replica_conn, threshold_seconds=2):
    row = replica_conn.execute("SHOW REPLICA STATUS").fetchone()
    lag = row["Seconds_Behind_Source"]

    if lag is None:
        # 为 None 通常意味着复制线程已经断了,这是严重故障
        alert("从库复制中断", level="critical")
        return False
    if lag > threshold_seconds:
        # 延迟超过阈值,这段时间内读从库会读到明显的旧数据
        alert(f"从库复制延迟 {lag}s,已超阈值", level="warning")
        return False
    return True

这一节的认知是:复制延迟不是一个可以忽略的"理论上的小数",它是一个真实存在、且会随负载大幅波动的变量——而且,越是系统繁忙、写入量大的时候,它越大。这里有个很要命的"反直觉":你的系统什么时候写入量最大、最繁忙?往往就是大促、热点事件、流量高峰的时候。而这恰恰也是复制延迟最大的时候。也就是说,复制延迟不是在系统空闲、没人用的时候来添乱,它专挑你最忙、用户最多、最输不起的时候放大。所以你绝不能用"平时测着延迟就几毫秒"来安慰自己——平时不算数,要算的是峰值。把复制延迟当成一个必须持续监控、必须按峰值来评估的量,你才不会在高峰期被它打个措手不及。

三、"读自己写":刚写完立刻读,为什么读到旧数据

复制延迟会引发好几类问题,其中最高频、最伤体验的一类,有个专门的名字,叫"读自己写"(read-your-own-writes)的一致性问题。它的场景非常具体:同一个用户,先做了一次写操作,紧接着读取自己刚刚写的那个东西。第一版里"发完评论刷新没了""改完资料显示旧值",全是这一类。

为什么这一类特别伤?因为它直接撞击用户的认知。用户做了一个操作,他百分之百确定这个操作发生了——评论是他刚打的字、资料是他刚改的值。然后他立刻去看,却看到操作好像没生效。对用户来说,这不是"数据有点延迟",这是"系统出 bug 了""我的操作丢了"。别人的数据晚几秒同步,用户根本不会察觉;但他自己刚做的事没了,他一眼就发现,而且会非常焦虑。

# "读自己写"问题:写走主库,紧接着的读走了从库

def post_comment(user_id, content):
    # 写评论,走主库,主库立刻有了这条数据
    master_conn.execute(
        "INSERT INTO comments (user_id, content) VALUES (%s, %s)",
        (user_id, content),
    )

def get_my_comments(user_id):
    # 读评论,按默认规则走从库
    # 但此刻从库可能还没同步到刚才那条 INSERT
    return replica_conn.execute(
        "SELECT * FROM comments WHERE user_id = %s", (user_id,),
    ).fetchall()

# 用户发完评论马上刷新:
#   post_comment 写进主库(瞬间完成)
#   get_my_comments 读从库(从库还差几百毫秒没追上)
#   -> 用户看不到自己刚发的评论

把"读自己写"这个问题单独拎出来,是因为它划出了一条清晰的界线:不是所有的"读旧数据"都同样严重。读到别人的、稍旧一点的数据(比如信息流里别人几秒前发的帖子),用户基本无感,完全可以容忍;但读到"自己刚刚写的东西的旧版本",用户立刻就会感知为故障。这条界线,是你后面决定"哪些读必须特殊处理"的核心依据。

这一节的认知是:面对复制延迟,不要试图"消灭所有的不一致",而要去区分"哪些不一致用户根本不在乎、哪些不一致用户一眼就炸"。读写分离的全部价值,在于让大量的读走从库;如果你为了"绝对一致"把所有读都改回主库,那等于没做读写分离。所以正确的思路不是"全都要一致",而是"分级":绝大多数读,容忍秒级延迟,继续安心走从库,享受扩展性;少数"输不起"的读——尤其是"读自己刚写的"这一类——才需要特殊处理。能把"一致性需求"这样分级,而不是一刀切,是用好读写分离的关键。下一节就讲,对那些"输不起"的读,具体怎么处理。

四、怎么应对复制延迟:几种对策

对那些不能容忍旧数据的读,有几种对策,各有各的适用场景和代价。最直接的一种,叫"写后强制读主库":在一次写操作之后的一小段时间内,把同一个用户、同一类数据的读,强制路由到主库。因为主库永远是最新的,从主库读绝不会读到旧数据。

# 对策一:写后的一小段时间内,强制把读路由到主库

import time

# 记录每个用户最近一次写操作的时间戳
_last_write = {}

def mark_write(user_id):
    _last_write[user_id] = time.time()

def choose_conn_for_read(user_id, force_window=3):
    last = _last_write.get(user_id, 0)
    # 距离上次写还不到 force_window 秒,走主库,确保读到最新
    if time.time() - last < force_window:
        return master_conn
    # 过了这个窗口,认为从库大概率已追上,正常走从库
    return replica_conn

这个对策简单有效,但要注意它的代价:被强制路由的那部分读,落到了主库上,等于把一部分读压力又还给了主库。窗口设得越长,还回去的压力越多。所以窗口要设得恰到好处——略大于正常的复制延迟即可。另一种对策更精确,叫"等待复制位点":写操作完成后,会得到一个表示"写到了哪个位置"的位点,读之前先检查从库是否已经重放到了这个位点,追上了再读从库,没追上就读主库或稍等。

# 对策二:基于复制位点,精确判断从库是否已追上这次写

def write_and_get_position(sql, params):
    master_conn.execute(sql, params)
    # 写完后,拿到主库当前的 binlog 位点
    row = master_conn.execute("SHOW MASTER STATUS").fetchone()
    return row["File"], row["Position"]

def read_after_write(sql, params, write_pos):
    file, pos = write_pos
    # 查从库是否已经重放到 write_pos 这个位点
    replica_conn.execute(
        "SELECT MASTER_POS_WAIT(%s, %s, 1)", (file, pos),
    )
    behind = replica_conn.execute(
        "SHOW REPLICA STATUS").fetchone()["Seconds_Behind_Source"]
    # 追上了就读从库,没追上就降级读主库
    conn = replica_conn if behind == 0 else master_conn
    return conn.execute(sql, params).fetchall()

还有一种最简单粗暴的思路:对某些"绝对不能旧"的关键数据(比如账户余额、支付状态),干脆约定它的读永远走主库,不参与读写分离。这牺牲了这部分数据的读扩展性,但换来了绝对的一致性,对关键数据往往是值得的。下面把这几种对策对照一下:

应对复制延迟的几种对策对照

  对策              做法                     代价
  --------------------------------------------------------------
  写后强制读主库    写后 N 秒内的读走主库    部分读压力回到主库
  等待复制位点      读前确认从库已追上位点   实现复杂 读前有等待
  关键数据读主库    某些数据的读永远走主库   这部分数据无读扩展
  容忍并提示        照读从库 UI 上做乐观更新 不解决一致性 只缓解

  原则:按数据的"一致性要求"分级,
        输不起的用前三种,输得起的安心走从库。

这一节的认知是:应对复制延迟,没有一个"完美"的对策,每一种都是在"一致性"和"读扩展性"之间做的一次具体取舍。写后强制读主库,是用"还一部分读压力给主库"换"那段时间的一致";等待位点,是用"实现复杂度和读前的等待"换"精确的一致";关键数据走主库,是用"放弃这部分数据的读扩展"换"绝对的一致"。你会发现,你想要回的每一分"一致性",都要用一部分"读扩展性"去买——而读扩展性,恰恰是你当初做读写分离的目的。所以这里没有标准答案,只有"针对你这个具体场景,一致性和扩展性哪个更重要"的判断。把对策的代价看清楚,按数据的重要性分级地选,这才是用好读写分离的成熟做法。

把一次读请求该路由到哪里的完整决策画出来,就是下面这张图:

[mermaid]
flowchart TD
A[一次读请求] --> B{在事务内吗}
B -->|是| C[走主库]
B -->|否| D{读的是关键强一致数据吗}
D -->|是| C
D -->|否| E{该用户刚刚发生过写吗}
E -->|是 在强制窗口内| C
E -->|否| F{从库复制延迟正常吗}
F -->|否 延迟过大| C
F -->|是| G[走从库 享受读扩展]

五、事务里的读必须走主库

第一版还有一类问题,比"读自己写"更隐蔽,也更危险——第三种:一段业务逻辑里,先更新订单状态、紧接着把订单查出来做判断,查到的却是旧状态。这个问题的根子,是事务和读写分离的路由规则打了架。

设想一段业务逻辑:它在一个数据库事务里,先 UPDATE 了订单状态,然后又 SELECT 这个订单出来,根据查出来的状态决定下一步。按第一版"读走从库"的规则,这个 SELECT 会被路由到从库。问题来了:UPDATE 写进的是主库,而且因为在事务里还没提交,这个修改连从库都还没开始同步;那个被路由到从库的 SELECT,不仅读不到这次未提交的修改,它读的根本是另一个数据库连接、另一份数据。整个事务的逻辑,就建立在错误的数据上了。

# 事务里的读,绝不能被路由到从库

def process_order_wrong(order_id):
    with master_conn.begin():            # 事务在主库上开启
        master_conn.execute(
            "UPDATE orders SET status = 'paid' WHERE id = %s",
            (order_id,),
        )
        # 错误:这个 SELECT 按"读走从库"被路由到了从库
        # 从库上没有这次未提交的 UPDATE,读到的是旧状态
        order = replica_conn.execute(
            "SELECT status FROM orders WHERE id = %s", (order_id,),
        ).fetchone()
        # 基于旧的 status 做判断,逻辑全错

def process_order_right(order_id):
    with master_conn.begin():
        master_conn.execute(
            "UPDATE orders SET status = 'paid' WHERE id = %s",
            (order_id,),
        )
        # 正确:事务内的读,和写用同一个主库连接
        order = master_conn.execute(
            "SELECT status FROM orders WHERE id = %s", (order_id,),
        ).fetchone()

所以规则要补一条,而且是硬规则:一旦进入一个事务,这个事务里的所有操作——无论读还是写——都必须走主库,走同一个连接。原因有两层:一是事务的隔离性,要求事务内能读到自己未提交的修改,这只有在同一个连接上才成立;二是从库根本没有未提交的数据。事务是一个不可拆散的整体,你不能让它的写在主库、读在从库,那等于把一个事务劈成了两半。

这一节的认知是:读写分离的路由规则"读走从库",有一个绝对的例外——事务;在事务的边界之内,"读"和"写"的区分失效了,所有操作都必须待在主库这一个连接上。第一版的路由规则,只看一条 SQL"是读还是写",这个粒度太粗了。它漏掉了一个更高优先级的判断:"这条 SQL 是不是在一个事务里"。这个判断必须排在"读还是写"之前——先看在不在事务里,在事务里,无条件走主库,根本不用再问读写;不在事务里,才轮到"读走从库、写走主库"。很多读写分离的诡异 bug,都出在路由逻辑没有把"事务"这个维度放在最高优先级上。记住:事务的完整性,优先于读写分离的分流。

六、把读写分离做扎实,要避开的工程坑

前面五节讲清了复制延迟和路由规则。但要在生产里真正用好,还有几个坑得专门讲。第一个,就是第一版的第四种问题:从库不能成为单点。第一版把所有读都压在一台从库上,这台从库一旦故障,整个站点的读全废,而主库其实还活着。正确的做法是,从库故障时,读请求要能自动降级回主库——主库扛读虽然累,但至少站点还活着,这远比直接瘫痪强。

# 坑一:从库是会挂的,读要能自动降级回主库

def execute_read(sql, params):
    try:
        # 正常情况:走从库
        return replica_conn.execute(sql, params).fetchall()
    except (ConnectionError, OperationalError) as e:
        # 从库不可用,降级到主库,保证读请求不至于全部失败
        log.warning(f"从库读失败,降级到主库: {e}")
        return master_conn.execute(sql, params).fetchall()
    # 降级是应急,同时要触发告警,让人尽快修复从库

第二个坑,是多个从库之间的负载均衡。当你为了扛更大的读量,部署了多台从库,读请求要在它们之间分摊。但要注意,每台从库的复制延迟是各不相同的——这台可能延迟 1 秒,那台可能延迟 3 秒。简单的轮询会让同一个用户的连续两次读,落到延迟不同的两台从库上,可能出现"数据一会儿新一会儿旧"的诡异现象。

# 坑二:多从库负载均衡,要把延迟过大的从库踢出去

def pick_replica(replicas):
    healthy = []
    for r in replicas:
        lag = get_replication_lag(r)
        # 只把延迟在可接受范围内的从库纳入候选
        if lag is not None and lag < 2:
            healthy.append(r)

    if not healthy:
        # 所有从库延迟都过大,降级走主库
        return master_conn
    # 在健康的从库里选(可按连接数选最空闲的一台)
    return min(healthy, key=lambda r: r.active_connections)

还有几个坑值得点一下。其一,很多 ORM 框架和数据库中间件,自带读写分离功能,它们通常也能正确处理"事务内走主库",但你必须搞清楚它的具体规则,别想当然——比如有的框架里,一个手动写的原生 SQL 查询可能绕过它的路由判断。其二,复制延迟一定要接入监控和告警,Seconds_Behind_Source 这个指标要一直盯着,它突然飙高往往是故障的前兆。其三,主库本身依然是写操作的单点,读写分离只扩展了"读",没有扩展"写",如果你的瓶颈是写,读写分离帮不上忙,那要考虑的是分库分表。

这一节这几个坑,串起来是同一个意思:读写分离不是"配好主从、写条路由规则"就完事的一次性工作,它给你的系统引入了一整套新的运维和容错负担。从库会挂,你要有降级;从库有多台,你要处理它们延迟不一的负载均衡;复制延迟会波动,你要监控告警;框架的路由规则有细节,你要吃透。读写分离用"一份数据变成多份副本"换来了读扩展性,而副本一多,你就要承担"管理多个副本、应对副本故障、容忍副本不一致"的全部复杂度。把读写分离当成一个需要持续运维、需要容错设计的子系统,而不是一条静态的路由规则——这样它才能真正稳定地为你扛住读流量。

关键概念速查

概念 说明
读写分离 写请求走主库、读请求走从库,以扩展数据库的读能力
主从复制 从库通过复制主库的变更日志,持续同步出一份数据副本
主库 master 负责处理所有写操作的数据库实例
从库 replica 复制主库数据、分担读请求的数据库实例,数据略滞后
异步复制 主库写入与从库同步之间存在时间差,并非瞬间完成
复制延迟 从库落后主库的时间,随写入量和负载波动,峰值会很大
读自己写 用户读自己刚写的数据,因延迟读到旧值,体验上最伤
强制读主库 对输不起旧数据的读,在写后窗口内或永久路由到主库
事务内走主库 事务里所有读写必须用同一主库连接,优先于读写路由
从库降级 从库故障或延迟过大时,读请求自动回退到主库

避坑清单

  1. 不要以为主从数据时刻一致:复制是异步的,从库总比主库滞后一段时间。
  2. 不要只按读写路由就不管了:读写分离引入了主从不一致这个新问题,必须正视。
  3. 不要用平时的延迟值安心:复制延迟随负载波动,高峰期会涨到秒级甚至更大。
  4. 不要让"读自己写"走从库:用户读自己刚写的数据读到旧值,会被感知为故障。
  5. 不要对所有读一刀切:按一致性要求分级,输不起的特殊处理,输得起的走从库。
  6. 不要让事务内的读走从库:事务里所有操作必须走同一主库连接,这条优先级最高。
  7. 不要把读全压在一台从库上:从库会故障,要能自动降级回主库,避免单点全瘫。
  8. 不要忽视多从库的延迟差异:各从库延迟不一,要剔除延迟过大的,避免数据跳变。
  9. 不要不监控复制延迟:Seconds_Behind_Source 要接入告警,飙高常是故障前兆。
  10. 不要指望读写分离扩展写:它只扩展读,写仍是主库单点,写瓶颈要靠分库分表。

总结

回头看第一版那个"读走从库、写走主库"的读写分离,它的错误很典型。它不在那条路由规则本身,而在规则背后一个没说出口的假设:以为主库和从库的数据时刻一致,所以读哪个都一样。真相是,主从复制是异步的,主库写入和从库同步之间隔着一段会波动的复制延迟,在这段延迟里,从库的数据是旧的。读写分离不是一条无害的路由规则,它把一份数据变成了两份有时间差的副本,引入了"主从不一致"这个必须被正视和处理的新问题。

而把读写分离做对,工程量并不小。它不是写一条 if-else 路由那么简单,而是要理解复制延迟从哪来、会波动到多大,要识别出"读自己写"这类输不起旧数据的场景并为它选择对策,要把"事务内一律走主库"作为最高优先级的路由规则,还要做从库故障的降级、多从库的延迟感知负载均衡、复制延迟的监控告警。一套真正可靠的读写分离,是这些环节一个不少地拼起来的。

这件事其实很像一家公司里,老板和秘书的关系。老板(主库)是唯一能拍板做决定、改东西的人;秘书(从库)手里有一份老板决策的副本,专门用来对外回答各种询问,帮老板分担接待的压力。大多数时候这套配合很好——别人来问"公司地址在哪",秘书照着副本回答,又快又准,完全不用惊动老板。但问题出在:老板刚改了个决定,这个新决定要过一会儿才会同步给秘书。如果就在这个空档,有人去问秘书"老板最新的决定是什么",秘书照着还没更新的副本,会给出一个旧答案。尤其是,如果来问的正是那个刚刚和老板敲定了新决定的人,他一听秘书说的还是旧的,立刻就懵了——"我刚和老板说好的,怎么不算数了?"这就是"读自己写"。解决办法也和现实一样:无关紧要的询问,找秘书就行;但和自己刚敲定的事有关的、要紧的事,直接去问老板本人。读写分离要做对,就是要分清楚:哪些事可以问秘书,哪些事必须问老板。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你在本地或测试环境,主从之间没什么负载,复制延迟低到几毫秒,你写完立刻读,从库早就同步好了,怎么测都一致——你会觉得"读写分离"这套天衣无缝。真正会把复制延迟撑开的,是上线后的真实流量:用户量大、写入密集,尤其是高峰期,复制延迟从几毫秒涨到几秒,那个"写完立刻读从库"的空档被无数用户精准地踩中,"评论丢了""资料没保存"的投诉就涌进来了。所以如果你正在做读写分离,别等用户反馈"我的操作怎么没生效",才回头怀疑从库。在写下那条路由规则的同时就想清楚:复制是异步的、延迟会波动,哪些读输得起旧数据、哪些输不起——把"读写分离"和"主从一致"当成两件必须分别对待的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 流式输出完全指南:从一次"用户点了发送对着空白屏幕等十几秒"看懂为什么 AI 对话必须用流式

2026-5-22 20:44:15

技术教程

LLM 幻觉缓解完全指南:从一次"模型一本正经编了个不存在的制度条款"看懂喂资料为什么挡不住瞎编

2026-5-22 20:56:14

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索