生产故障复盘:10 个真实案例根因分析
第43章 生产故障复盘:10 个真实案例根因分析
生产环境中的 Redis 故障往往不是单点问题,而是配置不当、使用姿势错误、架构缺陷共同作用的结果。本章以"症状 → 排查过程 → 根因 → 修复 → 预防"的固定格式,复盘 10 个来自真实生产环境的典型故障案例,每个案例都附有可直接执行的排查命令和具体的修复方案。
案例 1:bigkey 导致主线程阻塞
症状
业务侧监控告警:Redis 响应时间偶发从 1ms 飙升到 30-50ms,每隔数分钟出现一次,持续约 200ms 后恢复正常。超时期间所有请求(包括简单 GET)都受影响。
排查过程
# 步骤1:确认延迟模式
redis-cli --latency-history -i 1
# → 输出每秒最大延迟,观察是否有规律性尖峰
# 步骤2:查看慢查询日志
redis-cli SLOWLOG GET 20
# → 1) 1) (integer) 14 # 慢查询 ID
# 2) (integer) 1706000000 # 时间戳
# 3) (integer) 198432 # 耗时(微秒)≈ 198ms
# 4) 1) "HGETALL"
# 2) "user:profile:hash" # ← 罪魁祸首
# 步骤3:扫描 bigkey(不阻塞,使用 SCAN 迭代)
redis-cli --bigkeys
# → Biggest hash found so far 'user:profile:hash' with 523714 fields
# 步骤4:确认 key 大小
redis-cli HLEN user:profile:hash
# → (integer) 523714
redis-cli DEBUG OBJECT user:profile:hash
# → Value at:0x7f... refcount:1 encoding:hashtable serializedlength:28432901 lru:...
# serializedlength ≈ 28MB!
根因
应用代码将所有用户的行为日志都 HSET 到同一个 Hash key(key 是用户 ID + 日期,field 是事件类型+时间戳),随着时间累积,单个 Hash 达到 52 万 field,每次 HGETALL 需要序列化 28MB 数据并通过网络传输,在主线程上执行约 200ms,期间所有其他命令全部排队等待。
修复
# 第一步:将 bigkey 拆分(不能直接删除,先迁移数据)
# 按 field 名前缀哈希分桶,拆成 500 个子 Hash
redis-cli --pipe << 'EOF'
# 使用 Lua 脚本批量迁移(每次迁移 1000 个 field)
EVAL "
local cursor = '0'
local bucket_count = 500
repeat
local res = redis.call('HSCAN', KEYS[1], cursor, 'COUNT', 1000)
cursor = res[1]
local fields = res[2]
for i = 1, #fields, 2 do
local field = fields[i]
local value = fields[i+1]
local bucket = tonumber(string.sub(field, 1, 8), 16) % bucket_count
redis.call('HSET', KEYS[1] .. ':' .. bucket, field, value)
end
until cursor == '0'
return 'done'
" 1 user:profile:hash
EOF
# 第二步:验证拆分结果
for i in $(seq 0 4); do redis-cli HLEN "user:profile:hash:$i"; done
# 第三步:异步删除原 bigkey(UNLINK 非阻塞,后台线程删除)
redis-cli UNLINK user:profile:hash
预防
# 1. 在 redis.conf 中设置慢查询阈值(记录超过 10ms 的命令)
slowlog-log-slower-than 10000 # 单位微秒
slowlog-max-len 1000
# 2. 代码层面:禁止 HGETALL 全量读取,改用 HMGET 按需读取
# 禁止:hgetall user:profile:hash
# 改为:hmget user:profile:hash field1 field2 field3
# 3. 监控告警:定期扫描 bigkey
redis-cli --bigkeys 2>&1 | grep "Biggest" | awk '{print $NF}' | sort -n
# 4. 写入时强制校验大小:
# 若 HLEN > 10000,则改写到新 bucket
案例 2:repl backlog 打满引发全量重同步风暴
症状
从库日志持续出现:Connecting to MASTER redis-master:6379。主库 CPU 和内存持续升高。INFO replication 显示 rdb_bgsave_in_progress:1 几乎持续为 1。业务层面:读写延迟均升高(从库离线导致读请求打到主库)。
排查过程
# 步骤1:检查主库复制状态
redis-cli -h redis-master INFO replication
# → master_repl_offset:52428800000
# repl_backlog_size:1048576 # 默认 1MB
# repl_backlog_histlen:1048576 # backlog 已满
#
# 从库信息:
# slave0:ip=10.0.1.2,port=6379,state=wait_bgsave,offset=52427750000,...
# # offset 差距 = 52428800000 - 52427750000 = 1050000 > 1048576 (backlog)!
# 步骤2:确认写入速率
redis-cli -h redis-master INFO stats | grep instantaneous_output_kbps
# → instantaneous_output_kbps:102400 # ≈ 100MB/s 写入速率!
# 步骤3:查看 BGSAVE 频率
redis-cli -h redis-master INFO persistence
# → rdb_last_bgsave_time_sec:45 # 上次 BGSAVE 耗时 45s
# rdb_current_bgsave_time_sec:12 # 当前 BGSAVE 已运行 12s
# 步骤4:计算 backlog 应有大小
# 峰值写速率 = 100MB/s,最大可接受网络抖动 = 30s
# 推荐 backlog = 100 × 30 × 2 = 6000MB
根因
repl-backlog-size 默认 1MB,写入峰值 100MB/s。从库与主库之间的网络出现 15 秒抖动(K8s 节点内存压力导致的网络超时),15 秒内主库写入 100 × 15 = 1500MB,但 backlog 只有 1MB,从库断连后重连已无法找到断点位置,只能触发全量重同步(PSYNC 失败 → SYNC)。全量重同步触发 BGSAVE,BGSAVE 期间内存翻倍,从库接收 RDB 期间又产生新的写入积压,形成恶性循环。
修复
# 立即修复(热配置,无需重启)
redis-cli -h redis-master CONFIG SET repl-backlog-size 536870912 # 512MB
# 验证
redis-cli -h redis-master CONFIG GET repl-backlog-size
# 同时启用无磁盘传输(减少 BGSAVE I/O 压力)
redis-cli -h redis-master CONFIG SET repl-diskless-sync yes
redis-cli -h redis-master CONFIG SET repl-diskless-sync-delay 5
# 持久化到 redis.conf
redis-cli -h redis-master CONFIG REWRITE
预防
# 计算公式:
# repl-backlog-size = 峰值写速率(MB/s) × 最大可接受网络抖动时间(s) × 2 × 1024 × 1024
# 监控告警:backlog 使用率 > 80%
redis-cli INFO replication | grep repl_backlog_histlen
# histlen / repl_backlog_size > 0.8 时告警
# 设置 repl-timeout 为合理值(网络抖动容忍度)
repl-timeout 60 # 默认 60s,通常足够
案例 3:热 key 击穿数据库
症状
促销活动期间,数据库 CPU 突刺到 100%,API 响应时间从 50ms 升至 3s。同时期 Redis 的 keyspace_hits / (keyspace_hits + keyspace_misses) 命中率从 99% 骤降到 20%。
排查过程
# 步骤1:定位热 key
redis-cli --hotkeys
# 注意:需要 maxmemory-policy != noeviction,且 Redis 4.0+
# → Sampled 1000000 keys in the keyspace
# Hot key 'product:flash:100' freq=98765
# 步骤2:实时监控访问频率(短暂开启,生产谨慎使用)
redis-cli MONITOR | grep -c "product:flash:100"
# 每秒统计命中次数
# 步骤3:检查 TTL
redis-cli TTL product:flash:100
# → 23 (还有 23 秒过期)
# → -2 (已过期!)
# 步骤4:确认数据库压力
# 数据库慢查询日志:大量 SELECT * FROM products WHERE id=100
# 同一时刻的并发 SELECT 数 = 业务 QPS × 缓存未命中率 = 1000 × 0.8 = 800 QPS 打到 DB
根因
热销商品 product:flash:100 设置了 60 秒 TTL。在促销高峰期,每分钟 key 到期时,1000 QPS 的并发请求同时发现缓存 miss,全部穿透到数据库执行查询,形成"缓存雪崩"中的"缓存击穿"(单 key 大量并发穿透)。
修复
# 方案一:互斥锁重建(使用 SET NX 分布式锁)
# 伪代码逻辑:
# value = redis.GET(key)
# if value is None:
# if redis.SET(key + ":lock", "1", NX=True, EX=5):
# # 获得锁,从 DB 查询并写入缓存
# value = db.query(id)
# redis.SET(key, value, EX=60)
# redis.DELETE(key + ":lock")
# else:
# # 未获得锁,短暂等待后重试
# time.sleep(0.05)
# value = redis.GET(key)
# 方案二:逻辑过期(key 永不物理过期,但 value 中嵌入过期时间)
# SET product:flash:100 '{"data": {...}, "expire_at": 1706001000}' (不设 EX)
# 读取时若 expire_at < now,异步启动后台刷新,当前请求返回旧值
# 方案三:本地二级缓存(Caffeine/Guava Cache)
# 热 key 在应用内存中缓存 10 秒,10 秒内无论 Redis 状态如何都直接返回本地缓存
预防
# 1. 热 key 不设置短 TTL,或设置随机 TTL 避免同时过期
# TTL = 基础TTL + random(0, 基础TTL * 0.1)
# 2. 预热:活动前主动将热 key 写入缓存,TTL 设置超过活动时长
redis-cli SET product:flash:100 "<value>" EX 7200 # 2小时
# 3. 监控:热 key 检测告警
redis-cli --hotkeys 2>&1 | awk '/Hot key/ {print $3, $5}' | sort -k2 -rn | head -10
案例 4:Lua 脚本死循环导致 Redis 完全不响应
症状
某次发布后,Redis 完全不响应任何命令(包括 PING)。连接可以建立但挂起(hang)。业务全量超时,触发熔断。
排查过程
# 步骤1:尝试 PING(超时)
redis-cli -h redis-host --no-auth-warning -a "$PASS" PING
# → (error) LOADING Redis is loading the dataset in memory
# 或无响应
# 步骤2:检查进程状态(不通过 Redis 协议)
ps aux | grep redis-server
# 进程存在,CPU 100%(单核)
# 步骤3:查看 Redis 日志
tail -f /var/log/redis/redis.log
# → WARNING overcommit_memory is set to 0!
# → Lua slow script detected...(若有 lua-time-limit 配置)
# 步骤4:尝试 SCRIPT KILL(另一个连接)
redis-cli -h redis-host SCRIPT KILL
# → OK (如果脚本没有执行写操作)
# 或
# → (error) UNKILLABLE Script: Sorry the script already executed write commands...
# 此时只能重启
# 步骤5:确认慢脚本
redis-cli DEBUG SLEEP 0 # 如果有响应说明脚本已被 kill
根因
开发人员提交了一个 Lua 脚本用于数据迁移,脚本中包含 while true do end(本意是等待某个条件,但条件判断逻辑有 bug 永远为真)。Redis Lua 引擎是单线程的,脚本一旦进入死循环,主线程被 100% 占用,所有其他命令无法执行。默认 lua-time-limit 5000(5秒)会在超时后允许 SCRIPT KILL 命令执行,但若脚本已执行写操作,SCRIPT KILL 失效。
修复
# 如果 SCRIPT KILL 有效:
redis-cli SCRIPT KILL
# 如果脚本已执行写操作,必须重启:
# 先检查是否可以使用副本接管(若有从库)
redis-cli -h redis-replica SLAVEOF NO ONE # 提升从库为主库
# 然后重启原主库
# 重启命令(K8s 环境):
kubectl rollout restart statefulset/redis-master -n redis
预防
# 1. 设置合理的 lua-time-limit(单位毫秒)
lua-time-limit 5000 # 默认 5s,超过后允许 SCRIPT KILL
# 2. 生产禁止直接执行迁移脚本,改用离线工具
# 3. CI/CD 中对 Lua 脚本进行静态分析(检查死循环模式)
# 4. 使用 Function 替代裸 Lua,便于版本管理和回滚
# 5. 脚本部署前在测试环境使用 redis-cli --eval 验证,带超时保护:
redis-cli --eval script.lua key1 key2 , arg1 arg2
# 超过 5s 自动触发 SCRIPT KILL
案例 5:集群脑裂双写导致数据丢失
症状
订单系统出现数据不一致:同一订单号在数据库中出现两条记录,金额不同。追溯时间线发现两条记录写入时间相差约 40 秒,与一次网络故障时间窗口吻合。
排查过程
# 步骤1:检查集群状态历史(从告警系统回溯)
redis-cli CLUSTER INFO
# → cluster_state:ok (故障已恢复)
# 步骤2:查看每个节点的认知
redis-cli -h node1 CLUSTER NODES
redis-cli -h node2 CLUSTER NODES
# 发现:故障期间 node1 和 node3 都认为自己是 slot 0-5460 的 master
# 步骤3:分析 Redis 日志
grep "MASTER MODE" /var/log/redis/redis-node3.log
# → [1234] 15 Jan 2024 14:30:05.123 # Failover election won: I'm the new master
grep "Connection refused" /var/log/redis/redis-node1.log
# → node1 与集群失联期间仍在接受写入
# 步骤4:重建事件序列
# 14:29:50 - node1(主) 与 node2、node4 网络分区
# 14:30:05 - 剩余多数派节点选出 node3 为新主
# 14:29:50 - 14:30:30 - node1 继续接受客户端写入(client 未感知分区)
# 14:30:30 - 网络恢复,node1 降级为从库,数据被 node3 覆盖
根因
网络分区导致 Redis Cluster 脑裂(split brain):旧主库(node1)与集群多数派失联期间,客户端仍向旧主写入,新主库选出后旧主重新加入集群降级为从库,期间旧主的写入数据全部丢失(被 FULLRESYNC 覆盖)。
修复
# 立即:停止业务写入,进行数据核对和人工修复
# 长期:配置防脑裂参数(热配置)
redis-cli CONFIG SET min-replicas-to-write 1
# 主库至少需要 1 个从库确认后才接受写入
# 若与所有从库失联,主库拒绝写入(返回错误),不再产生新的写入
redis-cli CONFIG SET min-replicas-max-lag 10
# 从库延迟超过 10s 则不计入确认数
# 持久化
redis-cli CONFIG REWRITE
预防
# 1. 设置 min-replicas-to-write(接受适度的可用性降低,换取数据安全)
min-replicas-to-write 1
min-replicas-max-lag 10
# 2. 重要写入使用 WAIT 命令等待同步确认
redis-cli SET order:1001 "..."
redis-cli WAIT 1 1000 # 等待至少 1 个副本确认,超时 1000ms
# → (integer) 1 (1 个副本已确认)
# 3. 客户端使用 Cluster-aware 客户端,感知拓扑变化
# 4. 网络层面:使用高可靠网络,监控节点间 RTT
案例 6:内存碎片率飙升导致 OOM Kill
症状
监控显示:used_memory=8GB,maxmemory=10GB,但 used_memory_rss=18GB(RSS = 进程实际占用物理内存)。K8s 节点内存不足,Redis Pod 被 OOM Kill,业务中断。
排查过程
# 步骤1:检查内存详情
redis-cli INFO memory
# → used_memory:8589934592 # Redis 认为自己用了 8GB
# used_memory_rss:19327352832 # OS 认为 Redis 占了 18GB
# mem_fragmentation_ratio:2.25 # 碎片率高达 2.25(正常 1.0-1.5)
# mem_fragmentation_bytes:10737418240 # 10GB 碎片!
# 步骤2:分析 key 分布(找到碎片根因)
redis-cli --bigkeys
redis-cli INFO keyspace
# → db0:keys=5000000,expires=4900000,avg_ttl=30000
# 500 万个 key,平均 TTL 30s,大量短生命周期 key 频繁创建/销毁
# 步骤3:查看 jemalloc 统计
redis-cli MEMORY MALLOC-STATS
# → ... 显示大量不同 size class 的 bin 使用情况
# 步骤4:确认碎片整理状态
redis-cli CONFIG GET activedefrag
# → "no" (碎片整理未开启!)
根因
商品库存系统每秒写入数万条 stock:{sku_id}:lock key(不同 SKU ID 导致不同 value 长度),每条 key 的 TTL 为 5-60 秒。jemalloc 按 size class 分配内存块,大量不同大小的 key 快速创建/销毁,导致内存碎片严重:已分配的内存块中充满"空洞"(已释放但未归还 OS 的内存)。used_memory_rss 持续膨胀,最终超出 K8s limits 触发 OOM Kill。
修复
# 立即:开启主动碎片整理
redis-cli CONFIG SET activedefrag yes
redis-cli CONFIG SET active-defrag-ignore-bytes 100mb # 碎片超过 100MB 才整理
redis-cli CONFIG SET active-defrag-enabled yes
redis-cli CONFIG SET active-defrag-threshold-lower 10 # 碎片率 > 10% 开始整理
redis-cli CONFIG SET active-defrag-threshold-upper 100 # 碎片率 > 100% 最大力度整理
redis-cli CONFIG SET active-defrag-cycle-min 1 # 最低 1% CPU 用于整理
redis-cli CONFIG SET active-defrag-cycle-max 25 # 最高 25% CPU 用于整理
# 监控整理进度(每 5 秒观察一次)
watch -n 5 "redis-cli INFO memory | grep -E 'mem_fragmentation|used_memory'"
# 调整 K8s limits(临时):将 limits 从 10Gi 改为 14Gi
kubectl patch statefulset redis-master -n redis -p \
'{"spec":{"template":{"spec":{"containers":[{"name":"redis","resources":{"limits":{"memory":"14Gi"}}}]}}}}'
预防
# 1. 常态开启主动碎片整理
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-cycle-max 25
# 2. K8s 内存三层规划(详见第42章)
# limits = maxmemory × 1.5(预留碎片空间)
# 3. 监控告警:碎片率 > 1.5 告警
# mem_fragmentation_ratio > 1.5 → warning
# mem_fragmentation_ratio > 2.0 → critical(考虑滚动重启)
# 4. 定期滚动重启(从库→切主→重启旧主)完全消除碎片
# 重启前确保 RDB/AOF 持久化正常
案例 7:KEYS * 命令阻塞生产集群
症状
某夜间维护脚本执行后,Redis 阻塞约 30 秒,期间所有业务请求超时,告警系统爆炸式告警。事后日志显示阻塞时间段内无其他异常,只有维护操作。
排查过程
# 步骤1:查看慢查询日志(事后分析)
redis-cli SLOWLOG GET 10
# → 1) 1) (integer) 201
# 2) (integer) 1706050000
# 3) (integer) 28432156 # 28秒!
# 4) 1) "KEYS"
# 2) "*"
# 步骤2:确认 key 数量
redis-cli DBSIZE
# → (integer) 5234821 # 超过 500 万个 key
# 步骤3:分析 KEYS * 的复杂度
# KEYS * 时间复杂度 O(N),N=500万时,在主线程上遍历 500万 key
# 每个 key 的字符串比较 ≈ 5μs,500万 × 5μs = 25s
# 步骤4:定位来源(查看 CLIENT LIST 历史,或从维护脚本追查)
redis-cli CLIENT LIST
# → id=1234 addr=10.0.1.100:54321 cmd=keys ...
根因
夜间维护脚本需要找到所有过期的 session key(格式:session:*),直接使用了 KEYS session:*。Redis 单线程架构下,KEYS 需要在主线程遍历全部 500 万个 key,耗时 28 秒,期间所有客户端请求全部排队等待。
修复
# 立即:SCRIPT KILL(若脚本通过 EVAL 执行)或等待完成
# 已完成,无法中断
# 禁用危险命令(热配置,重启后失效)
redis-cli CONFIG SET rename-command "KEYS" ""
# 持久化到 redis.conf
echo 'rename-command KEYS ""' >> /etc/redis/redis.conf
# 重写维护脚本,改用 SCAN
# 错误写法:redis-cli KEYS "session:*"
# 正确写法(SCAN 分批扫描,不阻塞):
redis-cli SCAN 0 MATCH "session:*" COUNT 100
# 循环直到游标返回 0
# Shell 脚本版本:
cursor=0
while true; do
result=$(redis-cli SCAN $cursor MATCH "session:*" COUNT 100)
cursor=$(echo "$result" | head -1)
keys=$(echo "$result" | tail -n +2)
if [ -n "$keys" ]; then
echo "$keys" | xargs redis-cli DEL
fi
[ "$cursor" = "0" ] && break
sleep 0.01 # 限速,避免对 Redis 造成持续压力
done
预防
# redis.conf 中禁用危险命令
rename-command KEYS ""
rename-command FLUSHDB ""
rename-command FLUSHALL ""
rename-command DEBUG ""
rename-command CONFIG "CONFIG-AUTH-REQUIRED" # 改为安全名称而非完全禁用
# 代码 review checklist 中加入:
# - 禁止使用 KEYS/SMEMBERS/HGETALL 全量操作
# - 所有遍历操作必须使用 SCAN/HSCAN/SSCAN/ZSCAN
案例 8:连接数耗尽(Too many connections)
症状
应用日志大量出现:JedisConnectionException: Could not get a resource from the pool,Redis 侧报错:ERR max number of clients reached。新连接无法建立,现有连接仍正常工作。
排查过程
# 步骤1:查看当前连接数
redis-cli INFO clients
# → connected_clients:10000 # maxclients 默认 10000,已打满!
# client_recent_max_input_buffer:32768
# blocked_clients:0
# 步骤2:分析连接来源(CLIENT LIST)
redis-cli CLIENT LIST | awk -F'[ =]' '{for(i=1;i<=NF;i++) if($i=="addr") print $(i+1)}' | \
cut -d: -f1 | sort | uniq -c | sort -rn | head -20
# → 3000 10.0.1.10 # 应用节点 1
# 2800 10.0.1.11 # 应用节点 2
# ...
# 500 10.0.1.100 # 运维跳板机(意外持有大量连接!)
# 步骤3:查看空闲连接
redis-cli CLIENT LIST | awk -F'[ =]' '{for(i=1;i<=NF;i++) if($i=="idle") print $(i+1)}' | \
sort -n | tail -20
# → 3600 # 3600 秒 = 1 小时的空闲连接!
# 步骤4:统计各类连接占比
redis-cli CLIENT LIST | grep -c "cmd=ping" # 监控连接
redis-cli CLIENT LIST | grep -c "cmd=replconf" # 从库连接
根因
Java 应用 JedisPool 配置 maxTotal=200,20 个 Pod 实例 = 4000 连接。加上:
- 从库复制连接:2 个从库 × 1 = 2 个
- Sentinel 连接:3 个 Sentinel × 2 = 6 个
- Prometheus redis-exporter:1 个
- 运维脚本遗留空闲连接:约 200 个(脚本执行完毕但未正确关闭连接)
实际连接数超过 10000 触发上限。
修复
# 立即:提高 maxclients(热配置)
redis-cli CONFIG SET maxclients 50000
# 清理空闲超过 1 小时的连接
redis-cli CLIENT LIST | awk '/idle=[3-9][0-9]{3}/ {match($0, /id=([0-9]+)/, a); print a[1]}' | \
xargs -I{} redis-cli CLIENT KILL ID {}
# 设置客户端空闲超时(热配置)
redis-cli CONFIG SET timeout 300 # 300 秒无活动自动断开
redis-cli CONFIG SET tcp-keepalive 60 # TCP keepalive 检测死连接
# 持久化
redis-cli CONFIG REWRITE
预防
# 连接数预算规划:
# 总连接数 = Σ(应用实例 × JedisPool.maxTotal) + 从库连接数 + Sentinel连接数 + 监控连接 + 运维预留
# 确保 总连接数 < maxclients × 0.8
# JedisPool 配置建议:
config.setMaxTotal(50) # 根据实际 QPS 计算,不要盲目设大
config.setMinIdle(5) # 最小空闲连接(保持温连接)
config.setTestOnBorrow(true) # 借用前验证连接存活
config.setMaxWait(Duration.ofMillis(3000)) # 等待超时
# 应用层设置合理的连接超时
config.setSoTimeout(2000) # 读超时 2s
案例 9:AOF rewrite 期间磁盘写满
症状
Redis 日志大量报错:MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist on disk。新写入可以执行,但 RDB 保存失败,AOF 持续增大。磁盘告警:/data 分区已用 100%。
排查过程
# 步骤1:检查磁盘使用情况(在 Redis 宿主机或 Pod 内执行)
df -h /data
# → /dev/sdb 20G 20G 0G 100% /data
# 步骤2:找出大文件
ls -lh /data/
# → total 20G
# -rw-r--r-- 1 redis redis 8.0G Jan 15 14:30 appendonly.aof
# -rw-r--r-- 1 redis redis 4.5G Jan 15 12:00 appendonly.aof.tmp.1
# -rw-r--r-- 1 redis redis 3.2G Jan 14 23:00 appendonly.aof.tmp.2
# -rw-r--r-- 1 redis redis 2.1G Jan 14 11:00 appendonly.aof.tmp.3
# -rw-r--r-- 1 redis redis 2.1G Jan 15 02:00 dump.rdb
# 步骤3:分析 .aof.tmp 文件来源
# 这些是历次 AOF rewrite 异常终止(OOM Kill)后残留的临时文件,未被清理
# 步骤4:确认当前 AOF rewrite 状态
redis-cli INFO persistence
# → aof_rewrite_in_progress:1
# aof_current_size:8589934592 # 8GB
# aof_base_size:1073741824 # 上次 rewrite 后 1GB
根因
AOF rewrite 因节点 OOM Kill 异常终止,appendonly.aof.tmp 临时文件未被清理(Redis 重启后不会自动清理上次异常留下的 tmp 文件)。此后每次触发 AOF rewrite,又产生新的 tmp 文件,多个 tmp 文件叠加最终撑满磁盘。
修复
# 步骤1:手动清理残留 tmp 文件(确认 Redis 当前没有在进行 rewrite)
redis-cli INFO persistence | grep aof_rewrite_in_progress
# → aof_rewrite_in_progress:0
# 步骤2:安全删除旧的 tmp 文件
ls -lt /data/*.tmp | head -5 # 确认文件列表
rm /data/appendonly.aof.tmp.1
rm /data/appendonly.aof.tmp.2
rm /data/appendonly.aof.tmp.3
# 步骤3:手动触发 AOF rewrite(可选,压缩 AOF 文件大小)
redis-cli BGREWRITEAOF
redis-cli INFO persistence # 监控 rewrite 进度
# 步骤4:扩容磁盘(K8s PVC 扩容)
kubectl patch pvc redis-data-redis-0 -n redis -p \
'{"spec":{"resources":{"requests":{"storage":"40Gi"}}}}'
预防
# 1. 磁盘告警阈值设 75%(而非默认 80%)
# 为 AOF rewrite 预留至少当前 AOF 大小的空间(rewrite 期间需要同时持有新旧文件)
# 2. Redis 数据目录独立挂载(PVC),不与 OS 根目录共享
# K8s 中每个 Redis Pod 使用 volumeClaimTemplate 独立 PVC
# 3. 监控 AOF rewrite 状态
redis-cli INFO persistence | grep -E "aof_rewrite|aof_current_size"
# 4. 使用 Redis 7.0 Multi-Part AOF 减少临时文件风险
# 7.0 的增量 INCR 文件较小,rewrite 失败时 tmp 文件也较小
案例 10:K8s Deployment 误用导致生产数据全量丢失
症状
运维在 K8s 中对 Redis 的 Deployment 执行了一次 kubectl rollout restart(为更新镜像版本),Redis Pod 重建后,业务方反馈所有缓存数据已不存在,数据库压力暴增。确认后:Redis 数据全量丢失,即使 RDB 持久化已开启。
排查过程
# 步骤1:查看 Pod 历史
kubectl describe pod redis-7d4f9b-abc12 -n prod
# → Name: redis-7d4f9b-abc12 (旧 Pod,已 Terminated)
kubectl get pod redis-8e5f0c-xyz89 -n prod
# → Name: redis-8e5f0c-xyz89 (新 Pod,Running)
# Pod 名称完全不同!
# 步骤2:检查 PVC 绑定情况
kubectl get pvc -n prod
# → NAME STATUS VOLUME CAPACITY
# redis-data Bound pvc-aaa 20Gi
# 步骤3:检查新 Pod 挂载的 PVC
kubectl describe pod redis-8e5f0c-xyz89 -n prod | grep "ClaimName"
# → ClaimName: redis-data (PVC 名称相同)
# 步骤4:检查旧 Pod 的挂载记录
# 旧 Pod Termination 期间,Deployment 并发启动新 Pod
# 新 Pod 挂载同一 PVC,但 PVC 竞争导致挂载点状态不一致
# → 新 Pod 实际挂载到了新建的空 PVC!(旧 PVC 被 Deployment 回收策略影响)
# 步骤5:查看 Deployment 的 volume 配置
kubectl get deployment redis -n prod -o yaml | grep -A 10 volumes
# → volumes:
# - name: redis-data
# persistentVolumeClaim:
# claimName: redis-data # 静态绑定,而非 volumeClaimTemplate!
根因
生产 Redis 使用了 Deployment 而非 StatefulSet 部署。Deployment 的 Volume 通过 claimName 静态绑定到 PVC。rollout restart 时,Kubernetes 先启动新 Pod(新 Pod 试图挂载同一 PVC,但 ReadWriteOnce PVC 只能被一个 Pod 挂载),新 Pod 因 PVC 锁定而 Pending,随后调度器重新规划,在另一个节点上创建了新的空目录作为临时存储(emptyDir 回退),新 Pod 正常启动。旧 Pod 因为新 Pod 已 Running 而被 Termination,持有 PVC 的旧 Pod 终止后,新 Pod 挂载到了原 PVC(但 RDB 文件的写入时机错过)—— 实际情况更复杂,不同 K8s 版本行为略有差异,但最终结果是:新 Pod 挂载的 PVC 内容为空。
修复
# 1. 立即降低数据库压力(Redis 缓存丢失,DB 压力暴增)
# 临时限流,打开数据库连接池上限
# 2. 尝试从备份恢复(见案例9修复步骤)
aws s3 ls s3://my-redis-backups/redis/ | sort | tail -5
# 找到最近的 RDB 文件,恢复到 Redis
# 3. 迁移到 StatefulSet(根本修复)
# 导出现有 Deployment 配置
kubectl get deployment redis -n prod -o yaml > redis-deployment.yaml
# 基于此创建 StatefulSet(主要变更:添加 volumeClaimTemplates,删除 volumes 中的 PVC 引用)
# 4. 数据预热(缓存重建)
# 对于无法从 Redis 备份恢复的数据,触发应用侧的缓存预热逻辑
预防
# 1. 强制规范:Redis 必须使用 StatefulSet
# 在 CI/CD Pipeline 中加入检查:
kubectl get deployments -n prod | grep redis
# 若发现 redis Deployment 则 Pipeline 失败并告警
# 2. OPA/Gatekeeper 策略:禁止在特定命名空间中对标有 redis 标签的资源使用 Deployment
# 3. 定期备份验证(见案例9预防)
# 4. 每季度灾难恢复演练(DR Drill):模拟 Pod 全量丢失,演练从备份恢复全流程
# 5. 重要操作前必须检查资源类型:
kubectl get all -n prod | grep -E "(deployment|statefulset).*redis"
故障预防总结矩阵
| 故障类型 | 检测命令 | 预防措施 |
|---|---|---|
| Bigkey 阻塞 | redis-cli --bigkeys + SLOWLOG |
禁止 HGETALL,代码 review |
| Backlog 溢出 | INFO replication 检查 offset 差 |
按峰值写速率 × 2 设置 backlog |
| 热 key 击穿 | redis-cli --hotkeys + MONITOR |
逻辑过期 + 本地二级缓存 |
| Lua 死循环 | 进程 CPU 100% | lua-time-limit + SCRIPT KILL |
| 脑裂双写 | CLUSTER NODES 对比 | min-replicas-to-write + WAIT |
| 内存碎片 OOM | INFO memory fragmentation_ratio |
activedefrag + limits 预留碎片空间 |
| KEYS 阻塞 | SLOWLOG | rename-command KEYS "" |
| 连接数耗尽 | INFO clients connected_clients |
连接数预算规划 + timeout 设置 |
| 磁盘写满 | df -h + AOF tmp 文件 |
磁盘 75% 告警 + 独立分区 |
| 误用 Deployment | kubectl get all |
强制使用 StatefulSet + CI 检查 |
每个案例的核心教训:监控必须先于故障。在以上 10 个案例中,所有的"排查过程"命令都应该成为日常监控指标,在问题发生之前就建立基线和告警阈值。