Redis 大 key 阻塞主线程导致集群级雪崩的复盘:18 分钟反复切换 + 5 种修法 + 9 条治理纪律

一次高价值用户标签 HGETALL 800MB 大 key 把 Redis 主线程阻塞 4.2 秒,Sentinel 误判反复切换 4 次,18 分钟内集群级雪崩。本文复盘完整排查过程、5 种修法(HSCAN/读 slave/分片/Sentinel 调参/SafeRedis 防护)、9 条治理纪律,以及 Redis 6.0 IO 多线程和持久化 fsync 的真相。

2025 年九月一个周六上午,我们的一个 Redis 集群突然炸了:Sentinel 标记主节点下线、发起主从切换、新主上任后又炸、再次切换……整整 18 分钟里反复切换了 4 次,期间所有依赖这个集群的下游服务(订单、库存、会话)全部失联,影响了大约 22 万用户,直接和间接损失估算超过 60 万。事后根因分析:一段一年前上线的"数据导出后台任务"对一个用户标签 hash 执行了 HGETALL,而这个 hash 在过去一年里默默膨胀到了 800MB+ 27 万字段,单次 HGETALL 阻塞主线程 4.2 秒,刚好超过 Sentinel 的健康检查超时(3 秒),触发了误判主从切换。这篇把完整事故现场、Redis 单线程为什么对大 key 如此敏感、SCAN/HSCAN 增量替代方案、大 key 拆分策略、5 种修法的横向对比,以及我们事后立的 9 条 Redis 治理纪律,完整写下来给大家。

事故服务和 Redis 集群拓扑

事故的核心是一个 Redis 7.0 集群,3 主 3 从,每个主节点 32GB 内存,部署在 3 台物理机上(每台一主一从,机器级故障域隔离)。这个集群是用户画像服务的核心存储,主要存"user_id → 标签 hash"这类数据,日常 QPS 大约 30 万,命中率 99.8%。同一个 Redis 集群被 12 个微服务依赖,所以一旦它出问题,影响面非常大。事故服务的"罪魁祸首"是一个不太起眼的离线任务,每周六凌晨跑一次,把高价值用户的标签全量导出到数据仓库,任务代码非常简单粗暴:遍历 user_id 列表,对每个用户的标签 hash 执行 HGETALL。

时刻 事件
09:00 每周六的"高价值用户标签导出"任务启动
09:32 第一次主节点 Sentinel 标记下线 (subjective_down)
09:33 多数 Sentinel 投票, 触发主从切换(failover), 新主上任
09:34 导出任务连上新主, 继续 HGETALL, 新主也卡死
09:36 第二次主从切换, 第三次, 第四次连环
09:38 SRE 收到大量告警, 紧急介入
09:42 定位到 HGETALL 调用栈, 杀掉导出任务进程
09:48 Redis 集群停止切换, 进入稳定状态
10:18 全部下游服务恢复正常, 事故持续 78 分钟(其中切换 18 分钟)
下午 开始深度根因分析

第一轮排查:为什么主从切换雪崩

事故发生时最让 SRE 困惑的是"主从切换本来应该让服务更可用,怎么会反过来加剧雪崩"。我们花了 20 分钟排查,慢慢拼出了事件链:第一次主节点被标记下线,是因为它处理 HGETALL 阻塞了 4.2 秒,Sentinel 在 3 秒内 PING 不到响应,judges 它 down 了。Sentinel 集群投票通过后,触发 failover,把流量切到从节点;但导出任务还在不停发新的 HGETALL 请求,新主上任后立刻收到请求又卡死,Sentinel 又判它 down……如此循环。

# 事故时观察到的 Sentinel 日志
$ tail -f /var/log/redis-sentinel.log
[09:32:14] +sdown master mymaster 10.1.2.3 6379
[09:32:14] +odown master mymaster 10.1.2.3 6379 #quorum 2/2
[09:32:14] +new-epoch 7
[09:32:15] +try-failover master mymaster 10.1.2.3 6379
[09:32:16] +elected-leader master mymaster
[09:32:17] +failover-state-send-slaveof-noone slave 10.1.2.4:6379
[09:33:02] +switch-master mymaster 10.1.2.3 6379 10.1.2.4 6379
[09:34:18] +sdown master mymaster 10.1.2.4 6379  # 新主又被判 down
[09:34:18] +odown master mymaster 10.1.2.4 6379 #quorum 2/2
[09:34:19] +try-failover master mymaster 10.1.2.4 6379
# 反复切换...

# Redis 主节点的 slowlog
$ redis-cli -h 10.1.2.3 slowlog get 10
1) 1) (integer) 142  # ID
   2) (integer) 1726384334  # 时间戳
   3) (integer) 4234567  # 耗时 4.23 秒(微秒)
   4) 1) "HGETALL"
      2) "user:tags:8847163920"  # key
   5) "10.1.5.42:54123"  # 客户端

SLOWLOG 一下就锁定了凶手:HGETALL user:tags:8847163920 耗时 4.2 秒。这个 key 是用户 8847163920 的标签 hash,我们立刻在另一个副本上 DEBUG OBJECT 看了下:

$ redis-cli -h 10.1.2.5 --no-auth-warning DEBUG OBJECT user:tags:8847163920
Value at:0x7f8c34000000 refcount:1 encoding:hashtable serializedlength:824831024 lru:0 lru_seconds_idle:0

$ redis-cli -h 10.1.2.5 HLEN user:tags:8847163920
(integer) 273482

$ redis-cli -h 10.1.2.5 MEMORY USAGE user:tags:8847163920
(integer) 891234567  # 850MB!

一个 hash 27 万字段、850MB!这个 hash 是某个内部账号的"测试标签池",一年前业务方一拍脑袋决定"把所有可能用到的标签都先打上",然后忘记设置上限,每周新版本上线都会往里加新标签,慢慢膨胀到了这个体量。HGETALL 在 Redis 主线程上要复制并序列化这 850MB 数据,4 秒只能算正常水平。

问题本质:Redis 单线程模型对大 key 的致命敏感

很多人对 Redis 单线程的理解停在"快",但很少有人深究"单线程意味着任何一个慢命令都会阻塞所有其他请求"。Redis 6.0 之后 IO 线程化了一部分(读写网络 buffer),但命令执行仍然是单线程。HGETALL 这种 O(N) 命令在大 key 上的代价非常致命,因为它在主线程里完成,期间所有其他请求都要排队。

这个事故里 Sentinel 的 3 秒超时是合理的(默认 down-after-milliseconds=30000,但我们为了快速切换调到了 3000),问题不在 Sentinel 本身,而在大 key 让 PING 也排队等了 4 秒。从 Sentinel 角度看,这个节点和死的没区别。所以这次事故的本质是"大 key + 短超时 = 误判 + 雪崩",两个因素叠加才出事故,任何一个解决都能避免。

修法 1:用 HSCAN 替代 HGETALL(必做)

最直接的修法是把所有 HGETALL/SMEMBERS/LRANGE 0 -1 这类"O(N) 全量读"改成SCAN 系列增量读。SCAN 用 cursor 分批扫描,每次返回少量元素(可以指定 COUNT),不会一次性占用主线程很久。改造后单次扫描时间在 1-2ms,完全不会触发健康检查超时。

import redis

r = redis.Redis(host='10.1.2.3', port=6379, decode_responses=True)

# 错误: 直接 HGETALL, 大 key 阻塞主线程
# data = r.hgetall('user:tags:8847163920')  # 4.2 秒卡死

# 正确: 用 HSCAN 增量读
def safe_hgetall(client, key, count=500):
    cursor = 0
    result = {}
    while True:
        cursor, items = client.hscan(key, cursor, count=count)
        result.update(items)
        if cursor == 0:
            break
        # 关键: 每批之间稍微 sleep, 给其他请求让路
        time.sleep(0.001)
    return result

data = safe_hgetall(r, 'user:tags:8847163920', count=500)
# 27 万字段, 分 540 批扫描, 每批 500 字段约 2-5ms
# 总耗时约 3-5 秒, 但每次只阻塞主线程 2-5ms, 完全不影响其他请求

# SCAN 系列命令对应:
# HSCAN: 替代 HGETALL / HVALS / HKEYS
# SSCAN: 替代 SMEMBERS / SUNION 等
# ZSCAN: 替代 ZRANGE 0 -1
# SCAN: 替代 KEYS *

这种"减小单次工作粒度"的思想在所有单线程数据库都适用,不只是 Redis。我们改完之后那个导出任务的总耗时变长了(从 30 分钟变成 45 分钟),但完全不会再阻塞主线程,稳定多了。

修法 2:从 slave 节点读,避免主节点压力

除了改命令粒度,另一个思路是把读流量从主节点切到从节点。导出任务这种"批量读 + 容忍轻微延迟"的场景非常适合走从节点,既不影响主节点的写入和健康检查,也能利用从节点的闲置资源。Redis 主从架构里,从节点和主节点有毫秒级延迟,对离线任务完全够用。

# 用读写分离的客户端
import redis
from redis.sentinel import Sentinel

# 通过 Sentinel 拿到读写分离的连接
sentinel = Sentinel(
    [('sentinel1', 26379), ('sentinel2', 26379), ('sentinel3', 26379)],
    socket_timeout=0.5
)

# 写操作连主
master = sentinel.master_for('mymaster', socket_timeout=0.5)

# 读操作连从 (随机选一个 healthy slave)
slave = sentinel.slave_for('mymaster', socket_timeout=2.0)

# 离线任务用 slave 客户端
def export_user_tags(user_ids):
    for uid in user_ids:
        # 即使这个 HGETALL 慢了, 也只影响 slave 节点
        # 主节点继续愉快地服务在线流量
        data = safe_hgetall(slave, f'user:tags:{uid}', count=500)
        export_to_warehouse(uid, data)

这个修法的代价是slave 的延迟会影响数据新鲜度,如果业务方要的是"严格 t-0"数据就不行;另外 slave 也会被 sentinel 监控,如果 slave 也被打挂,sentinel 会从 slave 列表移除但不切主,影响相对小。我们的实际方案是"修法 1 + 修法 2 一起上",HSCAN 改造 + slave 读,双重保险。

修法 3:大 key 拆分(根本性方案)

前两个修法是"工作绕过大 key",但大 key 本身还在,只是没人去碰它。真正的根治方案是把大 key 拆分成多个小 key。常见的拆分策略有按字段分片、按时间分片、按业务维度分片三种。

# 大 hash 拆分策略 1: 按字段 hash 分片
def get_shard_key(user_id, field, shard_count=64):
    """把一个用户的标签 hash 拆成 64 个分片"""
    h = hashlib.md5(field.encode()).hexdigest()
    shard = int(h, 16) % shard_count
    return f'user:tags:{user_id}:s{shard}'

# 写
def set_tag(r, user_id, tag_name, tag_value):
    key = get_shard_key(user_id, tag_name)
    r.hset(key, tag_name, tag_value)

# 读单个字段
def get_tag(r, user_id, tag_name):
    key = get_shard_key(user_id, tag_name)
    return r.hget(key, tag_name)

# 读全部(并行扫 64 个分片)
def get_all_tags(r, user_id, shard_count=64):
    pipe = r.pipeline()
    for i in range(shard_count):
        pipe.hgetall(f'user:tags:{user_id}:s{i}')
    results = pipe.execute()
    merged = {}
    for r in results:
        merged.update(r)
    return merged
# 64 个分片每个约 13MB, HGETALL 单分片约 60ms, 并行 pipeline 总耗时约 100ms
# 主线程每次只阻塞 60ms, 完全可接受

拆分的代价是跨分片操作变复杂(原来 HMGET 多字段直接走一个 hash,现在要按分片分组),但收益是从此再也不会有大 key 阻塞问题。我们的实践是对所有"可能持续增长"的 hash/list/set/zset 强制分片,新设计的 key 必须自带分片策略。

修法 4:Sentinel 调参,避免误判

除了消灭大 key,还有一个"防御性"修法是调整 Sentinel 参数,降低误判概率。我们事故时的 down-after-milliseconds 设为 3000(3 秒),为的是快速 failover,但代价是对慢命令不够宽容。事故后我们做了调整,并加了一些保护机制。

参数 事故前 事故后 说明
down-after-milliseconds 3000 10000 容忍主节点 10 秒不响应再判 down
failover-timeout 180000 300000 failover 总超时 5 分钟, 避免反复切
parallel-syncs 1 1 同时只一个 slave 在同步, 避免风暴
quorum 2 2 2/3 Sentinel 同意才 failover (不变)
min-slaves-to-write 0 1 主节点至少 1 个 slave 在线才接受写
min-slaves-max-lag 10 10 slave 延迟超 10 秒不算"在线"
# sentinel.conf 完整配置示例
sentinel monitor mymaster 10.1.2.3 6379 2
sentinel down-after-milliseconds mymaster 10000
sentinel failover-timeout mymaster 300000
sentinel parallel-syncs mymaster 1

# Redis 主节点 redis.conf
min-slaves-to-write 1
min-slaves-max-lag 10
# 解释: 至少 1 个 slave 在线且延迟 < 10 秒, 才接受写入
# 防止脑裂时主节点继续接受写, 数据分叉

这个修法的代价是真正主节点挂掉时,恢复时间从 3 秒变成 10 秒,业务会感知到 10 秒不可用。但相比"反复切换 18 分钟",这个代价完全值得。我们在 down-after 和 failover-timeout 上做了权衡,最终选了"宁可慢一点也不要乱切"。

修法 5:在客户端层加大 key 防护

最后一个修法是在客户端层做防护,禁止任何 O(N) 命令在线上执行。我们用 redis-py 的 hook 机制做了一个 wrapper,自动拦截危险命令。

# 客户端层的危险命令拦截
DANGEROUS_COMMANDS = {
    'KEYS', 'HGETALL', 'SMEMBERS', 'LRANGE',
    'ZRANGE', 'HVALS', 'HKEYS', 'FLUSHDB', 'FLUSHALL'
}
# LRANGE / ZRANGE 仍允许小范围 (后面有判断)

class SafeRedis:
    def __init__(self, client, hgetall_max_size=10000):
        self.client = client
        self.hgetall_max = hgetall_max_size

    def hgetall(self, key):
        # 先 HLEN 检查
        size = self.client.hlen(key)
        if size > self.hgetall_max:
            raise ValueError(
                f"HGETALL on {key} blocked: size={size} > limit={self.hgetall_max}. "
                f"Use HSCAN instead.")
        return self.client.hgetall(key)

    def lrange(self, key, start, stop):
        # 限制单次返回不超过 10000
        if stop - start > 10000 or stop == -1:
            raise ValueError(
                f"LRANGE on {key} too large: start={start} stop={stop}. "
                f"Use LRANGE with explicit small range.")
        return self.client.lrange(key, start, stop)

    def keys(self, pattern):
        raise ValueError("KEYS is forbidden in production. Use SCAN instead.")

# 业务代码统一用 SafeRedis
r = SafeRedis(redis.Redis(...))
# r.hgetall(big_key)  # 直接抛异常, 不会到 Redis 那里阻塞

这种"客户端保护"是最后一道防线,即使开发漏改、即使大 key 存在、即使 sentinel 配置不当,这层 wrapper 也能拦住绝大部分误用。我们把它作为基础库的强制依赖,所有 Python 服务统一用 SafeRedis,Java/Go 服务也写了对应的 wrapper。

横向对比:5 种修法的取舍

修法 实现成本 立刻见效 根治大 key 推荐顺序
HSCAN 替代 HGETALL 1 (紧急止血)
从 slave 读 2 (流量隔离)
大 key 拆分 3 (长期方案)
Sentinel 调参 4 (防御深度)
SafeRedis wrapper 5 (兜底)

这 5 种修法不是替代关系,而是层层叠加:HSCAN 改造解决"已经存在的大 key 不会再阻塞",拆分解决"未来不会再有大 key",sentinel 调参解决"即使阻塞了也不会乱切",SafeRedis 解决"开发误用也无法触发"。这种多层防御才能真正杜绝事故。

大 key 的发现和持续治理

除了修这次的事故,我们还系统化地建立了大 key 发现机制,定期扫描所有 Redis 实例找出潜在风险。Redis 自带了 --bigkeys 选项,但它的算法是采样的,不够精确;更彻底的做法是用 redis-rdb-tools 离线分析 RDB 文件。

# 方法 1: redis-cli --bigkeys (在线采样, 对生产影响极小)
$ redis-cli -h 10.1.2.3 -p 6379 --bigkeys
# Scanning the entire keyspace to find biggest keys ...
# Biggest hash found 'user:tags:8847163920' has 273482 fields
# Biggest string found 'session:cache:zone1' has 12 MB
# Biggest list  found 'queue:retry:dead' has 187234 items
# 输出 top key, 但只是采样, 可能漏掉

# 方法 2: RDB 离线分析 (最彻底)
# 先在 slave 节点上 SAVE 生成 dump.rdb
$ ssh redis-slave 'redis-cli SAVE && cat /var/lib/redis/dump.rdb' > local.rdb

# 用 redis-rdb-tools 解析
$ pip install rdbtools
$ rdb -c memory local.rdb > all_keys.csv
$ sort -t, -k4 -n -r all_keys.csv | head -50
# 输出所有 key 的精确大小, 排序后看 top 50

# 方法 3: 持续监控 (我们的方案)
# 每天凌晨 4 点跑一次 RDB 分析, 大于 10MB 的 key 入告警库
# 持续增长 (本周比上周涨 50%) 的 key 也告警

事故后我们对所有 Redis 实例跑了一遍 RDB 分析,发现还有 14 个潜在大 key(>50MB),全部追溯到业务方做了清理或拆分。这种"主动扫雷"的做法极大降低了二次事故概率,建议每个用 Redis 的团队都建立这个机制。

除了大 key,还有哪些 Redis 慢命令陷阱

事故复盘期间我们扩大了排查范围,梳理出 Redis 里所有"潜在的主线程杀手",这里列出来给大家警惕。这些命令本身没问题,但用错场景就会成为定时炸弹。

命令 复杂度 主线程影响 替代方案
KEYS pattern O(N) 致命, 100 万 key 阻塞 1-2s SCAN MATCH
SMEMBERS bigset O(N) 类似 HGETALL SSCAN
SORT BY GET O(N+M*log(M)) 大 list 很慢 客户端排序
SUNIONSTORE O(N) 大集合合并致命 分批 + 客户端聚合
ZRANGEBYLEX/LEX* O(log(N)+M) M 大时阻塞 限制 LIMIT
DEL bigkey O(N) (大 hash/set/list) 删 100 万元素 hash 阻塞 1-2s UNLINK (异步删)
FLUSHDB/FLUSHALL O(N) 清库阻塞 FLUSHDB ASYNC
LREM list 0 value O(N) 大 list 扫描 结构换 set

这张表最容易踩的是 DEL bigkey。Redis 4.0 之前 DEL 是同步的,删一个百万级 hash 会阻塞主线程 1-2 秒;Redis 4.0 引入 UNLINK,把内存释放放到异步线程执行,主线程只标记 key 不可见,几微秒返回。所以线上代码应该全部用 UNLINK 替代 DEL,尤其是清理大对象的场景。

Redis 大 key 排查决策树

团队立的 9 条 Redis 治理纪律

  1. 禁止线上 KEYS / HGETALL / SMEMBERS 等 O(N) 命令:由 SafeRedis 强制拦截,违反就抛异常,不到 Redis。
  2. 所有可能增长的集合必须有上限:hash/set/list/zset 设计阶段就要给出最大字段数,超过就分片。
  3. DEL 一律改为 UNLINK:基础库提供 safe_del 方法,内部调 UNLINK。
  4. 大 key 主动扫描周度执行:RDB 离线分析,>10MB 的 key 进治理 backlog。
  5. 离线任务必须走 slave 读:任何"批量遍历"的任务都不能直接读主节点。
  6. Sentinel down-after 不低于 10 秒:留出对慢命令的容忍空间。
  7. min-slaves-to-write 必须配置:防止脑裂时数据分叉。
  8. 新 key 必须有 TTL:除非业务方明确写文档说"永久保留",否则一律 7 天/30 天 TTL。
  9. 每次发布前过一遍"潜在新大 key":新增的 hash/list 字段写入逻辑,评审时必问"会不会无限增长"。

事故后做的长期机制建设

除了纪律,我们还建了几个长期机制。第一个是Redis 健康度大盘,展示每个集群的关键指标:慢命令数、大 key 数、内存碎片率、QPS 分布、命中率、主从延迟。第二个是每月一次的"Redis 治理评审",所有团队的 Redis 用法都要过一遍,新 key 设计要审、旧 key 改造要审、容量预估要审。第三个是Redis 培训,新员工入职第一个月必须完成 4 小时的 Redis 课程,内容包括单线程模型、数据结构选型、常见陷阱、慢命令清单。

这些机制建立后,过去一年我们再也没有出过 Redis 相关的 P0 事故,从"事故驱动治理"转变成"机制驱动预防"。这是这次 60 万损失换来的最大收益,远超修单个事故本身。

跨数据库对比:不同 NoSQL 对大 key 的容忍度

很多人会问:"既然 Redis 这么怕大 key,换 MongoDB 或 KeyDB 是不是就没事了?"答案是"换不换不重要,重要的是是否单线程"。Redis 单线程模型是核心瓶颈,KeyDB(多线程 Redis fork)能缓解但不能根治;MongoDB / Cassandra 多线程的就好很多,但大文档/大行也有其他问题(WiredTiger 缓存挤占、compaction 风暴)。这张表对比一下主流 NoSQL 对"大对象"的容忍度。

数据库 并发模型 大对象容忍度 典型问题
Redis 7.0 单线程命令 极差 大 key 阻塞
KeyDB 多线程 较好 仍有锁竞争
Dragonfly shard-per-thread 新, 生态待完善
MongoDB 7.0 多线程 + WiredTiger 大文档撑爆 cache
Cassandra 4.x 多线程 很好 大 partition 影响 compaction
ScyllaDB shard-per-core 极好 大行影响单 core

没有银弹,关键是理解每种数据库的并发模型和瓶颈点。我们选 Redis 是因为它的内存性能和工具生态无可替代,代价是要专门治理大 key;选 MongoDB 就要关心大文档和索引内存;选 Cassandra 就要管 partition 大小。理解这些 trade-off 才能用好。

给所有 Redis 用户的建议

最后给所有正在用 Redis 的团队几条建议。第一,不要相信"Redis 很快所以不用关心慢命令",单线程模型让任何慢命令都成为雪崩源,平均快 ≠ 不会卡。第二,大 key 不是"会被发现的 bug",而是"会持续累积的债务",需要主动扫描和治理,不是"等出事再修"。第三,客户端层的防护比服务端更可靠,因为客户端能"提前拦截",服务端只能"被动承受"。第四,Sentinel 配置不是越激进越好,过短的 down-after 会让"慢"被误判成"死",反而引发雪崩。

Redis 内存碎片和 jemalloc 调优

事故复盘期间我们还顺便深入看了一下 Redis 的内存使用情况,发现一个之前没注意的指标:mem_fragmentation_ratio(内存碎片率)。这个指标是 used_memory_rss / used_memory,简单说就是"操作系统给 Redis 的内存"和"Redis 自己认为用了多少"的比值。理想值是 1.0-1.5,我们集群事故前是 2.3,意味着差不多有一半的物理内存被碎片浪费了。

# 查看内存碎片情况
$ redis-cli -h 10.1.2.3 INFO memory | grep -E "fragment|used_memory_rss|used_memory_human"
used_memory:16234567890        # Redis 自己算的, ~15.1GB
used_memory_human:15.12G
used_memory_rss:37456789012    # 操作系统看到的 RSS, ~34.9GB
used_memory_rss_human:34.88G
mem_fragmentation_ratio:2.31   # 比值, 2.3 算高
mem_fragmentation_bytes:21222221122  # 碎片占用约 20GB
allocator_frag_ratio:1.18      # jemalloc 内部碎片 (相对正常)
allocator_rss_ratio:1.95       # jemalloc -> RSS 的开销 (偏高)
rss_overhead_ratio:1.00        # RSS 之外的开销

# 主动整理碎片 (Redis 4.0+)
$ redis-cli -h 10.1.2.3 CONFIG SET activedefrag yes
$ redis-cli -h 10.1.2.3 CONFIG SET active-defrag-threshold-lower 10
$ redis-cli -h 10.1.2.3 CONFIG SET active-defrag-threshold-upper 100
$ redis-cli -h 10.1.2.3 CONFIG SET active-defrag-cycle-min 1
$ redis-cli -h 10.1.2.3 CONFIG SET active-defrag-cycle-max 25

开启 activedefrag 后,Redis 会在 CPU 空闲时做内存压缩,我们的集群跑了一周后碎片率从 2.31 降到了 1.18,RSS 从 35GB 降到 18GB,直接释放了 17GB 物理内存,相当于免费扩容了一倍。activedefrag 是 Redis 里最被低估的优化,几乎所有线上集群都该开启,几乎没有副作用(只在 CPU 空闲时跑)。

Redis Cluster 模式下的大 key 额外坑

这次事故是 Sentinel 主从架构,但我们另外一个 Redis Cluster 集群也踩过类似的坑,而且 Cluster 模式下大 key 还有额外的迁移问题。Redis Cluster 用 16384 个 slot 分片,扩容时需要把 slot 从老节点迁移到新节点,而大 key 的 slot 迁移会阻塞主线程。一个 800MB 的 hash slot 迁移可能阻塞 4-8 秒,期间整个 cluster 的这个 slot 不可用。

# Cluster 模式下 slot 迁移命令
$ redis-cli --cluster reshard 10.1.2.3:6379 \
    --cluster-from  \
    --cluster-to  \
    --cluster-slots 100 \
    --cluster-yes

# 迁移过程中观察日志
# 正常 slot 几十毫秒完成
# 大 key 所在的 slot 会卡住 4-8 秒, 日志显示:
# Moving slot 12345 from 10.1.2.3:6379 to 10.1.2.7:6379:
#   Migrating slot... (4234 ms)
#   Migrating slot... (4256 ms)  # 同一个 key 反复重试

# Redis 7.0 引入了渐进式 migrate (cluster.migrate-batch-size)
# 配合 cluster-allow-reads-when-down yes 让迁移期间允许只读
$ redis-cli CONFIG SET cluster-migrate-batch-size 100

所以在 Cluster 模式下,大 key 不仅会阻塞日常请求,还会让扩容/缩容变成高风险操作。我们的另一个集群因为这个原因,一次扩容操作做了 6 小时(本来预计 1 小时),期间 P99 延迟波动很大,业务方非常不满。事故后我们对所有 Cluster 集群都做了全量 RDB 扫描,把所有 >50MB 的 key 在扩容前先拆分,确保扩容平稳。

用 Lua 脚本注意原子性陷阱

说到主线程阻塞,还有一个坑必须提:Lua 脚本。Redis 的 EVAL 在主线程执行 Lua,期间所有命令都要等。这个本来是好事(原子性),但如果 Lua 里写了循环、调用了 O(N) 命令,就会变成"自助式阻塞"。我们也踩过这个坑,一段"原子地清理过期标签"的 Lua 脚本,在大 hash 上跑了 2 秒,差点又触发主从切换。

-- 危险的 Lua 脚本: 在主线程里循环 O(N)
-- KEYS[1] = user:tags:xxx, ARGV[1] = 过期时间戳
local fields = redis.call('HKEYS', KEYS[1])  -- O(N), 27 万字段
for i, field in ipairs(fields) do
    local val = redis.call('HGET', KEYS[1], field)
    local data = cjson.decode(val)
    if data.expire < tonumber(ARGV[1]) then
        redis.call('HDEL', KEYS[1], field)
    end
end
return #fields
-- 这段 Lua 跑下来阻塞主线程 2-3 秒, 又是定时炸弹

-- 安全做法 1: 客户端循环 + 多次 EVAL 短 Lua
-- 安全做法 2: 用 lua-time-limit 强制超时 (默认 5 秒, 调到 200ms 更安全)
$ redis-cli CONFIG SET lua-time-limit 200
-- 超时的脚本会被 Redis 标记, SCRIPT KILL 可以中止

Lua 脚本的核心建议是"每个 EVAL 的执行时间必须 < 50ms"。复杂逻辑要拆成多次 EVAL,中间允许其他请求穿插。Redis 7.0 引入的 Function(用 LOAD 注册函数)也是类似限制,任何在主线程跑的代码都要快。

持久化策略对主线程的影响

另一个被严重低估的细节是持久化(RDB/AOF)对主线程的影响。RDB 用 fork 实现,fork 本身在主线程执行,大内存的 Redis fork 会阻塞主线程 100ms-1s。AOF 默认 everysec 刷盘,但如果磁盘 IO 慢,主线程 fsync 也会阻塞。我们事故复盘时发现集群配置了 RDB 每 5 分钟一次,fork 一次阻塞 300ms,虽然没到事故级,但常态化的 300ms 抖动也不健康。

# 查看 fork 阻塞情况
$ redis-cli INFO stats | grep -E "latest_fork|total_forks"
latest_fork_usec:298423   # 最近一次 fork 耗时 (微秒), 298ms
total_forks:1234

# 优化方案:
# 1. 增加 RDB 间隔, 减少 fork 频率
$ redis-cli CONFIG SET save "1800 1"  # 30 分钟 1 个写就保存
# 原来 "900 1 300 10 60 10000" 太激进

# 2. 关闭主节点 RDB, 持久化交给 slave 做
# 主节点 redis.conf:
#   save ""           # 禁用 RDB
# slave 节点 redis.conf:
#   save 1800 1       # slave 做持久化
# slave-read-only no  # slave 可写, 用于做持久化时不影响主

# 3. 用 BGSAVE 触发, 避开业务高峰
# crontab: 凌晨 3 点 BGSAVE
$ 0 3 * * * redis-cli BGSAVE

# 4. 监控 fork 阻塞, 超 500ms 告警
# Prometheus 配置:
#   redis_latest_fork_usec > 500000 -> alert

我们最终采用了"主节点不持久化,slave 持久化"的方案,主节点完全没有 fork 抖动,RDB 文件由其中一个 slave 生成后传到 OSS。这种配置在国内大厂里非常常见,但需要业务方接受"主节点宕机时丢失最近 N 分钟数据"的风险——所以一定要配合 slave 高可用,而不是真的零持久化。

客户端连接池配置经验

很多人会忽略一个细节:Redis 客户端的连接池配置错了,也会引发雪崩。事故复盘期间我们顺便审计了所有服务的 Redis 客户端配置,发现一堆问题。最常见的几个错误:连接池太小(20-30 连接)导致高并发时请求排队、连接没有 idle timeout 导致久不用的连接被服务端断开后客户端不知道、没有 retry 策略导致单次失败就抛异常。这里给出我们推荐的标准配置。

# Python redis-py 标准配置
import redis
from redis.connection import ConnectionPool

pool = ConnectionPool(
    host='10.1.2.3', port=6379,
    max_connections=200,           # 每个进程 200 连接, 高并发足够
    socket_timeout=2.0,            # 读写超时 2 秒
    socket_connect_timeout=1.0,    # 建连超时 1 秒
    socket_keepalive=True,         # 开启 TCP keepalive
    socket_keepalive_options={
        'TCP_KEEPIDLE': 60,         # 60 秒空闲后开始 probe
        'TCP_KEEPINTVL': 10,        # 每 10 秒 probe 一次
        'TCP_KEEPCNT': 3,           # 3 次失败判断为死
    },
    retry_on_timeout=True,          # 超时自动重试一次
    retry_on_error=[redis.ConnectionError, redis.TimeoutError],
    health_check_interval=30,       # 30 秒 PING 一次保持连接活
)
r = redis.Redis(connection_pool=pool)

# Java Jedis 标准配置
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(200);
config.setMaxIdle(50);
config.setMinIdle(20);
config.setMaxWaitMillis(2000);
config.setTestOnBorrow(false);    // 借出时不 PING (性能)
config.setTestWhileIdle(true);    // 空闲时定期 PING
config.setTimeBetweenEvictionRunsMillis(30000);
config.setMinEvictableIdleTimeMillis(60000);
JedisPool pool = new JedisPool(config, "10.1.2.3", 6379, 2000);

这套配置背后的关键原则是"主动探测、积极重试、合理超时"。主动探测让客户端尽早发现死连接,积极重试避免单次抖动直接抛业务异常,合理超时让"慢"不会拖死整个调用链。我们推广这套配置后,Redis 相关的业务异常下降了 80%,几乎所有"偶发性 Redis 报错"都消失了。

给老项目治理 Redis 的步骤

如果你手上有一个已经在生产跑的 Redis 集群,担心也有大 key 或类似隐患,建议按下面这个节奏推进治理。第一周:做盘点,在 slave 上跑一次 redis-rdb-tools 全量分析,得到 top 100 大 key 清单,标注每个 key 的业务方和增长趋势。第二周:做防护,先把 SafeRedis wrapper 推上去,把 KEYS/HGETALL 这些命令在客户端层禁掉,避免再有新的事故。第三-六周:做改造,按 key 的危险等级排序,逐个治理:能改 HSCAN 的改、必须拆分的拆。第七周:做防御深度,Sentinel 调参、监控告警阈值优化、CI 加大 key 检测。

整个治理过程不要追求"一次到位",分阶段稳步推进。每完成一个阶段都做总结回顾,看是否还有遗漏。我们的实战经验是 7 周完成主线治理,后续 3 个月做尾巴上的细节,总共半年彻底解决 Redis 治理问题。这个时间投入完全值得,因为之后再也不用半夜起来处理 Redis 事故。

Redis 6.0 IO 多线程的真相

事故复盘的两周里,有同事问我:既然单线程是瓶颈,Redis 6.0 不是引入了多线程吗,升级到 6.0 是不是就没事了?我专门花了三天时间研究 Redis 6.0 的 IO 多线程模型,得出的结论是:它解决的不是命令执行的瓶颈,而是网络 IO 的瓶颈。

Redis 6.0 引入的多线程只用于处理网络读写(socket read/write)和协议解析,实际的命令执行依然是单线程完成的。也就是说,即使开启了 IO 多线程,一个 HGETALL 800MB 大 key 的命令依然会阻塞主线程,IO 线程帮不上忙。这点很多人都搞错了,以为升级 6.0 就能解决所有 Redis 卡顿问题。

# Redis 6.0 IO 多线程配置(redis.conf)
io-threads 4              # 默认 1,改成 4 启用多线程 IO
io-threads-do-reads yes   # 默认 no,开启后读操作也用多线程

实测下来,IO 多线程对小命令高并发场景(几十万 QPS 的 GET/SET)有显著提升,延迟可以降低 30% 到 50%;但对大 key 操作完全没有效果,该阻塞还是阻塞。所以升级 6.0 不能替代大 key 治理,这两件事是正交的。我们最终升级了 Redis 7.2,但同时坚持执行大 key 拆分,这才是治本的方案。

持久化 fsync 行为对延迟的影响

事故复盘后的第四个月,我们又遇到一次诡异的延迟尖刺:每 30 秒一次的 P99 延迟从 0.5ms 飙到 80ms,持续 200ms 然后恢复。一开始怀疑是网络抖动、GC、慢命令,排查了一周毫无头绪。最后发现是 AOF 持久化的 fsync 策略导致的。

Redis 的 AOF 有三种 fsync 策略:always(每次写都 fsync,最安全但最慢)、everysec(每秒一次,默认)、no(由操作系统决定)。我们用的是 everysec,看似没问题,但每秒一次的 fsync 在磁盘繁忙时会阻塞主线程几十到几百毫秒,具体取决于磁盘 IO 负载和文件系统。

# 查看 fsync 阻塞情况
redis-cli info persistence | grep -E 'aof_delayed_fsync|aof_last_write_status'

# aof_delayed_fsync 表示延迟的 fsync 次数,这个数字持续增长就说明磁盘成瓶颈了
# 解决方案:1) 把 AOF 放到独立的 SSD;2) 调整为 no-appendfsync-on-rewrite yes 避免 rewrite 时 fsync 抢 IO

我们最后的策略是:主节点关闭 AOF 和 RDB,只在 slave 节点开启持久化,并且把 slave 的数据盘换成本地 NVMe SSD(不用云盘)。这样主节点完全没有 fsync 阻塞,slave 持久化时即使阻塞也不影响线上读写。这套架构跑了一年多,P99 延迟稳定在 0.8ms 以下,再也没出现过周期性尖刺。

总结

这次 78 分钟的 Redis 雪崩事故的核心教训是:"单线程 + 大 key + 短超时 = 集群级雪崩"。三个因素都不是问题,但叠加就成了灾难。我们这次幸运地在 18 分钟内定位到了根因(SLOWLOG 居功至伟),但 60 万的损失还是无法挽回。事故后的治理工作做了三个月,从 HSCAN 改造到 SafeRedis wrapper 到 9 条治理纪律,从此 Redis 集群再也没有出过类似事故。

给所有 SRE 和后端工程师一个建议:不要把 Redis 当成"小快灵的内存 KV",它是一个有自己复杂度模型的数据库,需要专业的容量规划、key 治理、参数调优、客户端防护。今天就检查一下你的 Redis 集群:有没有大 key、有没有慢命令、Sentinel 配置合理吗、客户端有没有 SafeRedis 这种防护层。提前一天的检查,可能就避免了一次 60 万的事故。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Java ThreadLocal 不 remove 引发的数据串号事故复盘:5 小时定位 + 5 种修法 + 8 条上下文纪律

2026-5-25 18:09:24

技术教程

TCP TIME_WAIT 占满端口导致支付网关全面失败的复盘:90 分钟故障 + 5 种修法 + 8 条网络编程纪律

2026-5-25 18:26:18

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索