KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 读写分离:收益在哪、延迟从哪来、「刚写完读不到」怎么解 — keel 龙骨

配好主从只是让数据多了一份,它不会自动分担任何读压力。把读打到从库是客户端的事——这一章就讲客户端怎么做,以及做完之后必然出现的那个问题:刚写完的数据,从库上还没有。

配好主从只是让数据多了一份,它不会自动分担任何读压力。把读打到从库是客户端的事——这一章就讲客户端怎么做,以及做完之后必然出现的那个问题:刚写完的数据,从库上还没有。


一、现场:保存成功,刷新却是旧内容

内容站点上了读写分离,第一个工单就来了:

编辑改完标题点保存,提示成功;刷新前台看到的还是旧标题,过一两秒再刷新才对。

这不是 bug,是复制的固有性质。第 01 章已经确认:复制是异步的,主库返回 OK 时从库可能还没收到。只要读被路由到从库,就必然存在「主库已写、从库未应用」的窗口。

架构决策要做的是两件事:让绝大多数读享受从库带来的吞吐;把不能容忍这个窗口的读挑出来,单独送回主库。

二、直觉模型:复制不提供路由,路由是客户端的事

这是本章最需要记住的一句话。很多人以为"配了 REPLICAOF 就实现了读写分离",实际上:

复制只做一件事:把主库的写命令流发给从库
至于「这次 GET 该发给谁」——Redis 服务端完全不参与,是客户端决定的

所以读写分离的实现形态是客户端侧的两个连接池(或一个能分辨读写的客户端):

写命令(SET/DEL/XADD/...)  → 主库连接池
读命令(GET/LRANGE/...)    → 从库连接池(可以多个,做负载均衡)

顺便纠正一个常见误解:从库不是"分担了一半压力"。写压力一点没减少(写仍然全在主库),减少的只有读。所以读写分离的收益与读占比直接相关:读占比 90% 时收益巨大,读写各半时收益有限,写为主时基本没有收益。

三、精确定义:延迟怎么量化,哪些读必须走主库

延迟的量化

第 01 章给出过两个数字:主库的 master_repl_offset 与从库的 slave_repl_offset。两者的差就是落后字节数。它比 INFO 里的 lag(秒,整数,精度粗)精确得多,是告警应该用的量。

三类必须走主库的读

用「能不能容忍旧值」给每个读接口分类,是上读写分离前唯一必须做的梳理工作:

类型 例子 走哪
写后读(read-after-write) 保存完立刻预览、提交订单后立刻查状态 必须主库
强一致读 库存余量、余额、锁状态查询 必须主库
可陈旧读 文章详情缓存、列表页、计数展示 从库

漏掉第二类是最常见的事故:某个查询"看起来只是读",但它读的值业务上不允许陈旧,切到从库后间歇性出错,而且只在主库繁忙(延迟变大)时才出现,极难复现。

四、一次完整运行:客户端把读写分开

用 redis-py 的哨兵客户端(第 03 章讲它怎么用哨兵发现地址,这一节只看读写分流这件事):

import redis
from redis.sentinel import Sentinel

sentinel = Sentinel(
    [("127.0.0.1", 26379)],
    socket_timeout=0.5,
    protocol=2,                       # Redis 5 不支持 RESP3
    decode_responses=True,
    sentinel_kwargs={"protocol": 2},  # 连哨兵那条连接也要单独降级
)

master = sentinel.master_for("mymaster")   # 写
replica = sentinel.slave_for("mymaster")   # 读

master.set("article:42", "v1")
print("replica 读到:", replica.get("article:42"))

实测输出(本机构建,主从复制正常):

== 向哨兵问主库地址(不是自己记地址) ==
  master: ('127.0.0.1', 6379)
  slaves: [('127.0.0.1', 6381)]
== 写主读从 ==
  master  role: master
  replica role: slave
  replica 读到: v1

注意 master_for / slave_for 拿到的两个连接对象,角色是可验证的(上面打印的 role 就是从 INFO replication 读出来的)——上线前应当断言这件事,否则配置写反了会一直写从库然后收 READONLY 报错。

关键设计:把"能不能陈旧"变成显式参数

不要把"走主还是走从"埋在客户端配置里,让它在调用点显式声明:

def read_article(article_id: str, *, allow_stale: bool = True) -> str | None:
    """allow_stale=True 走从库(吞吐优先),False 走主库(一致优先)。"""
    conn = replica if allow_stale else master
    return conn.get(f"article:{{{article_id}}}:detail")

现在每个调用点都必须回答一次"这个读能不能陈旧"。写接口在写入后返回前调用 read_article(..., allow_stale=False),列表页用默认值。漏掉的地方一眼就能在 code review 里看出来。

五、链路图:一次"写主读从"的完整路径

sequenceDiagram
    participant W as 写请求(应用)
    participant M as 主库 6379
    participant R as 从库 6381
    participant Q as 读请求(应用)

    W->>M: SET article:42 v1
    M-->>W: OK(主库已写,从库还没收到)
    M->>R: ① 异步复制:命令流
    Note over R: ② 从库单线程重放(落后窗口)
    Q->>R: GET article:42
    alt 已从库应用
        R-->>Q: v1(正确)
    else 还没追上
        R-->>Q: 旧值(写后读不一致)
    end
    Note over Q: 不能容忍旧值的读应改走主库<br/>或用 WAIT 缩小窗口

图上 ① 和 ② 是延迟的两个来源:网络传播与从库自身的执行。注意第 ② 条——从库是单线程重放命令的,一个跑在从库上的慢查询会同时拖慢复制应用和读响应。这是"从库突然变慢"最常见的根因,也是为什么从库上同样要禁 KEYS、禁大集合操作。

六、失败注入:制造延迟与从库失联

实验 A:把从库主线程卡住,看复制链路会怎样

redis-cli -p 6381 DEBUG SLEEP 8      # 阻塞从库主线程 8 秒

实测观察(本机构建):

127.0.0.1:6379> WAIT 1 1000
(integer) 0        # 没有从库确认——此时写入是不安全的

结论:从库长时间阻塞(慢查询、大 key、DEBUG SLEEP、RDB 加载)不只是"读变慢",它会让复制链路断开、退化成全量重传(第 01 章),同时把写入暴露在丢数据风险里。

实验 B:从库挂了,读还能不能成功

redis-cli -p 6381 SHUTDOWN NOSAVE

预期现象取决于客户端实现:

验证清单(四项都要过):

  1. 从库宕机时,读是否仍然成功(回退生效)?
  2. 回退有没有打点?(否则从库挂了三天你都不知道)
  3. 从库恢复后,读是否自动回到从库(而不是永久留在主库上)?
  4. WAIT 在全从库失联时返回 0,代码有没有处理这个分支?

七、「写后读」的四种解法与代价

解法 做法 代价
读主 这类查询显式走主库(allow_stale=False) 失去从库分担;需控制这类请求占比
写后短暂读主 写入后在本地记一个 1~2 秒的标记,期间该 key 读主 需要一点状态;比"永远读主"省资源
WAIT 关键写入后 WAIT 1 200,把窗口压到一次 ACK 往返 写延迟增加;不提供强一致(第 04 章)
不读 用写请求返回的内容直接渲染,或写进程内缓存 只适用于"读自己刚写的东西"

第四条最常被忽略,也最便宜:很多"保存后立刻刷新"的场景,页面完全可以用保存请求返回的新值渲染,根本不需要再读一次 Redis。

八、生产环境怎样替换

教学替身 生产替换 要点
单个从库地址 从库列表 + 负载均衡(轮询/最少连接) 多从库时不要固定打第一个,否则它会被打满
手写两个连接池 哨兵客户端(第 03 章)/ 集群客户端的读副本选项 手写池的问题是主库换了它不知道
无回退 从库失败回退主库 + 打点 没有回退,从库故障会直接变成读故障
人眼看 INFO 采集 master_repl_offset - slave_repl_offset、master_link_status、connected_slaves 告警用字节差,别用 lag 秒数
从库无慢日志监控 从库同样采集 SLOWLOG 从库慢查询会拖慢复制,是延迟的隐藏来源

九、什么时候不该做读写分离

十、练习与验收

练习 1:在你的系统里找三个读接口,逐个标注"能不能容忍 1 秒旧值",把不能容忍的改成读主,估算它们在总读量里的占比。

练习 2:按第 06 节停掉从库,确认读仍成功且回退打点出现;恢复从库后确认读回到从库。

练习 3:采集你的主库写入速率(字节/秒)与 repl_backlog_size,算出从库断线多久会触发全量重传,据此设定延迟告警阈值。

验收点:不看资料,说出读写分离分掉了什么压力、没分掉什么;解释 offset 差的含义;给出四种"写后读"解法及代价;说明为什么 WAIT 不能当作从库健康检查。


现在能解释什么:读写分离是路由出来的而不是配出来的;延迟的两个来源与量化方法;三类必须走主库的读;四种"写后读"解法;从库宕机时读路径应有的回退行为。

下一步:第 03 章处理更硬的问题——主库挂了怎么办,以及客户端怎么自动跟上新主。

进入 keel 阅读