Redis篇(五):分布式锁、缓存一致性与延迟队列

发布时间:2026/6/14 8:16:13

Redis篇(五):分布式锁、缓存一致性与延迟队列 一、缓存雪崩、击穿、穿透问题本质与解决方案在高并发场景下Redis 缓存失效可能导致数据库被瞬间压垮。这三个问题虽然名字相似但成因和解决方案完全不同。1.1 缓存雪崩Cache Avalanche问题本质大量 key 在同一时间过期或 Redis 故障宕机所有请求同时打到数据库导致数据库瞬间被压垮。触发条件批量设置缓存时使用了相同的 TTLRedis 集群宕机或重启缓存服务故障解决方案// 1. 随机过期时间基础 TTL 随机偏移intbaseTtl3600;intrandomOffsetThreadLocalRandom.current().nextInt(0,600);redisTemplate.opsForValue().set(key,data,Duration.ofSeconds(baseTtlrandomOffset));// 2. 多级缓存本地缓存 Redis DBCacheable(valuelocal,cacheManagercaffeineCacheManager)Cacheable(valueredis,cacheManagerredisCacheManager)publicStringgetData(Stringkey){returndb.query(key);}// 3. 熔断降级数据库压力过大时返回默认值SentinelResource(valuegetData,fallbackgetDataFallback)publicStringgetData(Stringkey){returnredisTemplate.opsForValue().get(key);}1.2 缓存击穿Cache Breakdown问题本质某个热点 key 恰好过期高并发请求瞬间穿透到数据库。触发条件秒杀商品详情页缓存过期热点新闻缓存失效解决方案方案一互斥锁Mutex LockpublicStringgetHotData(Stringkey){StringdataredisTemplate.opsForValue().get(key);if(data!null)returndata;// 获取互斥锁StringlockKeylock:key;booleanlockedredisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(10));if(locked){try{// 双重检查dataredisTemplate.opsForValue().get(key);if(datanull){datadb.query(key);redisTemplate.opsForValue().set(key,data,Duration.ofHours(1));}}finally{// Lua 脚本释放锁保证原子性Stringscriptif 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){StringjsonredisTemplate.opsForValue().get(key);if(jsonnull)returnnull;CacheDatacacheDataJSON.parseObject(json,CacheData.class);// 逻辑未过期直接返回if(cacheData.getExpireTime()System.currentTimeMillis()){returncacheData.getData();}// 逻辑已过期获取锁后开启独立线程重建StringlockKeylock:key;booleanlockedredisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(10));if(locked){CACHE_REBUILD_EXECUTOR.submit(()-{try{StringnewDatadb.query(key);CacheDatanewCachenewCacheData(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 进行查询数据库将承受巨大压力。解决方案方案一缓存空值StringdataredisTemplate.opsForValue().get(key);if(datanull){datadb.query(key);if(datanull){// 缓存空值防止重复查询 DB设置较短 TTLredisTemplate.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 Functionsk 个独立的哈希函数将输入映射到位数组的某个位置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 ✗ → 存在 bit0一定不存在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){StringdataredisTemplate.opsForValue().get(key);if(datanull){datadb.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(destinationmydb)publicvoidonBinlog(CanalEntry.Entryentry){if(entry.getHeader().getEventType()EventType.UPDATE){StringkeybuildCacheKey(entry);redisTemplate.delete(key);}}五、Redis 实现分布式锁5.1 什么是分布式锁分布式锁是用于协调分布式系统中多个节点对共享资源进行互斥访问的同步机制。在单机环境中可通过synchronized或ReentrantLock控制并发但在分布式环境下需跨节点协同。典型应用场景库存扣减防止超卖分布式任务调度避免重复执行配置中心原子更新分布式会话管理5.2 分布式锁的演进V1.0SETNX EXPIRE存在死锁风险SETNX lock:order:10011# 加锁EXPIRE lock:order:100110# 设置过期# 问题非原子操作如果 SETNX 后崩溃锁永远无法释放V2.0SET … NX PX原子加锁 过期SET lock:order:1001 request_id NX PX10000# 问题业务执行时间超过锁过期时间导致锁提前释放V3.0Redisson 看门狗原子加锁 自动续期RLocklockredisson.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 脚本保证原子性StringunlockScriptif 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需要额外组件Redisson5.6 合理的超时时间设置基于续约的方式// Redisson 自动处理默认 30s 过期看门狗每 10s 续期ConfigconfignewConfig();config.useSingleServer().setAddress(redis://127.0.0.1:6379);RedissonClientredissonRedisson.create(config);RLocklockredisson.getLock(myLock);lock.lock();// 看门狗自动续期// 业务逻辑lock.unlock();六、Redis 延迟队列实现延迟队列是一种特殊的消息队列消息在发送后不会立即被消费而是延迟指定时间后才能被处理。6.1 基于 ZSet 的实现ServicepublicclassRedisDelayQueue{AutowiredprivateStringRedisTemplateredisTemplate;// 添加延迟任务publicvoidaddTask(StringtaskId,longdelaySeconds){longexecuteTimeSystem.currentTimeMillis()delaySeconds*1000;redisTemplate.opsForZSet().add(delayed_queue,taskId,executeTime);}// 轮询消费到期任务Scheduled(fixedRate1000)publicvoidconsume(){longnowSystem.currentTimeMillis();// 获取 score ≤ currentTime 的任务SetStringtasksredisTemplate.opsForZSet().rangeByScore(delayed_queue,0,now,0,1);for(StringtaskId:tasks){// 原子移除防止多消费者重复消费LongremovedredisTemplate.opsForZSet().remove(delayed_queue,taskId);if(removed!nullremoved0){// 执行业务逻辑executeTask(taskId);}}}}6.2 适用场景场景说明订单超时关闭订单创建后 30 分钟未支付自动关闭定时提醒预约成功后 1 小时发送提醒任务重试失败任务延迟 5 分钟后重试优惠券过期优惠券到期前 1 天发送提醒6.3 Redis 延迟队列 vs 专业消息队列特性Redis ZSetRabbitMQ DLXRocketMQ实现复杂度简单中等复杂ACK 机制无有有消费组不支持支持支持数据可靠性可能丢失高可靠高可靠适用场景简单延迟任务复杂消息流大规模分布式七、热点数据动态缓存策略通过数据最新访问时间做排名过滤掉不常访问的数据只保留经常访问的数据。// 记录商品访问时间publicvoidrecordAccess(StringskuId){redisTemplate.opsForZSet().add(hot:products,skuId,System.currentTimeMillis());}// 获取 Top N 热点商品publicSetStringgetHotProducts(intn){returnredisTemplate.opsForZSet().reverseRange(hot:products,0,n-1);}// 定期清理冷数据Scheduled(cron0 0 * * * *)publicvoidcleanColdData(){// 移除 7 天前访问的商品longweekAgoSystem.currentTimeMillis()-7*24*3600*1000;redisTemplate.opsForZSet().removeRangeByScore(hot:products,0,weekAgo);}如果本文对你有帮助欢迎点赞 收藏 ⭐ 关注 你的支持是我持续创作的动力

相关新闻