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 秒
实测观察(本机构建):
- 阻塞期间主库写入后
WAIT 1 500仍返回1——这次写的确认发生在阻塞之前。所以别用"WAIT 返回值"来判断从库当时是否健康,它衡量的是"这次写有没有被确认",不是"从库现在活不活"。 - 阻塞持续到复制超时后,主库侧
connected_slaves从 1 变成 0,从库断开并重新走同步;此时WAIT 1 1000返回 0。
127.0.0.1:6379> WAIT 1 1000
(integer) 0 # 没有从库确认——此时写入是不安全的
结论:从库长时间阻塞(慢查询、大 key、DEBUG SLEEP、RDB 加载)不只是"读变慢",它会让复制链路断开、退化成全量重传(第 01 章),同时把写入暴露在丢数据风险里。
实验 B:从库挂了,读还能不能成功
redis-cli -p 6381 SHUTDOWN NOSAVE
预期现象取决于客户端实现:
- 没做兜底的客户端:走从库的读全部报错,读功能整体不可用;
- 正确实现:从库连接失败时回退主库,返回正确结果,同时打点报警。
验证清单(四项都要过):
- 从库宕机时,读是否仍然成功(回退生效)?
- 回退有没有打点?(否则从库挂了三天你都不知道)
- 从库恢复后,读是否自动回到从库(而不是永久留在主库上)?
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 章处理更硬的问题——主库挂了怎么办,以及客户端怎么自动跟上新主。