ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Redis避坑指南:穿透击穿雪崩、大Key与分布式锁实战解析

Redis避坑指南:穿透击穿雪崩、大Key与分布式锁实战解析 Redis好不好好。缓存、分布式锁、排行榜、计数、限流它几乎是后端服务的标配。但这玩意儿也不是个省心的主儿它本质是一个跑在内存里的单线程服务又把持着网络、持久化、集群同步这些复杂逻辑。你用得不讲究它就能用各种诡异现象教你做人缓存命中了数据库还被打瘫主从切换一下锁莫名丢了大KEY一删服务卡死几十秒AOF重写完那一刻延迟直接飙红。这篇博文就是把我这些年真实踩过、以及后来在业务排查里反复遇到的Redis的坑按场景拆开讲每个坑是怎么产生的、会在什么时机爆炸、我怎么规避的。适合正在维护线上服务的后端开发也适合刚接触Redis的初学者提前划个安全边界。1. 缓存三件套穿透、击穿、雪崩的真实杀伤力1.1 穿透、击穿和雪崩三者到底差在哪很多人把缓存穿透和击穿混为一谈其实机制完全不一样。穿透说的是请求一个根本不存在的数据Redis里没有这个key数据库里也没有那这个请求就会畅通无阻地打到数据库。如果上游是恶意刷参比如拿一堆负数ID、极长字符串轮着打接口DB就会反复执行无效查询连接池很容易被打满。击穿则完全不同。它针对的是一个确实存在、但刚刚过期的热点key在过期的那一瞬间有大量并发请求同时进入。因为它们都发现缓存里没数据于是集体冲向后端数据库数据库在几毫秒内被同一个查询打到爆。你可以把它理解成“一个很厚的大坝上有一个螺丝钉崩了整面墙就塌在这一个点上”。雪崩是更大范围的失效。一批key设置了相同的过期时间在某个整点同时过期或者Redis实例本身发生重启、主从切换导致大面积的缓存miss数据库被一浪接一浪的查询淹没。三者表现很像但处置方案完全不一样所以第一步一定是先分清自己面对的是哪种问题。问题数据是否存在失效范围典型触发场景数据库承受的压力穿透不存在单key持续未命中恶意刷参、无效ID一直被无效查询消耗击穿存在但正好过期单热点key瞬间失效热点商品、热点活动同一查询瞬间打爆雪崩大量key同时失效大面积失效批量设置相同TTL、实例重启整层被打穿1.2 各自怎么防布隆过滤器、互斥锁、随机过期与多级缓存穿透的常规解法有两个。第一个是缓存空值——查出来数据不存在也往Redis里写一个空结果只是TTL要设得很短比如30到60秒。这样同一个无效ID短时间内会被缓存挡住。第二个是布隆过滤器启动时把“可能存在”的key都加载进一个bitmap查询进来先问布隆过滤器它说“肯定不存在”就直接返回不再走缓存和数据库。布隆过滤器眼里的“不存在”很靠谱“存在”却可能误判。所以它用来拦穿透非常合适但不能把它当精确索引用。容量计算建议留一定余量比如预估10亿条数据、误判率控制在1e-5对应的bit位长度和hash函数个数直接用成熟库里的参数算就行别凭空估。击穿的解法我更推荐“逻辑过期”。所谓逻辑过期不是让Redis的TTL去失效key而是往value里塞一个业务过期时间比如“validUntil现在10分钟”。读到时发现逻辑过期了先把这个旧值返回给调用方同时只让一个线程去更新缓存其他线程不参与回源。这个方案不像互斥锁那样需要阻塞等待性能损耗小实现也不复杂。雪崩则要防“同时过期”。最简单的手段是给TTL加随机因子比如基础时间1小时实际设置成“1小时随机0到300秒”让key的失效时间分散开。业务启动前还要做缓存预热把所有热点数据在低峰期预先刷进去而不是等用户流量把缓存击穿了再慢慢回源。1.3 把方案落到代码里时的三个细节第一个细节空值缓存的TTL一定要和正常业务缓存分开关单独定义常量。我见过有人图省事把空值TTL也设置为和业务缓存相同结果热点无效参数被刷的时候缓存反复重建数据库压力不减反增。第二个细节逻辑过期方案千万不要在“过期瞬间”阻塞所有请求。核心是“有且仅有一个线程去回源”其他线程哪怕拿到旧值也先返回。否则你只是把击穿问题换了个方式重演一遍。第三个细节布隆过滤器上线后如果删掉了大量真实数据原有bitmap就会“失真”误判率上升。建议定期重建布隆过滤器或者采用带删除能力的Counting Bloom Filter但代价是内存占用更大。普通场景下我都是让布隆过滤器和空值缓存双保险不用纠结删除能力。2. 持久化的坑RDB和AOF都没你想的那么省心2.1 RDB的坑fork阻塞和丢失窗口并存RDB是定时生成全量快照这背后有个容易被忽略的操作叫fork。Redis要持久化不是直接读内存写文件而是fork一个子进程子进程共享父进程的内存快照由子进程去落盘。fork本身是阻塞主线程的实例内存越大fork耗时越长。20GB甚至更大的实例一次fork可能导致服务卡顿几十毫秒甚至更久业务高峰期就是灾难。RDB的另一个坑是丢失窗口。默认配置save 900 1的意思是900秒内至少1次写操作就做快照但极端情况下Redis还没来得及做下一次快照就宕机了最近几秒到几分钟的写数据全部丢失。如果业务能接受丢失部分秒级数据RDB没问题接受不了就得上AOF。不要一看RDB有坑就完全关掉它。Redis重启时RDB文件恢复的速度远快于AOF重放而且RDB作为冷备文件体积小、好归档。我的实践是保留RDB定时备份同时开启AOF做热备两条腿走路。2.2 AOF的坑三种刷盘策略怎么选AOF记录的是每条写命令刷盘策略有always、everysec、no三档。always是每条命令都刷盘最安全但性能最差Redis的吞吐会明显下降no是交给操作系统决定什么时候落盘性能好但宕机时可能丢一大批数据everysec是每秒刷一次综合表现最好最多丢1秒数据绝大多数业务都选它。AOF还有一个成长性坑文件会持续膨胀。虽然Redis自带AOF重写机制但重写动作本身要fork子进程、重新生成压缩后的AOF文件这个瞬间同样会占内存、占CPU、占磁盘IO。高频写入场景下AOF重写如果叠上业务高峰主线程调度都可能被影响。AOF文件损坏也是一类常见故障。Redis启动时会加载AOF文件头部坏了直接起不来这时候别慌用redis-check-aof --fix工具修复再把修复后的文件拷回去重新启动。另外AOF重写的临时文件也占磁盘空间磁盘写满时Redis会拒写所以运维上要监控磁盘水位。2.3 推荐组合混合持久化加定时冷备我现在的生产配置是开启AOF 混合持久化核心参数长这样appendonly yes appendfilename appendonly.aof appendfsync everysec aof-use-rdb-preamble yes auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mbaof-use-rdb-preamble yes是Redis 4.0以后很有用的特性。开启后AOF文件开头是RDB格式的快照数据后面才是增量写命令。这样AOF文件体积可以压得很小重启恢复时先加载RDB段再重放少量增量命令恢复速度比纯AOF快很多。在这个基础上我会额外写一个凌晨低峰期的定时任务执行BGSAVE生成RDB然后把RDB文件归档到云存储。真到了误操作或者实例彻底损坏的时候可以用它做最后一道恢复兜底。注意归档的RDB要和线上版本兼容Redis大版本升级期间我会停止归档等升级完再重新建立快照基线。3. 内存管理那点事大KEY、热KEY、过期与淘汰3.1 大KEY影响远比你想的大先定义清楚什么才算大key。字符串类型超过10KB算偏大超过100KB就是大key集合类型比如list、hash、set、zset单个元素数量超过5000个或者序列化后总大小超过几十MB也要警惕。最大的问题不在存储而是这几个操作环节都会因为大key而受伤。第一个环节是读取和网络传输。一个5MB的value被读取Redis生成响应、网络发送都要耗时间客户端多的话直接拖垮带宽。第二个环节是删除。用DEL删一个几百MB的keyRedis主线程会同步释放内存期间其他所有命令都得等着表现就是“Redis突然卡了一下”。第三个环节是持久化。RDB快照包含大key文件变大fork和写盘时间都变长。排查和清理的手段我已经固定下来了。平时按周期在低峰跑一次redis-cli --bigkeys --i 0.1--i 0.1表示每扫描100个key休息0.1秒降低对线上实例的冲击。扫描结果会列出不同类型的最大key看到特别大的就进一步用DEBUG OBJECT看它的编码和序列化长度。清理时用UNLINK而不是DELUNLINK是异步释放内存主线程不会被卡住。3.2 热KEY单节点的“流量黑洞”热key和big key是两回事。big key是“单次访问成本高”热key是“访问频率极高”。一个value很小的key如果每秒被百万次请求访问Redis单线程处理不过来同样会把CPU打满尤其是集群模式下大家以为做了水平扩容就能分担压力结果流量全打在一个分片上其他分片闲得发慌。发现热key线上别急着用MONITOR命令去抓MONITOR会输出所有命令对性能影响很大。我一般配合监控指标看某个分片节点的CPU或网络入流量明显高于其他节点再用redis-cli --hotkeys扫描依赖LFU策略需要把maxmemory-policy设为allkeys-lfu或者短时间定向观测。处理热key我的经验是分两层。第一层在应用本地加一层Caffeine之类的本地缓存把最热的读请求挡在Redis之前通常挡掉80%到90%没问题。第二层对确实没法缓存的热key做“打散”比如把商品库存的key从“stock:1001”拆成“stock:1001:0”到“stock:1001:9”十个分片请求随机落到一个分片再把各分片的计数累加。注意这只适合可聚合的场景比如计数、库存总量不适合强一致的单条查询。3.3 过期与淘汰机制没错用法容易错Redis的key过期采用“惰性删除定期删除”双机制。惰性删除是访问key的时候发现它过期了顺手删掉定期删除是后台每隔一段时间随机抽查一批带TTL的key把过期的那部分清掉。它没有全局定时扫描器所以一个key过期后实际内存里可能还要存一段时间如果一直没人访问它就一直占着空间。这就带来一个大众误解以为EXPIRE设置完内存就会马上释放。实际上Redis的used_memory不会立刻下降除非key被访问或抽查到。做容量规划时我给maxmemory一般留20%到30%的余量不能算得刚刚好。淘汰策略也要提前想明白。常用的是allkeys-lru所有key按LRU近似淘汰适合纯缓存场景。用volatile-lru则只淘汰设置了过期时间的key好处是没设TTL的长期key不会被挤掉但坏处是如果全实例几乎没人设置TTL它就退化成了noeviction内存写满后所有写命令直接报OOM错误。我见过线上明明配的volatile-lru结果因为新增业务没有给key设置过期时间某天缓存写不进去数据库被瞬间打穿。所以策略选择要跟着业务的数据分布走别照抄网上的配置。另外建议开启maxmemory-policy allkeys-lfu并配合--hotkeys扫描LFU在热点识别上比LRU更精准但要注意这会让Redis在写入时多做一次访问频率计数吞吐会有一点点损失能接受就换。4. 并发场景的分布式锁从setnx到Redisson的一路升级4.1 从setnx到SET原子命令锁是怎么一步步进化来的分布式锁是Redis除了缓存之外用得最多的场景也是翻车重灾区。最早很多人这样写先SETNX lock_key unique_value成功说明拿到锁然后马上EXPIRE lock_key 30设置过期时间。这个写法的致命伤在于SETNX和EXPIRE是两条独立命令中间如果进程崩了锁就会永远留在Redis里从此所有并发请求都拿不到锁业务死锁。后来大家学乖了改用一条原子命令SET lock_key unique_value NX EX 30000其中NX表示只有key不存在时才设置成功EX 30000表示30秒后自动过期。这一步解决的是“加锁和设置超时”的原子性。但光有这个还远远不够下面几个问题才是真正的连环雷。4.2 锁误删、超时与续期要处理的不只是并发假设场景回到业务里线程A拿到锁设置超时30秒结果A的业务逻辑执行了35秒。锁在30秒时自动释放线程B拿到锁开始执行。又过了5秒A终于执行完随手调用DEL lock_key把B的锁删了然后线程C也能拿到锁。三个线程同一时刻跑同一个业务分布式锁完全失效。解决误删的办法是给锁的value设置一个调用方唯一标识删除前先校验这个value是不是自己的。但校验和删除也得是原子的不能“先GET比较再DEL”。标准做法是用Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本先取出锁的value和调用方传进来的唯一标识比较一致才删除。这样A的删除命令到Redis时发现锁已经被B的标识替换了就不会误删。锁超时的问题也真实。业务执行时间不可控锁超时定太短会提前释放太长则万一持有锁的实例挂了要等很久才能恢复。更稳妥的做法是用Redisson的“看门狗”机制锁默认30秒过期持有锁的线程只要还在运行后台会每隔一段时间自动续期相当于把锁的生命周期和业务执行周期绑定。业务方也就彻底不用关心“这个业务到底要跑多久”了。注意看门狗只对Redisson自己的锁生效如果业务里混用了原生SET NX EX和Redisson续期逻辑会接不上。4.3 主从切换和RedLock并没有银弹还有一种锁丢失场景发生在Redis主从结构下。客户端A从主节点写入锁但这条数据还没同步到从节点主节点恰好宕机哨兵把从节点提升为主节点。新主节点上根本没有这把锁客户端B再来加锁就成功了两个客户端同时持有锁。有人会祭出RedLock也就是在多台相互独立的Redis节点上逐个加锁超过半数成功才算拿到锁。但RedLock不是万能钥匙它依赖系统时间戳节点时钟发生跳跃时可能出现加锁过期节点宕机后恢复也可能引发重叠期实现复杂度还不低。我个人的态度是对于多数互联网业务分布式锁需要的是“尽量降低并发重复”不是“某些安全场景下的强互斥”那就不必非上RedLock。单机Redis加Redisson锁再配合业务层唯一约束或者事务做兜底足够可靠。如果业务确实强一致比如订单支付、资金扣减锁的重心就不应该放在Redis上考虑直接用ZooKeeper临时顺序节点或者etcd租约它们在“节点故障后锁自动失效”这件事上语义更严格。锁是手段不是目的真正的防线永远是业务幂等设计。5. 数据类型和序列化数据乱码、ClassCastException的根源5.1 数据结构选错上线后改代码都是小事Redis的五种基础数据结构里最容易选错的是String和Hash。很多团队习惯把所有对象都用JSON序列化成字符串塞进String业务要改其中一个字段就得先GET出来、反序列化、改好再SET回去。并发更新时两个线程同时GET到旧值后面的覆盖前面的数据就悄悄丢了。这种情况用Hash更合适HSET user:1001 age 18只更新一个字段Redis内部帮你管理哈希表不会出现“整条覆盖”问题。排行榜和排序场景用ZSet别自己用String维护分数再手工排序去重统计用Set不需要精确计数的海量UV可以用HyperLogLog位图类状态判断用bitmap。选型的原则很简单先想清楚是“读整个对象”“改某个字段”“做集合运算”还是“排序取范围”再选对应的结构而不是无脑JSON。5.2 序列化配置乱码和ClassCastException的根源用Spring Data Redis的RedisTemplate默认的序列化器是JDK序列化。你会发现存进去的对象变成一坨不可读的二进制换客户端看全是乱码其他服务用不同序列化器读时直接抛异常。这个坑太常见了我基本每次接手项目都要改一遍。上线前请把RedisTemplate的序列化器显式配置好RedisTemplateString, Object template new RedisTemplate(); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());key和hash key务必用String序列化value用JSON。这样至少保证key在命令行里可读value在客户端里可读跨环境不容易出现ClassCastException。如果业务对反序列化要求带类型信息就用GenericJackson2JsonRedisSerializerJSON里会带class字段如果担心类型信息泄露或想压缩体积用Jackson的ObjectMapper只序列化具体类型也可以但消费端要保持一致。这里有个容易忽略的点一旦线上已经用JDK序列化写入过数据你再改序列化器旧数据就读不出来了。所以序列化器要在项目初期就定好中途改要评估存量数据最好先把对应key逐一迁移。5.3 几个隐蔽的数据语义坑用法对了之后还有一些低级但致命的坑值得记录。比如用INCR做计数器key设置了TTL过期后再次INCR会从0开始如果业务以为计数器是累加的就会出现统计莫名其妙的归零。限流、编号生成这类场景一定要注意TTL重置信语是不是符合预期。再比如Redis的过期时间只支持毫秒级精度SET时的EX参数是秒PX是毫秒不要弄混。集群环境下SCAN的游标是按节点返回的跨节点扫描结果不能简单拼接需要按节点分别遍历。这些坑本身不复杂但一出问题就非常隐蔽日志里看不出任何异常只有数据对不上账的时候才发现。6. 客户端、连接池与网络超时Lettuce的隐藏雷区6.1 Lettuce连接超时默认配置和真实瓶颈线上最典型的异常报错就是io.lettuce.core.RedisCommandTimeoutException: Command timed out after 3 second(s)Lettuce是Spring Boot默认的Redis客户端基于Netty异步实现。单看设计它很优秀但它默认没有开启连接池默认连接超时3秒。很多团队遇到超时第一反应是把spring.redis.timeout从3秒调到10秒甚至30秒结果问题并没有消失反而把故障时间拉长了——因为超时只是“客户端等不到响应”的表现真正的原因可能是Redis端存在慢命令、网络抖动、连接数打满。正确的排查顺序是先看Redis侧监控QPS、CPU、慢日志、客户端连接数再看应用侧GC暂停和网络延迟。绝大多数“Lettuce超时”其实是Redis单线程被某个慢命令卡住或者跨机房访问导致的网络耗时偏大调大timeout只是在拖延发现真相。6.2 连接池设置了也没用的场景在Spring Boot 2.x里如果只用默认依赖Lettuce的连接池其实是未启用的。要真正配连接池需要先引入commons-pool2这样配置才会生效。参考配置如下spring.redis.timeout3s spring.redis.lettuce.pool.max-active32 spring.redis.lettuce.pool.max-idle32 spring.redis.lettuce.pool.min-idle0 spring.redis.lettuce.shutdown-timeout200ms注意连接池不是开得越大越好。Redis是单线程处理命令开200个连接并不会让你每秒多处理两倍请求反而会增加线程切换开销、文件描述符占用和Redis侧的内存占用。我一般是先压测从32开始压看哪个值让P99延迟最稳如果连接池配置不变但超时依然出现重点查慢命令和网络。还有一个典型场景Lettuce在“一次连接上发起多个异步命令”时表现很好但如果你在同步调用中写出“大量短连接、高频建连”连接池就形同虚设。一定要复用LettuceConnectionFactory别在每次请求里手动创建连接。6.3 排查慢命令的实操流程我排查Redis慢命令有一套固定动作。先看Redis慢日志redis-cli SLOWLOG GET 20返回结果里能看到执行时长超过阈值的命令、参数和耗时。阈值默认是10毫秒线上我会调成2毫秒抓得更细。紧接着执行redis-cli INFO commandstats这个命令统计每个命令的总调用次数和总耗时能看到哪些命令是CPU热点。比如KEYS开头的全量扫描、SMEMBERS返回大集合、HGETALL读大Hash都是高发慢命令。如果确实有大KEY问题用第三部分提到的--bigkeys扫描定位。注意整个排查过程尽量在从节点或低峰期执行避免在主节点高峰现场再补一刀。7. 集群、主从与日常运维那些沉默的隐性坑7.1 集群跨slot操作的硬限制Redis Cluster把key按CRC16算法分布到16384个槽位一个key属于哪个槽位由key本身决定。这在带来扩容灵活性的同时也带来一个硬限制MGET、PIPELINE、事务MULTI/EXEC、Lua脚本里涉及多个key时所有key必须落在同一个slot上。否则Redis直接返回CROSSSLOT错误。这个问题在业务上线前最容易踩。一个原子操作要读商品和库存两个key单机Redis没问题迁移到Cluster后全线报错。解决办法有两个一是给key加哈希标签比如把“user:123:name”和“user:123:orders”改成“user{123}:name”和“user{123}:orders”这样只有中间{}的部分参与槽位计算两个key必然落在同一槽二是应用层自己分拆并行处理放弃单次原子性。哈希标签好用但滥用会加剧数据倾斜所有key挤在一个节点上别为了解决槽位把热点全聚到一起。7.2 主从复制、全量同步与脑裂主从复制有个容易忽略的性能坑全量同步。当从节点初次加入、或者主从断连过久触发全量重同步时主节点要fork子进程生成RDB再把RDB全量传给从节点。这个过程中主节点本身要承受fork阻塞和巨大网络IO。大带宽场景下还好跨机房同步会让专线打满业务延迟明显上升。repl-backlog-size的设置也很关键。它决定了从节点断连后能增量同步多少数据。如果设置得太小从节点稍微断开久一点就要重新全量同步循环伤害。我一般配置64MB到128MB根据写流量大小再调。全量同步期间不要频繁做BGSAVE两者叠加会让主节点压力翻倍。脑裂问题在哨兵模式和集群模式都存在。主节点出现网络分区后哨兵或集群感知到主节点失联会提升新的主节点但原主节点其实还在运行继续接收写请求。等分区恢复原主节点降级为从节点它持有的那些“新增写入”就永远丢了。规避思路有两个一是调整cluster-node-timeout和sentinel的判定参数让故障转移别太激进避免短暂抖动就切换二是在客户端写路径上做重试和幂等即使主节点切换应用层的状态也能兜住。7.3 线上禁用的危险命令与可视化客户端安全日常运维中最该警惕的是误操作命令。KEYS *会全量扫描所有key执行期间Redis被完全阻塞线上实例几秒钟甚至几分钟不可用直接引发雪崩。替代方案是用SCAN命令它每次返回一小批key配合游标迭代不会阻塞主线程。此外FLUSHALL、FLUSHDB、CONFIG SET、DEBUG SLEEP、SHUTDOWN这些在线上都该禁用或用rename的方式改名。生产环境的配置可以加这么几行rename-command FLUSHALL rename-command FLUSHDB rename-command CONFIG CONFIG_BLOCKED rename-command DEBUG DEBUG_BLOCKED rename-command KEYS KEYS_BLOCKED注意rename-command只对后续连接生效客户端工具如果还要用原命令名会失败所以别一股脑全禁先确认监控和运维工具依赖哪些命令。可视化客户端方面开发环境用Another Redis Desktop Manager这一类的工具很顺手但连生产环境要谨慎。生产Redis至少应该开启requirepass并配置protected-mode yes有条件再用TLS加密。网上不少教程直接docker run -p 6379:6379 redis既没挂数据卷又没配密码容器一删数据全没了公网一扫描还能被人拿来挖矿。正确的容器启动至少是这样docker run -d --name redis \ -p 6379:6379 \ --restartalways \ -v /data/redis/conf/redis.conf:/etc/redis/redis.conf \ -v /data/redis/data:/data \ redis:7.2 redis-server /etc/redis/redis.conf数据目录和配置目录必须以卷的方式持久化配置文件里写好requirepass和appendonly yes这才算能上生产的部署。Windows下没有官方Redis安装包网上各种移植版版本老旧建议用WSL或者Docker跑官方镜像省掉一堆环境兼容的烦恼。这些坑大部分不会在新手教程里出现。Redis本身是个好组件但它的好建立在“使用姿势正确”的前提下。我写这篇也是给自己提个醒每次改Redis相关的配置先问一句“这个操作在主线程上会不会卡住”“这个key会不会被扫一遍”“这个锁会不会在极端情况下丢失”想清楚了再动手能少掉很多头发。
返回列表