☰
Redis缓存实战:穿透、击穿、雪崩与一致性问题排查指南
2026/10/22 4:46:39 网站建设 项目流程

做缓存这件事,很多团队最初的想法都差不多:业务读库太慢,把热点数据扔进Redis,读的时候先查缓存,再查数据库,完事。等流量真涨起来,才发现Redis缓存里的门道比想象中多得多——命中率莫名下跌、几个key同时过期把数据库打爆、更新完数据库缓存还是旧的、Redis自己挂了全站跟着抖。这篇内容就是围绕Redis缓存,把我这些年在一线踩过的坑和总结出来的排查思路完整讲一遍。既适合刚把Redis接入业务的开发同学,也适合正在支撑高并发项目的运维和架构师参考。

1. 命中率掉到60%之后:一次缓存摸排的完整复盘

1.1 告警触发的第一反应

先说一个真实场景。当时某个会员积分项目的接口监控突然弹出告警:Redis缓存命中率从95%跌到了60%以下,数据库的SELECT数量翻了三倍,平均查询响应时间从5ms涨到接近200ms。群里一片紧张,第一反应都是"是不是Redis挂了"。但打开Redis的INFO stats一看,keyspace_hits和keyspace_misses都在增长,说明Redis本身服务正常,问题是业务代码把大量查询变成了一条条打到数据库的漏网之鱼。

这里有个习惯值得培养:不要只看总命中率,一定要按业务接口拆开看。我当时在项目里把每一次缓存操作都封装成统一函数,函数内部用日志记录key的前缀、命中状态、耗时和value大小。这些日志投到日志平台,按分钟聚合出每个业务块的命中率。排查时一眼就看到了元凶——会员等级的查询接口,缓存key设计成了level:{userId},而大量请求里的userId来自临时会话用户,这张用户表里根本没有对应记录,每次查询都穿透到数据库,查不到结果后甚至还会回写一个空值到缓存。

1.2 命中率背后是key的"人生轨迹"

那次复盘之后我明白了一个道理:命中率问题往往是设计问题,不是运气问题。一个缓存key的"人生轨迹"包括它的写入时机、过期时间、value大小、访问分布。后端同学容易忽略的是,缓存不是数据库的影子副本,它只缓存"值得缓存"的数据。

什么是值得缓存的数据?高频访问且更新频率可控的数据。比如会员等级这种读多写少的数据,可以缓存;但如果是库存扣减这种每次更新都必须实时生效的数据,硬要套缓存只会带来一堆一致性麻烦。另外value大小也很影响命中率——有人喜欢把一个几百KB的JSON整体塞进去,导致内存消耗大、网络传输慢,LRU淘汰时会优先把这些大key踢出去,看起来命中率自然就低。

还有TTL设计。统一过期时间会让key的"生命周期"高度同步,后面雪崩部分我会专门讲。当时的教训是:每个缓存key都该有独立的过期策略,不要图省事全部设成固定的24小时。宁可让key早一点失效,也不要让大量key在同一秒内集体"退休"。

1.3 用bigkeys、slowlog、monitor组合验证

定位到问题业务后,我习惯用三个工具做二次确认。第一是redis-cli --bigkeys,它会遍历整个实例找出value偏大的key。生产环境慎用,因为会占用CPU,最好在低峰期执行,或者用SCAN命令自己写脚本分批扫描。第二是SLOWLOG GET 50,看是否有耗时超过预设阈值的慢命令。缓存场景里的慢命令大多数是KEYS、HGETALL、大集合的SORT这些。第三是MONITOR,这个命令会实时打印所有命令,对性能有影响,不要在高峰期开太久,适合在测试环境或已确认的特定实例上短暂观察。

那次排查里,bigkeys扫描发现level:*的空值缓存value虽然小,但数量巨大,占了大几十万个key。问题拆成两块:空值缓存本身要对量做限制,也要给更短的TTL;同时调用方要判断userId是否合法,不要让垃圾userId一路渗透到数据库。两块一起修,命中率一小时后涨回90%以上。

2. 穿透、击穿、雪崩:缓存最容易"外部体现"的三种事故

2.1 穿透:把布隆过滤器放在缓存前面

缓存穿透是排查过最多的问题。场景描述起来很简单:查询一个一定不存在的数据,因为缓存里没有,数据库里也没有,所以每次请求都会绕过缓存直接打到数据库。如果这种请求是攻击者伪造的,比如循环生成一批不存在的ID去刷接口,数据库瞬间就可能被打穿。

最基础的对策是缓存空值。当数据库查询返回空结果时,也往Redis写入一个空值,并设置一个较短的过期时间比如60秒。这样同一个查询在短期内不会再次穿透。但缓存空值有两个漏洞:空值数量可能膨胀,攻击者换一个不存在的ID就又是一个新key;业务数据真的被创建了之后,这个空值缓存会让业务看到假空数据。

更好的方案是引入布隆过滤器。它的原理是使用一个位数组和多个哈希函数,判断一个元素"一定不存在"非常准,但判断"可能存在"有误判率。具体到代码,Java项目里可以使用客户端库提供的布隆过滤器,初始化时根据预估数据量和可接受的误判率设置容量和误差参数。每次查询前先经过布隆过滤器,如果判定不存在,就直接返回空,不再访问Redis和数据库。需要注意的是布隆过滤器没法删除元素,如果业务上有删除数据的场景,布隆过滤器和空值缓存要组合使用。

2.2 击穿:热点key过期瞬间的并发风暴

缓存击穿针对的是单个热点key。比如秒杀活动里大家都在抢同一个商品,这个商品的库存缓存key在某个瞬间正好过期,此时成千上万个请求全部落到数据库。

我第一次处理这类问题用的是互斥锁的方案。加锁时先执行SET lock_key 1 NX EX 5,只有成功的那个线程能去访问数据库并重建缓存,其他线程在一段时间内重试读取缓存。这个方案简单有效,但要注意锁的超时时间要合理,太短会放大并发,太长会阻塞后续更新请求;业务代码要用try-finally保证锁一定释放,防止异常导致死锁。实践中我会把锁key和业务key分开,避免锁错误地覆盖缓存数据。

更进一步的方案是逻辑过期。不设置Redis的TTL,而是在缓存value里塞一个过期时间字段,比如expireAt。读取时如果发现已经超过expireAt,并不立即返回失败,而是先把旧值返回给调用方,同时后台开一个线程去数据库加载新值并更新缓存。这个方案胜在读取无阻塞,代价是极端情况下会短暂返回旧数据。对秒杀这种数据一致性要求高的业务不太合适,但对查询类热点数据效果很好。

热点key还可以做打散,把原本的secKillStock拆成secKillStock_1、secKillStock_2等若干分片,查询时随机选一个分片。打散的前提是每个分片都要提前缓存好,否则散开后哪个分片都是冷启动。

2.3 雪崩:大面积过期不是随机事件,是设计失误

缓存雪崩的表现是整个缓存区域在同一时间段内大面积失效,请求全部落到数据库。我见过最典型的失误是:一张用户表全量缓存,所有key的TTL统一设成24小时,等到凌晨零点,几十万个key在片刻间集体过期,数据库直接被流量灌满。

解决手段一个是TTL随机化。设置过期时间时在基础值上叠加一个随机偏移量,比如TTL = 86400 + random(0, 300)。这样做让key的过期时间均匀分布,避免"齐步走"。另一个是前置缓存,比如在进程内加一层本地缓存(Caffeine或Guava),即使Redis里的key过期了,本地缓存还能扛住一部分流量,给后台留出重建时间。

还需要区分"缓存key失效"和"Redis实例整体不可用"。后者也是雪崩的一种形态,但应对方式完全不同。Redis不可用时,应用层要有降级开关:是继续打数据库,还是直接返回默认值,或者切换到本地缓存。这个开关一定要演练过才能上线,否则真出事的时候你会发现开关自己都没法触达。

3. 数据更新了,缓存为什么还是旧的:一致性问题的实操解法

3.1 顺序推导:为什么"先更新缓存再更新DB"是反模式

Redis缓存和数据库的一致性,结合具体代码才能讲清楚。业内最常用的是Cache Aside模式。读请求:先查缓存,缓存没有就查数据库,然后回填缓存。写请求:先更新数据库,再删除缓存。

为什么更新顺序不能反过来?假设有两个线程T1和T2同时更新同一条数据。如果先更新缓存再更新数据库,T2把缓存改成了新值B,但数据库更新时失败,结果缓存里是B、数据库里还是A,数据就彻底不一致了;可数据库的成功率做不到百分之百,这属于不可接受的脏状态。反过来先更新数据库再删缓存,最多是缓存里还保留旧值,等TTL过期后自然恢复一致。

但"先更新DB再删缓存"也不是万无一失。考虑这个交错:T1更新数据库为A;T2读到数据库旧值B并写回缓存;T1删除缓存。最终缓存里是B,数据库里是A,不一致窗口被凭空拉长了。这种情况在并发场景下真实存在,于是有人提出了延迟双删。

3.2 延迟双删的正确用法与盲区

延迟双删的思路是,更新数据库之前先删一次缓存,更新成功后间隔一段时间再删一次缓存。第二次删除的作用,就是把T2可能在中间填进去的旧值B再清掉。

关键点在"间隔时间"怎么选。这个延迟必须大于读请求填充缓存所需的时间,否则第二次删除太早,后面还是会有旧值回填。经验值我一般配几百毫秒到一秒,具体要看业务读库和回填的耗时分布。要用压测和监控来确定,不要拍脑袋写个固定值。

不过延迟双删的局限也要讲清楚。第一,延迟时间段内依然存在不一致窗口,只是被缩小了。如果业务要求绝对强一致,比如余额支付场景,用缓存做数据源本身就是错的,这类数据应该直接查库。第二,如果第二次删除因为网络超时或Redis抖动没执行成功,缓存还是会脏,所以必须加重试。第三,一个key如果被高频率更新,每更新一次就要延迟删两次,缓存形同虚设,性能损耗很大,不如干脆不缓存这个key。

3.3 最终一致性的兜底:监听DB变更与重试队列

对一致性和性能都有要求的场景,我的建议是脱离纯业务代码的思路,引入异步解耦。流程变成:业务代码里只更新数据库,数据库的binlog变更由中间件监听完推送消息到MQ,消费端拿到变更事件后去删除对应的Redis缓存。如果删除失败,就进入重试队列,通过定时任务反复补偿。

这套方案多引入了一个消息组件,小团队会觉得重。简化版做法是:在业务数据库里建一张"缓存清理任务表",更新完主表数据的同时往任务表插入一条待删除的缓存key记录,由后台定时任务扫描这张表并执行缓存删除,成功后删除任务记录。比起binlog中间件,这个方案侵入性更低,也能保证最终一致性。

无论哪种方案,核心思想都一样:把"删缓存"这个动作从主链路里挪出去,做成可重试的异步任务。一致性不是靠运气,而是靠重试机制兜底。

4. 缓存服务本身的底线:内存淘汰、持久化与集群的实践认知

4.1 maxmemory与逐出策略:宁可丢缓存,不能丢请求

Redis是内存数据库,内存满了之后的策略决定整个系统的表现。配置文件里的maxmemory如果没设,Redis会一直用到机器物理内存耗尽,然后被系统OOM Killer干掉。所以生产实例一定要设置。

逐出策略maxmemory-policy常见几个:noeviction表示内存满时写入直接报错,不适合做缓存;allkeys-lru对所有key按LRU算法淘汰,适合一般缓存;volatile-lru只淘汰设置了TTL的key,适合需要常驻的热数据;allkeys-lfu按访问频率淘汰,适合有明显长尾热度的场景。

我个人的经验:大部分业务场景选allkeys-lfu或allkeys-lru就好。但要注意LFU对新建key不太友好——一个即将爆发的新热点key,刚写入时访问次数低,可能还没来得及累积热值就被淘汰了。所以要么对热点key做预热,要么在淘汰策略里结合业务对核心key做保护性设置。maxmemory也不能调到接近物理内存上限,要预留20%给操作开销和内存碎片。

内存碎片是个容易被忽略的点。INFO memory里mem_fragmentation_ratio超过1.5就说明碎片率偏高。常见解决办法是重启节点、使用activedefrag yes开启自动整理,或者调整jemalloc的分配策略。高配低用不如干脆把maxmemory设为实际需要容量的两倍,给碎片留空间。

4.2 RDB与AOF:缓存场景的持久化观

很多团队认为Redis是缓存,丢了可以重建,于是把持久化关了。这句话只对纯缓存冷数据成立。如果Redis里还存了分布式锁、限流计数器、购物车这类有状态的数据,持久化一旦缺失,主节点宕机恢复后数据全空,业务会受到二次打击。

RDB快照和AOF日志各有取舍。RDB恢复快但丢数据窗口大;AOF记录每次写操作,数据安全度更高。缓存场景我一般建议开AOF,并设置appendfsync everysec,兼顾性能和安全性。这里有一个实操细节:AOF重写如果和RDB快照同时触发,fork子进程会带来CPU瞬时增高,网络延时会跟着抖动。所以生产环境要把自动重写阈值调大,比如auto-aof-rewrite-min-size 2gb、auto-aof-rewrite-percentage 100,把重写时机尽量挪到低峰期。手动触发重写时,我也习惯在业务低峰期执行。

4.3 Cluster模式的槽位陷阱

当单机内存不够,很多团队会升级到Redis Cluster。Cluster将数据划分为16384个槽位,每个节点负责一段槽位范围。这套机制引入了一些使用上的硬约束,最容易踩坑的是跨槽位操作。

比如MGET命令同时读取多个key,如果这些key分布在不同节点上,Cluster会直接报CROSSSLOT错误。解决办法是使用hash tag,让同组key带同一个"标签"从而落入同一个槽位,比如user:{1001}:cart和user:{1001}:order。但hash tag会让相关数据堆积在少数节点,用多了必然造成数据倾斜。这里要权衡:是为批量操作方便做hash tag,还是接受跨节点访问的复杂度。Lua脚本也一样,执行脚本时要求所有key都在同一个槽位,否则跑不起来。

还有一个容易被忽略的坑:从节点升主后,参数配置不一定和原主节点一致。比如原主节点内存设为8G并开了AOF,从节点却沿用初始的2G配置,升级后内存直接写满。这个问题的根源是配置管理太分散,建议用统一的配置中心管理所有节点的Redis配置文件,变更走发布流程,而不是登录服务器手动改。

5. 落地一份"缓存巡检清单":把经验变成制度

5.1 连接池和超时:被忽略的首个瓶颈

很多缓存问题不是Redis慢,而是客户端连接管理出了问题。以常见的连接池为例,maxTotal设得过大,每个连接都占用一个文件描述符,Redis网络线程会被大量空闲连接拖累;设得过小,高峰期请求排队等待连接,响应时间飙升。粗略估算连接数的公式是:最大QPS乘以单次请求平均耗时(秒)。比如QPS为5000、平均耗时为10ms,那么连接数大约是50。实际生产环境我会留一倍余量,同时用监控观察等待连接数,再微调。

超时参数同样要注意。连接超时connectTimeout一般设几百毫秒,读超时socketTimeout建议100到300毫秒。这个值如果设得太长,Redis一次卡顿会让业务线程堆积上千,最后整个服务被打挂。配合熔断降级组件,当Redis超时率达到阈值时自动切到备源,比死等更健康。

5.2 热点key和big key的定期梳理方法

缓存问题很多是渐进式的,只有定期巡检才能在爆发前发现苗头。热点key方面,开了LFU策略的实例可以用redis-cli --hotkeys观察访问频次最高的key;没有开LFU就自己在业务代码里打点。big key方面,字符串value超过10KB、集合类型元素超过5000个都需要记录在册。

排查big key时不要用KEYS *,它会在单线程下长时间阻塞Redis。用SCAN配合DEBUG OBJECT或者直接用redis-cli --bigkeys的低峰期扫描,风险都小很多。发现big key之后的处理,除非业务允许直接删除,否则建议改造数据结构。比如一个大list存储一个月的埋点事件,可以按天拆成多个list;一个巨大hash缓存了一批用户信息,可以按业务维度拆成多个小hash。

5.3 故障演练:让缓存挂了也不可怕

没有经历过Redis宕机的系统,不算真正做过缓存治理。故障演练至少要覆盖这样的场景:把主实例手动停掉,观察哨兵或Cluster是否自动完成主从切换,客户端是否快速感知并重连;在切换期间,应用的降级开关是否生效,有没有出现数据库被流量冲垮的情况。演练之后整理一份回顾,把客户端重连慢、超时时间过长等问题都记下来逐条修。

还有预热脚本也很关键。Redis重启后,如果热点key全是冷的,数据库会在瞬间被灌满。上线前建议在应用启动阶段把核心业务的key按预估流量批量写入缓存,并提前验证预热逻辑对大key的处理。这样就算真的发生宕机切换,也可以快速恢复服务。

最后分享一个我个人的习惯:每次新加缓存前,先写清楚这个key的写入时机、过期时间、value大小和失效后的重建逻辑,在代码评审时过一遍。这套"缓存四问"看起来繁琐,但能让团队避免很多拍脑袋设计。缓存不是越快越好,而是要算清楚每一层数据在什么时候抵达、什么时候离开、什么时候坚持返回旧值更合理。把这些边界设明白,Redis缓存才能真正成为业务背后稳定且沉默的加速器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询