一、缓存雪崩、击穿、穿透:问题本质与解决方案
在高并发场景下,Redis 缓存失效可能导致数据库被瞬间压垮。这三个问题虽然名字相似,但成因和解决方案完全不同。
1.1 缓存雪崩(Cache Avalanche)
问题本质:大量 key 在同一时间过期或 Redis 故障宕机,所有请求同时打到数据库,导致数据库瞬间被压垮。
触发条件:
- 批量设置缓存时使用了相同的 TTL
- Redis 集群宕机或重启
- 缓存服务故障
解决方案:
// 1. 随机过期时间:基础 TTL + 随机偏移intbaseTtl=3600;intrandomOffset=ThreadLocalRandom.current().nextInt(0,600);redisTemplate.opsForValue().set(key,data,Duration.ofSeconds(baseTtl+randomOffset));// 2. 多级缓存:本地缓存 + Redis + DB@Cacheable(value="local",cacheManager="caffeineCacheManager")@Cacheable(value="redis",cacheManager="redisCacheManager")publicStringgetData(Stringkey){returndb.query(key);}// 3. 熔断降级:数据库压力过大时返回默认值@SentinelResource(value="getData",fallback="getDataFallback")publicStringgetData(Stringkey){returnredisTemplate.opsForValue().get(key);}1.2 缓存击穿(Cache Breakdown)
问题本质:某个热点 key 恰好过期,高并发请求瞬间穿透到数据库。
触发条件:
- 秒杀商品详情页缓存过期
- 热点新闻缓存失效
解决方案:
方案一:互斥锁(Mutex Lock)
publicStringgetHotData(Stringkey){Stringdata=redisTemplate.opsForValue().get(key);if(data!=null)returndata;// 获取互斥锁StringlockKey="lock:"+key;booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(10));if(locked){try{// 双重检查data=redisTemplate.opsForValue().get(key);if(data==null){data=db.query(key);redisTemplate.opsForValue().set(key,data,Duration.ofHours(1));}}finally{// Lua 脚本释放锁(保证原子性)Stringscript="if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('del', KEYS[1]) else return 0 end";redisTemplate.execute(newDefaultRedisScript<>(script,Long.class),Collections.singletonList(lockKey),"1");}}else{// 未获取到锁,短暂休眠后重试Thread.sleep(100);returngetHotData(key);}returndata;}方案二:逻辑永不过期
// 缓存不设物理 TTL,通过逻辑时间判断publicStringgetDataWithLogicExpiry(Stringkey){Stringjson=redisTemplate.opsForValue().get(key);if(json==null)returnnull;CacheDatacacheData=JSON.parseObject(json,CacheData.class);// 逻辑未过期,直接返回if(cacheData.getExpireTime()>System.currentTimeMillis()){returncacheData.getData();}// 逻辑已过期,获取锁后开启独立线程重建StringlockKey="lock:"+key;booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(10));if(locked){CACHE_REBUILD_EXECUTOR.submit(()->{try{StringnewData=db.query(key);CacheDatanewCache=newCacheData(newData,System.currentTimeMillis()+Duration.ofHours(1).toMillis());redisTemplate.opsForValue().set(key,JSON.toJSONString(newCache));}finally{redisTemplate.delete(lockKey);}});}// 返回旧数据(逻辑过期但物理未删除)returncacheData.getData();}1.3 缓存穿透(Cache Penetration)
问题本质:查询一个不存在的数据,由于缓存中没有,请求直接打到数据库。攻击者大量构造不存在的 key 进行查询,数据库将承受巨大压力。
解决方案:
方案一:缓存空值
Stringdata=redisTemplate.opsForValue().get(key);if(data==null){data=db.query(key);if(data==null){// 缓存空值,防止重复查询 DB(设置较短 TTL)redisTemplate.opsForValue().set(key,"",Duration.ofMinutes(5));}else{redisTemplate.opsForValue().set(key,data,Duration.ofHours(1));}}方案二:布隆过滤器(推荐)
# 使用 RedisBloom 模块BF.ADDusersuser:1001 BF.ADDusersuser:1002# 查询BF.EXISTSusersuser:1003# 返回 0 → 一定不存在,直接返回# 返回 1 → 可能存在,继续查缓存/DB二、布隆过滤器:空间换时间的概率型数据结构
布隆过滤器是一种高效的概率型数据结构,用于快速判断一个元素是否可能存在于集合中。核心特点是空间效率极高,但存在一定的误判率。
2.1 核心组成
- 位数组(Bit Array):长度为 m 的二进制数组,初始所有位为 0
- 多个哈希函数(Hash Functions):k 个独立的哈希函数,将输入映射到位数组的某个位置
2.2 工作流程
添加元素:
元素 x → h1(x)=3, h2(x)=5, h3(x)=8 → bit[3]=1, bit[5]=1, bit[8]=1查询元素:
元素 y → h1(y)=3, h2(y)=5, h3(y)=2 → bit[3]=1 ✓, bit[5]=1 ✓, bit[2]=0 ✗ → 存在 bit=0,一定不存在2.3 关键特性
| 特性 | 说明 |
|---|---|
| 一定不存在 | 某个 bit 为 0 → 该 key 绝对不在集合中 |
| 可能存在 | 所有 bit 都为 1 → 该 key 可能在集合中(误判) |
| 不支持删除 | 删除一个 key 会影响其他 key 的判断 |
| 空间效率 | 1% 误判率仅需 9.6 bits / 元素 |
2.4 Redis 实现
# 安装 RedisBloom 模块后BF.RESERVE myfilter0.011000000# 误判率 1%,预计 100 万元素BF.ADD myfilter user:1001 BF.ADD myfilter user:1002 BF.EXISTS myfilter user:1003# 返回 0 或 1三、缓存一致性策略:五种方案对比
3.1 Cache-Aside 旁路缓存(最常用)
读流程:先读缓存 → 未命中读 DB → 回填缓存
写流程:先更新 DB → 再删除缓存
publicStringread(Stringkey){Stringdata=redisTemplate.opsForValue().get(key);if(data==null){data=db.query(key);redisTemplate.opsForValue().set(key,data,Duration.ofHours(1));}returndata;}publicvoidwrite(Stringkey,Stringdata){db.update(key,data);redisTemplate.delete(key);// 删除缓存,非更新}特点:适合读多写少场景,存在短暂不一致窗口。
3.2 Double Delete 双删策略(高并发优化)
写流程:先删缓存 → 更新 DB → 延迟再删缓存
publicvoidwriteWithDoubleDelete(Stringkey,Stringdata){redisTemplate.delete(key);// 第一次删除db.update(key,data);// 更新数据库// 延迟第二次删除(通过消息队列或延迟队列)delayedQueue.add(()->redisTemplate.delete(key),500);// 延迟 500ms}特点:通过二次删除解决并发期间写不一致,延迟时间需大于业务 RT。
3.3 Read/Write-Through 穿透读写
写流程:缓存代理更新,同步更新 DB
读流程:缓存代理查询,未命中自动加载
特点:数据强一致,但写入性能较低,适合金融交易等一致性要求强的系统。
3.4 Write-Behind 异步写(高性能)
写流程:只更新缓存,异步批量写 DB
特点:高性能但存在数据丢失风险,适合秒杀库存、点赞等可容忍丢失的场景。
3.5 Binlog 同步(最终一致)
MySQL binlog → Canal/Maxwell → 解析日志 → 更新缓存特点:保证最终一致,延迟约 100ms~1s,业务代码无入侵,适合多级缓存同步。
3.6 生产推荐组合
Cache-Aside + 延迟双删 + Binlog 补偿:
写操作:先删缓存 → 更新 DB → 延迟双删 ↓ Canal 监听 binlog → 异步补偿删除缓存 ↓ 最终一致性保障四、如何保证删除缓存操作一定能成功?
4.1 消息队列重试机制
// 删除缓存失败,放入消息队列重试publicvoiddeleteCacheWithRetry(Stringkey){try{redisTemplate.delete(key);}catch(Exceptione){// 放入消息队列,消费者重试删除mqProducer.send(newCacheDeleteMessage(key));}}4.2 订阅 Binlog 补偿
// Canal 监听 binlog,异步删除缓存@CanalListener(destination="mydb")publicvoidonBinlog(CanalEntry.Entryentry){if(entry.getHeader().getEventType()==EventType.UPDATE){Stringkey=buildCacheKey(entry);redisTemplate.delete(key);}}五、Redis 实现分布式锁
5.1 什么是分布式锁?
分布式锁是用于协调分布式系统中多个节点对共享资源进行互斥访问的同步机制。在单机环境中可通过synchronized或ReentrantLock控制并发,但在分布式环境下需跨节点协同。
典型应用场景:
- 库存扣减(防止超卖)
- 分布式任务调度(避免重复执行)
- 配置中心原子更新
- 分布式会话管理
5.2 分布式锁的演进
V1.0:SETNX + EXPIRE(存在死锁风险)
SETNX lock:order:10011# 加锁EXPIRE lock:order:100110# 设置过期# 问题:非原子操作,如果 SETNX 后崩溃,锁永远无法释放V2.0:SET … NX PX(原子加锁 + 过期)
SET lock:order:1001 request_id NX PX10000# 问题:业务执行时间超过锁过期时间,导致锁提前释放V3.0:Redisson 看门狗(原子加锁 + 自动续期)
RLocklock=redisson.getLock("order:1001");try{lock.lock();// 执行业务逻辑}finally{lock.unlock();}5.3 Redisson 看门狗机制
业务线程获取锁 ↓ 看门狗线程启动(delay = lockWatchdogTimeout / 3,默认 10s/3 ≈ 3.3s) ↓ 每 3.3s 检查锁是否仍被持有 ↓ 若是 → 续期至 30s ↓ 业务完成 → unlock() → 看门狗停止 ↓ 异常崩溃 → 锁自动过期释放(避免死锁)5.4 保证加锁和解锁的原子性
// 加锁:SET key value NX PX 10000(原子操作)// 解锁:Lua 脚本保证原子性StringunlockScript="if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('del', KEYS[1]) "+"else return 0 end";redisTemplate.execute(newDefaultRedisScript<>(unlockScript,Long.class),Collections.singletonList("lock:order:1001"),requestId);5.5 分布式锁的优缺点
| 优点 | 缺点 |
|---|---|
| 性能高效 | 超时时间不好设置 |
| 实现方便 | 主从复制异步导致锁不可靠 |
| 避免单点故障(RedLock) | 需要额外组件(Redisson) |
5.6 合理的超时时间设置
基于续约的方式:
// Redisson 自动处理:默认 30s 过期,看门狗每 10s 续期Configconfig=newConfig();config.useSingleServer().setAddress("redis://127.0.0.1:6379");RedissonClientredisson=Redisson.create(config);RLocklock=redisson.getLock("myLock");lock.lock();// 看门狗自动续期// 业务逻辑lock.unlock();六、Redis 延迟队列实现
延迟队列是一种特殊的消息队列,消息在发送后不会立即被消费,而是延迟指定时间后才能被处理。
6.1 基于 ZSet 的实现
@ServicepublicclassRedisDelayQueue{@AutowiredprivateStringRedisTemplateredisTemplate;// 添加延迟任务publicvoidaddTask(StringtaskId,longdelaySeconds){longexecuteTime=System.currentTimeMillis()+delaySeconds*1000;redisTemplate.opsForZSet().add("delayed_queue",taskId,executeTime);}// 轮询消费到期任务@Scheduled(fixedRate=1000)publicvoidconsume(){longnow=System.currentTimeMillis();// 获取 score ≤ currentTime 的任务Set<String>tasks=redisTemplate.opsForZSet().rangeByScore("delayed_queue",0,now,0,1);for(StringtaskId:tasks){// 原子移除,防止多消费者重复消费Longremoved=redisTemplate.opsForZSet().remove("delayed_queue",taskId);if(removed!=null&&removed>0){// 执行业务逻辑executeTask(taskId);}}}}6.2 适用场景
| 场景 | 说明 |
|---|---|
| 订单超时关闭 | 订单创建后 30 分钟未支付自动关闭 |
| 定时提醒 | 预约成功后 1 小时发送提醒 |
| 任务重试 | 失败任务延迟 5 分钟后重试 |
| 优惠券过期 | 优惠券到期前 1 天发送提醒 |
6.3 Redis 延迟队列 vs 专业消息队列
| 特性 | Redis ZSet | RabbitMQ DLX | RocketMQ |
|---|---|---|---|
| 实现复杂度 | 简单 | 中等 | 复杂 |
| ACK 机制 | 无 | 有 | 有 |
| 消费组 | 不支持 | 支持 | 支持 |
| 数据可靠性 | 可能丢失 | 高可靠 | 高可靠 |
| 适用场景 | 简单延迟任务 | 复杂消息流 | 大规模分布式 |
七、热点数据动态缓存策略
通过数据最新访问时间做排名,过滤掉不常访问的数据,只保留经常访问的数据。
// 记录商品访问时间publicvoidrecordAccess(StringskuId){redisTemplate.opsForZSet().add("hot:products",skuId,System.currentTimeMillis());}// 获取 Top N 热点商品publicSet<String>getHotProducts(intn){returnredisTemplate.opsForZSet().reverseRange("hot:products",0,n-1);}// 定期清理冷数据@Scheduled(cron="0 0 * * * *")publicvoidcleanColdData(){// 移除 7 天前访问的商品longweekAgo=System.currentTimeMillis()-7*24*3600*1000;redisTemplate.opsForZSet().removeRangeByScore("hot:products",0,weekAgo);}如果本文对你有帮助,欢迎点赞 👍 + 收藏 ⭐ + 关注 🔖,你的支持是我持续创作的动力!