第 43 章

生产故障复盘: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=8GBmaxmemory=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 连接。加上:

实际连接数超过 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 个案例中,所有的"排查过程"命令都应该成为日常监控指标,在问题发生之前就建立基线和告警阈值。

本章评分
4.6  / 5  (3 评分)

💬 留言讨论