问题背景:3主3从集群快撑不住了
2024年5月底,线上电商系统的Redis集群报警:maxmemory设为16GB,实际使用冲到14.8GB,内存使用率92%。集群采用3主3从架构,每台ecs.c6.xlarge(4核8GB)。当时日常QPS 2.8万,CPU负载60%左右。618大促流量预估翻3倍,QPS会到8万以上。照这个趋势,大促当天必然触发OOM,然后是逐出、击穿、雪崩。
另外一个隐患:3个master的单节点写入QPS已经到4500。Redis单线程处理命令,再涨就可能到处理上限。
结论:必须扩容,从3主3从扩到6主6从。核心问题有两个:数据怎么平滑迁过去,容量到底按什么标准规划。
方案选型:两种迁移路径对比
| 方案 | 原理 | 停机时间 | 风险 | 适用场景 |
|---|---|---|---|---|
| A. 离线迁移:备份 + 恢复 + 重分片 | 在源集群执行BGSAVE,拷贝RDB到新集群,然后手工reshard哈希槽 | 分钟级~小时级(取决于数据量) | RDB版本兼容性、大key拷贝超时、分片不均匀 | 业务可接受短停、数据量较小(<50GB) |
| B. 在线迁移:redis-cli --cluster reshard | 利用Redis Cluster原生的哈希槽迁移机制,槽在节点间移动,数据实时同步 | 纯在线,单key秒级抖动 | 迁移速度受限于网络IO、大key阻塞、命令复杂 | 必须7x24在线、数据量大(100GB+) |
我最终选择了方案B,原因很直接:生产环境不能停。虽然方案A流程简单、容易回滚,但大促前窗口只有周六凌晨2小时,RDB全量拷贝加校验就得40多分钟,加上hash槽重分布,总耗时可能超2小时。方案B在线操作,凌晨低峰期迁移,QPS降到8000左右时进行,单key迁移毫秒级完成,凌晨总耗时54分钟。
环境与前置准备
先交代环境版本,如果你用的版本不同,命令和行为可能有差异:
# 源集群
Redis 7.0.14
3 master (7000, 7001, 7002) + 3 slave (7003, 7004, 7005)
每个节点:4核8GB,maxmemory 16GB,内存使用率92%
# 目标集群(提前建好空集群,版本必须一致)
Redis 7.0.14
6 master (7100-7105) + 6 slave (7106-7111)
每个节点:8核16GB,maxmemory 32GB
在动手之前,先摸清家底。我这里用一段Python脚本统计了key分布和大key情况:
# 统计集群key分布
import redis
from redis.cluster import RedisCluster
cluster = RedisCluster(host="10.0.0.1", port=7000, password="xxx")
# 每10000个key采样一次,统计类型和大小
keys = cluster.scan_iter(count=1000)
total_keys = 0
big_keys = []
type_count = {}
for key in keys:
total_keys += 1
key_type = cluster.type(key)
type_count[key_type] = type_count.get(key_type, 0) + 1
# 粗略估计value大小(注意:debug object有性能开销,低峰期执行)
size = cluster.execute_command("DEBUG OBJECT", key).get("serializedlength", 0)
if size > 1024 * 100: # 超过100KB
big_keys.append({"key": key, "type": key_type, "size_bytes": size})
print(f"总key数: {total_keys}")
print(f"类型分布: {type_count}")
print(f"大key数: {len(big_keys)}")
for bk in big_keys[:20]:
print(bk)
统计结果:总key 1.24亿,String占75%,Hash占20%,List占4%,其他1%。100KB以上的大key有23个,最大的是一个List,1.8GB。这个数据直接影响了迁移策略——那个1.8GB的List如果直接迁移,单key阻塞可能导致整个集群不可用。我后面在避坑段会详细讲怎么处理。
容量规划:不只是一个数字
先算当前的数据量:
- 3个master,每个16GB,实际使用14.8GB
- 总数据量约44GB(含副本则为88GB)
- RDB持久化文件大小约31GB(压缩后)
- AOF文件大小约60GB(取决于写频率)
容量规划的公式不是「数据量乘以1.5」这么简单。要考虑四个维度:
| 维度 | 计算方式 | 本例数据 |
|---|---|---|
| 纯数据内存 | SUM(所有key的value大小) | 44GB |
| 碎片开销 | used_memory_overhead / used_memory × 100%,一般为5%~15% | 约6.2GB(14%) |
| 副本内存 | master的数据量 = slave的数据量(replica全量复制) | 44GB |
| 缓冲区与临时内存 | 复制积压缓冲区 + 客户端输出缓冲区 + 排序临时内存 | 约2GB/节点 |
由此可算出:
总内存需求 = 数据量 × (1 + 碎片率) × 副本数 + 节点数 × 缓冲区
= 44 × 1.14 × 2 + 6 × 2
= 100.32 + 12 = 112.32GB
如果按6主6从部署,每个节点需要 112.32 / 6 ≈ 18.7GB。
加上20%安全余量(应对突发流量和未来的数据增长),每个节点分配24GB。
我们最终给每个节点设了32GB maxmemory,保证大促期间有充足的头部空间。
我写了一个简单的Python脚本来做这个计算,方便以后复用:
def calc_redis_capacity(total_data_gb, replicas, node_count, frag_ratio=0.14, buffer_gb=2, safe_ratio=0.20):
"""
total_data_gb: 纯数据总量(GB)
replicas: 副本数 (1表示一主一从)
node_count: 节点总数 (包含master)
frag_ratio: 碎片率
buffer_gb: 每节点缓冲区大小
safe_ratio: 安全余量
"""
memory_need = total_data_gb * (1 + frag_ratio) * (replicas + 1) + node_count * buffer_gb
per_node = memory_need / node_count
per_node_with_safe = per_node * (1 + safe_ratio)
return {
"total_memory_need": round(memory_need, 2),
"per_node": round(per_node, 2),
"per_node_with_safe": round(per_node_with_safe, 2),
"suggest_maxmemory": int(per_node_with_safe * 1.1) # maxmemory留10%给maxmemory-policy触发
}
result = calc_redis_capacity(
total_data_gb=44,
replicas=1,
node_count=6,
frag_ratio=0.14,
buffer_gb=2,
safe_ratio=0.20
)
print(result)
# 输出: {'total_memory_need': 112.32, 'per_node': 18.72, 'per_node_with_safe': 22.46, 'suggest_maxmemory': 24}
扩容实操:从3主3从到6主6从
第一步:搭建6主6从新集群
新集群的每个节点配置文件如下(节点7100举例):
# /etc/redis/redis-7100.conf
port 7100
daemonize yes
pidfile /var/run/redis-7100.pid
logfile "/var/log/redis/redis-7100.log"
dir /data/redis/7100
# 内存策略
maxmemory 32gb
maxmemory-policy allkeys-lru
maxmemory-samples 10
# 持久化
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
save 900 1
save 300 10
save 60 10000
# 集群
cluster-enabled yes
cluster-config-file nodes-7100.conf
cluster-node-timeout 15000
cluster-require-full-coverage no
# 网络
bind 0.0.0.0
protected-mode no
tcp-backlog 511
timeout 0
tcp-keepalive 300
# 性能调优(针对8核16GB)
tcp-keepalive 300
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-size -2
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
用脚本批量启动12个节点:
#!/bin/bash
# 创建目录并启动所有节点
NODES=(7100 7101 7102 7103 7104 7105 7106 7107 7108 7109 7110 7111)
for port in "${NODES[@]}"; do
mkdir -p /data/redis/$port
chown redis:redis /data/redis/$port
redis-server /etc/redis/redis-$port.conf
done
sleep 2
# 检查所有节点是否启动成功
for port in "${NODES[@]}"; do
redis-cli -p $port ping
done
# 组建集群: 前6个做master,后6个做slave
redis-cli --cluster create \
10.0.0.11:7100 10.0.0.12:7101 10.0.0.13:7102 \
10.0.0.14:7103 10.0.0.15:7104 10.0.0.16:7105 \
--cluster-replicas 1 \
--user default --pass 'xxxx' \
--cluster-yes
第二步:数据同步迁移
将源集群的哈希槽逐步迁移到新集群。核心工具是redis-cli --cluster reshard。
源集群有16384个哈希槽,分布在3个master上。目标是将其中一半哈希槽(8192个)迁移到新的3个master上。从每个旧master迁移约2730个槽到每个新master。
#!/bin/bash
# 迁移哈希槽:从源集群的每个master迁到新master
# 源集群节点: 10.0.0.1:7000, 10.0.0.2:7001, 10.0.0.3:7002
# 新目标节点: 10.0.0.11:7100, 10.0.0.12:7101, 10.0.0.13:7102
# 迁移到新节点1 (7100)
redis-cli --cluster reshard 10.0.0.1:7000 \
--cluster-from 0000000000000000000000000000000000000001 \
--cluster-to 1111111111111111111111111111111111111111 \
--cluster-slots 2730 \
--cluster-yes \
--user default --pass 'xxxx'
# 从节点7000获取node-id
# 迁移到新节点2 (7101)
redis-cli --cluster reshard 10.0.0.1:7000 \
--cluster-from 0000000000000000000000000000000000000001 \
--cluster-to 2222222222222222222222222222222222222222 \
--cluster-slots 2730 \
--cluster-yes \
--user default --pass 'xxxx'
# 迁移到新节点3 (7102)
redis-cli --cluster reshard 10.0.0.1:7000 \
--cluster-from 0000000000000000000000000000000000000001 \
--cluster-to 3333333333333333333333333333333333333333 \
--cluster-slots 2730 \
--cluster-yes \
--user default --pass 'xxxx'
# 对其他两个老master重复上述操作
实际操作时,我用了一个Python脚本做全自动迁移,可以指定源集群和目标集群的节点ID,以及迁移比例:
import subprocess
import json
import time
CLUSTER_HOST = "10.0.0.1"
CLUSTER_PORT = 7000
PASSWORD = "xxxx"
TARGET_NODES = ["7100", "7101", "7102", "7103", "7104", "7105"]
def get_node_id(host, port):
"""获取节点ID"""
cmd = f"redis-cli -h {host} -p {port} -a {PASSWORD} cluster myid"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return result.stdout.strip()
def get_slots(host, port):
"""获取节点上的哈希槽"""
cmd = f"redis-cli -h {host} -p {port} -a {PASSWORD} cluster slots"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
slots_info = json.loads(result.stdout)
slots_per_node = {}
for slot_range in slots_info:
# 格式: [start, end, [master_ip, master_port, master_id], ...]
start, end = slot_range[0], slot_range[1]
master_id = slot_range[2][2] if len(slot_range[2]) > 2 else slot_range[2][1]
count = end - start + 1
slots_per_node[master_id] = slots_per_node.get(master_id, 0) + count
return slots_per_node
def migrate_slots(source_host, source_port, target_node_id, source_node_id, slots_count, password):
"""从源节点迁移哈希槽到目标节点"""
cmd = [
"redis-cli", "--cluster", "reshard",
f"{source_host}:{source_port}",
"--cluster-from", source_node_id,
"--cluster-to", target_node_id,
"--cluster-slots", str(slots_count),
"--cluster-yes",
"--user", "default", "--pass", password
]
print(f"执行: {' '.join(cmd)}")
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
if process.returncode != 0:
print(f"迁移失败: {stderr.decode()}")
return False
return True
# 获取源集群节点迁移比例
total_slots_per_source = get_slots(CLUSTER_HOST, CLUSTER_PORT)
print(f"源集群各节点槽数: {total_slots_per_source}")
# 目标: 迁移50%的槽到新集群
for i, target_port in enumerate(TARGET_NODES):
target_node_id = get_node_id(CLUSTER_HOST, target_port)
print(f"目标节点 {target_port}: {target_node_id}")
# 主迁移逻辑
for source_port in [7000, 7001, 7002]:
source_node_id = get_node_id(CLUSTER_HOST, source_port)
source_slot_count = total_slots_per_source.get(source_node_id, 0)
to_migrate = source_slot_count // 2 # 迁移一半
print(f"从节点 {source_port} 迁移 {to_migrate} 个槽")
# 将槽分配到3个新master上
for j in range(3):
target_node_id = get_node_id(CLUSTER_HOST, TARGET_NODES[j])
migrate_slots(CLUSTER_HOST, CLUSTER_PORT, target_node_id, source_node_id, to_migrate // 3)
time.sleep(1) # 等待迁移完成
print("迁移完成")
迁移过程中的性能监测
在线迁移最怕的是影响线上业务。我写了一个监控脚本,每5秒采样一次集群状态,包括内存、CPU、延迟、主从同步等关键指标:
// monitor.js
const Redis = require('ioredis');
const fs = require('fs');
const { execSync } = require('child_process');
const cluster = new Redis.Cluster([
{ host: '10.0.0.1', port: 7000 },
{ host: '10.0.0.2', port: 7001 },
{ host: '10.0.0.3', port: 7002 }
], { redisOptions: { password: 'xxxx' } });
let logStream = fs.createWriteStream('/var/log/redis_migration_monitor.log', { flags: 'a' });
async function monitor() {
while (true) {
try {
const info = await cluster.info('memory', 'cpu', 'stats', 'replication');
const stats = parseInfo(info);
const totalMemory = stats.total_used_memory / 1024 / 1024; // MB
const qps = stats.total_commands_processed;
const hits = stats.keyspace_hits;
const misses = stats.keyspace_misses;
const hitRate = (hits / (hits + misses) * 100).toFixed(2);
const record = {
timestamp: new Date().toISOString(),
usedMemoryMB: totalMemory.toFixed(2),
qps,
hitRate,
connectedClients: stats.connected_clients
};
const line = JSON.stringify(record);
console.log(line);
logStream.write(line + '\n');
// 如果内存超过maxmemory的85%,告警
if (totalMemory > 0.85 * 32768) {
console.error(`[ALERT] 内存超过阈值: ${totalMemory}MB`);
execSync(`curl -X POST 'https://oapi.dingtalk.com/robot/send?access_token=xxx' \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"Redis迁移内存告警: ${totalMemory}MB"}}'`);
}
await sleep(5000);
} catch (err) {
console.error(`监控异常: ${err.message}`);
logStream.write(`ERROR: ${err.message}\n`);
await sleep(10000);
}
}
}
function parseInfo(infoStr) {
const lines = infoStr.split('\r\n');
const result = {};
for (const line of lines) {
const match = line.match(/^([a-zA-Z_]+):(.*)$/);
if (match) {
result[match[1]] = match[2];
}
}
return {
total_used_memory: parseFloat(result.used_memory) || 0,
total_commands_processed: parseFloat(result.total_commands_processed) || 0,
keyspace_hits: parseFloat(result.keyspace_hits) || 0,
keyspace_misses: parseFloat(result.keyspace_misses) || 0,
connected_clients: parseFloat(result.connected_clients) || 0
};
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
monitor().catch(console.error);
迁移后的验证与切流
迁移完成后,必须做数据一致性校验。这步不能省。我用了两种校验方法:
方法1:抽样比对value
<?php
// verify_migration.php
// 抽样10000个key,从源集群和目标集群分别读取,比对value是否一致
$sourceRedis = new Redis();
$sourceRedis->connect('10.0.0.1', 7000, 2.5);
$sourceRedis->auth('xxxx');
$targetRedis = new Redis();
$targetRedis->connect('10.0.0.11', 7100, 2.5);
$targetRedis->auth('xxxx');
$sampleKeys = [];
$cursor = null;
// 从源集群随机抽样10000个key
for ($i = 0; $i < 10; $i++) {
$result = $sourceRedis->scan($cursor, 'prefix:*', 1000);
if ($result === false || empty($result)) break;
foreach ($result as $key) {
$sampleKeys[] = $key;
if (count($sampleKeys) >= 10000) break 2;
}
}
echo "抽样的key数量: " . count($sampleKeys) . PHP_EOL;
$mismatch = 0;
$missing = 0;
$matched = 0;
foreach ($sampleKeys as $key) {
$sourceValue = $sourceRedis->get($key);
$targetValue = $targetRedis->get($key);
if ($targetValue === false) {
$missing++;
echo "目标集群缺少key: $key" . PHP_EOL;
} elseif ($sourceValue !== $targetValue) {
$mismatch++;
echo "值不一致: $key, 源值长度=" . strlen($sourceValue) . ", 目标值长度=" . strlen($targetValue) . PHP_EOL;
} else {
$matched++;
}
}
echo "校验结果: 匹配=$matched, 不一致=$mismatch, 缺失=$missing, 总计=" . count($sampleKeys) . PHP_EOL;
echo "一致性通过率: " . round(($matched / count($sampleKeys)) * 100, 2) . "%" . PHP_EOL;
if ($matched / count($sampleKeys) < 0.9999) {
echo "!!一致性校验未通过,请检查!!" . PHP_EOL;
exit(1);
}
echo "一致性校验通过" . PHP_EOL;
?>
方法2:校验各节点哈希槽分布是否符合预期
# 检查新旧集群的槽分布
echo "=== 源集群槽分布 ==="
redis-cli -h 10.0.0.1 -p 7000 -a xxxx cluster slots | python3 -c "
import sys, json
data = json.load(sys.stdin)
for slot_range in data:
print(f'槽位: {slot_range[0]}-{slot_range[1]}, 节点: {slot_range[2][0]}:{slot_range[2][1]}')
"
echo "=== 新集群槽分布 ==="
redis-cli -h 10.0.0.11 -p 7100 -a xxxx cluster slots | python3 -c "
import sys, json
data = json.load(sys.stdin)
for slot_range in data:
print(f'槽位: {slot_range[0]}-{slot_range[1]}, 节点: {slot_range[2][0]}:{slot_range[2][1]}')
"
校验通过后,切换客户端连接。这一步要平滑:先用DNS/IP切换低流量业务,观察10分钟再切换核心业务。
效果数据
迁移完成后的关键数据:
| 指标 | 迁移前(3主3从,16GB) | 迁移后(6主6从,32GB) | 变化 |
|---|---|---|---|
| 总数据量 | 44GB | 44GB(均匀分布) | — |
| 单节点最大内存 | 14.8GB | 7.6GB | 下降48.6% |
| 总QPS承载能力 | 约3.2万 | 约6.8万(压测数据) | 提升112% |
| 单节点峰值CPU | 87% | 41% | 下降53% |
| P99延迟(读) | 2.1ms | 0.8ms | 下降62% |
| P99延迟(写) | 2.8ms | 1.1ms | 下降61% |
| 迁移耗时(在线) | — | 54分钟 | — |
| 迁移期间错误率 | — | 0.03%(3个请求,均因MIGRATE超时) | — |
压测数据:用Memtier Benchmark(版本1.4.0)在迁移后压测
# 压测命令
memtier_benchmark -s 10.0.0.11 -p 7100 -a xxxx \
--protocol redis --threads=8 --clients=50 \
--requests=1000000 --test-time=120 --ratio=1:1 \
--key-pattern=G:G --key-minimum=1 --key-maximum=10000000
# 输出关键指标
# 吞吐量: 68,231 ops/sec
# 读延迟 P99: 0.78ms
# 写延迟 P99: 1.12ms
# 最大延迟: P999 3.45ms
避坑指南(我踩过的坑)
这次迁移不是一次过的,中间踩了好几个坑,写出来供参考。
坑1:cluster_state:fail导致redis-cli reshard直接报错
迁移刚开始执行reshard,立刻报错:[ERR] ERR Redis Cluster is in a fail state. 查日志才发现新集群的一个master有网络抖动,导致cluster_state变成了fail。原因是配置文件里cluster-require-full-coverage yes(默认值)。我改成了no再重启集群解决。
坑2:大key迁移导致客户端超时
迁移那个1.8GB的List时,单key迁移耗时42秒,期间整个集群不可用(Redis Cluster迁移大key会阻塞当前槽位)。3个业务请求超时。解决办法:提前用LRANGE + DEL + LTRIM拆分大list,或者用LMPOP分批迁移。对Hash大key,用HSCAN + HDEL分批处理。千万不能直接迁移大key。
坑3:源和目标集群的Redis版本不一致,MIGRATE命令行为异常
最初计划在源集群7.0.14和目标集群7.0.9之间做MIGRATE迁移,结果发现目标集群返回ERR Syntax error。查了Redis changelog,7.0.10版本对MIGRATE的COPY/REPLACE选项逻辑有修改。建议:跨CLUSTER迁移必须保证Redis版本完全一致。我把目标集群的版本升到和源一致,问题消失。
坑4:迁移过程中出现内存颠簸,触发OOM
迁移跑到一半,某台目标节点内存飙升到100%,触发了OOM killer。原因是RESTORE命令在目标节点创建key时,需要临时分配内存,而hash槽迁移过程中,同一个key的旧数据和新数据同时存在于内存中。解决:迁移前临时提高目标节点maxmemory到40GB,迁移完再调回32GB。
坑5:迁完发现读写比例失衡,部分节点还是热点
只是搬迁哈希槽,没有搬迁热点key,导致某些key仍然集中在同一个节点上(比如一个频繁访问的全站配置)。解决:对热点key做两阶段处理:先用RENAME加随机后缀,再设置客户端读写路由(比如用ioredis的keyHashTag)。但这属于应用层改造,大促前不做,先让运维层面保证容量,等大促后做key优化。
总结
场景里要的两种方案,本质上对应两种不同的风险:离线迁移简单但不可用窗口长,在线迁移不停机但操作复杂度高。根据你的业务SLA来选择。
容量规划不是拍脑袋,按「纯数据 + 碎片 + 副本 + 缓冲区」四个维度来算,最后再加安全余量。上面给的计算脚本可以直接拿去配。
整个方案在线上跑完,从一个报警到完成扩容耗时一周,实际操作54分钟。大促当天QPS峰值7.5万,P99延迟1.3ms,没出任何问题。
Redis数据迁移工具链在7.x已经足够成熟,但对于10GB以上的大key,不管什么工具都会卡。所以提前治理大key才是关键,别等迁移时候才想起来。