凌晨三点的告警: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 |
| 复制积压缓冲buffer | repl-backlog-size + client-output-buffer-limit | 1GB + 2GB min=2GB |
| AOF重写buffer | aof-rewrite-min-size + 峰值写入×重写时间 | 约1.5GB |
| 内存碎片 | 有效数据× (avg_fragmentation_ratio-1) | 37.3GB×0.34≈12.7GB |
| 系统预留buffer | mem_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% |
| DBSIZE | 1.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集群迁移就是可预期、可重复的运维操作,而不是凭感觉的冒险。