Administrator
发布于 2021-03-07 / 7254 阅读
58

Redis Cluster 集群搭建与扩容实践

32 G 内存的 Redis,用到 28 G 就没人敢再往里放了

3 月初做大促容量评估,缓存这块的数据很难看:

$ redis-cli info memory
used_memory_human:28.41G
maxmemory_human:32.00G
mem_fragmentation_ratio:1.31
evicted_keys:1842033

evicted_keys 184 万,说明已经因为内存满在淘汰 key 了。我们这个实例是主从架构,单机 32 G,没做集群。峰值 QPS 6.2 万,其中读 5.8 万。

单机的天花板是硬性的:内存不能超过单机,写 QPS 也不能超过单线程的处理能力。这次决定上 Redis Cluster。

搭集群

版本选的 Redis 6.0.9。6.0 在 2020 年 5 月发布,最大的变化是引入了多线程 IO(io-threads),但注意命令执行依然单线程,多线程只处理网络读写。默认关闭,我们开了 4 个:

# redis.conf
io-threads 4
io-threads-do-reads yes

拓扑是 3 主 3 从,6 台 8 G 内存的机器(预算有限,机器是从别的项目匀过来的)。

$ redis-cli --cluster create \
    10.0.2.11:6379 10.0.2.12:6379 10.0.2.13:6379 \
    10.0.2.21:6379 10.0.2.22:6379 10.0.2.23:6379 \
    --cluster-replicas 1

注意 --cluster-replicas 1 的含义是每个主节点配 1 个从节点。这 6 个地址会按顺序分配:前 3 个是主,后 3 个分别作为前 3 个的从。

>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 10.0.2.22:6379 to 10.0.2.11:6379
Adding replica 10.0.2.23:6379 to 10.0.2.12:6379
Adding replica 10.0.2.21:6379 to 10.0.2.13:6379
[OK] All 16384 slots covered.

哈希槽:16384 是怎么来的

Cluster 把整个 key 空间分成 16384 个槽,key 到槽的映射是:

slot = CRC16(key) % 16384

CRC16 用的是 XMODEM 变体(16 位输出)。为什么是 16384 而不是 65536?Redis 作者 antirez 在 GitHub 的 issue 里解释过两个原因:

  • 集群节点之间要定期发心跳包,包里携带"我负责哪些槽"的信息,用的是 bitmap。16384 个槽的 bitmap 是 16384 / 8 = 2 KB,65536 的话就是 8 KB。节点多了以后差别明显。
  • Redis Cluster 设计上不建议超过 1000 个主节点,16384 个槽平均分给 1000 个节点,每个节点 16 个槽,粒度够用。

所以这是个工程权衡:槽数够多保证数据分布均匀,又不能多到心跳包过大。

MOVED 和 ASK 重定向

连上集群后随便敲个命令,如果 key 不在当前节点:

$ redis-cli -c -h 10.0.2.11 -p 6379
10.0.2.11:6379> get user:882341
-> Redirected to slot [12945] located at 10.0.2.13:6379
"..."

注意我加了 -c 参数,没加的话会直接报错:

10.0.2.11:6379> get user:882341
(error) MOVED 12945 10.0.2.13:6379

两种重定向的区别:

响应含义客户端该做什么
MOVED slot host:port这个槽永久归别人管更新本地槽映射缓存,重新发请求
ASK slot host:port这个槽正在迁移,只有这一个 key 在对方那里先发 ASKING 再发命令,不更新本地缓存

ASK 的设计很讲究:迁移过程中,槽的所有权还没变,只是部分 key 已经搬走了。如果客户端收到 ASK 就更新缓存,那迁移完成后反而会指向错的地方。

客户端适配:这里坑最多

Spring Boot 2.4 默认用的是 Lettuce(不是 Jedis)。配置:

spring:
  redis:
    cluster:
      nodes:
        - 10.0.2.11:6379
        - 10.0.2.12:6379
        - 10.0.2.13:6379
        - 10.0.2.21:6379
        - 10.0.2.22:6379
        - 10.0.2.23:6379
      max-redirects: 3
    lettuce:
      pool:
        max-active: 16
        max-idle: 8

坑一:主从切换后客户端连不上

这是我们踩的最疼的一个。某天凌晨一个主节点宕机,Cluster 自动把从节点提升为主,集群本身 15 秒就恢复了。但应用侧一直报错

io.lettuce.core.RedisCommandExecutionException: CLUSTERDOWN The cluster is down
  at io.lettuce.core.cluster.ClusterCommand.lambda$...

原因:Lettuce 默认不会主动刷新集群拓扑。它在启动时拉一次槽映射就缓存住了,主节点换了它不知道,还在往老节点发请求。

必须开启动态刷新:

@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceCustomizer() {
    return builder -> builder.clientOptions(
        ClusterClientOptions.builder()
            .topologyRefreshOptions(
                ClusterTopologyRefreshOptions.builder()
                    // 1. 周期性刷新,每 60 秒
                    .enablePeriodicRefresh(Duration.ofSeconds(60))
                    // 2. 自适应刷新:收到 MOVED/ASK 等事件时立即刷新
                    .enableAdaptiveRefreshTrigger(
                        ClusterTopologyRefreshOptions.RefreshTrigger.MOVED_REDIRECT,
                        ClusterTopologyRefreshOptions.RefreshTrigger.PERSISTENT_RECONNECTS)
                    .adaptiveRefreshTriggersTimeout(Duration.ofSeconds(10))
                    .build())
            .build());
}

两个都要开。周期刷新兜底,自适应刷新保证故障切换后 10 秒内恢复。我们加上之后,同样的故障演练,应用侧恢复时间从"需要重启"变成 8 秒。

坑二:CROSSSLOT 跨槽操作

Cluster 下多 key 操作要求所有 key 落在同一个槽,否则直接报错:

127.0.0.1:6379> MGET user:1 user:2 user:3
(error) CROSSSLOT Keys in request don't hash to the same slot

解决办法是 hash tag:key 里用 {} 包住一段,只有 {} 里的内容参与 CRC16 计算

127.0.0.1:6379> MGET user:{882341}:profile user:{882341}:orders user:{882341}:coupons
1) "..."
2) "..."
3) "..."

我们把同一用户的所有 key 都改成了 biz:{userId}:xxx 的格式。这个改造涉及 17 处代码,改了两天。

还要提醒一句:hash tag 用多了会导致数据倾斜。如果你把大量 key 都归到少数几个 hash tag 下,那几个槽所在节点压力会很大。我们的 cache:{dict}:all 这种全局 key 都是单独的,没有强行加 tag。

坑三:Pipeline 在 Cluster 下的表现

Lettuce 在 Cluster 模式下执行 pipeline 时,如果多个命令落在不同节点,它会自动拆分成多个子批次并异步发送。这听起来很美好,实际上它会占用连接池里的多个连接。我们有一次批量任务把连接池打满,导致正常请求排队。

后来的做法:批量任务里自己按槽分组,同一组的 key 才放一个 pipeline。

Map<Integer, List<String>> bySlot = keys.stream()
        .collect(Collectors.groupingBy(k -> SlotHash.getSlot(k)));

for (List<String> slotKeys : bySlot.values()) {
    if (slotKeys.size() > 200) {
        // 拆小,避免单批过大
        Lists.partition(slotKeys, 200).forEach(this::doPipeline);
    } else {
        doPipeline(slotKeys);
    }
}

扩容:从 3 主到 4 主

4 月中旬大促前做了一次扩容,加一组主从。两步:先加节点,再搬槽。

# 1. 启动新节点后加入集群(先作为空主节点)
$ redis-cli --cluster add-node 10.0.2.14:6379 10.0.2.11:6379

# 2. 加从节点
$ redis-cli --cluster add-node 10.0.2.24:6379 10.0.2.11:6379 \
    --cluster-slave --cluster-master-id <新主节点的ID>

# 3. 迁移槽,把 4096 个槽(16384 的 1/4)挪过去
$ redis-cli --cluster reshard 10.0.2.11:6379 \
    --cluster-from all \
    --cluster-to <新主节点ID> \
    --cluster-slots 4096 \
    --cluster-yes

迁移过程中的实测数据(共 812 万 key,约 4.1 GB):

迁移前 QPS          62,000
迁移中 QPS          58,400
迁移中 P99          4.8 ms(平时 1.2 ms)
迁移中 P999        41.2 ms(平时 3.8 ms)
总耗时              18 分 22 秒
超时错误数          3(均发生在 MIGRATE 大 key 时)

那 3 次超时都和一个 1.2 MB 的 key 有关。Redis 迁移大 key 时会阻塞源节点和目标节点一小段时间。事先扫一遍大 key 很有必要

$ redis-cli --bigkeys

# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.
[00.00%] Biggest string found so far 'cache:region:tree' with 1258291 bytes
[12.34%] Biggest list   found so far 'queue:notify:retry' with 41203 items

我们把 cache:region:tree 那个 1.2 MB 的 key 提前拆成了按省份拆分的 34 个小 key,迁移就很顺了。

必须加的监控

$ redis-cli cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:8
cluster_size:4

告警项:

  • cluster_state != ok → 严重告警,电话
  • cluster_slots_fail > 0 → 严重告警(说明有槽完全不可用了)
  • 各节点 used_memory / maxmemory > 0.8 → 警告
  • 主从延迟:从节点 master_repl_offset 与主节点差值 > 10000 持续 1 分钟 → 警告

第三条特别重要。Cluster 模式下每个节点的容量是独立的,某个节点先满会导致该节点的 key 被淘汰,而整体内存可能还很宽裕。我们监控的是每个节点单独的内存使用率,不是平均值。

先到这

《Redis Cluster 集群搭建与扩容实践》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考