Redis Sentinel 故障切换:服务恢复了,已确认的写入为什么还会丢
Sentinel 解决的是监控、发现和自动切换,不是同步复制,也不保证零丢失。高可用方案要把「多久恢复写入」和「哪些已确认的写入能留下」拆成两个目标分别回答。
一次演练:1 个 primary、2 个 replica、3 个 Sentinel,客户端每秒写入 200 条带序号的 key,每收到一个 OK 记一次确认。然后把 primary 从复制网络上断开,但客户端仍然连着它:
+0.00s primary r1 与 replica、Sentinel 之间的网络断开,客户端仍能访问 r1
+3.95s Sentinel 判定 r1 主观下线(+sdown)
+4.02s 两个 Sentinel 同意,客观下线(+odown)
+5.23s 客户端从 Sentinel 得知新 primary 是 r3,开始写 r3
在此之前,r1 一直在确认客户端的写入
分区恢复 Sentinel 把 r1 改为 r3 的 replica,r1 全量同步
结果 客户端确认过的 1,028 条写入不存在于最终数据中服务恢复了,数据却丢了。本文用 Redis 8.10.1 的实测把这段时间线拆开:复制靠什么延续、Sentinel 怎样判断和切换、丢失从哪里来、min-replicas-to-write 和 WAIT 各能收敛到什么程度。
一、先说结论
- Redis 复制是异步的。 primary 执行写命令后立即回复客户端,再把命令发给 replica;切换时,新 primary 没收到的写入就丢了。
- 进程崩溃时丢得很少,网络分区时丢得很多。 实测 primary 被
SIGKILL,已确认的 4,627 条写入一条没丢;primary 被分区时,它在切换完成前确认的 1,028 条写入全部丢失。 min-replicas-to-write缩短窗口,但不能关闭它。 设为 1、min-replicas-max-lag 2后,旧 primary 在分区后 3.0 秒开始拒绝写入,丢失从 1,028 条降到 584 条。WAIT让客户端知道哪些写入还没复制。 每次写入后WAIT 1 100,经WAIT确认的写入丢失 0 条,分区期间的 32 条写入返回 0(未确认)。代价是每次写入多等一次副本确认,分区时每次最多等 100ms;它不回滚、不阻止写入,不是强一致。- 断线期间的写入超出 backlog,重连就要全量同步。 实测 replica 断开后又写入 200KB:backlog 1MB 时部分同步,16KB 时全量同步。
二、复制状态靠什么延续
每个 primary 有一个复制 ID(replication ID)和一个不断增长的复制偏移量(offset),replica 记录自己处理到哪个 ID 的哪个 offset。primary 还在内存里保留最近一段复制流,即复制积压缓冲区(backlog,repl-backlog-size,默认 1MB)。
replica 断线重连时发送 PSYNC <replid> <offset>:
- 复制 ID 一致、offset 之后的数据还在 backlog 里:部分同步,只补发缺的那一段;
- 复制 ID 对不上,或需要的数据已经被挤出 backlog:全量同步,primary 生成快照发给 replica,再发送快照期间的新写入。
切换之后,新 primary 生成新的复制 ID,并把旧 ID 记为 master_replid2,其他 replica 可以凭旧 ID 在新 primary 上继续部分同步。但被分区的旧 primary 在分区期间自己又写了数据,它的复制历史和新 primary 分叉了,重新加入时只能全量同步,它独有的写入就在这一步被覆盖。
backlog 可以看作「断线窗口预算」:断线期间的写入量超过它,就只能全量同步。实测暂停一个 replica,等 primary 因 repl-timeout(实验中设为 3 秒)断开它之后,再写入 200KB:
repl-backlog-size | 重连结果 |
|---|---|
| 1MB(默认) | 部分同步 |
| 16KB(最小值) | 全量同步 |
全量同步要 fork、生成快照、传输、在 replica 上清空并加载,数据集越大越贵,还可能因为写入太快而反复失败。backlog 按「峰值写入速率 × 预期断线时长」来估,比默认值大几倍是常见做法。
三、Sentinel 怎样判断故障、授权切换
几个术语:
- 主观下线(SDOWN):某个 Sentinel 在
down-after-milliseconds内没有收到 primary 的有效回复; - 客观下线(ODOWN):至少
quorum个 Sentinel 认为它主观下线; - 授权切换:要真正执行切换,必须有一个 Sentinel 被多数 Sentinel 选为 leader。quorum 只用于检测,授权看的是多数。
所以 3 个 Sentinel、quorum 为 2 时,一个 Sentinel 挂了还能切换;如果 3 个 Sentinel 里有 2 个和 primary 在同一侧被分区,少数一侧永远不会发起切换。
选择新 primary 时,Sentinel 先排除与 primary 断开太久的 replica(超过 down-after-milliseconds 的 10 倍再加上 primary 不可达的时长),再依次比较 replica-priority(越小越优先,0 表示永不提升)、处理过的复制 offset(越大越优先)和 run ID。offset 最大的 replica 丢得最少,但它仍然可能缺少 primary 最后确认的那些写入。
实测 primary 进程被 SIGKILL(down-after-milliseconds 3000):
+0.10s 旧 primary 最后一次确认写入
+3.16s +sdown
+3.23s +odown(3/2 同意)
+5.18s 客户端在新 primary 上第一次写入成功
+5.38s s1 收到 +switch-master(客户端询问的另一个 Sentinel 更早更新了配置)
结果 已确认 4,627 条,丢失 0 条;故障期间 16 次写入失败检测约 3 秒由 down-after-milliseconds 决定,调小能更快切换,但更容易把短暂的网络抖动或慢命令当成故障。从判定到客户端恢复还有约 2 秒,花在选举、提升、配置广播和客户端重新发现上。
四、数据从哪里丢
异步复制的尾巴。 primary 被杀前最后几毫秒确认的写入,如果还没发到 replica,就会丢。实测复制几乎没有积压,这一项是 0;复制延迟大(大 key、带宽不足、replica 忙)时就不是了。
被分区的旧 primary 继续接受写入。 这是丢得最多的情况。旧 primary 自己并不知道已经被取代,和它在同一侧的客户端继续写,直到客户端从 Sentinel 得知新地址。实测这段时间有 5.2 秒,确认的 1,028 条写入在分区恢复后全部被覆盖。这正是官方文档在 Sentinel 部署示例中描述的场景。
客户端发现得慢。 客户端缓存了旧地址、连接池里还是旧连接,或者依赖 DNS,都会把这段时间拉长。实验客户端每 200ms 询问一次 Sentinel,生产上一般订阅 +switch-master 并配合连接和命令超时。
五、能做哪些收敛
| 手段 | 做什么 | 实测 | 代价与边界 |
|---|---|---|---|
min-replicas-to-write 1 + min-replicas-max-lag 2 | 可用的 replica 不足时,primary 拒绝写入 | 旧 primary 3.0 秒后返回 NOREPLICAS,丢失 1,028 → 584 条 | 仍有 max-lag 这段窗口;所有 replica 都断开时,健康的 primary 也会拒绝写入 |
每次写入后 WAIT 1 100 | 等到至少 1 个 replica 确认收到,或超时 | WAIT 确认的写入丢 0 条,32 条返回未确认 | 每次写入多一次等待,分区时每次等满 100ms;超时不撤销写入,未确认的写入可能存在也可能丢 |
WAITAOF | 等待本地或副本 fsync | 未单独实测 | 同 WAIT,并叠加 fsync 延迟 |
| 业务可重放、幂等、对账 | 丢失的写入能从上游补回,重试不产生重复效果 | — | 需要业务侧设计 |
这里没有一项是线性一致性保证。WAIT 的含义是「这条写入在返回时已经有 N 个 replica 收到」,而不是「这条写入一定不会丢」:被确认的 replica 之后也可能挂掉,新 primary 也可能选到另一个没收到的 replica。它的价值在于把「不知道有没有丢」变成「知道哪些没确认」,交给业务去补偿。
六、客户端契约
- 只从 Sentinel 获取 primary 地址,不要写死 IP;订阅
+switch-master,并且在收到READONLY、连接错误时重新询问。 - 写入重试要考虑「已执行但响应丢了」。 连接在发送命令后断开,客户端无法知道这条写入是否生效;重试必须是幂等的(
SET覆盖、带唯一 ID 的写入),计数类操作(INCR)要用幂等键兜底。 - 从 replica 读,就要接受陈旧读。 切换前后,replica 上的数据可能落后。
- 设置连接超时和命令超时。 实验客户端两者都是 1 秒,太长会把故障时间拉长,太短会在慢命令时误判。
七、监控与演练
INFO replication:master_link_status、每个 replica 的offset与lag、master_repl_offset与各 replica 的差值;INFO stats:sync_full、sync_partial_ok、sync_partial_err,全量同步次数上涨说明 backlog 不够或 replica 频繁断开;- Sentinel:
SENTINEL CKQUORUM <name>检查能否形成 quorum 与多数;订阅+sdown、+odown、+switch-master并告警; - 定期演练:杀进程和网络分区两种都要做,记录客户端最后确认的序号和切换后数据中的最大序号,而不只是「切换成功」。
八、常见误区
- 「有 Sentinel 就不会丢数据」:实测分区时丢了 1,028 条已确认的写入。Sentinel 管的是切换,数据安全取决于复制方式。
- 「quorum 设成 1 切得更快」:quorum 只决定何时判定客观下线,授权切换仍需要多数;quorum 太小会让单个 Sentinel 的误判触发切换流程。
- 「
WAIT就是强一致」:它只报告已经收到的副本数,不撤销写入,也不影响选主。 - 「
min-replicas-to-write能防止脑裂丢数据」:它只能在max-lag之后拒绝写入,实测仍丢了 584 条。 - 「replica 断开重连会自动补齐」:只有断线期间的写入还在 backlog 里才是部分同步,否则是全量同步。
小结
Sentinel 把服务恢复时间缩短到秒级(实测 5 秒左右,其中 3 秒是故障判定),但它不改变复制是异步的这一事实。已确认的写入会不会丢,取决于故障类型:进程崩溃几乎不丢;网络分区时,旧 primary 在被取代之前确认的一切都会丢。min-replicas-to-write 缩短这段时间,WAIT 让客户端知道哪些写入没有被副本收到。真正不能丢的写入,要在业务层可重放、可对账。
Redis 自身的持久化窗口见 Redis 持久化与恢复;Cluster 的故障提升同样依赖异步复制,见 Redis Cluster 的应用契约;MySQL 的同类问题见 MySQL 故障切换与读一致性。
配套实验
- codesphere-labs/cache/redis-sentinel-failover:部分同步与全量同步、primary 进程被杀、网络分区(异步 /
min-replicas-to-write/WAIT),每次写入的结果、Sentinel 事件与切换前后的复制状态全部归档(验证记录)
参考资料