广告:Codex Token 低价中转站稳定接口 · 快速接入 · 开发者备用通道
Engineering article

缓存穿透击穿雪崩解决 | 成本优化

缓存穿透、击穿、雪崩是高并发系统中必须解决的三大缓存问题,处理不当会直接导致数据库压力爆炸,服务不可用,甚至引发连锁故障。我见过最狠的缓存穿透是用户ID传参,比如123456789这种可能性极高的随机数,导致缓存未命中,数据库暴增百万级请求。解决方法不是简单加个空值缓存,而是用布隆过滤器提前拦截无效请求,减少无谓的数据库访问。击穿问题更容

缓存穿透击穿雪崩解决 | 成本优化
配图来源于网络和AI生成,仅供参考。
▌ 技术引导 缓存穿透、击穿、雪崩是高并发系统中必须解决的三大缓存问题,处理不当会直接导致数据库压力爆炸,服务不可用,甚至引发连锁故障。我见过最狠的缓存穿透是用户ID传参,比如123456789这种可能性极高的随机数,导致缓存未命中,数据库暴增百万级请求。解决方法不是简单加个空值缓存,而是用布隆过滤器提前拦截无效请求,减少无谓的数据库访问。击穿问题更容易出现在热点数据失效时,比如促销活动结束,缓存过期后大量请求直冲数据库,这时候加互斥锁或者定时更新缓存可以有效缓解。雪崩则是大量缓存同时失效,需要设置不同的过期时间,或者使用集群缓存,避免同一时间大量请求穿透。这些方案在实际部署中必须结合业务场景,不能一股脑全堆上去,成本优化才是最终目的。 缓存穿透最常见的是用户ID传参,比如查询某个用户是否注册,但ID是随机生成的,数据库里根本不存在,造就了大量无效请求。这时候布隆过滤器是必须的,它用位数组和哈希函数快速判断一个Key是否存在,拦截掉无效请求。我见过有些团队直接把空值缓存进Redis,效果没那么好,因为恶意攻击者可以构造大量不存在的Key,导致缓存中堆积无效数据。布隆过滤器应该和缓存一起部署,防止数据被污染。另外,布隆过滤器误判率可控,可以选择合适的哈希函数和位数组大小,比如使用murmurhash和redis的setbit命令,误判率控制在0.1%以内就足够了。 击穿问题实质是热点数据失效后,大量请求同时访问数据库,导致CPU和IOPS飙升。解决方法是加锁,比如Redis的SETNX命令或者用Lua脚本实现分布式锁,保证同一时间只有一个线程去更新缓存。但锁机制容易引发并发问题,比如超时导致请求失败。我见过有些团队直接设置缓存的过期时间比业务逻辑的更新时间晚一点,这样热点数据不会在短时间内失效,减少击穿风险。还可以用本地缓存做兜底,比如Guava Cache,当Redis锁失败时,先查本地缓存,防止全部掉到数据库。真实场景中,这种组合策略效果最好,但要注意本地缓存的更新延迟。 雪崩问题属于缓存批量失效,比如定时任务清空了所有缓存,或者某个Key集群失效,导致大量请求穿透。这时候需要把缓存的过期时间打散,比如使用随机过期时间,或者设置不同的过期策略,比如部分Key设置不同的TTL。还可以在缓存失效前,主动更新缓存,比如使用定时任务预热。我见到过一些公司用Redis集群部署,配合Redis的Pipeline技术,批量更新缓存,减少网络抖动带来的影响。此外,如果缓存层无法避免雪崩,可以引入熔断机制,比如Hystrix,当数据库负载过高时,直接返回默认值,避免服务崩溃。 成本优化是缓存系统设计的关键,不能只顾性能,还要考虑资源消耗。布隆过滤器虽然能拦截无效请求,但本身占用内存,且需要额外的计算开销。实际部署时,要衡量布隆过滤器的内存成本与数据库压力的降低是否划算。比如一个百万级用户系统,每个用户ID占用500字节,布隆过滤器只需要10MB就能处理,成本非常低。缓存击穿时,加锁会带来额外的线程竞争,影响吞吐量,这时候可以评估是否用本地缓存做缓存层,或者干脆采用异步更新策略,比如用RabbitMQ把更新任务放进队列,避免同步锁的性能损耗。雪崩问题的解决方案比如预热和打散过期时间,需要根据业务特性调整,不能一刀切。 ▌ 技术参考 一 缓存穿透的解决方案 缓存穿透是由于查询了大量不存在的数据,直接穿透到数据库。最常见的场景是用户ID传参,比如查询某个用户是否注册,但该用户从未存在。这时候布隆过滤器是首选方案,它用位数组和哈希函数存储所有可能查询到的Key,比如用户ID,当请求来时先查布隆过滤器,如果不存在就直接返回空,避免访问数据库。实现时可以用Redis的setbit命令写入布隆过滤器,用getbit命令查询。误判率控制在0.1%以内,通常使用murmurhash3作为哈希函数,位数组大小根据数据量和误判率计算。也可以用第三方库如RedisBloom,这样更高效,且支持动态扩容。实际部署时,布隆过滤器需要与缓存层同步,确保数据一致性,否则可能误判。 二 布隆过滤器的实现细节 布隆过滤器的核心是位数组和哈希函数,实现时需要计算位数组大小和哈希函数数量。例如,若预计有1000万条数据,误判率0.1%,那么位数组长度应为10000000 ln(2)/ln(1/0.1) = 11.5MB,哈希函数数量为10000000 ln(2)/ln(1/0.1) = 7。在代码中,可以用redis-cli命令设置位数组,比如setbit bloom:users 123456789 1,代表该用户ID存在。查询时用getbit bloom:users 123456789,若返回0则不存在,直接返回空。需要注意的是,布隆过滤器不支持删除,因此需要配合其他数据结构,比如Redis的set存储真实数据,再通过布隆过滤器拦截无效请求。误判率过高会浪费内存,而过低则增加计算复杂度,需根据业务均衡。 三 缓存击穿的解决方案 缓存击穿是热点数据失效后,大量请求同时访问数据库,导致CPU和IOPS飙升。这时候加锁是最直接的方案,用Redis的SETNX命令或者Lua脚本实现分布式锁,确保同一时间只有一个线程去更新缓存。例如,在查询用户信息前,先获取锁,如果存在则直接返回缓存,否则去数据库查询并更新缓存。但锁机制容易引发死锁和并发争用,影响吞吐量。替代方案是使用本地缓存,比如Guava Cache,当缓存未命中时先查本地缓存,防止全部掉到数据库。还可以在缓存失效前触发预更新,比如用定时任务提前重载缓存,避免同时失效。实际中,这些方案组合使用效果最佳,比如用锁+本地缓存+异步更新。 四 互斥锁的实现与性能影响 互斥锁的实现可以通过Redis的SETNX命令,例如: SETNX lock:users 1 如果返回1,表示锁成功,可以执行操作,执行完成后用DEL命令释放锁。但要注意锁的超时时间,比如设置一个5秒的过期时间,防止线程崩溃导致锁未释放。实际部署中,如果业务逻辑执行时间超过锁的过期时间,会导致锁失效,多个线程同时更新缓存,反而加剧问题。这时候可以用Lua脚本封装加锁逻辑,确保原子性。例如,用redis-cli -x lua script.lua 123456789,其中script.lua包含加锁和解锁逻辑。此外,互斥锁在高并发下会显著降低吞吐量,因为线程等待锁的时间可能超过缓存更新时间,所以需要评估是否真的需要锁,或者用异步更新替代。 五 热点数据失效的防击穿策略 热点数据失效是缓存击穿的主要诱因,比如某个商品在促销活动结束时,缓存过期,但大量用户继续访问。这时候需要在缓存失效前触发预更新,比如用定时任务或者事件驱动机制。例如,在Redis中设置一个Key的过期时间为10分钟,但提前2分钟用另一个Key记录该Key将要失效,然后用定时任务去重载。或者用消息队列,比如Kafka,在缓存失效后发送消息给消费者更新缓存。此外,可以将热点数据的TTL延长,比如设置为1小时,这样可以减少击穿概率。但延长TTL会增加缓存占用空间,需要结合业务生命周期评估。 六 本地缓存的使用场景与限制 本地缓存适用于热点数据访问频率高但更新频率低的场景,比如Guava Cache,它的默认TTL是5分钟,支持并发访问,且内存占用可控。使用时可以在服务启动时预热缓存,比如用HikariCP连接数据库,批量查询热门用户信息并存入本地缓存。当Redis缓存未命中时,先查本地缓存,减少数据库压力。但本地缓存不能完全替代Redis,因为数据一致性问题。比如,当Redis更新缓存后,本地缓存未及时同步,可能导致数据不一致。因此,本地缓存通常只作兜底,不作为主缓存,且需要设置适当的更新策略,比如定时刷新或监听Redis变更。 七 Redis集群的缓存失效管理 在Redis集群环境中,缓存失效管理需要考虑数据分布和节点负载。比如,使用Redis Cluster时,每个Key会根据哈希槽分配到不同的节点,失效时可能影响多个节点。这时候可以设置不同的TTL策略,比如随机TTL,避免所有Key同时失效。例如,在设置Key时加入一个随机值,如EXPIRE key 600+rand(0, 100),这样不同Key的过期时间差异较大,减少雪崩风险。此外,可以利用Redis的KEYS命令或SCAN命令,定期清理过期Key,比如用KEYS | xargs redis-cli -x expire 300。但不要频繁执行,否则会增加CPU负载。配合Redis的淘汰策略,比如volatile-lru,可以有效管理内存。 八 RedisPipeline的使用与优化 RedisPipeline用于批量操作,减少网络往返次数。例如,在更新大量缓存时,用Pipeline一次性发送多个命令,而不是逐条发送。代码示例: Pipeline p = redisTemplate.getPipeline(); for (User user : users) { p.opsForValue().set("user:" + user.getId(), user); } p.flush(); 这样可以提升吞吐量,但要注意Pipeline的操作不能打断,否则可能导致事务失败。此外,可以用Lua脚本封装Pipeline操作,进一步减少网络延迟。比如,在Lua脚本中写入多个SET命令,然后用EVAL命令执行。这样不仅减少网络开销,还保证了原子性。Pipeline的使用需要结合业务逻辑,避免因为缓存更新失败导致数据不一致。 九 Redis的分布式锁与并发控制 Redis的分布式锁主要依赖SETNX和EXPIRE命令,但容易出现锁失效。这时候可以用Lua脚本实现锁的原子操作,例如: if redis.call("SETNX", KEYS[1], 1) == 1 then redis.call("EXPIRE", KEYS[1], 5) return 1 else return 0 end 这样可以确保锁的设置和超时时间同时执行。锁的粒度要足够细,比如针对每个用户ID单独加锁,而不是全局锁,否则会影响并发性能。另外,锁的持有时间不能设置为固定值,应该根据业务逻辑动态调整。比如,如果数据更新时间较长,锁的持有时间也要相应延长,否则可能被误判为锁失效,导致线程重新获取锁,加剧CPU负载。 十 缓存穿透与击穿的性能对比 缓存穿透和击穿都涉及大量无效请求,但解决方式不同。缓存穿透需要拦截,而击穿需要控制访问。比如,布隆过滤器拦截了90%的无效请求,但每次查询需要额外的哈希计算,增加了CPU开销。而互斥锁在热点数据失效时能有效缓解击穿,但每个请求都可能等待锁,影响吞吐量。性能对比中,布隆过滤器的处理速度比直接查数据库快10倍以上,但占用内存。互斥锁在高并发下可能造成请求阻塞,导致平均响应时间增加300%。因此,实际中需要根据业务场景选择是否采用布隆过滤器,或者在击穿时使用锁机制。 十一 雪崩问题的预防与应对 雪崩问题往往发生在定时任务清空缓存后,大量请求同时访问数据库。解决方法是设置不同的TTL,比如用随机数调整过期时间,确保不同Key的过期时间不会重叠。例如,在设置Key时加入一个随机值,如EXPIRE key 600+rand(0, 100),这样每个Key的过期时间略有差异,减少同时失效的概率。此外,可以使用缓存预热策略,在系统启动时加载高频访问的数据到缓存。或者在业务高峰期前,用定时任务提前加载缓存,避免在业务高峰期出现雪崩。但预热需要额外的计算资源,可能增加启动时间。 十二 Redis的淘汰策略与内存管理 Redis默认的淘汰策略是noeviction,不删除数据,但可能导致内存溢出。在实际中,应该启用volatile-lru或volatile-ttl策略,避免内存撑爆。例如,设置maxmemory 1024mb,maxmemory-policy volatile-lru,这样当内存不足时会淘汰最近最少使用的Key。但要注意,某些业务场景下,比如订单数据,不宜使用LRU策略,因为可能会删除正在使用的数据。这时候可以使用allkeys-lru策略,或者结合LFU策略。这些策略会增加CPU开销,但能有效防止内存溢出,保证系统稳定性。 十三 缓存预热与冷启动优化 缓存预热是防止雪崩的有效手段,尤其是在系统冷启动时。例如,在Spring Boot中,可以用@PostConstruct注解在应用启动后加载热门数据到Redis。代码示例: @PostConstruct public void init() { List hotUsers = userMapper.findHotUsers(); hotUsers.forEach(user -> redisTemplate.opsForValue().set("user:" + user.getId(), user)); } 预热可以避免在业务高峰期出现缓存空洞,提升用户体验。但预热需要提前知道哪些数据是热点,这可以通过A/B测试或历史数据分析获得。另外,预热任务不能太耗时,否则会阻塞应用启动。可以用多线程分批次加载,或者用异步任务,在应用启动后逐步预热缓存。 十四 RedisPipeline与慢查询优化 使用Pipeline可以将多个命令一次性发送到Redis,减少网络延迟。比如,在查询多个用户信息时,可以用Pipeline批量获取,避免多次网络往返。代码示例: Pipeline p = redisTemplate.getPipeline(); List keys = Arrays.asList("user:1", "user:2", "user:3"); List values = new ArrayList<>(); for (String key : keys) { values.add(p.opsForValue().get(key)); } p.flush(); 这样的操作能提升查询吞吐量,尤其是在高并发场景下。但要注意,Pipeline中的操作不能被打断,否则可能导致数据不一致。此外,可以用Redis的批量操作,比如MGET和MSET,减少命令数量。这些优化的收益在每秒1000次请求的场景下最明显,但增加了一定的代码复杂度。 十五 布隆过滤器的误判与漏判处理 布隆过滤器的误判率直接影响系统性能,比如当误判率是0.1%时,每百万次查询可能有1000次误判,但漏判率几乎为零,只有少数Key被错误判断为不存在。因此,布隆过滤器更适合拦截大量无效Key,而不是保证所有Key的正确性。在实际中,可以结合Redis的set存储真实数据,当布隆过滤器误判时,再查Redis是否真的存在。这种方式的误判率在实际业务中通常可以接受,因为误判不会造成实际影响,而漏判会引发数据库压力。因此,布隆过滤器的误判率需要根据业务需求进行权衡,一般控制在1%以下即可。 十六 缓存穿透的替代方案与进阶技巧 除了布隆过滤器,还可以用本地数据库或文件存储历史访问记录,作为二次拦截。例如,记录所有访问过的Key,当Key不在缓存中时,先查本地数据库,如果不存在再返回空。这种方式可以避免布隆过滤器的内存开销,但会影响查询性能。或者在Redis中用set存储所有可能存在的Key,当查询时先查set,存在才查缓存,否则直接返回空。这种方法虽然简单,但可能导致缓存数据冗余。在高并发场景下,可结合这些方案,形成多层拦截,降低数据库压力。 十七 Redis的集群部署与负载均衡 在Redis Cluster中,每个Key被分配到不同的节点,因此缓存失效管理需要考虑节点负载。例如,使用Redis的KEYS命令或SCAN命令,定期清理过期Key,比如在容器启动时执行KEYS | xargs redis-cli -x expire 300。但不要频繁执行,否则会影响性能。此外,可以配合Redis的replica机制,避免主节点过载。比如,主节点处理写请求,从节点处理读请求,同时在主节点失效时,从节点可以快速接管。但要注意,复制延迟可能影响数据一致性,尤其在快速更新的场景下。 十八 缓存穿透的实战场景与成本评估 在电商系统中,用户ID传参是缓存穿透的常见场景,比如查询某个用户是否登录。这时候布隆过滤器可以有效拦截无效请求,减少数据库压力。但布隆过滤器本身需要额外的内存和计算开销,比如一个百万级用户系统,布隆过滤器可能占用10MB内存,但能避免百万次无效查询,节省CPU和I/O资源。而直接查询数据库,每百万次请求可能消耗几十MB内存,但需要更多时间处理。实际中,布隆过滤器的成本远低于数据库查询,因此值得部署。但要注意,不能用布隆过滤器代替Redis,而是作为前置拦截层。 十九 本地缓存的性能指标与监控 本地缓存的性能指标包括命中率、更新延迟和内存占用。比如Guava Cache的命中率可以达到95%以上,而更新延迟通常在毫秒级。监控方面,可以用Prometheus和Grafana追踪本地缓存的命中率和内存使用情况。例如,在代码中添加计数器,记录缓存命中和未命中次数,以及缓存大小。当命中率下降时,说明缓存策略需要调整,比如增加预热任务或延长TTL。此外,本地缓存的更新策略要合理,比如使用定时刷新或事件监听,避免数据不一致。监控可以帮助及时发现瓶颈,优化缓存策略。 二十 Redis的内存优化与资源分配 Redis的内存占用是缓存系统的核心问题,尤其在高并发场景下。可以用Redis的INFO memory命令查看当前内存使用情况,通过调整maxmemory参数限制内存上限,比如设置为10GB。同时,选择合适的淘汰策略,比如volatile-ttl,在少量内存时更高效。此外,可以用Redis的RDB和AOF持久化策略,减少内存压力。在部署时,避免使用默认配置,而是根据业务需求调整。例如,对于用户数据,可以设置TTL为24小时,而临时数据设置为5分钟,这样既能保证性能,又能降低内存消耗。合理分配内存是成本优化的关键。