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 集群搭建与扩容实践》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。