Redis集群数据迁移与容量规划实战
发布日期: 2026/08/05 阅读总量: 0

问题背景: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)变化
总数据量44GB44GB(均匀分布)
单节点最大内存14.8GB7.6GB下降48.6%
总QPS承载能力约3.2万约6.8万(压测数据)提升112%
单节点峰值CPU87%41%下降53%
P99延迟(读)2.1ms0.8ms下降62%
P99延迟(写)2.8ms1.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加随机后缀,再设置客户端读写路由(比如用iorediskeyHashTag)。但这属于应用层改造,大促前不做,先让运维层面保证容量,等大促后做key优化。

总结

场景里要的两种方案,本质上对应两种不同的风险:离线迁移简单但不可用窗口长,在线迁移不停机但操作复杂度高。根据你的业务SLA来选择。

容量规划不是拍脑袋,按「纯数据 + 碎片 + 副本 + 缓冲区」四个维度来算,最后再加安全余量。上面给的计算脚本可以直接拿去配。

整个方案在线上跑完,从一个报警到完成扩容耗时一周,实际操作54分钟。大促当天QPS峰值7.5万,P99延迟1.3ms,没出任何问题。

Redis数据迁移工具链在7.x已经足够成熟,但对于10GB以上的大key,不管什么工具都会卡。所以提前治理大key才是关键,别等迁移时候才想起来。