我在追踪线上问题的时候,发现很多人对 Redis 的认知还停留在“用来做缓存而已”,但真正遇到流量高峰、内存抖动、数据恢复这些场景时,笔记里如果没有沉淀过一套排查思路,现场很容易乱成一团。这篇内容算是我长期生产实践中的 Redis 手记整理,围绕慢查询、内存淘汰、缓存异常、连接池、原子操作和持久化这六个高频方向,把我在项目里踩过的坑和验证过的做法梳理成一套可直接落地的经验。
无论你是刚接手 Redis 运维的开发者,还是已经在用 Redis 但遇到莫名其妙延迟和丢数据的团队,这篇笔记都能提供一些排查和设计上的参考。
1. 一次线上毛刺引发的追查:慢查询日志到底该配多大
我印象很深的一次故障排查,来自一个电商侧的在线服务。大促前流量还在爬坡,接口延迟却开始一跳一跳地往上动。当时看系统监控,CPU 不高,内存也平稳,网络层面没有明显异常,可请求就是时不时卡住几百毫秒。后来登录 Redis 实例执行了SLOWLOG GET,结果直接刷新了我的认知——里面密密麻麻排着几十条执行时间在 50 毫秒以上的命令。
慢查询日志在很多人眼里只是一个排查辅助工具,但真正经历过一次流量高峰后,我才意识到它是预防性调优的第一道防线。Redis 是单线程处理命令的,只要一条大命令在事件循环里卡住,后面所有排队的命令都会一起遭殃。所以慢查询日志绝不只是一份事故记录,它能帮你在大规模故障真正来临之前,就把那些导致延迟放大的命令揪出来。
1.1 先搞清楚 slowlog 的配置边界
我在每次初始化新环境时都会核一遍这几个参数,它们决定慢查询日志能不能在关键时刻派上用场:
slowlog-log-slower-than:单位是微秒,默认值 10000,也就是超过 10 毫秒的命令才会被记录。如果你的业务要求 P99 延迟在 30 毫秒以内,10 毫秒这个阈值显然太宽松。slowlog-max-len:最多保存多少条慢日志,默认只有 128。一旦进入流量高峰,128 条慢日志可能几秒钟就被刷出去了,真正有分析价值的命令根本留不下来。CONFIG GET slowlog-*:查看当前实例生效的配置。SLOWLOG GET n:拉取最近 n 条慢日志,可以看到命令耗时、来源地址、命令名和参数。
我目前的实践配置是:slowlog-log-slower-than调到 2000,也就是 2 毫秒就算慢命令;slowlog-max-len拉到 1024。这样既能捕获到潜在的批量命令和大集合操作,也不至于一直存着大量垃圾日志干扰分析。
这个阈值并不是越激进越好。阈值设置过低会让日志里充满正常的耗时波动,排查时反而要花更多时间做筛选;设置过高又容易漏掉真正的隐患。2 毫秒在缓存场景下是一个比较合理的警报线,因为 Redis 纯内存命令的常见耗时通常远低于这个量级,一旦超过,基本可以判断命令本身的复杂度或者网络交互出了问题。
1.2 慢查询背后的命令复杂度排查
拿到慢日志之后,我的排查顺序一般是:先看是不是某条命令的执行时间异常,再通过redis-cli --latency检查本机到 Redis 实例的往返时延。这两个数据可以帮我把问题定位在“网络传输”还是“命令处理”哪一侧。
如果问题出在命令本身,最常出现的几类元凶我基本已经摸清了:SINTER操作的两个集合体积过大、ZRANGEBYSCORE一次拉取的分数区间太宽、HGETALL取出一个超大哈希的全部字段、以及业务代码在循环里逐条执行GET导致大量网络往返。这类问题的共同特征就是单命令处理时间高出平均线一个数量级,一旦遇到并发请求叠加,就会表现为实例的“毛刺时延”。
另外要特别提醒的是,慢日志里显示执行耗时很长的KEYS或SMEMBERS这类命令,最好直接视为高危操作。它们的 O(N) 复杂度会随着键的数量增加线性增长,在百万键级别的实例上执行一次,就可能让整个实例对外服务暂停几秒钟。针对这种场景,都需要切换到游标迭代方案,这个我在后面讲遍历命令的章节里会单独展开。
2. 内存淘汰策略和过期键:配错一个开关,数据就静默消失
很多人对 Redis 的预期是“当作缓存用,Redis 不会主动删数据”,这是一个潜在风险很大的认知。当内存使用量逼近配置的maxmemory后,不同淘汰策略的表现差异很大,有的策略会让部分键直接消失,而业务方可能完全没有意识到。
我在实际项目里见过的情况是:内存到达上限后,如果使用的是默认的noeviction策略,写命令会直接报错,业务调用方收到的是一连串OOM command not allowed when used memory > 'maxmemory'。这时候如果业务链路没有对写失败做兜底,用户端就会出现保存失败、会话丢失等问题。反之,如果用了allkeys-lru,那些不带过期时间的重要配置键也可能因被淘汰而消失。所以内存淘汰策略从来不是配完就结束的一个开关,必须和业务场景对齐。
2.1 maxmemory-policy 的选项如何取舍
我整理这套笔记时,把常用的几种淘汰策略单独列了一张表,方便在新项目里快速决策:
| 策略 | 淘汰范围 | 适用场景 | 主要风险 |
|---|---|---|---|
| noeviction | 不淘汰,写命令报错 | 数据不可丢失、可接受写入失败的场景 | 写失败可能向上层业务传导 |
| allkeys-lru | 所有键,按最近最少使用淘汰 | 纯缓存场景,所有数据可重建 | 不带过期的配置键也可能被误淘汰 |
| volatile-lru | 仅带 TTL 的键 | 缓存键与不可丢键混合 | 内存可能无法降到安全水位 |
| allkeys-lfu | 所有键,按访问频率淘汰 | 请求分布很不均衡的业务 | 冷启动阶段数据可能被过早淘汰 |
| volatile-lfu | 仅带 TTL 的键,按访问频率淘汰 | 需要控制热数据和过期键的混合场景 | 同上 |
判断依据我通常会落到一句话:存的数据丢了能不能重建?能重建,优先 allkeys-lru 或 allkeys-lfu;不能重建,优先 noeviction;如果业务里既有可重建的缓存键,又有不可丢的令牌或状态键,volatile-lru 会更妥当。但这些都不是唯一答案,我建议每个团队都先盘点 Redis 里保存的数据类型和缺失容忍度,再回头定策略。
淘汰策略调优之后还要留意一个现象:当evicted_keys指标开始不断增加,说明淘汰策略虽然在工作,但内存水位已经明显偏高,靠淘汰硬撑不是长久方案。正确的做法是结合过期时间、键体积和数据分布去优化缓存设计,把大 key 拆分、把冷数据下沉到更便宜的存储层,而不是让淘汰策略持续吞掉你的热数据。
2.2 过期键删除对访问时延的隐性影响
过期键的清理并不像很多人想象的那样,时间一到就立即从内存里消失。Redis 实际使用的是惰性删除加定期删除的组合策略。惰性删除发生在读取操作触摸到某个键时,如果发现键已过期,就返回空并顺手删除;定期删除则是后台每隔一段时间抽检一部分设置了过期时间的键并清理。
这套机制带来的两个坑特别值得记录:
- 大量过期键集中同一时间失效,会让定期删除的 CPU 开销瞬间拉高,表面上没有一条慢命令,但实例整体响应时间会整体变差。
- 如果某些过期键一直无人访问,惰性删除永远不会被触发,定期删除的抽检范围又有限,这些键就会一直占用内存,容易误导你对内存水位的判断。
所以我在代码里做 TTL 设置时,从某次事故之后养成了一个习惯:固定过期时间加上一个随机偏移。比如原本要把缓存设置为 3600 秒,现在写成3600 + random(300, 900)秒。这个改动成本极低,却能把大规模键同时失效的概率降下来,对避免缓存雪崩也有正向作用。
2.3 通过指标验证淘汰和过期情况
监控面板上我长期保留四个指标:used_memory、evicted_keys、expired_keys和keyspace_hits。evicted_keys一旦持续增长,说明内存已经长期处在压力区;expired_keys出现脉冲尖峰,往往对应某一批业务数据集中过期;keyspace_hits与keyspace_misses的比值可以侧面反映缓存命中率,命中率过低时就要回看键的设计是否合理。
单纯看绝对值意义不大,必须结合业务流量一起观察。比如大促期间命中率短暂下降是很正常的,但如果持续一个多小时还不回升,就要怀疑是否有人把缓存键的有效期设置得太短,或者代码里存在缓存写入后立即失效的误操作。把指标和业务时间线对齐,才能做出有效的判断。
3. 缓存穿透、击穿、雪崩:我把这几类故障做成了排查清单
缓存故障里,大家听得最多的就是穿透、击穿和雪崩这三个词。可在实际排查时,能把它们准确区分开的人其实不多。我在某个项目的故障复盘里就见过,代码里明明对击穿做了互斥锁保护,结果发生的却是雪崩,防护根本没作用到点上。所以排查之前先把定义捋清楚。
3.1 先分清三种问题的本质差异
- 穿透:查询一个后端根本不存在的数据,Redis 里也没有,每次请求都绕过缓存直达数据库,放大底层压力。典型场景是恶意请求不断构造不存在的商品 ID。
- 击穿:某一个热点 key 在过期瞬间,大量请求同时涌入,大家都没有拿到缓存,于是一起重建缓存,缓存层被瞬时流量打崩。
- 雪崩:大量 key 在同一时间窗口集体失效,缓存整体变成空洞,流量全部涌向后端数据库,数据库面临远超预期的读压力。
三者的核心差异是失效键的数量规模和并发特征。穿透是无处安放的空查询,击穿是单个热点 key 失效,雪崩是大规模键同时失效。对症方案也因此不同。
3.2 防穿透:布隆过滤器与缓存空值的组合
对付穿透,最直接的办法是缓存空值。后端查询结果为空的 key 也写进 Redis,设置一个几秒钟的过期时间,后续同样的请求可以在缓存层直接命中,不再穿透到数据库。这个方案写起来很简单,但有一个先天弱点:如果攻击者使用枚举策略不断换 key,空值缓存方案就会失效,因为每次都是一个新 key,缓存里根本不存在对应的占位值。
所以对安全要求更高的系统,我会在业务入口加一层布隆过滤器。把合法 key 的集合映射进一个足够大的位数组,查询前先判断 key 是否可能合法,如果命中“绝对不存在”,就直接返回空结果。布隆过滤器本身的误判率可以通过位数组长度和哈希函数数量来调节。实际项目里我通常把两者组合:过滤器挡住大概率非法的请求,缓存空值兜住合法但暂时无数据的查询,后端压力能小一个数量级。
3.3 防击穿:互斥锁和“逻辑过期”的取舍
热点 key 过期后,最关键的是限制同时重建缓存的线程数。一个成熟做法是使用互斥锁,让同一时刻只有一个线程真正去查后端并写缓存,其他线程等锁释放后直接读缓存。Redis 端可以用SET lock_key value NX EX 10实现原子占位,锁释放用 Lua 脚本判断持有者后删除,避免误删他人锁。
互斥锁的缺点是,如果后端重建缓存耗时较长,其他线程会被阻塞很久。在流量大且对响应时间敏感的场景,我更倾向于“逻辑过期”方案:缓存值里额外存一个过期时间戳,并不让 Redis 真正删除这个键。读取时发现逻辑过期后,先返回旧值,同时在后台异步触发一次刷新。这样请求侧不会被阻塞,代价是短暂时间内读到的数据是旧版本。这个方案在有大量并发热点且能接受轻微数据陈旧时,明显比单纯互斥锁更平滑。
3.4 防雪崩:随机化过期时间加本地二级缓存
雪崩的根源是大规模键同时失效。把 TTL 加上随机偏移能有效错开失效时间,这是最基础的手段。除此之外,我会对读多写少、一致性要求不高的数据再叠一层本地缓存,本地缓存的存活时间比 Redis 更长。当 Redis 出现集中失效窗口时,本地缓存仍然能兜住一部分请求,给后端重建缓存争取时间。
二级缓存带来的后果是要处理本地缓存与 Redis 的一致性,还有额外的本地内存开销。但从保护数据库的角度看,它确实是有效的手段,尤其在集群规模有限、数据库抗压能力不强的系统里很值得用。
4. 吞吐上不去的隐形瓶颈:连接池、pipeline 和遍历命令
有时候服务端各项指标都正常,缓存命中率也不低,可整体吞吐就是上不去。这时候我往往会把注意力从 Redis 服务端移到客户端侧代码,再仔细查一遍连接池和命令组织方式。
4.1 连接池参数比想象中更重要
连接池的连接数不是越多越好。连接太少,请求在池子里排队等待获取;连接太多,每个连接都要占用 Redis 端内存,同时事件循环处理连接事件的开销也会上升。我在项目里习惯针对接口的 QPS 和平均耗时做一次简单估算,再结合部署实例数定连接池上限,通常不会把最大连接数堆到令人夸张的数值。
还有一个容易忽略的是连接获取超时时间。高并发下,如果连接池的空闲连接恰好被耗尽,新请求进入等待队列,获取超时设置得过长会让调用线程长时间悬挂,进而引发上游完全超时。建议在所有客户端初始化代码里显式配置空闲回收时间、最小空闲连接数、连接获取超时时间,不要完全依赖各语言客户端的默认值,因为默认值差异很大,而且不一定贴合你的核心链路场景。
4.2 用 pipeline 提升吞吐量,但要控制批量规模
pipeline可以把多次网络往返合并成一次,批量写缓存或大量加载数据时收益非常明显。但我见过不少项目把每次请求里上千条命令打包进单个 pipeline,结果服务端处理时间延长,返回结果一次性堆积在客户端,内存被快速打满,一个慢命令还会拖垮整个批次。
我对 pipeline 的使用约束是:单次批量规模控制在 100 到 200 条之间,同时充分考虑单个 key 的数据体积。如果某个阶段命令之间依赖前一命令的返回值,也不能放进同一条 pipeline。把它们拆成多条小批量命令,虽然网络往返会增加,但整体可控性要好得多。
4.3 遍历命令的教训:线上不要直接使用 KEYS
凡是看过我这份笔记的人,基本都被我提醒过:线上别用KEYS *。当实例里的键数量达到百万级,一条KEYS命令就会让 Redis 事件循环长时间阻塞,等于让整个实例对外暂停。替换方案是SCAN,它按游标分批迭代,一次只返回少量键,不会长时间阻塞。注意SCAN的游标要在同一个连接上下文中保持,因为迭代过程中如果连接断开重新建立,游标状态可能不再连续,最终遍历集合的完整性就会受影响。
5. 原子操作与分布式锁:从 Lua 脚本开始的优雅解耦
Redis 的原子性能力经常被低估,或者反过来被误用。以前我在一个秒杀模块里看到很多问题,都是因为“先判断后修改”没有做成原子操作,导致并发下库存被多扣。这里我把自己总结的取舍方式整理出来。
5.1 判断是否需要原子性:看读写之间是否依赖中间结果
原子操作的核心价值在于:检查条件和后续修改必须是一个不可分割的整体,不能允许其他请求在中间插队。典型业务包括库存扣减、奖品领取次数限制、重复提交控制等。
Redis 为此提供了三套手段:单命令原子操作、事务MULTI/EXEC、Lua 脚本。单命令如INCR、DECR、SETNX能覆盖简单场景。事务的执行过程确实不会被其他命令插入,但它的错误处理和回滚机制并不直观,我基本不推荐在核心链路里依赖它。通用性最好的是 Lua 脚本,Redis 在执行脚本期间天然阻塞其他命令,等于把多步检查和修改封装成了原子单元,而且脚本本身可以复用和维护。
5.2 用一段 Lua 脚本处理库存扣减
下面这段脚本是我项目里用过的模板,逻辑是把库存和已扣数量分开存储,每次扣减前先检查剩余量,足够再扣,否则返回失败:
-- KEYS[1]: 库存 key -- KEYS[2]: 已扣数量 key -- ARGV[1]: 本次扣减数量 local stock = tonumber(redis.call("GET", KEYS[1])) local used = tonumber(redis.call("GET", KEYS[2])) if not stock or not used then return -1 end local rest = stock - used if rest < tonumber(ARGV[1]) then return 0 end redis.call("SET", KEYS[2], used + tonumber(ARGV[1])) return 1脚本执行时,并发请求会在 Redis 内部被串行处理,不会出现两个请求同时判断“库存足够”然后一起扣减的问题。把库存和已扣数量分开存,是便于业务对账,同时也能让剩余量判断变得可控。客户端需要约定返回值含义,通常 0 表示库存不足,1 表示成功,-1 表示初始数据缺失。
5.3 分布式锁别把 setnx 当万能药
早期很多项目用SETNX key value做分布式锁,业务结束后再主动删除锁。这套方案的问题在于:如果业务执行时间超过了锁的过期时间,锁自动失效后别的线程就能拿到同一把锁,而原线程执行完后还会把自己的锁删掉,导致锁直接被误释放。
所以我设计的分布式锁一定满足三个条件:加锁时设置超时时间,例如SET lock_key token NX EX 30;删除锁前比较持有者标识,确认这把锁属于自己;锁的持有时间和业务执行时长之间必须留有足够余量,不能拍脑袋定一个过期时间就完事。集群多实例下的互斥一致性要求更高,可以引入基于多数节点的协议方案,但它的部署成本和复杂度通常只有在强一致要求非常明确时才值得投入。
6. 持久化选型与容灾:我对 RDB 和 AOF 的最终配置建议
除非你只是拿 Redis 当进程级缓存,否则只要数据需要跨重启存活,就必须认真配置持久化。我在笔记里专门写了这一章,因为不少人把持久化和备份混为一谈,或者以为配置了 RDB 就万事大吉,真实故障恢复时才发现文件损坏、版本不匹配这些问题。
6.1 RDB 与 AOF 各自保障什么
RDB 是内存数据的周期性二进制快照,恢复速度快,适合做全量备份;但两次快照之间的数据会丢失。AOF 记录每次写命令,恢复粒度更细,最多丢失多少数据取决于刷盘策略,但文件体积大,重启恢复时间也长。
我个人的最终配置倾向是:如果是纯缓存且可以从冷启动重新填充,可以只保留 RDB 并配置合适的保存策略,关闭 AOF 以换取更高的写入性能;如果存了订单状态、会话标识、任务进度这类重要中间数据,必须开启 AOF,并将appendfsync设为everysec,在性能和数据安全之间取均衡点。always虽然最安全,但每笔写都强制刷盘,吞吐下降明显,除非写入量极小,否则不建议。
6.2 AOF 重写和日志增长的坑
AOF 文件开启后只会不断增长,里面会积累大量重复命令,比如同一个 key 被反复更新。为了控制体积,Redis 提供了 AOF 重写机制,它根据当前数据集生成最小命令集来压缩日志。运维时要关注auto-aof-rewrite-percentage和auto-aof-rewrite-min-size两个配置,让重写能在低峰期自动完成。
这里的坑在于,如果一直忽略磁盘监控,等发现时 AOF 文件可能已经占掉几十 GB,实例所在磁盘被写满,连持久化都做不下去。所以在日常巡检里,磁盘使用率和 AOF 文件大小是需要长期盯着的指标。
6.3 恢复演练比配置本身更重要
配置 RDB 和 AOF 并不等于确认数据一定能恢复。我见过备份文件损坏、AOF 头部不完整、Redis 版本升级后新旧文件不兼容等问题,都只在真实故障时才会暴露。因此我会在测试环境做恢复演练:定期把备份文件恢复到一台全新实例,核对数据量和关键键的值。这个动作每次花不了太多时间,但到故障真正发生时,团队已经熟悉了整个流程,不会在慌乱中反复踩坑。
在 Redis 的日常运营里,最值得投资的从来不是配置得多花哨,而是把每个参数和业务场景对应起来,形成一套可以反复验证的检查清单。这份笔记里的内容,都是我基于真实项目经验沉淀下来的做法,其中包含了大量从线上故障里反向总结出的细节。如果你在自己环境里复现这些经验,建议先从慢查询阈值、内存淘汰策略和持久化恢复演练这三个方面入手,它们能帮你把最基础也最容易忽略的风险先控制住。