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 章"诚实边界"的延续):

六、切换期间的写入失败窗口

除了丢数据,还要接受"有一段时间写不进去":

主库宕机 → down-after-milliseconds 判定 → 选举 → 提升 → 客户端重新发现
           └──────────── 这段时间内写入全部失败 ────────────┘

这个窗口的量级是秒级(由 down-after-milliseconds 主导,默认 30 秒,本课实测环境设为 5 秒)。演练时务必用自己的配置实测,不要照抄默认值。

应用侧必须做的三件事:

  1. 写操作要有重试(第 03 章的 safe_write);
  2. 重试次数/间隔要覆盖这个窗口;
  3. 重试耗尽后要有降级路径(写本地队列、返回明确错误),而不是无限重试把线程池占满。

七、误判澄清

误解 核对 结论
"上了哨兵,写入就不会丢了" 复制异步;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 秒) 调太小会误判,调太大延长不可写窗口

九、主动破坏清单

  1. 写入后立刻杀主库,统计新主上缺失的 key,量化你的丢数据窗口;
  2. 断掉所有从库并写入,验证 min-slaves-to-write 是否让主库拒绝写(若没配,主库照常写,此时宕机必丢);
  3. 切换进行中持续获取锁,用两个进程竞争同一个锁 key 并打印各自持有的时间区间,验证是否出现重叠;
  4. 演练一次网络分区(本机可用防火墙规则模拟),观察旧主在分区期间是否仍在接受写。

十、练习与验收

练习 1:按第 03 节做一次"写完立刻杀主库",统计丢失的 key 数量,并分别在不使用 / 使用 WAIT 1 两种情况下对比。

练习 2:给你系统里的每个 Redis 锁标注"并发执行会怎样",据此判断是否需要 fencing token。

练习 3:把 min-slaves-to-write 打开,断掉所有从库后写入,观察行为;讨论你的业务能不能接受这种失败方式。

验收点:不看资料,说出丢数据窗口的成因、WAIT 能缩小什么不能保证什么、min-replicas 的取舍、以及锁在切换后失效的机理与三条应对。


现在能解释什么:HA 与写入安全的区别;WAIT 与 min-replicas 各自能挡住什么;脑裂的成因与 Redis 的有限防护;锁在切换下失效的机理,以及 Redlock 争议双方的核心论点与本课程的实践建议。

下一步:第 05 章进入集群——用分片解决前四章都解决不了的容量问题。

进入 keel 阅读