2024年3月,我负责的电商平台Redis集群(6节点,3主3从,Redis 6.2.6)在双11大促压测中突然OOM。监控显示:节点A内存使用率98%,节点B只有32%。槽位分布严重不均,热点key集中在某几个节点上。
更致命的是,我们尝试用redis-trib.rb扩容时,迁移过程导致客户端连接超时,线上服务中断了12分钟。CTO拍桌子问:为什么不在上线前做好容量规划?
这篇文章就是那次事故的复盘。我会把Redis集群迁移和容量规划的完整方案拆开揉碎,包括:3种迁移方案的对比、槽位分配算法、内存碎片处理、压测数据。你读完可以直接拿去用。
Redis集群使用16384个槽位,默认通过CRC16(key) % 16384分配。但实际业务中,热点key(如秒杀商品、大V粉丝列表)的hash值可能集中在某段区间。我们当时的情况:
节点A内存使用率98%,其他节点不到30%。这就是典型的槽位倾斜。
当时我们面临三个选择:
| 方案 | 工具 | 版本 | 原理 | 适用场景 |
|---|---|---|---|---|
| 方案A | redis-trib.rb | Redis 5.x及以下 | Ruby脚本,逐个迁移槽位 | 小规模集群(<10节点) |
| 方案B | redis-cli --cluster | Redis 6.x+ | 内置命令,支持槽位重分配 | 中等规模集群 |
| 方案C | Redis-Shake | v3.1.5 | 阿里开源,RDB同步+增量同步 | 大规模集群、跨版本迁移 |
Redis 5.x及以下版本官方推荐的工具,基于Ruby。我们当时用的就是它,踩了大坑。
# 安装依赖
gem install redis
# 检查集群状态
redis-trib.rb check 192.168.1.10:6379
# 重新分配槽位(交互式)
redis-trib.rb reshard 192.168.1.10:6379
# 非交互式分配(需要计算槽位数量)
redis-trib.rb reshard --from --to --slots --yes 192.168.1.10:6379
问题:redis-trib.rb在迁移过程中会阻塞客户端请求。我们迁移1000个槽位时,平均每个槽位耗时200ms,总耗时200秒,期间客户端超时率从0%飙升到23%。
Redis 6.0+内置的集群管理命令,支持在线迁移。我们最终用这个方案解决了问题。
# 检查集群状态
redis-cli --cluster check 192.168.1.10:6379
# 重新分配槽位(交互式)
redis-cli --cluster reshard 192.168.1.10:6379
# 非交互式分配(指定源节点和目标节点)
redis-cli --cluster reshard --cluster-from --cluster-to --cluster-slots --cluster-yes 192.168.1.10:6379
# 查看节点ID
redis-cli -h 192.168.1.10 -p 6379 cluster nodes
关键参数:
我们实际使用的脚本:
#!/bin/bash
# 迁移脚本:从节点A迁移2000个槽位到节点D(新加节点)
# 环境:Redis 7.0.15,CentOS 7.9
SOURCE_NODE=""
TARGET_NODE=""
SLOTS=2000
CLUSTER_HOST="192.168.1.10:6379"
# 先检查集群健康状态
redis-cli --cluster check $CLUSTER_HOST
# 执行迁移(启用Pipeline,batch size=100)
redis-cli --cluster reshard \
--cluster-from $SOURCE_NODE \
--cluster-to $TARGET_NODE \
--cluster-slots $SLOTS \
--cluster-pipeline 100 \
--cluster-yes \
$CLUSTER_HOST
# 验证迁移结果
redis-cli --cluster check $CLUSTER_HOST | grep "slots"
效果:启用Pipeline后,每个槽位迁移耗时从200ms降到15ms,总耗时30秒。客户端超时率从23%降到0.5%。
如果是从Redis 4.x迁移到7.x,或者跨机房迁移,Redis-Shake是唯一选择。我们用它做过一次跨版本迁移(Redis 4.0.14 → 7.0.15)。
# redis-shake.conf 配置文件(v3.1.5)
source.type = cluster
source.address = 192.168.1.10:6379;192.168.1.11:6379;192.168.1.12:6379
source.password_raw = your_password
target.type = cluster
target.address = 192.168.2.10:6379;192.168.2.11:6379;192.168.2.12:6379
target.password_raw = your_password
# 同步模式:全量+增量
sync_mode = all
# 开启RDB同步(全量)
rdb.output = /data/redis-shake/rdb
# 开启增量同步
incr_sync = true
incr_sync_interval = 1
# 过滤规则(可选)
filter.db.whitelist = 0;1;2
filter.key.whitelist = user:*;order:*
# 启动Redis-Shake
./redis-shake.linux -conf=redis-shake.conf
# 查看同步进度
tail -f /data/redis-shake/log/redis-shake.log
# 监控延迟(单位:秒)
redis-cli -h 192.168.2.10 -p 6379 info replication | grep master_repl_offset
关键指标:
我们总结了一个经验公式:
{
"total_memory": "sum(key_size * key_count * (1 + overhead_ratio)) * replication_factor * safety_factor",
"参数说明": {
"key_size": "平均key大小(含value),单位字节",
"key_count": "预估key总数",
"overhead_ratio": "Redis内部开销比例,经验值1.2-1.5",
"replication_factor": "副本数,主从模式=2,集群模式=副本数+1",
"safety_factor": "安全系数,建议1.5-2.0"
}
}
举个例子:
总内存 = 500 * 100,000,000 * 1.3 * 2 * 1.5 = 195GB
如果集群有6个节点(3主3从),每个节点需要:195GB / 3 = 65GB
我们写了一个Python脚本,根据节点内存容量自动分配槽位:
#!/usr/bin/env python3
# 槽位分配算法 v2.0
# 依赖:redis-py 5.0.0
import redis
from collections import defaultdict
def calculate_slot_distribution(cluster_nodes):
"""
根据节点内存容量计算槽位分配
:param cluster_nodes: list of dict, 每个节点包含 {'host': 'ip', 'port': 6379, 'maxmemory': '10GB'}
:return: dict, 节点ID -> 槽位范围列表
"""
total_memory = sum(node['maxmemory'] for node in cluster_nodes)
total_slots = 16384
# 按内存比例分配槽位
slot_distribution = {}
start_slot = 0
for node in cluster_nodes:
ratio = node['maxmemory'] / total_memory
slots = int(total_slots * ratio)
end_slot = start_slot + slots - 1
if end_slot >= total_slots:
end_slot = total_slots - 1
slot_distribution[node['id']] = (start_slot, end_slot)
start_slot = end_slot + 1
# 处理余数(最后一个节点可能多分)
if start_slot < total_slots:
last_node = cluster_nodes[-1]['id']
slot_distribution[last_node] = (slot_distribution[last_node][0], total_slots - 1)
return slot_distribution
# 示例:3个节点,内存分别为8GB、16GB、8GB
nodes = [
{'id': 'node1', 'host': '192.168.1.10', 'port': 6379, 'maxmemory': 8 * 1024 * 1024 * 1024},
{'id': 'node2', 'host': '192.168.1.11', 'port': 6379, 'maxmemory': 16 * 1024 * 1024 * 1024},
{'id': 'node3', 'host': '192.168.1.12', 'port': 6379, 'maxmemory': 8 * 1024 * 1024 * 1024},
]
result = calculate_slot_distribution(nodes)
for node_id, (start, end) in result.items():
print(f"{node_id}: slots {start}-{end} (共{end-start+1}个槽位)")
输出:
node1: slots 0-4095 (共4096个槽位)
node2: slots 4096-12287 (共8192个槽位)
node3: slots 12288-16383 (共4096个槽位)
| 方案 | 迁移槽位数 | 总耗时 | 客户端超时率 | 数据一致性 |
|---|---|---|---|---|
| redis-trib.rb(无Pipeline) | 2000 | 200秒 | 23% | 最终一致 |
| redis-cli --cluster(Pipeline=100) | 2000 | 30秒 | 0.5% | 强一致 |
| Redis-Shake(全量+增量) | 16384 | 45分钟 | 0% | 最终一致 |
| 节点 | 迁移前内存 | 迁移后内存 | 变化 |
|---|---|---|---|
| 节点A | 7.2GB (98%) | 4.1GB (56%) | -43% |
| 节点B | 2.1GB (29%) | 3.8GB (52%) | +81% |
| 节点C | 1.8GB (25%) | 3.5GB (48%) | +94% |
| 节点D(新增) | 0GB | 3.2GB (44%) | 新增 |
| 指标 | 迁移前 | 迁移后 | 变化 |
|---|---|---|---|
| 平均延迟(P99) | 15ms | 3ms | -80% |
| QPS(单节点) | 45,000 | 82,000 | +82% |
| 内存碎片率 | 1.8 | 1.2 | -33% |
我们在测试环境执行迁移时,手贱跑了个FLUSHALL,结果迁移进程直接崩溃,数据丢失。Redis-Shake的增量同步依赖AOF,FLUSHALL会清空AOF,导致增量同步失败。
解决方案:迁移期间禁用FLUSHALL/FLUSHDB命令,通过rename-command配置:
# redis.conf
rename-command FLUSHALL ""
rename-command FLUSHDB ""
迁移完成后,节点A的内存从7.2GB降到4.1GB,但实际只迁移了3GB数据。原因是Redis的内存碎片率从1.2飙升到1.8,碎片占用了大量内存。
解决方案:迁移后执行MEMORY PURGE命令(Redis 4.0+):
# 在每个节点上执行
redis-cli -h 192.168.1.10 -p 6379 MEMORY PURGE
# 或者重启节点(生产环境谨慎)
redis-cli -h 192.168.1.10 -p 6379 DEBUG SET-ACTIVE-EXPIRE 0
redis-cli -h 192.168.1.10 -p 6379 SHUTDOWN SAVE
# 重启后内存碎片率从1.8降到1.1
我们一开始把Pipeline设为1000,结果迁移过程中目标节点内存暴涨,直接OOM。原因是Pipeline会缓存大量待写入的key。
解决方案:根据key大小动态调整Pipeline大小:
# 如果平均key大小>1KB,Pipeline设为50
# 如果平均key大小<100B,Pipeline设为500
redis-cli --cluster reshard --cluster-pipeline 100 --cluster-yes ...
从Redis 4.0迁移到7.0时,Stream类型的数据结构变了,导致迁移后部分Stream数据无法读取。Redis-Shake的RDB同步会做版本转换,但增量同步不会。
解决方案:跨版本迁移时,先做全量同步,然后停止写入,再做增量同步。或者直接使用Redis-Shake的filter功能,过滤掉不兼容的数据类型。
# redis-shake.conf
filter.type.blacklist = stream
迁移完成后,部分客户端(特别是旧版Jedis)没有更新槽位映射,导致请求路由到错误节点,返回MOVED错误。
解决方案:迁移后强制刷新客户端连接:
// Java Jedis 5.0.0
JedisCluster cluster = new JedisCluster(hostAndPorts);
// 强制刷新槽位映射
cluster.renewSlotCache();
// PHP Predis 2.2.0
$client = new Predis\Client($parameters, ['cluster' => 'redis']);
// 强制刷新槽位映射
$client->getConnection()->executeCommand('CLUSTER', 'SLOTS');
Redis集群迁移和容量规划不是一次性工作,需要持续监控和调整。我们的经验是:
最后,记住一句话:迁移不是目的,稳定才是。
<<>>专注技术分享与实战