KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 高可用的诚实边界:什么会丢、锁什么时候失效 — keel 龙骨
第 03 章把切换跑通了,但"能自动切换"不等于"数据安全"。这一章专讲哨兵方案做不到的事:异步复制必然存在的丢数据窗口、网络分区下的脑裂、以及一个很容易被忽略的连锁反应——切换会让分布式锁失效。
第 03 章把切换跑通了,但"能自动切换"不等于"数据安全"。这一章专讲哨兵方案做不到的事:异步复制必然存在的丢数据窗口、网络分区下的脑裂、以及一个很容易被忽略的连锁反应——切换会让分布式锁失效。
一、现场:切换很成功,任务却少了一批
某系统用 Redis 当任务队列(ARQ)。一次主库宕机后,哨兵在几秒内完成切换,应用很快恢复写入。但事后对账发现:切换前入队的 30 多个任务再也没有被执行。
它们"存在过"——客户端收到了入队成功的返回——却随着旧主一起消失了。
原因不是哨兵有 bug,而是这条链路上每一环都在按设计工作:
客户端 XADD → 主库写入并返回(此时从库还没收到)
→ 主库宕机,这段写永远没机会传给任何从库
→ 哨兵把某个从库扶正为新主
→ 新主的数据集里从来没有这批任务
"返回 OK"和"数据安全"之间隔着一次异步复制。 这是本章的第一条结论。
二、概念边界:HA 解决的是"恢复时间",不是"写入安全"
| 维度 | 哨兵方案的表现 |
|---|---|
| 主库宕机后多久能写 | 秒级(第 03 章) |
| 宕机瞬间的写入会不会丢 | 可能丢——取决于有没有复制到从库 |
| 丢多少 | 取决于复制延迟,通常就是"最后一小段时间"的写 |
| 能不能彻底避免 | 不能,只能缩小窗口 |
Redis 官方文档对复制的表述很明确:复制是异步的,主库在把写命令发给从库之后不等从库确认就返回。同理,官方文档对 WAIT 的说明也强调:WAIT 不把 Redis 变成强一致存储——它只是"等 N 个副本确认收到",在故障转移后仍可能丢失。
三、一次完整运行:把丢数据窗口测出来
实验设计:主库连续写入,中途杀掉主库进程,然后看新主缺了多少。
# 1) 主库写入并等待一个副本确认
redis-cli -p 6379 SET job:1001 queued
redis-cli -p 6379 WAIT 1 200 # 返回 1,说明有一个副本收到了
# 2) 不等复制,直接写入后立刻杀主库
redis-cli -p 6379 SET job:1002 queued
redis-cli -p 6379 SHUTDOWN NOSAVE
# 3) 哨兵完成切换后,在新主上查
redis-cli -p 6381 GET job:1001 # 通常还在(WAIT 过)
redis-cli -p 6381 GET job:1002 # 通常没了(没来得及复制)
实测到的两种 WAIT 返回值(本机构建):
127.0.0.1:6379> WAIT 2 1000
(integer) 2 # 两个副本都确认收到了
127.0.0.1:6379> WAIT 1 1000
(integer) 0 # 没有副本确认(副本已断开/阻塞)——这次写入是"裸奔"的
第二条是本课最重要的观测之一:WAIT 返回 0 意味着这次写没有任何副本收到,主库此刻宕机就一定会丢。所以代码里不能只看"没报错",要判断返回值:
def enqueue_with_ack(client, stream: str, payload: dict, timeout_ms: int = 200) -> bool:
"""入队后等一个副本确认;返回 False 表示这次写入没副本兜底。"""
client.xadd(stream, payload)
acked = client.execute_command("WAIT", 1, timeout_ms)
if acked == 0:
# 降级:记一条可补偿的日志,或改为同步落库
return False
return True
用 min-replicas 把"可能丢"变成"明确失败"
Redis 允许主库在"连接的副本数不足"时直接拒绝写入:
# Redis 5 的写法(本课实测环境)
min-slaves-to-write 1
min-slaves-max-lag 10
# Redis 7 起改名为(语义相同)
min-replicas-to-write 1
min-replicas-max-lag 10
含义:至少有 1 个副本连着、且它的 lag 不超过 10 秒,主库才接受写。副本全掉线时主库会拒绝写入,把"静默丢数据"变成"显式写失败"。
代价要说清楚:这是用可用性换安全性。副本全挂时,缓存类业务会整体不可写(通常不可接受),而任务入队类业务则可能更希望"宁可失败也不能丢"。按角色拆实例(第 07 章)在这里又一次体现出价值:两套实例可以有不同的取舍。
四、脑裂:网络分区下的"两个主"
比宕机更麻烦的是主库没死,只是和哨兵/从库失联了:
┌──── 网络分区 ────┐
应用 A ──→ 旧主(它自己还是 master,哨兵联系不上它,改不了它)
哨兵+从库 ──→ 选出新主,应用 B 连上了新主
└─────────────────┘
此时两边都在接受写入——这就是脑裂(split brain)。分区恢复后,哨兵会把旧主降级为新主的从库,旧主要清空数据重新全量同步(第 01 章),于是分区期间写进旧主的数据全部丢失。
Redis 的自我保护很有限:min-replicas-to-write 能让旧主在"发现自己没有副本"时拒绝写入,从而缩短这个窗口。但它不解决根本问题——Redis 的复制没有 fencing 机制(没有"租约"或"世代令牌"能让旧主自己判定"我已经不再是主了")。
一个可观测的信号(第 01 章出现过):如果切换后仍往旧主写,会收到
READONLY You can't write against a read only replica.
说明旧主已被成功降级。真正危险的是降级之前那段窗口——它没有任何报错。
五、连锁反应:切换会让分布式锁失效
这是本章最容易被忽略、也最危险的一条。
回顾 Redis 工程课第 04 章的锁:SET lock:report:42 <uuid> NX EX 30 + Lua 比对值再删。它的安全性建立在"这条 SET 只在一个权威副本上判定"这一点上。切换之后:
sequenceDiagram
participant A as 进程 A
participant M as 主库(旧)
participant R as 从库(将被扶正)
participant B as 进程 B
A->>M: SET lock:report:42 uuidA NX EX 30
M-->>A: OK(A 认为自己持有了锁)
Note over M,R: 这条 SET 还没到达从库
M--xR: 主库宕机,复制中断
Note over R: 哨兵把它扶正为新主<br/>新主的数据集里没有这个锁
B->>R: SET lock:report:42 uuidB NX EX 30
R-->>B: OK(B 也"持有"了同一把锁)
Note over A,B: A 和 B 同时进入临界区 —— 互斥失效
结论:单实例(或主从)Redis 上的分布式锁,在故障切换后可能同时被两个进程持有。 这不是实现细节问题,是异步复制的必然结果。
三条可选应对,以及各自代价
1. 承认锁是"减少重复劳动"而非"互斥保证"
很多场景用锁只是为了"别重复生成同一份索引"。偶尔失效只是多算一次,代价可接受。先判断你的临界区属于哪一类——这决定后面两条要不要做。
2. 用 fencing token(真正正确的做法)
获取锁时拿一个单调递增的序号,写数据时带上它;下游(数据库/存储)拒绝序号更小的写入。这需要被保护的资源侧配合,但它不依赖任何关于 Redis 的假设。这是分布式系统里唯一站得住的互斥方案。
3. Redlock(多节点多数派锁)
由 Redis 作者 antirez 提出:向 N 个(通常 5 个)互相独立的主节点依次申请同一把锁,超过半数成功且总耗时小于 TTL 才算拿到。
但它存在公开的技术争议,本课如实记录:
| 立场 | 主要论点 |
|---|---|
| Martin Kleppmann(2016 批评) | Redlock 依赖"各节点本地时钟大致同步"这一假设;GC 暂停、时钟跳跃会让客户端在锁过期后仍以为自己持有。他主张的正确解法是 fencing token |
| antirez(回应) | Redlock 不要求精确时钟同步,只要求各节点时钟以近似相同的速率前进;且获取超时后可直接判定失败,能规避大部分问题 |
实践建议(本课程的立场,也是 Redis 工程课第 04 章"诚实边界"的延续):
- 需求是"尽量别重复执行" → 单实例锁 + 幂等就够;
- 需求是"绝不允许两个进程同时改同一份数据" → 用 fencing token,需要下游配合(例如数据库里存
max_fencing_token做条件更新); - Redlock 可以作为降低概率的手段,但不要把它当成互斥的证明,且要清楚它在社区里没有共识。
六、切换期间的写入失败窗口
除了丢数据,还要接受"有一段时间写不进去":
主库宕机 → down-after-milliseconds 判定 → 选举 → 提升 → 客户端重新发现
└──────────── 这段时间内写入全部失败 ────────────┘
这个窗口的量级是秒级(由 down-after-milliseconds 主导,默认 30 秒,本课实测环境设为 5 秒)。演练时务必用自己的配置实测,不要照抄默认值。
应用侧必须做的三件事:
- 写操作要有重试(第 03 章的
safe_write); - 重试次数/间隔要覆盖这个窗口;
- 重试耗尽后要有降级路径(写本地队列、返回明确错误),而不是无限重试把线程池占满。
七、误判澄清
| 误解 | 核对 | 结论 |
|---|---|---|
| "上了哨兵,写入就不会丢了" | 复制异步;WAIT 官方文档明确"不提供强一致" |
只能缩小窗口;真正不能丢的数据要换存储或加对账 |
| "切换是瞬间完成的" | 判定期 + 选举 + 提升 + 重配,各步都耗时 | 存在秒级不可写窗口,应用必须重试 |
| "副本越多越安全" | 副本多会增加主库的复制输出负担;N 个副本时 WAIT N 也更慢 |
通常 2~3 个副本足够,配合 WAIT 1 |
| "锁是 Redis 里的,Redis 高可用所以锁也高可用" | 切换后新主可能没有那条锁(时序图已演示) | 互斥性在切换下会破;需要幂等或 fencing token |
"min-replicas-to-write 让数据更安全" |
它只是把"静默丢"变成"显式失败" | 是可用性换安全性的开关,不是一致性保证 |
八、生产环境怎样替换
| 教学替身 | 生产替换 | 要点 |
|---|---|---|
| 靠观察判断有没有丢 | 定期对账:任务数、订单状态、索引完整性 | 没有对账,"有没有丢"永远是猜测 |
| 所有数据一套 Redis | 按敏感度拆实例(缓存 / 队列 / 锁) | 让"要安全"的数据不用替"要吞吐"的数据做妥协 |
| 锁只靠 Redis | 临界区幂等 + 下游 fencing token | 顺序:先做幂等(便宜),再考虑 token(需要下游配合) |
| 切换无人知晓 | +switch-master 告警 + 写失败率告警 |
频繁切换本身就是故障信号 |
默认 down-after-milliseconds |
按业务容忍度调(常见 5~10 秒) | 调太小会误判,调太大延长不可写窗口 |
九、主动破坏清单
- 写入后立刻杀主库,统计新主上缺失的 key,量化你的丢数据窗口;
- 断掉所有从库并写入,验证
min-slaves-to-write是否让主库拒绝写(若没配,主库照常写,此时宕机必丢); - 切换进行中持续获取锁,用两个进程竞争同一个锁 key 并打印各自持有的时间区间,验证是否出现重叠;
- 演练一次网络分区(本机可用防火墙规则模拟),观察旧主在分区期间是否仍在接受写。
十、练习与验收
练习 1:按第 03 节做一次"写完立刻杀主库",统计丢失的 key 数量,并分别在不使用 / 使用 WAIT 1 两种情况下对比。
练习 2:给你系统里的每个 Redis 锁标注"并发执行会怎样",据此判断是否需要 fencing token。
练习 3:把 min-slaves-to-write 打开,断掉所有从库后写入,观察行为;讨论你的业务能不能接受这种失败方式。
验收点:不看资料,说出丢数据窗口的成因、WAIT 能缩小什么不能保证什么、min-replicas 的取舍、以及锁在切换后失效的机理与三条应对。
现在能解释什么:HA 与写入安全的区别;WAIT 与 min-replicas 各自能挡住什么;脑裂的成因与 Redis 的有限防护;锁在切换下失效的机理,以及 Redlock 争议双方的核心论点与本课程的实践建议。
下一步:第 05 章进入集群——用分片解决前四章都解决不了的容量问题。