Redis 大 key 与热 key 完全指南:从一次"一条 DEL 卡死整个实例,集群扩容也救不了"看懂单线程阻塞

2020 年我接手一个电商系统的 Redis 缓存层商品详情用户信息各种排行榜全压在 Redis 上扛怎么用好它这件事我压根没多想第一版我用得很顺手要缓存什么就 SET 进去要读就 GET 出来要存一组数据就用 Hash 要存列表就用 List 就完事了本地测试环境一跑真不错所有命令都是微秒级返回我心里很笃定Redis 不就是个内存数据库所有操作都是 O1 的瞬间完成 key 存多大哪个 key 多热门有什么关系可等这套缓存真正上线扛起生产的真实流量一串问题冒了出来第一种最先把我打懵某个接口偶发性超时整体的 P99 延迟周期性地往上尖刺第二种最难缠我清理一个不再用的 key 一条 DEL 命令下去整个 Redis 卡了好几秒第三种最头疼集群加了节点扩容可有一个节点的 CPU 始终是别的节点的好几倍第四种最莫名其妙每到主从同步做 RDB 持久化的时候 Redis 就周期性地卡顿一下我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 Redis 所有操作都是 O1 的瞬间完成 key 多大多热都无所谓可它根本不是 Redis 的命令处理是单线程的只要有一条命令执行得慢排在它后面的所有命令都得干等着而且不是所有命令都是 O1 的一个大 key 的读它删它序列化它的操作复杂度是 ON 要花和 key 大小成正比的时间几十甚至几百毫秒一个大 key 的 ON 操作会独占那条唯一的线程几百毫秒成千上万个本该微秒级返回的普通请求全部被堵在队列里而热 key 是另一个维度的事 Redis 集群是按 key 的哈希值把数据分片到不同节点的一个被超高频访问的 key 它的所有流量永远只落在它所在的那一个节点上加再多节点也分摊不掉真正把 Redis 用扎实核心不是把数据塞进去就行而是认清命令处理单线程一个慢操作会阻塞所有请求认清大 key 的操作是 ON 不是 O1 学会发现大 key 并安全地删除它认清热 key 是集群扩容也救不了的单点要靠多级缓存和 key 拆分来化解并把慢日志大 key 热 key 这些信号接进监控本文从头梳理为什么单线程下一个慢操作会拖垮所有请求大 key 的操作为什么不是 O1 怎么发现和安全删除大 key 热 key 为什么集群扩容也救不了热 key 该怎么应对以及一些把 Redis 用扎实要避开的工程坑

2020 年我接手一个电商系统的 Redis 缓存层:商品详情、用户信息、各种排行榜,全压在 Redis 上扛。怎么用好它?这件事我压根没多想。第一版我用得很顺手:Redis 嘛,内存数据库,快得飞起,要缓存什么就 SET 进去、要读就 GET 出来,要存一组数据就用 Hash、要存列表就用 List就完事了。本地、测试环境一跑——真不错:所有命令都是微秒级返回,延迟低到可以忽略。我心里很笃定:"Redis 不就是个内存数据库?所有操作都是 O(1) 的、瞬间完成,key 存多大、哪个 key 多热门,有什么关系?"可等这套缓存真正上线、扛起生产的真实流量,一串问题冒了出来。第一种最先把我打懵:某个接口偶发性超时,我去看 Redis 监控,绝大多数命令明明都很快,可整体的 P99 延迟周期性地往上尖刺。第二种最难缠:我清理一个不再用的 key,一条 DEL 命令下去,整个 Redis 卡了好几秒,所有业务接口跟着一起抖。第三种最头疼:Redis 集群加了节点扩容,可有一个节点的 CPU 始终是别的节点的好几倍,扩容根本没把压力分摊到它头上。第四种最莫名其妙:每到主从同步、做 RDB 持久化的时候,Redis 就周期性地卡顿一下。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Redis 所有操作都是 O(1) 的、瞬间完成,key 多大、多热都无所谓"。这句话把 Redis 当成了一个性能无限、对怎么用它毫不挑剔的黑盒。可它根本不是我脑子里,Redis 是一个性能无限的内存黑盒:我往里塞什么、塞多大、某个 key 被读得多疯,它都能瞬间应付,因为它是内存操作、是 O(1)。可这个想法,漏掉了 Redis 两个最关键的事实。第一个事实:Redis 的命令处理是单线程的。它快,是因为没有锁竞争、没有线程切换,所有命令在一条线程上排着队、一个接一个执行。但这也意味着——只要有一条命令执行得慢,排在它后面的所有命令,无论本身多快,都得干等着。整个 Redis 的吞吐,卡在那条最慢的命令上。第二个事实:不是所有命令都是 O(1) 的。对一个普通的小 key,GET/SET 确实是 O(1)、是微秒级。但如果一个 key 本身很大——一个装了几十万个字段的 Hash、一个几 MB 的 String、一个百万元素的 Set——那么读它、删它、序列化它的操作,复杂度是 O(N),N 就是它的大小;这种操作要花和 key 大小成正比的时间,几十甚至几百毫秒。把这两个事实合起来,后果就很可怕了:一个大 key 的 O(N) 操作,会独占那条唯一的线程几百毫秒,在这几百毫秒里,成千上万个本该微秒级返回的普通请求,全部被堵在队列里——这就是 P99 尖刺、就是一条 DEL 卡死整个实例。而热 key 是另一个维度的事:Redis 集群是按 key 的哈希值把数据分片到不同节点的,一个被超高频访问的 key,它的所有流量永远只落在它所在的那一个节点上,你给集群加再多节点,也分摊不掉这一个 key 的热度。所以 Redis 根本不是个对你怎么用它毫不挑剔的黑盒,它对 key 的大小、对 key 的访问分布,极其敏感。真正把 Redis 用扎实,核心不是"把数据塞进去就行",而是认清 Redis 命令处理是单线程的、一个慢操作会阻塞所有请求,认清大 key 的操作是 O(N) 不是 O(1),学会发现大 key 并安全地删除它,认清热 key 是集群扩容也救不了的单点、要靠多级缓存和 key 拆分来化解,并把慢日志、大 key、热 key 这些信号接进监控。这篇文章就把 Redis 大 key 与热 key 这个坑梳理一遍:为什么单线程下一个慢操作会拖垮所有请求、大 key 的操作为什么不是 O(1)、怎么发现和安全删除大 key、热 key 为什么集群扩容也救不了、热 key 该怎么应对,以及一些把 Redis 用扎实要避开的工程坑。

问题背景

这个坑普遍,是因为 Redis 的"快"名声太大了——快到让人潜意识里觉得它性能无限、怎么用都行,从不去想"用得对不对"。它错得隐蔽,是因为大 key 和热 key 都是慢慢长出来的:一个 Hash 上线时只有几十个字段,跑了几个月才膨胀到几十万个;一个商品 key 上线时无人问津,直到它成了爆款才变热。开发测试时它们都还小、都还不热,你看不出任何问题。它们只在生产跑了一段时间、数据和流量真正积累起来之后才暴露——大 key 的 O(N) 操作开始周期性卡死实例,热 key 把单个分片打满,而你还以为"Redis 这么快,能有什么事"。

把这个现象拆开,错误认知和真相是这样对应的:

  • 现象:P99 延迟周期性尖刺;一条 DEL 卡死整个实例;集群扩容后某节点 CPU 仍居高不下;持久化时周期卡顿。
  • 错误认知一:以为 Redis 所有操作都是 O(1)、瞬间完成。真相是命令处理单线程,一个慢操作会阻塞它后面的所有请求。
  • 错误认知二:以为 key 多大都无所谓。真相是大 key 的读、删、序列化都是 O(N),会独占线程几百毫秒。
  • 错误认知三:以为集群扩容能解决一切热点。真相是热 key 的流量永远落在一个分片上,加节点也分摊不掉。
  • 真相:Redis 对 key 的大小和访问分布极其敏感,要主动发现并治理大 key、用多级缓存和 key 拆分化解热 key。

一、为什么单线程下一个慢操作会拖垮所有请求

先看清 Redis 那条致命的性质:命令处理是单线程的。所有客户端发来的命令,不管来自多少个连接,最终都汇进同一条线程,排成一个队列,一个接一个地执行。这正是 Redis 快的原因——没有锁、没有线程上下文切换。但代价也藏在这里。先看第一版怎么不知不觉养出一个大 key 的:

# 第一版:不知不觉养出一个大 key(反面教材)
import redis
r = redis.Redis()

# 用一个 Hash 存"某个商品的所有评论",看起来很合理
def add_comment(product_id, comment_id, comment_data):
    # 但如果这个商品是爆款,评论几十万条,
    # 这个 Hash 就会膨胀成一个装了几十万字段的巨型 key
    r.hset(f"comments:{product_id}", comment_id, comment_data)

# 读评论时,有人图省事一把全捞出来 —— 这就是灾难的开始
def get_all_comments(product_id):
    # HGETALL 一个几十万字段的 Hash:这是个 O(N) 操作,
    # 要在单线程上跑几百毫秒,期间整个 Redis 被它独占
    return r.hgetall(f"comments:{product_id}")

这段代码没有任何语法错误,数据结构选得也"看起来合理"。它的问题是埋着的:comments:{product_id} 这个 Hash 会随着评论增长而无限膨胀,而 HGETALL 把整个 Hash 一次性捞出来,是个 O(N) 操作。在单线程模型下,这个 O(N) 操作执行的那几百毫秒里,Redis 这条唯一的线程被它彻底占住,排在它后面的所有命令——哪怕是个最简单的 GET——全都得等。这就是 P99 周期性尖刺的真相。把这个阻塞过程画出来:

[mermaid]
flowchart TD
A[多个客户端请求同时到达] --> B[Redis 单线程命令队列]
B --> C{当前在执行的命令是哪种}
C -->|普通命令 O(1)| D[微秒级完成 立刻处理下一个]
C -->|大 key 操作 O(N)| E[独占线程几十到几百毫秒]
D --> F[整体延迟平稳]
E --> G[排在后面的所有请求全部阻塞干等]
G --> H[P99 延迟尖刺 接口偶发超时]

这里要建立的第一个、也是最重要的认知是:在任何一个"串行处理、共享同一个执行资源"的系统里,整体的性能表现,不是由"大多数任务有多快"决定的,而是由"最慢的那个任务有多慢"决定的。Redis 的单线程,就是一个最纯粹的"串行共享资源":所有命令共用一条线程,一个接一个跑。在这种结构下,你不能再用"平均"的眼光看性能——平均延迟是个骗人的指标,它会告诉你"99.9% 的命令都是微秒级,Redis 健康得很",而把那 0.1% 的、足以阻塞一切的大 key 操作,稀释得无影无踪。真正能反映这种系统健康度的,是最坏情况:那个最慢的操作有多慢,它就能把整条线程堵多久,就能让多少无辜的请求陪绑。这个认知能迁移到大量场景:一个单线程的事件循环(比如 Node.js),一个同步阻塞的函数若执行很慢,会卡死整个循环;一条流水线,产能由最慢的那道工序决定;一个 Web 请求里串行调用了十个服务,总延迟由最慢的那个服务决定。它们的共性是:只要是串行、共享、一个堵住后面全等,你的注意力就必须从"平均值""大多数"挪开,死死盯住"最慢的那个"。所以面对这类系统,优化的第一要务不是去让快的更快(它们已经够快了,优化它们对整体毫无意义),而是去找出并消灭那个最慢的操作——因为在串行共享的世界里,系统的上限,就是它最短的那块板。

二、大 key:它的操作根本不是 O(1)

顺着上一节,得把"大 key"这个概念彻底说清。所谓大 key,不是说这个 key 的名字长,而是说这个 key 所承载的值很大。它有两种形态:一种是单个 value 本身就很大,比如一个存了几 MB 内容的 String;另一种更常见——一个集合类型的 key(Hash、List、Set、ZSet)里装了海量的元素,比如一个几十万字段的 Hash。为什么大 key 危险?因为针对它的很多操作,复杂度是 O(N):

为什么大 key 的操作是 O(N),不是 O(1):

  小 key(一个普通 String,几十字节):
     GET / SET        O(1)    微秒级,瞬间完成

  大 key(一个 50 万字段的 Hash):
     HGETALL          O(N)    要遍历 50 万个字段,几百毫秒
     DEL              O(N)    要逐个释放 50 万个字段的内存
     HKEYS / HVALS    O(N)    同样要全量遍历

  关键:O(N) 里的 N,就是这个 key 的元素个数。
       N 是几十,无所谓;N 是几十万,这个操作就要几百毫秒。
       而这几百毫秒,单线程的 Redis 什么别的事都干不了。

  额外伤害:大 key 还会拖慢持久化(RDB/AOF)、拖慢主从同步、
           在集群里造成数据倾斜(它所在的分片内存远大于别的分片)

所以"一条 DEL 卡死整个 Redis"就有了解释:DEL 一个大 key,Redis 要在单线程里同步地、一个一个地回收这个 key 里几十万个元素的内存,这个回收过程就是 O(N),期间整个实例被冻住。同理,持久化时要把大 key 完整地序列化一遍、主从同步时要把它完整传一遍,都会因为它的体量而引发周期性卡顿。大 key 的根本问题,是它把一个本该 O(1) 的操作,偷偷变成了 O(N)。

这里要建立的认知是:大 key 这个坑,要教给你一个特别容易被忽视的真相——一段代码的复杂度,从来不是只由"你写的操作"决定的,它同样由"你操作的数据规模"决定;而数据规模,是会随着时间偷偷长大的。我第一版写下 HGETALL 时,我以为我写的是一个 O(1) 的、廉价的读操作——因为我脑子里那个 Hash,是上线时只有几十个字段的样子。我犯的错,是把"代码当下的复杂度"刻成了"代码永远的复杂度"。可代码是静止的,数据是生长的:同一行 HGETALL,作用在 50 个字段上是廉价的 O(1) 体感,作用在 50 万个字段上就是致命的 O(N)。代码一个字没改,它的真实代价却随着数据的膨胀,悄悄翻了上万倍。这件事的本质是:任何一个"复杂度和数据规模相关"的操作,你都不能只看它今天的表现就给它判"安全",你必须问一句——它操作的那个数据,有没有一个增长的趋势?它会不会长到一个让这个操作变得危险的规模?这个意识能帮你避开无数同类的雷:一个 `for` 循环今天遍历 100 个元素很快,它遍历的那个列表三年后会有多少个?一条不带 `LIMIT` 的 SQL 今天返回 10 行,那张表长到千万行时它返回多少?一个全量加载到内存的配置今天 1MB,它有没有可能涨到 1GB?所以写下任何一个"代价随规模增长"的操作时,都要养成一个习惯:不只看它此刻的数据规模,更要预判它的增长曲线,问一句"当这个数据涨大 100 倍、10000 倍时,这行代码还安全吗?"。把"数据会生长"这件事纳入你对代码复杂度的判断里,你才不会写出那种"上线时一切美好、半年后突然爆炸"的定时炸弹。

三、怎么发现大 key、怎么安全地删除大 key

认清了大 key 的危害,接下来是两个实操问题:怎么发现它们,以及发现之后怎么安全地删除它们。先说发现——Redis 自带工具能扫出大 key:

# 发现大 key:几个常用手段

# 1. redis-cli 自带的 --bigkeys:扫一遍,报告每种类型最大的 key
#    它用 SCAN 渐进式扫描,不会阻塞实例,可以在线上跑
redis-cli --bigkeys

# 2. 查单个 key 到底占多少内存(字节)
redis-cli MEMORY USAGE comments:10086

# 3. 查一个集合类型 key 的元素个数 —— 元素数是大 key 的核心指标
redis-cli HLEN comments:10086      # Hash 的字段数
redis-cli LLEN some_list           # List 的长度
redis-cli SCARD some_set           # Set 的元素数

# 经验阈值(可按业务调):
#   String 的 value 超过 10KB,或集合类型元素数超过 5000,
#   就该警惕;超过几万,基本就是必须治理的大 key

发现之后,删除大 key 千万不能直接 DEL——前面说过,DEL 一个大 key 是同步 O(N),会卡死实例。正确的做法有两条。一是用 UNLINK 代替 DEL:它把内存回收的活儿交给后台线程异步做,主线程立刻返回。二是对集合类型的大 key,用 SCAN 系列命令分批、渐进式地删:

# 安全删除大 key:分批渐进式删除,绝不一把 DEL

import redis
r = redis.Redis()

def safe_delete_big_hash(key, batch=500):
    cursor = 0
    while True:
        # HSCAN 每次只取一批字段(比如 500 个),不会阻塞实例
        cursor, fields = r.hscan(key, cursor, count=batch)
        if fields:
            # 分批把这些字段删掉,每批是个小操作
            r.hdel(key, *fields.keys())
        if cursor == 0:        # cursor 回到 0,说明扫完了
            break
    # 此时 Hash 已空,最后用 UNLINK 异步回收这个空壳
    r.unlink(key)

# 对一个超大的 key,直接 r.delete(key) 会卡死实例;
# 上面这样"小批量多次"地删,把一个 O(N) 的大操作,
# 拆成了 N/500 个互不阻塞的小操作 —— 这是处理大 key 的通用思路

这里要建立的认知是:安全删除大 key 的这个套路——把一个"大而慢、会长时间独占资源"的操作,拆成"许多小而快、each 都能很快让出资源"的小操作——是一个在工程里通用到不可思议的解法,它的名字叫"分批处理"或者"任务切片"。它的核心洞察是:在一个共享资源的系统里,真正有害的不是"总工作量大",而是"单次占用资源的时间长"。一个大 key 总共要删 50 万个字段,这个总工作量是删不掉的、必须做完;但"一次性删 50 万"和"分一千次、每次删 500"——总工作量完全一样,对系统的影响却天差地别:前者独占线程几百毫秒、把一切堵死,后者每次只占用线程几百微秒,在两批之间,其他请求都有机会被处理。你没有减少任何工作,你只是把工作切碎了,让它不再"霸占",而是"穿插"。这个思路你一旦掌握,会发现到处都能用:要删数据库里几百万行旧数据,别一条 DELETE 锁全表,要分批 LIMIT 删;要给一个大列表做处理,别一次性加载进内存,要分页流式处理;一个耗时的计算任务,要切成小块、在事件循环里穿插着做,别一口气跑完卡死 UI。它们的内核完全一致:面对一个无法回避的大工作量,且这个工作量会争抢一个共享资源时,不要试图"一次做完",而要把它切片,让每一片都小到能迅速做完、迅速让出资源。把"大块独占"改造成"小块穿插",是你在共享资源系统里,化解一切"大操作"的一把万能钥匙。

四、热 key:集群扩容也救不了的单点

大 key 说的是"一个 key 太大",热 key 说的是另一个维度的事——"一个 key 被访问得太频繁"。比如一个突然爆火的商品、一个全站置顶的公告,它对应的那个 key,可能承接了全站百分之几十的读流量。为什么热 key 是个棘手的问题?关键在于 Redis 集群的分片机制:

Redis 集群是怎么把数据分到不同节点的:

  整个集群有 16384 个槽位(slot)
  每个 key:  slot = CRC16(key) % 16384
  每个节点负责其中一段连续的 slot

  例:key "product:6688" 算出来 slot=9527,
      slot 9527 归节点 C 管 —— 那么对这个 key 的所有读写,
      永远、只会、落到节点 C 上。

  这就是热 key 的死结:
    一个 key 的 slot 是固定的,它的流量永远只压在一个节点上。
    如果 product:6688 是个爆款,承接了全站 30% 的读,
    那这 30% 的流量,全部砸在节点 C 一个节点头上。

    你给集群加 10 个新节点?没用。
    新节点分走的是"别的 slot",product:6688 这个 slot 还在 C 上,
    它的 30% 流量,一点都没被分摊掉。

这就解释了第三个怪现象:集群扩容后,某个节点 CPU 始终居高不下。扩容增加的是节点总数、是 slot 的分布,但它改变不了"单个 key 永远落在单个节点"这个事实。热 key 的流量是一个无法靠"加机器"来水平拆分的单点——这是它和普通容量问题最本质的区别。普通的容量不够,加机器就行;热 key 的压力,加机器无效。

这里要建立的认知是:热 key 这个问题,要教给你一个关于"扩展性"的深刻区分——不是所有的性能问题都能靠"加机器"解决,你必须分清你面对的,是一个"可水平拆分"的问题,还是一个"不可拆分的单点"问题。水平扩展(加机器)这个手段之所以强大,有一个默默的前提:你的负载,是可以被均匀地切分、分摊到多台机器上的。集群按 slot 分片、负载均衡按请求分发,都是在依赖这个前提。可热 key 恰恰打破了这个前提:它是一个"原子的、不可再分"的负载单元——对单个 key 的访问,你没法把它"切成两半"分给两台机器,因为这个 key 物理上就只能存在一个地方。一旦某个负载单元变成了不可拆分的单点,水平扩展这个武器就当场失效了。这个区分极其重要,它能帮你在面对性能问题时,第一时间判断该往哪个方向使劲:如果问题是"总量大但可拆分"(比如总 QPS 高、但分散在无数个 key 上),那么加机器、加分片就是正解;如果问题是"压力集中在一个不可拆分的点上"(热 key、单行数据库记录的热点、一个全局锁、一个单点的 ID 生成器),那么加机器是无效的,你必须换一套完全不同的思路——要么给这个单点加一层缓存把流量挡在它前面,要么想办法把这个不可拆分的单点,人为地拆分开。识别出"这是个单点问题,水平扩展救不了它",是解决它的第一步,也是最关键的一步——否则你会在"再加几台机器"的错误方向上,白白耗掉所有的力气。

五、热 key 的应对:多级缓存与 key 拆分

既然热 key 是个加机器救不了的单点,那应对它,就得用上一节末尾说的两个思路:挡流量拆单点。第一个思路——挡流量——就是在 Redis 前面再加一层本地缓存(进程内缓存)。热 key 的特点是访问极频繁但数据更新没那么频繁,正好适合在应用进程的内存里缓存一小会儿,让绝大多数读请求在本地就被消化掉,根本到不了 Redis:

# 应对热 key 之一:多级缓存,用进程内本地缓存挡住绝大部分读流量
import time

class LocalCache:
    def __init__(self, ttl=5):
        self.ttl = ttl          # 本地只缓存很短时间,比如 5 秒
        self.store = {}

    def get(self, key, loader):
        now = time.time()
        if key in self.store:
            value, expire_at = self.store[key]
            if now < expire_at:
                return value     # 命中本地缓存,根本不碰 Redis
        # 本地没有或已过期,才去 Redis 取,再回填本地
        value = loader(key)
        self.store[key] = (value, now + self.ttl)
        return value

local_cache = LocalCache(ttl=5)

def get_hot_product(product_id):
    # 一个爆款商品的读流量,99% 会被本地缓存这一层挡掉,
    # 真正打到 Redis 的,每 5 秒每个进程才一次
    return local_cache.get(f"product:{product_id}",
                           loader=lambda k: r.get(k))

第二个思路——拆单点——是把一个热 key 人为地复制成多个 key,给它们加上不同的后缀,让它们的 slot 分散到不同节点,读的时候随机挑一个,把流量摊开:

# 应对热 key 之二:key 拆分,把一个热 key 复制成 N 个,分散到不同节点
import random

HOT_KEY_COPIES = 10

def write_hot_key(key, value):
    # 写的时候,把同一份数据写进 10 个带后缀的副本 key
    for i in range(HOT_KEY_COPIES):
        r.set(f"{key}:copy{i}", value)

def read_hot_key(key):
    # 读的时候,随机挑一个副本 —— 流量被均匀打散到 10 个 key 上,
    # 这 10 个 key 的 slot 不同,会落在不同节点,单点压力被拆成 1/10
    i = random.randint(0, HOT_KEY_COPIES - 1)
    return r.get(f"{key}:copy{i}")

# 代价:写要写 10 份、有数据冗余,副本间还有短暂不一致。
# 所以 key 拆分适合"读极热、写很少、能容忍秒级不一致"的场景。

这里要建立的认知是:对比"多级缓存"和"key 拆分"这两个应对热 key 的方案,你会发现它们各有代价——本地缓存引入了"多个进程的缓存不一致、有几秒的数据延迟",key 拆分引入了"写放大、数据冗余、副本间不一致"。注意,这两个方案都没有完美地、零成本地解决问题,它们都是拿一种新的代价,换掉了那个原来的问题。这恰恰是要建立的认知:在工程里,尤其在性能优化里,你极少能找到一个"纯赚不赔"的解法;绝大多数时候,你做的事不是"消除代价",而是"把代价从一个你无法承受的地方,转移到一个你能承受的地方"。热 key 的原始代价是"单节点被打爆",这个代价你承受不起。多级缓存做的,是把这个代价转移成了"数据有几秒延迟"——如果你的业务能容忍商品信息晚几秒更新,那这笔交换就划算。key 拆分做的,是把代价转移成了"写的时候要多写几份、存储有冗余"——如果你的场景是读极多写极少,那多写的那点成本完全可以忽略,这笔交换也划算。你看,选哪个方案,根本不是选"哪个更好",而是看"哪种代价是你的业务能承受的"。这个认知会让你成为一个清醒得多的工程师:当有人给你一个方案,说它"解决了"某个问题,你要立刻追问一句——它把代价转移到哪去了?那个新的代价,落在了谁头上、是什么形式?如果一个方案宣称自己毫无代价,那往往只是因为它的代价藏在了你还没看到的地方。看清每个方案"用什么换什么",再对照你的业务"什么能舍、什么不能舍"去做选择——这才是技术选型真正在做的事。

六、工程里那些 Redis 的坑

大 key 和热 key 的主线理顺了,落地时还有几个工程坑反复咬人。第一个,线上禁用一批危险命令KEYS * 会全量遍历所有 key、是个能卡死实例的 O(N) 操作,生产环境必须用 SCAN 渐进式扫描代替;FLUSHALLFLUSHDB 这类也该在配置里禁掉或改名。第二个,给 key 设合理的过期时间。不设过期的 key 会无限堆积,最终把内存撑爆、触发淘汰甚至 OOM;同时要避免大量 key 在同一时刻集中过期,否则会引发缓存雪崩。第三个,大 key 要在源头预防。设计数据结构时就要想清楚这个集合会不会无限增长,会的话就提前做拆分(比如按时间或按 ID 段把一个大 Hash 拆成多个小 Hash)。第四个,用 pipeline 合并小命令。一次要发几百个命令,别一条条发(每条都有一个网络往返),用 pipeline 打包成一次发送。第五个,慢日志要打开并定期看——SLOWLOG 会记录下执行慢的命令,它是你发现大 key 操作、危险命令的第一现场。把这些信号都接进监控,你才有数据判断 Redis 健不健康:

Redis 上线后必须盯死的几个信号:

  SLOWLOG GET            慢命令日志,大 key 操作会在这里现形
  latency P99            命令延迟的 P99,周期性尖刺多半是大 key 在作祟
  redis-cli --bigkeys    定期扫描,及早发现正在长大的大 key
  used_memory            内存用量,逼近 maxmemory 要警惕淘汰和 OOM
  keyspace_hits/misses   缓存命中率,持续走低说明缓存策略有问题
  单节点 CPU/QPS 倾斜      集群里某节点远高于其他,是热 key 的典型信号
  expired_keys           过期 key 的速率,集中过期会引发雪崩

这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"用 Redis"乃至"用任何一个基础组件"的总体判断——会用一个组件,和用好一个组件,中间隔着的是"理解它的实现原理"。第一版的我,是"会用" Redis 的:我知道 SET/GET/HSET,我能用它存数据、取数据,功能上毫无问题。但我用不"好"它,因为我对它的内部一无所知——我不知道它是单线程的,不知道它的命令有 O(1) 和 O(N) 之分,不知道集群是按 slot 分片的,不知道 KEYS 会遍历全库。而这一节的每一个坑——禁用 KEYS、设过期、防大 key、用 pipeline、看慢日志——你会发现,它们没有一个是能从 API 文档的函数签名里看出来的,它们全都源于对 Redis "内部怎么运转"的理解:你只有知道它单线程,才理解为什么要禁 KEYS、为什么慢命令致命;你只有知道它按 slot 分片,才理解热 key 为什么扩容无效;你只有知道 pipeline 省的是网络往返,才知道什么时候该用它。这就是"会用"和"用好"的分水岭:会用,只需要读懂 API 文档,知道每个函数怎么调;用好,需要读懂这个组件的实现原理,知道它在底下到底是怎么干活的、它的性能模型长什么样、它在什么情况下会崩。这里要建立的通用认知是:任何一个你要在生产环境里认真依赖的基础组件——数据库、缓存、消息队列、网络框架——都不要满足于"我会调它的 API"。一定要再往下走一步,花时间去理解它的核心实现:它的并发模型是什么?它的数据是怎么组织和存储的?它的每个操作的代价模型是怎样的?它在什么边界条件下会退化、会失败?这一步投入,看起来比"赶紧调通 API 交差"要慢,但它正是"能把东西用对、用稳"的人,和"能把东西跑起来、然后等着它在生产爆炸"的人之间,那道真正的分界线。

关键概念速查

概念 说明 关键点
单线程模型 Redis 命令处理在一条线程上排队执行 一个慢操作会阻塞它后面的所有请求
大 key 承载的 value 很大或集合元素极多的 key 它的读删序列化是 O(N) 不是 O(1)
O(N) 操作 耗时与 key 大小成正比的命令 HGETALL DEL 在大 key 上会独占线程几百毫秒
UNLINK 异步回收内存的删除命令 删大 key 用它代替同步阻塞的 DEL
渐进式删除 用 SCAN 系列分批删除大 key 的元素 把一个大 O(N) 拆成许多不阻塞的小操作
热 key 被超高频访问的单个 key 流量永远落在一个分片 加机器救不了
集群 slot 分片 按 CRC16(key) 取模把 key 分到节点 单个 key 的 slot 固定 流量不可水平拆分
多级缓存 在 Redis 前加一层进程内本地缓存 挡住热 key 绝大部分读 代价是秒级不一致
热 key 拆分 把热 key 复制成多个带后缀的副本 分散到不同节点 代价是写放大与冗余
SLOWLOG 记录执行慢的命令的慢日志 发现大 key 操作和危险命令的第一现场

避坑清单

  1. 记住 Redis 命令处理是单线程的,一个慢操作会阻塞后面所有请求,盯住最坏延迟。
  2. 别对大 key 用 HGETALL 等全量操作,它是 O(N),会独占线程几百毫秒。
  3. 删大 key 绝不能直接 DEL,用 UNLINK 异步回收,或 SCAN 系列分批渐进式删。
  4. 定期用 --bigkeys 扫描,大 key 是慢慢长大的,要在它变危险前发现。
  5. 设计集合类型时预判增长,会无限增长的就提前按时间或 ID 段拆成多个小 key。
  6. 热 key 别指望集群扩容,它的流量永远落单分片,要用本地缓存或 key 拆分。
  7. 线上禁用 KEYS FLUSHALL 等危险命令,用 SCAN 渐进式扫描代替 KEYS。
  8. 给 key 设过期时间并打散过期点,不设会撑爆内存,集中过期会引发雪崩。
  9. 批量命令用 pipeline 打包,别一条条发,每条单发都有一次网络往返开销。
  10. 打开 SLOWLOG 并把 P99、节点倾斜接进监控,主动发现大 key 和热 key。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为 Redis 是个性能无限、怎么用都行的内存黑盒,所有操作都是 O(1)、瞬间完成。可 Redis 有两个我从没正视的事实——它的命令处理是单线程的,一个慢操作会堵死它后面的一切;它的命令不全是 O(1),一个大 key 的读、删、序列化都是 O(N)。这两个事实合起来,一个大 key 就能独占线程几百毫秒、把成千上万个请求陪绑;而热 key 又是另一回事,它的流量集中在一个分片上,加机器也分摊不掉。

真正把 Redis 用扎实,工作量不在"把数据塞进去",而在一次认知的转变:承认 Redis 对 key 的大小和访问分布极其敏感,它不是一个对你毫不挑剔的黑盒。一旦接受这一点,该做的事就都浮现出来了——主动扫描和治理大 key,删大 key 用 UNLINK 或分批渐进式删,设计数据结构时就预防它无限膨胀,热 key 用多级缓存挡流量、用 key 拆分拆单点,把慢日志和节点倾斜接进监控。每一步都不复杂,难的是先承认:你手里的不是一个性能无限的魔法盒子,而是一个有明确性能模型、有脾气、需要你顺着它的脾气来用的工具。

我后来常拿超市的收银台来想这件事。单线程的 Redis,就是一个手速快得惊人、但只有一个的收银员。绝大多数顾客只买一两件东西,几秒钟就结完账走人,队伍流畅得很。可只要队伍里来了一个推着满满一整车货的顾客——他一个人的结账要花十分钟,而这十分钟里,他后面排队的所有人,哪怕只买一瓶水,也只能干等着。这个推满车的顾客,就是大 key;那条卡死所有人的 DEL,就是让这个顾客一次性结账。而正确的做法,是请他把一车货分成许多小批、和别的顾客穿插着结——这就是渐进式删除。至于热 key,它是另一种景象:整个商场所有人,不约而同地都涌向同一个收银台。这时候你新开多少个别的收银台都没用,因为人群就是认准了那一个。你能做的,要么在那个收银台前面摆个自助查询机,让大部分人不用排队就把事办了(本地缓存);要么把那个最抢手的服务,在十个收银台都复制一份,把人群分流开(key 拆分)。

这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你测试时,那个 Hash 还只有几十个字段,HGETALL 它依然是微秒级;那个商品还无人问津,根本谈不上热。大 key 和热 key 都是在生产环境里、随着数据和流量的日积月累,才慢慢长出来的——它上线那天是个乖巧的小 key,半年后才膨胀成一颗炸弹。而 Redis 的延迟尖刺、节点倾斜这些征兆,又不会在功能测试里喊疼。所以别等接口开始周期性超时、等一条 DEL 卡死整个实例,才想起去看 key 的大小和热度:设计每一个 Redis 数据结构的那一刻,就该问自己两个问题——这个 key 会不会无限长大?这个 key 会不会被某个热点疯狂访问?把这两个问题在设计阶段就想清楚,把大 key 扫描和热点监控在第一天就配上,你才算真正跳出了那个把 Redis 当成性能无限的黑盒、出了事还在盲目加机器的坑。

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

RAG 混合检索完全指南:从一次"搜精确型号死活搜不到,纯向量检索却自以为很先进"看懂 BM25 与 RRF 融合

2026-5-22 19:01:52

技术教程

AI Agent 记忆系统完全指南:从一次"聊到几十轮 context 直接爆掉,早说过的话它全忘了"看懂记忆分层

2026-5-22 19:15:22

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