只读请求为什么也会触发 Redis Key 淘汰
从一次本地缓存重建异常出发,拆解 Pipeline、客户端输出缓冲区、maxmemory 与淘汰策略之间的连锁反应。
Redis 里的 Key 突然少了,很多人的第一反应是 TTL 写错、同步任务漏数据,或者某段代码执行了删除。还有一种更绕的情况:数据写入完全正常,过期时间也正确,一批看起来只读的请求却让 Redis 开始淘汰 Key。
读请求不会直接修改 Value,但 Redis 为返回数据分配的客户端输出缓冲区同样占内存。大批量 Pipeline 与并发缓存重建叠加时,回复产生速度可能超过客户端读取速度,缓冲区持续增长。实例接近 maxmemory 后,配置的淘汰策略便可能删除数据集中的 Key。
本文用一个合成场景复盘完整的排查路径。示例里的数据量、实例数、Key 和时间均为演示值,不对应任何真实业务或生产环境。
一、问题从本地缓存少了数据开始
假设一个 API 服务需要在本地维护一份用户集合。离线任务每天生成最新快照,同步程序把数据拆进数百个 Redis Set,再通知所有 API 实例重建本地缓存。
流程大致如下:
离线数据源
↓
同步任务写入 Redis Set
↓
更新快照版本
↓
多个 API 实例收到通知
↓
批量读取全部 Set,重建本地缓存
为了防止当天快照尚未生成,读取端会同时加载今天和昨天的数据,最后取并集。这个设计本身合理:新快照失败时,旧快照还能兜底。
一次发布后的第二天,监控显示本地缓存元素量从预期值突然降到三分之一。重建没有整体失败,接口仍能工作,只是缓存内容明显不完整。
这类问题麻烦在于症状出现在链路末端。缓存变小可能来自上游少产数据、同步任务漏写、Redis 丢 Key、读取失败,或者本地合并逻辑错误。若从代码末端开始逐行看,很容易耗掉几个小时。
二、按证据逐层排除
1. 数据源有没有少
先核对离线任务的输出记录数、去重后的主键数和分区时间。只看任务状态“成功”不够,成功可能意味着程序正常退出,不代表产物数量正确。
假设数据源数量和前几天一致,抽样内容也正常,那么问题位于同步或读取链路。
2. 写入 Redis 的命令有没有完整执行
同步任务应记录每个批次的输入数、成功响应数、失败数和目标快照版本。如果只打印“任务结束”,很难区分提交了多少命令,以及服务端实际确认了多少。
在合成场景中,所有 SADD 都返回成功,每个 Set 的成员数量也大致符合预期。写入阶段暂时没有异常证据。
3. TTL 是否符合设计
对缺失 Key 执行 TTL:
TTL snapshot:2026-09-30:42
Redis 的常见返回含义是:
| 返回值 | 含义 |
|---|---|
| 大于等于 0 | 剩余生存时间 |
-1 |
Key 存在,但没有过期时间 |
-2 |
Key 不存在 |
如果业务设置了几天 TTL,Key 却在写入后十几分钟就返回 -2,自然过期不成立。还要检查代码是否在后续流程里重复设置了更短 TTL,但不能把所有 -2 都归因于过期。
4. 是否查了同一个节点和同一条读链路
代理集群、主从架构和多可用区环境里,排查工具可能默认连接到另一条链路。某处能读到 Key,只能证明那条链路存在数据,不能证明应用正在读取的节点也有。
因此每条证据都要带上实例、角色、数据库编号、路由方式和时间。若主节点上的 Key 已经消失,副本或另一套集群仍然完整,用错误入口查询会把调查引向业务代码。
5. 检查淘汰计数
当 TTL 与显式删除都解释不了 Key 消失时,应查看:
redis-cli INFO stats | grep -E 'evicted_keys|expired_keys'
redis-cli INFO memory | grep -E 'used_memory:|used_memory_peak|maxmemory|mem_clients_normal'
expired_keys 增长说明 Key 因 TTL 到期删除;evicted_keys 增长说明 Redis 因 maxmemory 策略主动淘汰。两者是不同的删除路径。
如果缓存重建期间 evicted_keys 快速上升,调查重点应转向内存峰值。此时继续盯着 SADD 数量或本地集合并集,已经偏离根因。
三、为什么内存明明不高,还是超过了 maxmemory
Redis 的几个内存指标很容易混在一起:
| 指标 | 说明 |
|---|---|
used_memory |
Redis 分配器统计的内存,包括数据与多种运行开销 |
used_memory_dataset |
数据集自身占用的内存 |
used_memory_peak |
进程启动以来 used_memory 的峰值 |
used_memory_rss |
操作系统看到的常驻内存 |
maxmemory |
Redis 用于限制缓存数据与触发策略的配置值 |
mem_clients_normal |
普通客户端连接使用的内存 |
mem_not_counted_for_evict |
淘汰判断时排除的内存,主要是复制与 AOF 临时缓冲区 |
maxmemory 不是机器物理内存,也不等于容器上限。节点可能有几 GB 物理内存,Redis 的 maxmemory 只配置了其中一部分。反过来,即使 used_memory 已经下降,RSS 也可能因为分配器尚未把页归还操作系统而保持较高。
排查瞬时问题时,当前值往往比峰值低。事故发生后只看 used_memory,可能得到“内存很充足”的错觉。应把 used_memory_peak、evicted_keys 的增量和同一时间窗的客户端内存一起看。
还有一个边界需要说清:复制与 AOF 的部分缓冲内存在淘汰判断中会通过 mem_not_counted_for_evict 排除,防止淘汰操作本身制造更多复制数据并形成反馈循环。普通客户端的查询与输出缓冲区不是同一类豁免项。
四、只读请求怎样制造内存峰值
Redis 客户端使用请求/响应协议。普通的串行调用大致是:
客户端发送 GET
Redis 执行并返回结果
客户端读取结果
客户端再发送下一个 GET
Pipeline 会一次发送多条命令,再批量读取回复:
客户端:GET key:1
客户端:GET key:2
客户端:GET key:3
...
Redis:reply:1
Redis:reply:2
Redis:reply:3
...
它减少了网络往返和系统调用,吞吐通常更高。代价是 Redis 必须暂存还没有被客户端读取的回复。Redis 官方文档明确提醒,大 Pipeline 会迫使服务端排队回复并使用额外内存,建议拆成合理大小的批次,读取一批结果后再发送下一批。
每个连接至少可能涉及:
- 查询缓冲区,保存客户端已经发送但尚未处理的命令;
- 输出缓冲区,保存已经生成但尚未发送完的回复;
- 参数解析和连接对象等其他内存。
当客户端发送命令的速度高于 Redis 把回复送出的速度,或者客户端暂时没有读取,输出缓冲区就会增长。CLIENT LIST 中可以观察:
| 字段 | 含义 |
|---|---|
qbuf |
查询缓冲区长度 |
obl |
固定输出缓冲区长度 |
oll |
输出列表中的回复数量 |
omem |
输出缓冲区使用的内存 |
tot-mem |
该客户端连接消耗的总内存 |
普通客户端的输出缓冲区限制在很多版本中默认是 0,也就是不主动限制。大批量读取返回的数据越多,单连接峰值越高。
并发缓存重建如何放大一次读取
单个 API 实例分批读取数百个 Set,通常问题不大。如果所有实例收到同一个版本通知后同时重建,本来的一次读取会乘上实例数。
可以粗略估算命令数:
总命令数 = 应用实例数 × 快照份数 × shard 数
再估算回复量:
总回复字节 ≈ 应用实例数 × 每份快照的序列化大小
假设有 120 个应用实例,每个实例读取两份快照,每份拆成 600 个 Set:
120 × 2 × 600 = 144,000 条读取命令
如果所有实例在几秒内集中执行,代理和 Redis 会同时看到明显的 QPS、入流量与出流量峰值。更重要的是,每条 SMEMBERS 可能返回大量成员,响应字节通常远大于请求字节。
这就是“读也可能导致 Key 淘汰”的完整链路:
统一通知触发并发重建
↓
每个实例提交深 Pipeline
↓
Redis 产生回复的速度暂时高于客户端读取速度
↓
普通客户端输出缓冲区增长
↓
Redis 内存越过 maxmemory
↓
allkeys-lru 等策略开始淘汰数据 Key
↓
后续读取遇到缺失 Key,本地缓存变小
读请求本身没有删除 Key。它制造了连接内存峰值,淘汰策略随后删除 Key。两者之间隔着内存判断,所以从业务日志看起来会很诡异。
五、Set 编码为什么不是根因
排查过程中很容易被 Redis 底层编码吸引。只包含整数且成员较少的 Set,可以使用紧凑的 intset;超过阈值或出现非整数成员后,会转换为哈希表编码。
intset 通过连续内存节省空间,插入时需要保持有序,某些位置的插入会移动后续元素。若每个 Set 有几百个成员,确实应该评估写入成本和编码转换。
但它无法解释两个关键证据:
- Key 是在批量读取阶段消失,而不是写入阶段;
evicted_keys与客户端内存峰值同时增长。
底层编码是合理的性能假设,却不是当前证据支持的根因。排障时应该允许假设被证伪。看到一个听起来复杂的内部机制后继续深挖,很容易错过监控里更直接的信号。
六、怎样在本地复现
复现实验需要让数据集接近 maxmemory,再制造一个产生大量回复但暂时不读取的客户端。测试只能在本地或隔离环境执行,不要对共享 Redis 运行。
1. 启动一个受限 Redis
docker run --rm --name redis-buffer-lab -p 6380:6379 redis:7.2-alpine \
redis-server \
--maxmemory 32mb \
--maxmemory-policy allkeys-lru \
--client-output-buffer-limit 'normal 0 0 0'
这里故意关闭普通客户端输出缓冲区限制,让现象更容易出现。不同 Redis 版本、操作系统 socket 缓冲区和数据规模会改变复现阈值。
2. 准备测试数据
写入几百个较大的 Value,让 used_memory_dataset 接近但不要超过 maxmemory。保留足够余量启动连接和执行命令。
redis-cli -p 6380 INFO memory
redis-cli -p 6380 CONFIG GET maxmemory
redis-cli -p 6380 CONFIG GET maxmemory-policy
3. 创建一个只发命令、暂时不读回复的客户端
下面的 Python 使用原始 RESP 协议,不依赖 Redis 客户端库。它重复查询测试 Key,发送完后等待十秒才开始读回复:
import socket
import time
def resp_get(key: str) -> bytes:
raw = key.encode("utf-8")
return b"*2\r\n$3\r\nGET\r\n$" + str(len(raw)).encode() + b"\r\n" + raw + b"\r\n"
sock = socket.create_connection(("127.0.0.1", 6380))
payload = b"".join(resp_get(f"blob:{i % 400}") for i in range(20_000))
sock.sendall(payload)
# 故意不读,让回复在服务端和内核缓冲区积累。
time.sleep(10)
while sock.recv(64 * 1024):
pass
若本机 socket 很快吸收了回复,可以增加 Value、命令数或并发连接。目标是观察机制,不是追求固定数字。
4. 从另一个连接观察
redis-cli -p 6380 INFO memory
redis-cli -p 6380 INFO stats | grep evicted_keys
redis-cli -p 6380 CLIENT LIST TYPE NORMAL
redis-cli -p 6380 DBSIZE
重点看 used_memory_peak、mem_clients_normal、客户端的 omem/tot-mem、evicted_keys 和 DBSIZE。若输出缓冲区增长并伴随淘汰计数上升,实验就复现了同一机制。
复现失败也有信息价值。可能是客户端读取太快、数据离 maxmemory 太远、普通客户端缓冲区限制提前断开连接,或者当前版本对客户端内存有新的保护。不要为了“证明文章正确”随意降低系统保护后忘记恢复。
七、第二个问题:为什么还会超时
即使容量足够,不受约束的批量读取也可能造成超时。
一次 SMEMBERS 请求很小,回复可能包含成百上千个成员。多个实例同时读取完整快照时,代理出流量、Redis 网络带宽和客户端反序列化都会成为瓶颈。日志里看到的是连接超时,Redis 慢日志却可能没有明显的慢命令,因为单条命令执行不慢,拥塞发生在总流量、排队与传输阶段。
排查这类超时要同时看:
- Redis 与代理的入流量、出流量;
- 客户端连接池等待时间;
- 命令 QPS 与返回字节;
- 单次 Pipeline 的命令数和总响应大小;
- 应用实例开始重建的时间分布;
- 客户端反序列化与本地缓存构建耗时。
只看慢查询会漏掉“每条都快,但同一秒来了太多条”的问题。
缓存重建失败时,还要保留上一份成功快照。正确的切换顺序是:读取新数据,完整校验,在独立对象里构建成功,然后原子替换引用。不要边读边清空旧缓存,否则一次 Redis 超时就会把可用数据一起丢掉。
八、修复方案
1. 给重建任务加抖动
所有实例收到通知后立即执行,会形成惊群。可以根据实例 ID 计算稳定延迟,或在一个时间窗口内加入随机抖动,让流量从几秒摊到几十秒。
Duration jitter = Duration.ofSeconds(
Math.floorMod(instanceId.hashCode(), 60));
scheduler.schedule(this::rebuildCache, jitter);
稳定散列比每次完全随机更容易排查,同一实例的行为可预测,同时仍能打散整体流量。
2. 限制 Pipeline 深度
不要一次把全部 shard 放进一个 Pipeline。按固定批次发送,读取并处理结果后再进入下一批:
Set<Long> result = new HashSet<>();
for (List<String> batch : partition(keys, 100)) {
List<Set<String>> replies = redis.pipeline(batch, pipeline -> {
List<Response<Set<String>>> pending = new ArrayList<>();
for (String key : batch) {
pending.add(pipeline.smembers(key));
}
return pending;
});
replies.stream()
.flatMap(Set::stream)
.map(Long::parseLong)
.forEach(result::add);
}
批次大小不能只按命令数设定。读取大 Set 时,应同时限制预计回复字节。100 个小 Key 和 100 个大 Key 的内存峰值完全不同。
3. 控制重建并发
实例内使用 single-flight,避免配置通知、定时刷新和人工刷新同时触发三次构建。集群层面可以设置并发上限,或由少量加载器生成压缩快照,再让其他实例从对象存储或分发服务获取。
集中加载会引入新的可用性与分发成本,适合快照较大、实例很多的场景。小规模服务通常先做抖动与分批已经足够。
4. 为 Redis 留瞬时内存余量
容量规划不能只装下稳定状态的数据集。客户端连接、复制、AOF、fork 写时复制、重哈希和碎片都会占用额外内存。
maxmemory 应给运行开销留下空间。提高阈值或扩容可以止损,但如果并发读取模式不变,峰值仍会随实例数继续增长。
5. 配置客户端内存保护
Redis 支持按客户端类型配置输出缓冲区硬限制和软限制。达到限制后连接会被关闭,避免单个慢客户端无限占用内存。限制太低会让合法的大响应频繁断连,需要基于真实返回大小设置。
Redis 7.0 起还提供 maxmemory-clients,限制所有客户端连接的总内存。超过阈值时,Redis 会优先断开占用内存较多的客户端。它把故障形态从“删除缓存数据”转成“部分重建请求失败”,配合上一份缓存兜底通常更可控。
maxmemory-clients 5%
这不是统一推荐值。应结合连接数、Pipeline 大小与数据集容量压测后确定。
6. 重新审视淘汰策略
allkeys-lru 适合数据可重新生成的纯缓存。当 Redis 中的数据承担状态或快照源角色时,淘汰任意 Key 可能直接造成不完整结果。
这里还有一个容易被忽略的风险:同一实例里可能不只有缓存,还放着分布式锁。典型的 Redis 锁会通过下面的命令写入一个带过期时间的 Key:
SET lock:order:123 6f19c8... NX PX 30000
假设线程 A 拿到锁后开始处理订单,锁的租约是 30 秒。5 秒后,大批量读取产生的客户端缓冲区把实例推过 maxmemory,锁 Key 被淘汰。线程 B 此时再次执行 SET ... NX 会成功,因为从 Redis 看,这个 Key 已经不存在。A 并不知道自己的锁提前消失,仍会继续执行,于是两个线程同时进入临界区。
这和“业务执行超过 30 秒,锁自然过期”不是一回事。后者属于租约设计问题,前者则是内存淘汰绕过了原本约定的有效期。排查时可以用 expired_keys 与 evicted_keys 区分两条路径:前者增长表示 TTL 到期,后者增长表示 Key 因内存压力被主动删除。
不同淘汰策略对锁的影响如下:
| 策略 | 锁 Key 是否可能被淘汰 | 原因 |
|---|---|---|
allkeys-lru |
会 | 所有 Key 都在候选集合中 |
allkeys-lfu / allkeys-random |
会 | 策略不同,但同样允许淘汰任意 Key |
volatile-lru |
会 | 正确的锁通常带 TTL,恰好属于 volatile Key |
volatile-lfu / volatile-random / volatile-ttl |
会 | 只要锁带过期时间,就可能进入候选集合 |
noeviction |
不会因内存策略被删除 | 内存不足时,可能增加内存的写命令会失败 |
因此,把锁改成带 TTL 后再使用 volatile-lru,并不能保护它。LRU 中的“最近使用”也不是生存承诺;Redis 使用近似 LRU,内存持续紧张时,即使锁刚被访问过,也不能把正确性建立在它“大概率不会被选中”上。
更稳妥的做法是把锁与可淘汰缓存放在不同的 Redis 实例中,而不是只分到不同逻辑 DB。逻辑 DB 仍共享同一份内存上限和淘汰策略。锁实例使用 noeviction,预留足够内存余量,并对写入失败、used_memory、evicted_keys 做报警。普通缓存实例则可以根据数据是否可重建选择 LRU 或 LFU。
noeviction 只消除了“锁被淘汰”这一条故障路径,并不会让分布式锁自动变得可靠。业务执行超过 TTL、续期失败、主从切换丢失尚未复制的锁状态,都可能破坏互斥。释放锁时还必须校验随机值,不能直接 DEL,否则一个执行缓慢的旧线程可能删掉后来线程刚获得的新锁。对于订单、支付、库存等不能接受重复提交的操作,还应在数据库或下游资源中加入幂等键、版本号或 fencing token。
选择淘汰策略前,先把 Redis 中的 Key 分成两类:丢失后能够重建的缓存,以及丢失会破坏业务正确性的状态。两类 Key 混在同一个会淘汰数据的实例里,容量问题最终可能变成一致性问题。
九、监控应该覆盖哪些信号
仅监控内存百分比和 QPS,不足以还原这类问题。建议按五个维度建立观察面:
| 维度 | 指标或证据 |
|---|---|
| 数据集 | used_memory_dataset、Key 数、各类型数量、大 Key |
| 总内存 | used_memory、used_memory_peak、RSS、碎片率 |
| 客户端 | mem_clients_normal、连接数、omem、tot-mem |
| 淘汰 | evicted_keys、expired_keys、淘汰超限持续时间 |
| 流量 | 请求/响应字节、Pipeline 深度、超时、连接池等待 |
应用侧还应记录每次缓存重建的版本、开始时间、完成时间、批次数、读取 Key 数、结果元素数和失败原因。这样可以把 Redis 峰值与具体重建批次对应起来。
建议把下面几种报警拆开:
used_memory接近maxmemory;mem_clients_normal在短时间快速增长;evicted_keys出现增量;- 单客户端
omem过大; - 多个实例在同一时间窗口启动重建;
- 新缓存规模相较上一版本异常下降。
最后一项很重要。底层监控没有及时报警时,业务不变量仍能发现异常:完整快照不应该无原因地从几十万条降到几万条。检测到规模异常后,应拒绝用坏快照替换当前缓存。
十、这类问题为什么容易误判
故障链跨越了四层:应用通知制造并发,客户端 Pipeline 放大批量,Redis 缓冲区产生内存峰值,淘汰策略删除数据。任何一层单独看都符合预期。
开发者容易先查代码,因为缓存内容变少通常像业务逻辑错误。Redis 当前内存又可能在事故后迅速下降,让“内存不足”显得不合理。若排查工具连接的是另一条链路,还会得到“Key 明明都在”的相反证据。
排查时要保持证据链完整:
- 先确认数据在哪一步开始减少;
- 区分过期、显式删除和
maxmemory淘汰; - 查看事故时间窗的峰值,不只看当前值;
- 把请求数量换算成返回字节与客户端内存;
- 用隔离实验复现,再逐项验证优化。
Pipeline 是一个很好用的吞吐工具。批次大小、并发实例数和响应体积没有边界时,它也能制造很陡的瞬时峰值。把缓存重建当作一次受预算约束的数据传输任务,通常比把它当作“循环读几个 Key”更接近真实成本。
公开资料
如果这篇文章对你有帮助