Redis 故障排查与最佳实践
内存满:写入失败与淘汰失控
现象
应用日志里出现:
(error) OOM command not allowed when used memory > 'maxmemory'.
或者没有报错,但缓存命中率断崖式下跌、evicted_keys 疯涨、RT 抖动。这两种表现对应两种不同的配置。
排查
先确认内存水位和策略,这是一切判断的起点:
redis-cli INFO memory | grep -E 'used_memory_human|used_memory_rss_human|maxmemory_human|maxmemory_policy|mem_fragmentation_ratio'
redis-cli INFO stats | grep -E 'evicted_keys|expired_keys|keyspace_hits|keyspace_misses'
对照结果分四种情况:
maxmemory是 0:等于没设上限,Redis 会一直吃到把机器内存耗尽,然后被系统 OOM Killer 杀掉,或者把整台机器拖死。这是最危险的一种,dmesg里能看到Out of memory: Killed process ... redis-server。maxmemory_policy是noeviction:写满就拒绝写,读不受影响。适合「数据不能丢」的实例,但缓存实例配这个就是自找麻烦。- 策略是
volatile-lru之类,但evicted_keys不涨:说明没有可淘汰的 key——所有 key 都没设 TTL,策略退化成noeviction。这是线上「明明配了淘汰却不生效」的头号原因。 evicted_keys在涨但业务还是慢:淘汰本身要采样、要删 key,QPS 高时也是负担;同时说明容量确实不够了,淘汰掉的可能是热点数据。
还有一种隐蔽情况:used_memory 里很大一部分不是业务数据。客户端输出缓冲、复制缓冲区、AOF 缓冲区都算在里面,maxmemory 一到就先触发淘汰或拒绝写,看起来像「数据没多少却 OOM」。用 INFO memory 里的 mem_clients_normal、mem_replication_backlog、mem_aof_buffer 能确认是哪块吃掉了内存。另外,从库默认 replica-ignore-maxmemory yes,不主动淘汰,容量规划时要按「主库数据 + 复制缓冲」留余量。
另外别忘了客户端缓冲区。Pub/Sub 的慢消费者、MONITOR 的长连接、大 MGET 的响应,都会让输出缓冲暴涨,这部分内存算在 used_memory 里,但和业务数据无关:
redis-cli CLIENT LIST | grep -o 'omem=[0-9]*' | sort -t= -k2 -n -r | head -5
# 关注 obl(输出缓冲长度)和 omem(输出缓冲占用)异常大的连接
处置
短期止血,按影响从小到大:
# 1. 确认是不是某类 key 异常膨胀
redis-cli --bigkeys
redis-cli --memkeys
# 2. 临时提高上限(确认机器还有余量!)
redis-cli CONFIG SET maxmemory 6gb
# 3. 纯缓存实例切到 allkeys-lru
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# 4. 清理明确无用的 key,用 UNLINK 避免阻塞
redis-cli --scan --pattern 'tmp:*' | head -1000 | xargs -r -n 50 redis-cli UNLINK
长期要把容量规划做起来:按「峰值写入量 × 平均 TTL」估算内存,留 30% 余量;maxmemory 设成机器内存的 60%~70%,别和系统抢;80% 水位就告警,别等 100%。
FLUSHALL 会清空整个实例,缓存瞬间全失效,所有请求打回数据库,很容易引发雪崩;如果实例上还混着锁、队列、session,影响面更大。真要清,用 SCAN 按前缀分批 UNLINK,并且先确认这些 key 可以删。
fork 卡顿:持久化和复制带来的毛刺
为什么会卡
RDB 快照、AOF 重写、主从全量同步,都要 fork 一个子进程。fork 本身要复制父进程的页表,内存越大、页表越长,耗时越久。更麻烦的是 copy-on-write(COW):
- fork 之后父子进程共享物理内存页,谁写谁复制。
- 主进程每处理一条写命令,就可能复制一页;写入越猛,复制的页越多,内存占用短时间可能接近翻倍。
- 复制页的过程发生在主线程,会造成延迟毛刺;如果此时内存不够,还会触发 swap 甚至 OOM。
所以「fork 卡顿」的根因通常不是 fork 本身,而是内存太大 + 写入太猛 + 大页放大。
排查
# 最近一次 fork 耗时,单位微秒;超过 10000(10ms)就要关注
redis-cli INFO stats | grep latest_fork_usec
# 最近一次 RDB 保存耗时和结果
redis-cli INFO persistence | grep -E 'rdb_last_bgsave_time_sec|rdb_last_bgsave_status|aof_last_bgrewrite_status'
# 开延迟监控,记录超过 100ms 的事件
redis-cli CONFIG SET latency-monitor-threshold 100
redis-cli LATENCY LATEST
redis-cli LATENCY HISTORY fork
结合监控看时间点:如果 RT 尖刺和 save 配置的周期、AOF 重写、从库全量同步的时间吻合,基本可以确定是 fork 引起的。
缓解
按收益排序:
- 控制单实例内存。经验值是 10~16GB 以内,超过就分片。32GB 的实例 fork 一次几百毫秒很常见。
- 关闭透明大页(THP)。THP 把页从 4KB 放大到 2MB,COW 时复制一个字节就要拷 2MB,毛刺被放大几百倍:
cat /sys/kernel/mm/transparent_hugepage/enabled
# 期望输出 [never]
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
写入只对当前运行生效,重启会恢复。要持久化就写进 systemd 的 ExecStartPre 或 /etc/rc.local。
- 设置
vm.overcommit_memory=1。fork 需要申请内存,严格 overcommit 策略下可能直接失败:
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-redis.conf
sudo sysctl -p /etc/sysctl.d/99-redis.conf
- 把持久化放到从库。主库关掉 RDB/AOF(或只留 AOF
everysec),由从库负责快照和备份,主库就只剩主从全量同步时才会 fork。 - 错开集中过期。大批 key 同一秒过期,主进程要连续删 key,COW 页复制跟着暴增。TTL 加随机抖动。
- AOF 重写期间不 fsync:
no-appendfsync-on-rewrite yes,用一点数据安全换平稳的延迟。
连接打满
现象
新连接被拒绝,客户端报:
ERR max number of clients reached
INFO clients 里 connected_clients 贴近 maxclients,INFO stats 里 rejected_connections 持续增长。
排查
redis-cli CONFIG GET maxclients
redis-cli INFO clients
redis-cli INFO stats | grep rejected_connections
# 连接都是从哪来的、在干什么
redis-cli CLIENT LIST | grep -o 'addr=[^ ]*' | cut -d: -f1 | sort | uniq -c | sort -rn
redis-cli CLIENT LIST | grep -E 'idle=[0-9]{3,}' | head
# 系统层面
ulimit -n
ss -s
CLIENT LIST 每行关键字段:addr 来源地址、age 连接存活秒数、idle 空闲秒数、cmd 最近执行的命令、qbuf/obl 缓冲大小。如果大量连接 idle 上千秒,就是连接池只借不还或者没设空闲回收。
常见原因:
- 连接池上限乘上实例数超了。20 个应用实例 × 每池 64 连接 = 1280,再乘上几个环境共用一个 Redis,很容易顶到 10000。
- 连接泄漏。代码里拿了连接没
close,或者异常分支漏了归还,跑几天就满。 - 短连接。每次请求新建连接,
TIME_WAIT堆积,同时maxclients被快速消耗。 - 慢命令把连接占住。
KEYS、大HGETALL、BLPOP不设超时,连接一直不释放。 ulimit -n太小。Redis 启动时会把maxclients压到「文件描述符上限 - 32」,配置写 10000 实际只有几百。
处置
先扩容,再治根:
# 临时提高上限(同时要改 systemd 的 LimitNOFILE)
redis-cli CONFIG SET maxclients 20000
# 先列出空闲时间很长的连接,确认业务能重连后再按 addr 踢掉
redis-cli CLIENT LIST | grep -E 'idle=[0-9]{4,}'
redis-cli CLIENT KILL ADDR 10.0.0.21:42318
连接池配置要算总账,以 Spring Boot + Lettuce 为例:
spring:
data:
redis:
timeout: 2000ms
lettuce:
pool:
max-active: 32 # 单实例最大连接数
max-idle: 16
min-idle: 8
max-wait: 2000ms # 拿不到连接最多等 2 秒,别无限等
规则很简单:应用实例数 × max-active < maxclients × 0.7,剩下的留给运维、监控和其他应用。同时给 Redis 配 timeout 300(空闲 300 秒断开),给系统配 net.core.somaxconn 和 tcp-backlog,避免握手队列溢出。
主从延迟与哨兵误切
主从延迟
redis-cli INFO replication
# 主库看 master_repl_offset,从库看 slave_repl_offset,两者之差就是落后字节数
# slave0 那行的 lag 是最后一次 ACK 距今的秒数
lag 持续大于 1 秒,或者 offset 差值持续扩大,就是复制延迟。常见原因:
- 网络带宽或延迟:跨机房、跨云复制,RTT 高,带宽被打满。
- 主库写入太猛:多个应用线程持续写主库,从库单线程重放,追不上。
- 大 key 操作:
DEL一个百万元素的 Hash、FLUSHDB、SORT,主库阻塞的同时从库重放也慢。 - 从库在做持久化:从库 fork 时 CPU 和 IO 被抢,重放变慢。
- backlog 太小:断连后无法增量同步,反复全量,越同步越慢。
- 输出缓冲限制:
client-output-buffer-limit replica设置过小,从库消费慢就被主库断开,然后重新全量。
处置:把 repl-backlog-size 调到 64MB 以上,从库关掉不必要的持久化或错开时间,避免大 key,跨机房部署时接受延迟并让业务读主。
哨兵误切
哨兵误判的典型信号:业务短时间大量连接失败,哨兵日志里出现 +sdown → +odown → +switch-master,但主库其实只是卡了一下。
常见诱因:
down-after-milliseconds太小。主库做一次 RDB fork 卡了 3 秒,哨兵设的 2 秒,直接判死。生产建议 10~30 秒。- 网络抖动。哨兵和主库跨机房,或者中间有丢包,ping 超时被当成宕机。
- 主库被慢命令或大 key 删除阻塞,响应超时。
- 哨兵节点资源不足。CPU 被占满,自己的定时任务都跑不准。
- quorum 配置不当。2 个哨兵的 quorum 设成 2,挂一个就永远切不了;设成 1 又太敏感。
处置:
# 调大判定时间,给主库留出恢复窗口
sentinel down-after-milliseconds mymaster 30000
# 故障转移超时也给足,避免切一半又切回来
sentinel failover-timeout mymaster 180000
同时把哨兵和 Redis 部署在同一机房/可用区,监控 +switch-master 事件(出现就告警),定期演练切换。频繁切换比不切换更伤——每次切换都有连接闪断和可能的数据丢失。
最佳实践清单
架构
- 数据量能单机装下就用主从 + 哨兵,别为了「高可用」三个字上 Cluster。
- 单实例内存控制在 10~16GB,超过就分片;预留 30% 内存余量。
- 缓存和持久数据分实例部署,别让队列、锁、session 和缓存抢同一份内存。
配置
maxmemory必须设,取机器内存的 60%~70%;纯缓存用allkeys-lru。- 关 THP、设
vm.overcommit_memory=1、ulimit -n拉到 65535。 - 生产开
appendonly yes+appendfsync everysec,同时保留 RDB 快照做备份。 bind内网地址、requirepass强密码、protected-mode yes、非 root 运行。
使用
- 所有缓存 key 必须带 TTL,加随机抖动;禁止
KEYS,用SCAN。 - 单 key 元素数量设上限,大 key 拆分;删除用
UNLINK。 - 分布式锁用
SET NX PX+ Lua 校验删除,关键业务别只依赖 Redis 锁。 - Lua 脚本保持短小,集群下 key 用 hash tag 同槽。
运维
- 监控五项:内存水位、连接数、命中率、淘汰/拒绝连接、持久化状态。
- 慢查询阈值 10ms 起步,每周看一次
SLOWLOG GET。 - 变更前记录
CONFIG GET快照,变更后用CONFIG REWRITE固化。 - 定期做故障演练:杀主库、拔网线、模拟从库延迟,验证哨兵和客户端行为。
小结
- 内存满先看
maxmemory和策略:0 是裸奔,noeviction会写失败,volatile-*没 TTL 会退化成noeviction;治本靠容量规划和 TTL。 - fork 卡顿来自页表复制和 COW,内存越大写入越猛越明显;关 THP、控实例大小、持久化挪到从库是主要手段。
- 连接打满先算「实例数 × 池大小」,再看泄漏和空闲连接;
maxclients受ulimit -n限制,别忘了改。 - 主从延迟看 offset 和 lag,哨兵误切多半是超时设太短;两者都要靠监控和演练兜底。
- 最佳实践落成一句话:容量留余量、缓存必带 TTL、危险命令禁用、五项指标常看、切换定期演练。
到这里系列正篇结束。命令记不全时翻附录《Redis 命令速查》,需要查官方口径时看《参考资料》。