Redis集群迁移与容量规划实战:从告警到落地的完整排坑
发布日期: 2026/08/14 阅读总量: 0

凌晨三点的告警:Redis集群内存爆了

周一凌晨03:14,值班手机被钉钉告警轰炸:线上Redis集群内存使用率达到92%,swap已开始增长。打开监控面板,内存曲线在过去两周几乎线性爬升,赶不上月末活动,按这个速度最多再撑三天。

这是一个部署在Kubernetes中的Redis Cluster集群,三主三从,每个实例分配4GB内存,共12GB,存储了约1.2亿个key,总内存占用9.86GB。活动预热数据还在持续写入,每天净增约800MB。摆在面前只有两条路:扩内存,或者迁移到容量规划更合理的新集群。

这次故障的根因是早期设计bug:创建集群时预估单个key平均大小是80字节,实际却达到了150字节,再加上Redis自身的碎片率(avg_fragmentation_ratio=1.34)和AOF重写buffer,内存使用直接翻了近一倍。这个教训直接催生了本文的容量规划方法——不是拍脑袋估,而是用算法推。

我当时的选型是:搭建一个新集群,配置由4GB×6实例改为8GB×6实例,使用RedisShake v4完成数据迁移,并将冷数据分层到本地磁盘。整个过程涉及四个阶段:容量预估→新集群部署→数据同步→流量切换。

两种迁移方案的对比:horizontal拆分 vs 整体搬迁

迁移方案不是一拍脑袋定的。我对比了两种主流路径,各有适用场景。先给结论:如果只是容量问题,优先考虑水平拆分;如果拆分后单实例仍吃紧,或者想顺带升级版本/调整拓扑,走整体搬迁

方案A:水平拆分扩容

把现有集群从3主3从拆成6主6从。原集群每个主节点承载约1.64GB有效数据,拆成两个主节点后,每个主节点只承载约820MB。优点是不用搬数据,直接用RDB文件恢复即可;缺点是slot迁移期间会增加CPU和网络负载,且对已经90%+内存的集群来说风险太大——万一迁移中途OOM,整个集群会雪崩。

方案B:RedisShake整体搬迁

搭建一套全新的6主6从集群(内存8GB),用RedisShake v4做全量+增量同步,最后切换流量。优点是从根上解决容量问题,可以顺手调整实例规格、持久化策略(AOF+appendfsync everysec改为RDB+AOF混合),还能对key做过滤和清洗。缺点是部署步骤多、切换窗口需要精细化操作。

我的选择:方案B。原因有两个——其一,原集群内存已濒临极限,在92%使用率下做slot迁移风险不可控;其二,原集群使用的Redis版本是6.0.16,存在已知的CVE-2022-24735安全漏洞,需要升级到7.0.12+,整体搬迁正好满足这个需求。

容量规划:用3.4倍预估法算内存

容量规划不是拍脑袋,是算出来的。下面这套方法被我验证过多次,可以作为通用基数。

第一步:统计实际数据体积。redis-cli --bigkeys只能看到大key,不全面。我需要精确的数字——用DBSIZE拿key数量,用MEMORY USAGE抽样不同长度key的真实占用,再用info memory看used_memory_rss。

实测数据:1.2亿key(约1.15亿是字符串,其余为hash和zset),抽样1000个key计算平均内存占用为311字节/个(含16字节key本身+数据结构开销+sizeof(robj)等)。总有效数据=1.2亿×311字节≈37.3GB(注意这不是文件大小,是内存中的真实占用)。

第二步:加上Redis运行所需的内存。Redis实际内存分为以下五块(我用info memory逐项查看):

内存分项计算方式本例实测值
有效数据1.2亿×311字节37.3GB
复制积压缓冲bufferrepl-backlog-size + client-output-buffer-limit1GB + 2GB min=2GB
AOF重写bufferaof-rewrite-min-size + 峰值写入×重写时间约1.5GB
内存碎片有效数据× (avg_fragmentation_ratio-1)37.3GB×0.34≈12.7GB
系统预留buffermem_fragmentation_ratio在1.5以下的正常波动2GB

第三步:乘以安全系数。Redis官方建议内存使用率保持在65%以下,预留35%给持久化、重写、主从同步和突发流量。

公式:实际需要的内存 = (有效数据 + 运行开销) / 0.65

代入数据:(37.3GB + 5.5GB) / 0.65 ≈ 65.8GB,向上取整到6主×12GB=72GB。最终我选择了每实例12GB、6主6从的配置(总可用内存72GB,主节点内存36GB,预留了充足的buffer给AOF重写和碎片整理),部署在K8s中,每个实例限制内存为14GB(留出系统预留)。

如果早按这个公式算,当初3主×4GB=12GB的集群就不会在1.2亿key下活不过第三个月。

迁移工具选型:RedisShake v4

主流迁移工具有三种,我全部测过:

  • redis-port(腾讯)——已停止维护,对Redis 7.x支持不完整,pass
  • redis-migrate-tool(唯品会)——需要安装依赖较多,文档残缺,pass
  • RedisShake v4(阿里云开源)——活跃维护,支持全量+增量,支持过滤规则,支持RDB和AOF两种持久化格式,选它

RedisShake v4的架构是:读取源Redis的RDB快照(或AOF增量)→ 解析 → 写入目标集群。它有三种同步模式:

  • dumplevel:全量同步一次,适合一次性搬迁(但需要停写)
  • sync:全量+增量,适合不停服迁移
  • rump:SCAN遍历式同步,不产生RDB,适合数据量小但key大的场景

我们用的是sync模式:全量同步RDB文件(约5分钟)后,自动切换到增量同步(解析AOF中的写命令),持续追平数据差。

RedisShake v4的配置语法和v3完全不同,v3用redis-shake.conf,v4用yaml。我用的是v4.1.2版本(GitHub tag: v4.1.2)。核心配置如下:

# redis-shake-sync.yaml
source:
  type: cluster
  address: "redis://:password@source-cluster-ip:17001" # 源集群任意节点
  username: ""
  password: "your-source-password"
  tls:
    enable: false
target:
  type: cluster
  address: "redis://:password@target-cluster-ip:17001"   # 目标集群任意节点
  username: ""
  password: "your-target-password"
  tls:
    enable: false
  db: 0
# 同步模式
syncer:
  type: sync  # dumplevel | sync | rump
  # 并行度,本质上是goroutine数,过高会打满源实例CPU
  parallel: 4
  # 单批同步的key数量
  batch_size: 256
  # 过滤规则:只迁移前缀为"user:"和"order:"的key
  filter:
    allow:
      - "user:.*"
      - "order:.*"
    deny: []
# 日志与性能
log:
  level: "info"
metrics:
  enable: true
  port: 9101

注意:parallel参数决定了迁移速度,但也会直接影响源集群的负载。我压测后发现,parallel=4的情况下,CPU使用率上升约18%,命令延迟P99从3.2ms上升到7.8ms——这在业务高峰期是不可接受的。所以我的执行策略是:凌晨02:00-05:00执行迁移(业务低峰),期间parallel设为8,P99延迟峰值9.1ms,仍在容忍范围。

执行命令(注意v4是直接执行二进制,注意--daemonize参数):

# 先测试连通性
./redis-shake-linux-amd64-v4.1.2 ping --conf redis-shake-sync.yaml

# 启动迁移(前台运行,便于观察日志)
./redis-shake-linux-amd64-v4.1.2 run --conf redis-shake-sync.yaml

# 或者后台运行
./redis-shake-linux-amd64-v4.1.2 run --conf redis-shake-sync.yaml --daemonize

# 查看同步进度
curl http://localhost:9101/metrics | grep "scan_count\|sync_done"

迁移期间的监控与验证:确保数据不丢

光跑工具不够,我得知道当前进度和数据完整性。我的做法是:用一个循环脚本,每30秒抓取一次源和目标集群的DBSIZE、内存使用、增量延迟这三个关键指标。

#!/bin/bash
# redis_migration_monitor.sh
# 用法:./redis_migration_monitor.sh source.conf target.conf
SOURCE_REDIS="$1"
TARGET_REDIS="$2"
LOG_FILE="/tmp/redis_migration_$(date +%Y%m%d_%H%M%S).log"

while true; do
  TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
  # 源集群db大小(从任意节点读取)
  SRC_DBSIZE=$(redis-cli -u "$SOURCE_REDIS" DBSIZE 2>/dev/null)
  # 目标集群db大小
  TGT_DBSIZE=$(redis-cli -u "$TARGET_REDIS" DBSIZE 2>/dev/null)
  # 源集群内存使用率
  SRC_MEMORY=$(redis-cli -u "$SOURCE_REDIS" INFO memory | grep "used_memory:" | awk -F: '{print $2}')
  # 目标集群延迟(ping)
  TGT_DELAY=$(redis-cli -u "$TARGET_REDIS" PING 2>/dev/null)

  # 计算差异百分比,当diff小于0.1%时提醒可以切换
  DIFF=$((TARGET_DBSIZE - SRC_DBSIZE))
  # 对比DBSIZE只是一个粗粒度,更精确的要对比key数量加上增量追平
  echo "$TIMESTAMP | src_dbsize=$SRC_DBSIZE | tgt_dbsize=$TGT_DBSIZE | diff=$DIFF" >> "$LOG_FILE"

  # 每30秒记录一次
  sleep 30
done

光看DBSIZE不够。Redis的DBSIZE统计的是“有效key的个数”,但容量规划时必须包含“逻辑过期但未清理的key”和“TTL未生效的key”。更准确的做法是使用MEMORY USAGE抽样对比:

# 在源端随机抽1000个key,记录内存占用
for i in $(seq 1 1000); do
  key=$(redis-cli -u "$SOURCE_REDIS" RANDOMKEY)
  mem_usage=$(redis-cli -u "$SOURCE_REDIS" MEMORY USAGE "$key")
  echo "$key $mem_usage" >> /tmp/src_memory_sample.txt
done

# 在目标端同样抽1000个key做对比,注意要选同样的key
while read -r key mem; do
  # 校验目标端是否存在该key
  target_mem=$(redis-cli -u "$TARGET_REDIS" MEMORY USAGE "$key")
  if [ "$target_mem" != "$mem" ]; then
    echo "MISMATCH: key=$key src_mem=$mem tgt_mem=$target_mem"
  fi
done < /tmp/src_memory_sample.txt

生产环境迁移有一个很重大的坑:如果目标节点内存不足,迁移过程中会触发驱逐。我在测试环境验证RedisShake时,目标端实例内存只有1GB,迁移400MB数据时出现过maxmemory触发eviction的情况——数据没写入,但日志里不打印错误,只显示evicted_keys: 128。后来给目标端加了maxmemory-policy noeviction,让问题直接暴露为OOM报错而非静默丢数据。

流量切换:从gateway层面动刀

数据同步追平后,最后一步是切换流量。这一步的粒度很讲究——不建议直接改应用的Redis连接配置然后重启应用,因为启动时会有大量并发连接打过来,瞬间冲垮目标集群。更稳妥的方案是通过gateway(我用的是自研的RedisProxy,基于Envoy+Redis filter)做灰度切换:先切5%流量,观察5分钟;再切50%,观察10分钟;最后全量。切换脚本如下:

#!/bin/bash
# switch_redis_traffic.sh - 分阶段切换Redis流量
# 依赖:jq curl

# 第一阶段:5%流量切换
echo "阶段1:切5%流量"
curl -X POST http://gateway-control-plane:8080/v1/rules/redis \
  -d '{
        "rule_name": "redis_split_5",
        "percentage": 5,
        "new_backend": "redis-cluster-new:17001",
        "old_backend": "redis-cluster-old:17001"
      }' -H "Content-Type: application/json" | jq .
sleep 300

# 第二阶段:50%流量切换
echo "阶段2:切50%流量"
curl -X POST http://gateway-control-plane:8080/v1/rules/redis \
  -d '{
        "rule_name": "redis_split_50",
        "percentage": 50,
        "new_backend": "redis-cluster-new:17001",
        "old_backend": "redis-cluster-old:17001"
      }' -H "Content-Type: application/json" | jq .
sleep 600

# 第三阶段:全量切换
echo "阶段3:全量切换"
curl -X POST http://gateway-control-plane:8080/v1/rules/redis \
  -d '{
        "rule_name": "redis_all_new",
        "percentage": 100,
        "new_backend": "redis-cluster-new:17001",
        "old_backend": ""
      }' -H "Content-Type: application/json" | jq .

echo "切换完成,请观察Redis指标15分钟"

如果不想引入gateway,也可以直接在应用侧做双写双读。双写的实现不复杂,关键是要异步化:应用写完旧集群,同时投递一条消息到本地队列,由另一个线程写入新集群。读的时候优先走新集群,不命中时直接穿透到旧集群,一旦命中就回填到新集群。这个方案的好处是可以随时回滚:出问题就把双写关掉,流量切回旧集群,数据不会不一致。

双写方案的Java伪代码如下(我们内部用的就是这个模式):

// Redis双写核心逻辑(简化版)
public class RedisDualWrite {
    private final JedisCluster oldCluster;
    private final JedisCluster newCluster;
    private final ExecutorService writeExecutor;

    public RedisDualWrite(JedisCluster oldCluster, JedisCluster newCluster) {
        this.oldCluster = oldCluster;
        this.newCluster = newCluster;
        this.writeExecutor = Executors.newFixedThreadPool(4);
    }

    public void set(String key, String value, int expireSeconds) {
        // 先写旧集群(同步等待结果)
        oldCluster.setex(key, expireSeconds, value);

        // 异步写新集群,失败不影响主流程
        writeExecutor.submit(() -> {
            try {
                newCluster.setex(key, expireSeconds, value);
            } catch (Exception e) {
                // 记录失败日志,之后通过定时任务补偿
                log.error("write to new cluster failed, key: {}", key, e);
            }
        });
    }

    public String get(String key) {
        // 优先读新集群,未命中则回源旧集群
        String value = newCluster.get(key);
        if (value == null) {
            value = oldCluster.get(key);
            // 补偿写入新集群
            if (value != null) {
                newCluster.set(key, value);
            }
        }
        return value;
    }
}

切换前还有一道工续要做:在目标集群开启AOF重写窗口。迁移期间产生的增量数据可能存在大量重复写入,造成AOF文件膨胀。我迁移后的AOF文件达到了源文件的1.8倍(11.2GB→20.1GB),不开启AOF重写的话,后续的RDB持久化会非常慢。

# 连接新集群任意节点
redis-cli -h target-cluster-ip -p 17001 -a password
# 开启AOF重写(让AOF文件瘦身)
127.0.0.1:17001> BGREWRITEAOF
# 查看重写进度
127.0.0.1:17001> INFO persistence
# aof_rewrite_in_progress:0
# aof_last_bgrewrite_status:ok

效果数据:迁移前后全对比

迁移完成后,团队做了72小时的持续观察。直接说数据和结论:

观测项迁移前(旧集群)迁移后(新集群)变化
内存使用率92%(已触发swap)41%下降51个百分点
命令P99延迟24.7ms(高负载下)3.8ms下降84.6%
DBSIZE1.2亿1.2亿一致(抽样1000个key,MEMORY USAGE全部匹配)
AOF文件大小4.2GB(已重写后)8.6GB(重写后)因实例规格从4G扩到12G,AOF重写阈值变大
CPU使用率78%(avg)36%(avg)下降53.8%
每分钟写入量约24万个写命令约24万个写命令业务无感知,读写正常
数据丢失量0通过对比DBSIZE+抽样key的MEMORY USAGE,确认零丢失

迁移耗时:全量RDB同步5分23秒,增量同步追平用了12分钟(期间有每秒2000+的写请求),流量灰度切换40分钟。所有操作在两小时内完成,业务零感知。

迁移后的容量预测:按每天800MB的净增速度,新集群72GB可用内存在35%的冗余下,大约还能支撑50个月。这个数字不是估算,是从实际增长速度外推的,准确率在95%以上(线性增长)。对于突发流量,我额外设置了内存告警阈值:当used_memory超过12GB(即实例配置的85%)时,75%的告警级别为P2,95%为P1,95%+时触发自动扩容流程。

迁移完成后的一个补充操作:把活跃key分布均匀性检查加入巡检。我用redis-cli --cluster check验证slot分布,确保6个主节点上的key数差异小于10%。这一步不是为了现在,而是为了未来做容量预测时不会因为倾斜导致热点节点提前击穿。

避坑指南:这次迁移踩过的7个坑

写这篇文章之前,我特意回忆了这次迁移过程中遇到的所有问题。下面这些坑,一个比一个隐蔽,全是我或同事用真金白银换来的教训。

坑1:目标集群内存不足导致静默丢数据

RedisShake的默认行为是:目标端写入失败时只打WARN日志,不会报错,更不会中断。测试环境第一次跑迁移,目标实例maxmemory只有1GB,我没有开noeviction,迁移400MB数据时内存打满,触发了默认的volatile-lru策略,把一批key给逐出了。而RedisShake日志里只显示evicted_keys: 128,看起来像正常信息,实际上数据已经丢了。

方案:所有迁移的目标集群,一律设置maxmemory-policy noeviction。让内存打满时直接OOM报错,迫使问题在迁移期间暴露,而不是事后查数据才发现。

坑2:源集群密码在RedisShake配置里不能加引号

RedisShake v4配置里,密码不能带引号,也不能带特殊字符转义。我一开始写成password: "Abc@123",启动时报认证失败。后来在GitHub issue里看到,v4的yaml配置password字段回自动做trim,所以直接写password: Abc@123。注意:如果密码里含@,用url encode(%40)处理,否则会被解析成host分隔符。

坑3:过滤规则用正则,别用通配符

RedisShake v4的filter段落支持正则匹配,和v3的通配符不同。我一开始用user:*,结果匹配到的key数量是0。翻文档才知道要写user:.*。这个坑很小,但排查起来很费劲——你看到的日志是filter working fine,但实际一个key都没迁移。

坑4:迁移完成不是结束,还要清理旧集群的缓冲和连接

旧集群虽然不再承载业务流量,但它仍然会和客户端建立大量TCP连接(默认tcp-keepalive为300秒),这些连接会持续消耗内存文件描述符。如果旧集群的maxmemory还是原配置,这些残留连接可能导致内存还在高位。切换流量后,一定要旧集群执行CLIENT KILL TYPE normal清掉所有普通客户端连接,然后观察内存是否回落。

坑5:大key会拖垮同步速度

RedisShake同步是单key串行的,一个5MB的hash key会导致redis-shake阻塞5秒以上。我排查后发现有一个list key存储了380万个元素,内存占用1.2GB。这种key无法通过配置解决,只能在同步前拆掉。我的做法是:在源集群扫描出所有大于10MB的key,单独写脚本按range分段拆出来,迁移完成后再重建。

# 扫描大key脚本(来自redis-cli --bigkeys但更可控)
redis-cli -u "$SOURCE_REDIS" --bigkeys --no-raw 2>/dev/null | grep "biggest" 

坑6:迁移期间不要执行BUILDSAVE或设置持久化

在RedisShake跑全量同步的时候,如果目标集群同时触发BGSAVE或AOF重写,磁盘I/O会严重争抢,同步时间可能从5分钟拉长到40分钟。我的方案是:迁移窗口期内,在目标集群把所有持久化策略置为save ""(即禁用RDB),只保留AOF。等数据追平后,再重新开启RDB快照。

# 目标集群禁用RDB快照(迁移期间)
redis-cli -h target-cluster-ip -p 17001 CONFIG SET save ""

坑7:集群状态不是你想象的样子

源集群如果是Cluster模式,RedisShake连接任意一个节点后,会自动获取cluster nodes列表并逐个同步。但有一种坑:如果集群中某个节点标记为fail但并未真正下线(例如网络分区后自动恢复),RedisShake v4会跳过这个节点,但日志里只打印nodes count: 5,不会报错。导致的结果是:某个哈希槽的key完全没有同步到目标端,而DBSIZE依然对得上(因为fail节点上的key数量少)。

排查办法:同步完成后,用redis-cli --cluster check对比两边集群的slot分布,而不是只看DBSIZE。用下面的脚本确保两边slot覆盖一致:

#!/bin/bash
# 检查源和目标集群的slot覆盖是否一致
for port in 17001 17002 17003; do
  echo "=== 源集群 $port slot覆盖 ==="
  redis-cli -h source-cluster-ip -p $port CLUSTER SLOTS | jq 'length'
  echo "=== 目标集群 $port slot覆盖 ==="
  redis-cli -h target-cluster-ip -p $port CLUSTER SLOTS | jq 'length'
done

一点补充:容量规划不是一次性事儿

这次迁移完成后,我把容量规划写成了自动化巡检的一部分。核心动作是:每天凌晨4点采集每个Redis实例的used_memory、DBSIZE、平均key大小、碎片率,存入ES。基于这些数据,做线性回归预测接下来7天的内存趋势。当预测值达到配置的85%时,自动创建工单并通知值班人。

预测脚本的核心逻辑如下(简化版):

#!/usr/bin/env python3
# redis_capacity_predict.py
# 基于历史内存数据做线性回归预测
import time
import json
import requests
from datetime import datetime, timedelta

# 从ES拉取最近30天的内存监控数据
r = requests.get('http://es-master:9200/redis_monitor/_search', json={
    "size": 0,
    "aggs": {
        "daily_max": {
            "date_histogram": {"field": "@timestamp", "calendar_interval": "day"},
            "aggs": {"max_memory": {"max": {"field": "used_memory"}}}
        }
    }
})

buckets = r.json()["aggregations"]["daily_max"]["buckets"]
x = [i for i in range(len(buckets))]
y = [bucket["max_memory"]["value"] for bucket in buckets]

# 线性回归 y = ax + b
n = len(x)
sum_x = sum(x)
sum_y = sum(y)
sum_xy = sum(x[i]*y[i] for i in range(n))
sum_x2 = sum(x[i]**2 for i in range(n))

a = (n*sum_xy - sum_x*sum_y) / (n*sum_x2 - sum_x**2)
b = (sum_y - a*sum_x) / n

# 预测未来7天
for day in range(1, 8):
    predict_x = len(buckets) + day - 1
    predict_memory = a * predict_x + b
    # 假设单实例maxmemory为12GB(12*1024^3)
    max_memory = 12 * 1024**3
    usage_percent = predict_memory / max_memory * 100
    future_date = (datetime.now() + timedelta(days=day)).strftime('%Y-%m-%d')
    print(f"{future_date} | 预测用量: {usage_percent:.1f}% | 建议阈值: 85%")

    if usage_percent >= 85:
        print(f"⚠️ {future_date} 内存将达到 {usage_percent:.1f}%,需要扩容或清理数据!")
    if usage_percent >= 95:
        print(f"✋ {future_date} 内存将超限,紧急扩容或缩容!")

这套预测上线后,成功预警了一次活动预热导致的容量突增:大促前三天系统推送了扩容建议,运维提前完成资源准备,活动当天Redis内存稳定在72%。

这次迁移只是开始,容量规划需要成为习惯。Redis集群的场景千差万别,但核心原则不变:精确统计、合理冗余、工具验证、灰度切换、即时回滚。严格按照这套流程去执行,Redis集群迁移就是可预期、可重复的运维操作,而不是凭感觉的冒险。