Administrator
发布于 2019-08-04 / 5434 阅读
81

Redis 主从复制与哨兵高可用部署实践

故障演练那天,我们丢了 400 多条缓存 key

公司 7 月底做了一次故障演练,运维在预发环境把 Redis 主节点的进程 kill 掉,看哨兵能不能自动切换。切是切成功了,但演练结束后比对数据,主库的 key 数量是 12,480,331,切过去之后新主库只有 12,479,905,少了 426 个。

这 426 个 key 是演练前 3 秒刚写入的。我当时负责这个 Redis 集群,被问到的时候答不上来,回去把主从复制的原理啃了一遍。

复制是怎么工作的

我们的拓扑是一主两从,三个哨兵。先看主库的状态:

$ redis-cli -h 10.20.31.11 -p 6379 info replication
# Replication
role:master
connected_slaves:2
slave0:ip=10.20.31.12,port=6379,state=online,offset=8273491022,lag=0
slave1:ip=10.20.31.13,port=6379,state=online,offset=8273490881,lag=1
master_repl_offset:8273491022
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:8272442447
repl_backlog_histlen:1048576

注意 slave1 的 offset 是 8273490881,比主库的 master_repl_offset 8273491022 少 141 字节。这 141 字节就是还没同步过去的写命令,是异步复制的正常延迟。

复制的完整流程是:

  1. 从库启动,执行 slaveof 10.20.31.11 6379,向主库发 PSYNC ? -1
  2. 主库判断:如果这个从库之前同步过,且断线期间的写命令还在 repl_backlog(复制积压缓冲区)里,就走部分重同步,只发缺失的那部分命令;否则走全量同步
  3. 全量同步时主库 fork 一个子进程执行 BGSAVE 生成 RDB,期间的新写命令存进 repl_backlog。RDB 生成完发给从库,从库清空旧数据加载 RDB,主库再把缓冲区里的增量命令补发过去。
  4. 之后每有一个写命令,主库就异步地把它发给所有从库。

关键点在第 4 步:主库不等从库确认就返回给客户端了。Redis 的复制是异步的,设计上就是为了性能——如果等从库确认,一次写操作就从 0.1 毫秒变成至少一次网络往返。所以主库宕机时,那些已返回成功但还没同步给从库的写,就永久丢了。

我们那 426 个 key 就是这么没的。演练时脚本以 3000 QPS 在写,主库被 kill 的瞬间,约有 141~800 字节(大概几百条命令)还在网络缓冲区里没发出去。

Redis 2.8 之前只有全量同步,从库断线重连一次就要全量重传,代价极大。2.8 引入 PSYNCrepl_backlog 之后才有部分重同步。这个 backlog 是环形的,默认 1 MB(上面 repl_backlog_size:1048576)。写满之后会覆盖最老的数据,如果从库断线太久,缺失的命令已经被覆盖,就只能全量同步。

怎么算它该设多大?公式:backlog 大小 ≥ 平均每秒写命令字节数 × (断线最长时间 + 全量同步时间)。我们压测时每秒写命令约 180 KB,允许从库断线 30 秒,全量同步约 20 秒,算出来至少要 9 MB。我们设成了 32 MB:

# redis.conf
repl-backlog-size 32mb
repl-backlog-ttl 3600

repl-backlog-ttl 是主库没有从库连接时,backlog 保留多久再释放,默认 3600 秒。设成 0 表示永不释放。

哨兵怎么选新主库

哨兵做的事分三步:监控、通知、自动故障转移。

# sentinel.conf
port 26379
sentinel monitor mymaster 10.20.31.11 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 60000

最后那个 2quorum:至少 2 个哨兵认为主库主观下线,才能判定客观下线。我们有 3 个哨兵,quorum 设 2,能容忍 1 个哨兵挂掉。要注意 quorum 只用于"判定下线",真正执行故障转移还需要多数派选举:需要 max(quorum, 哨兵数/2 + 1) 个哨兵授权,3 个哨兵就是要 2 个,和 quorum 一样。

选新主库的规则是按顺序筛的:

  1. 过滤掉断线的、5 秒内没回复过 PING 的、跟主库断开时间超过 down-after-milliseconds * 10 的从库。
  2. slave-priority 排序(redis.conf 里配,默认 100,设 0 表示永不当选)。
  3. 优先级相同,按复制偏移量排序,offset 越大数据越新,优先当选。这一条很关键,它让数据最全的从库上位。
  4. offset 也相同,按 runID 字典序最小的当选。

演练那次的日志是这样的:

3174:X 31 Jul 2019 15:22:07.441 # +sdown master mymaster 10.20.31.11 6379
3174:X 31 Jul 2019 15:22:07.503 # +odown master mymaster 10.20.31.11 6379 #quorum 2/2
3174:X 31 Jul 2019 15:22:07.503 # +new-epoch 3
3174:X 31 Jul 2019 15:22:07.503 # +try-failover master mymaster 10.20.31.11 6379
3174:X 31 Jul 2019 15:22:07.512 # +vote-for-leader 8f2c1a... 3
3174:X 31 Jul 2019 15:22:07.529 # +elected-leader master mymaster 10.20.31.11 6379
3174:X 31 Jul 2019 15:22:07.529 # +failover-state-select-slave master mymaster 10.20.31.11 6379
3174:X 31 Jul 2019 15:22:07.631 # +selected-slave slave 10.20.31.12:6379
3174:X 31 Jul 2019 15:22:07.631 # +failover-state-send-slaveof-noone slave 10.20.31.12:6379
3174:X 31 Jul 2019 15:22:07.714 # +failover-state-wait-promotion slave 10.20.31.12:6379
3174:X 31 Jul 2019 15:22:08.402 # +promoted-slave slave 10.20.31.12:6379
3174:X 31 Jul 2019 15:22:08.403 # +failover-state-reconf-slaves master mymaster
3174:X 31 Jul 2019 15:22:08.471 * +slave-reconf-sent slave 10.20.31.13:6379
3174:X 31 Jul 2019 15:22:09.438 * +slave-reconf-done slave 10.20.31.13:6379
3174:X 31 Jul 2019 15:22:09.503 # +failover-end master mymaster 10.20.31.11 6379
3174:X 31 Jul 2019 15:22:09.503 # +switch-master mymaster 10.20.31.11 6379 10.20.31.12 6379

+sdown(主观下线)到 +switch-master(切换完成)一共 2.06 秒。其中 down-after-milliseconds 设的是 5000 毫秒,但日志里 +sdown+odown 只用了 62 毫秒,说明哨兵之间几乎是同时探测到失败的。

parallel-syncs 设成 1,表示故障转移后,同一时刻只允许 1 个从库向新主库发起全量同步。如果设 2,两个从库同时拉 RDB,新主库的网卡和 CPU 会被打满,影响到正常的读写请求。代价是切换后恢复得慢一些。

三种数据风险

搞清楚原理之后,我把哨兵方案的风险点列了一遍,主要有三个。

一、异步复制丢数据。就是演练遇到的情况。Redis 提供了两个参数缓解:

# 至少有 1 个从库的延迟不超过 10 秒,否则主库拒绝写
min-slaves-to-write 1
min-slaves-max-lag 10

配了之后,如果所有从库都断连或者延迟超过 10 秒,主库直接拒绝写命令。这样至少保证不会"主库写了但一个从库都没收到"。代价是从库全挂时整个 Redis 不可写。我们评估过,缓存服务宁可不可写也不能丢数据(因为丢数据会导致缓存与数据库不一致,更难修),所以开了这个。开了之后正常时期没影响,只在从库网络抖动超过 10 秒时会短暂拒绝写。

二、脑裂。主库因为网络分区被哨兵误判为下线,哨兵选出新主库;但老主库其实还活着,只是跟哨兵和从库失联了。如果客户端还在往老主库写,这部分数据既不会同步给新主库,也会在网络恢复后被清空(老主库降级为从库时会做全量同步)。

上面那两个 min-slaves-to-write 参数同样能缓解脑裂:老主库跟从库失联后,满足条件的从库数量变成 0,写请求会被拒绝,最多丢 10 秒的数据窗口。这是 Redis 官方推荐的做法,本质上是用 CAP 里的 C 换一点 A。

三、切换期间的读写失败。这 2 秒里,客户端往老主库写会失败,读从库倒是正常。业务侧必须有重试和降级。我们的 Jedis 连接池配了:

JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(200);
config.setMaxIdle(50);
config.setMinIdle(20);
// 拿连接最多等 1 秒,避免切换期间线程全堵在等连接上
config.setMaxWaitMillis(1000);
// 空闲连接检测
config.setTestWhileIdle(true);
config.setTimeBetweenEvictionRunsMillis(30000);

// 连接超时和读超时分开设
JedisSentinelPool pool = new JedisSentinelPool(
        "mymaster",
        Set.of("10.20.31.11:26379", "10.20.31.12:26379", "10.20.31.13:26379"),
        config,
        2000,      // connectionTimeout
        2000,      // soTimeout
        null, 0, null);

另外在 DAO 层加了 3 次重试,每次间隔 100 毫秒。切换那 2 秒内的请求,重试第 2 次或第 3 次就能连上新主库。压测时模拟 kill 主库,业务侧错误率峰值 4.7%,持续 1.8 秒,之后归零。

小结

  • Redis 复制是异步的。主库写完就返回,不等从库确认,所以主库宕机必然丢最后那批写命令。这是设计取舍,不是 bug。
  • repl-backlog 是环形缓冲区,默认 1 MB 太小,按 每秒写字节数 × (最大断线时间 + 全量同步时间) 估算,我们设了 32 MB。
  • 哨兵选主的顺序:过滤不健康的 → slave-priority → 复制偏移量最大的优先 → runID 最小。第二条规则保证数据最全的从库上位。
  • quorum 判定客观下线,执行故障转移还需要多数派授权。3 个哨兵配 quorum=2 能容忍 1 个哨兵故障。
  • min-slaves-to-write + min-slaves-max-lag 同时缓解丢数据和脑裂,代价是从库全挂时不可写。缓存场景我建议开。
  • 客户端必须配超时和重试。切换期间的错误只能靠业务侧兜,服务端解决不了。Jedis 的 maxWaitMillis 一定要设,否则线程会在等连接时堆满。

演练之后我们把这份配置推到了生产。8 月中旬主库所在的物理机真的宕了一次,切换耗时 2.3 秒,丢了 31 个 key(都是缓存,从数据库能重建),业务侧无感知。

参考