Skip to content

Redis Sentinel 故障切换:服务恢复了,已确认的写入为什么还会丢 ​

Sentinel 解决的是监控、发现和自动切换,不是同步复制,也不保证零丢失。高可用方案要把「多久恢复写入」和「哪些已确认的写入能留下」拆成两个目标分别回答。

一次演练:1 个 primary、2 个 replica、3 个 Sentinel,客户端每秒写入 200 条带序号的 key,每收到一个 OK 记一次确认。然后把 primary 从复制网络上断开,但客户端仍然连着它:

text
+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>:

replica 重连PSYNC replid offsetreplid 一致?或等于切换前的 replid2offset 还在 backlog?repl-backlog-size部分同步补发缺的字节全量同步快照 + 缓冲是是否否(新 primary 的复制历史不同)实测 replica 暂停后重连:backlog 1 MB → 部分同步;16 KB → 全量同步
图 1 · 断线期间的写入还留在 backlog 里,就只补发这一段;超出 backlog 或复制 ID 对不上,就要生成快照全量同步
  • 复制 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):

text
+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 r1Sentinel新 primary r3分区仍在确认写入:1028 条,最终全部丢失+sdown 4.0s+odown 4.0s+switch-master 6.2s5.2s 起写入分区恢复后,Sentinel 把 r1 改为 r3 的 replica,r1 全量同步,分区期间独有的写入被覆盖客户端每 200ms 询问一次 Sentinel;+switch-master 取自 s1 的事件,其他 Sentinel 可能更早更新
图 2 · 分区后 Sentinel 约 4.0 秒判定主观下线,客户端 5.2 秒才切到新 primary;这段时间旧 primary 确认的 1028 条写入,在它重新加入时被丢弃

异步复制的尾巴。 primary 被杀前最后几毫秒确认的写入,如果还没发到 replica,就会丢。实测复制几乎没有积压,这一项是 0;复制延迟大(大 key、带宽不足、replica 忙)时就不是了。

被分区的旧 primary 继续接受写入。 这是丢得最多的情况。旧 primary 自己并不知道已经被取代,和它在同一侧的客户端继续写,直到客户端从 Sentinel 得知新地址。实测这段时间有 5.2 秒,确认的 1,028 条写入在分区恢复后全部被覆盖。这正是官方文档在 Sentinel 部署示例中描述的场景。

客户端发现得慢。 客户端缓存了旧地址、连接池里还是旧连接,或者依赖 DNS,都会把这段时间拉长。实验客户端每 200ms 询问一次 Sentinel,生产上一般订阅 +switch-master 并配合连接和命令超时。

五、能做哪些收敛 ​

primary 进程被杀异步复制0 条网络分区旧 primary 持续确认到切换1028 条分区 + min-replicas3.0s 后拒绝写入584 条分区 + WAIT 1另有 32 条返回未确认0 条每秒 200 次写入,Redis 8.10.1,1 primary + 2 replicas + 3 Sentinel,down-after-milliseconds 3000
图 3 · 进程崩溃时复制几乎没有积压;分区时旧 primary 继续确认写入,min-replicas 只能缩短这段时间,只有 WAIT 让客户端知道哪些写入没有被副本收到
手段做什么实测代价与边界
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 故障切换与读一致性。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权