FEATURED · 精选文章

缓存击穿加个 Redis 互斥锁就行了,为什么还要搞永不过期、逻辑过期这些方案?

发布时间 / 2026/8/28 22:13:26
来源 / 创域科博编辑部
栏目 / 资讯中心
缓存击穿加个 Redis 互斥锁就行了,为什么还要搞永不过期、逻辑过期这些方案? 缓存击穿这个问题面试背过八股文的人都知道热点 Key 过期的瞬间大量请求同时穿透到数据库把 DB 打挂。解决方案也能张口就来加互斥锁只放一个线程去查 DB 重建缓存其他线程等着。逻辑没毛病面试也能过。但你真把这个方案原封不动搬到线上大概率会出事。互斥锁到底有什么问题先看一个标准的互斥锁实现public Object getData(String key) { Object value redis.get(key); if (value ! null) { return value; } // 缓存没命中尝试获取锁 String lockKey lock: key; boolean locked redis.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (locked) { try { // 拿到锁查 DB 重建缓存 value db.query(key); redis.opsForValue().set(key, value, 30, TimeUnit.MINUTES); } finally { redis.delete(lockKey); } } else { // 没拿到锁等一会儿再重试 Thread.sleep(50); return getData(key); } return value; }看起来很合理。一个线程去查库重建缓存其他线程 sleep 50 毫秒后重试重试的时候缓存大概率已经建好了皆大欢喜。问题是线上环境不是面试吹逼。线程全在等服务直接卡死假设这个热点 Key 的并发量是每秒 5000 次请求。Key 过期的瞬间5000 个请求同时打过来只有 1 个拿到锁去查 DB剩下的 4999 个全在 sleep 重试。如果用的是 Spring MVC Tomcat默认线程池大小是 200。这 200 个线程全被这个 Key 的请求占满了在那 sleep 等着。这时候其他正常接口的请求进来拿不到线程直接排队超时。一个热点 Key 的过期把整个服务的所有接口都拖慢了。用户访问首页、查订单、看消息全部转圈。重建缓存如果慢等待时间会爆炸互斥锁方案有一个隐含假设查 DB 重建缓存很快几十毫秒就搞定。但实际业务中热点数据往往不是一条简单的 SELECT。比如一个商品详情页的缓存可能要查商品基本信息、价格、库存、促销活动、评价统计再拼装成一个大 JSON。这个过程涉及 5-6 次数据库查询甚至跨服务调用耗时可能在 500 毫秒到 2 秒之间。这 2 秒内所有请求都在等。sleep 50 毫秒重试一次2 秒就是重试 40 次。每次重试都要访问一次 Redis 检查缓存有没有建好这本身也在消耗连接资源。如果重建过程中 DB 本身也在承压比如刚好赶上慢查询耗时可能更长等待的请求越堆越多线程池耗尽上游调用方开始超时触发重试请求量翻倍恶性循环。分布式环境下锁的问题上面的代码用 Redis SETNX 做锁在分布式部署下还有额外的坑。拿到锁的那个节点如果在重建缓存的过程中挂了锁虽然有过期时间兜底但在锁过期之前所有请求都在空等。如果锁的过期时间设得比重建时间短锁提前释放了另一个线程又拿到锁可能出现两个线程同时在重建。这些问题不是不能解决用 Redisson 的看门狗机制可以自动续期。但问题是你为了解决一个缓存击穿引入了一套分布式锁的复杂度整体方案的维护成本已经很高了。其他解决思路互斥锁的矛盾点在于它保证了数据一致性只有一个线程重建缓存但牺牲了可用性其他所有线程都在等。但很多业务场景下并不需要这么强的一致性。商品详情页的价格晚更新 2 秒用户根本感知不到。排行榜数据晚刷新 5 秒没有任何影响。推荐列表的内容旧了 10 秒用户甚至分不出来。对于这些场景与其让 5000 个请求排队等不如先把旧数据返回去后台悄悄重建缓存等建好了新请求自然就拿到新数据了。这就是逻辑过期和永不过期方案的出发点。逻辑过期物理上不给 Key 设过期时间让它永远存在于 Redis 里。但在缓存的 value 里面塞一个逻辑过期时间字段。Data public class CacheData { private Object data; private long expireTime; // 逻辑过期时间戳 }读取的时候先判断逻辑过期时间有没有到。如果没过期直接返回。如果过期了先把旧数据返回给用户同时异步触发一个线程去重建缓存。private staticfinal ExecutorService REBUILD_EXECUTOR Executors.newFixedThreadPool(10); public Object getData(String key) { String json redis.opsForValue().get(key); if (json null) { // 缓存完全不存在走 DB 查询冷启动场景 return rebuildAndReturn(key); } CacheData cacheData JSON.parseObject(json, CacheData.class); // 没过期直接返回 if (cacheData.getExpireTime() System.currentTimeMillis()) { return cacheData.getData(); } // 逻辑过期了返回旧数据异步重建 String lockKey lock: key; boolean locked redis.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (locked) { REBUILD_EXECUTOR.submit(() - { try { Object newData db.query(key); CacheData newCache new CacheData(); newCache.setData(newData); newCache.setExpireTime( System.currentTimeMillis() TimeUnit.MINUTES.toMillis(30)); redis.opsForValue().set(key, JSON.toJSONString(newCache)); } finally { redis.delete(lockKey); } }); } // 不管有没有拿到锁都返回旧数据 return cacheData.getData(); }注意最后那一行不管有没有拿到锁都返回旧数据。这是跟互斥锁方案最本质的区别。互斥锁方案里没拿到锁的线程在 sleep 等待直到缓存重建完毕才能返回。逻辑过期方案里所有线程都是立即返回没有任何等待只不过在缓存重建完成之前返回的是旧数据。这意味着不管并发量多大接口响应时间始终是毫秒级的。不会出现线程堆积、不会占满线程池、不会拖垮其他接口。代价就是在重建缓存的那几百毫秒到几秒内一部分用户拿到的数据是旧的。永不过期 主动更新比逻辑过期更彻底的做法Key 永远不设过期时间连逻辑过期的判断都省了。数据更新完全靠主动推送。// 数据变更时主动更新缓存 EventListener public void onProductUpdate(ProductUpdateEvent event) { Product product event.getProduct(); redis.opsForValue().set( product: product.getId(), JSON.toJSONString(product) ); }或者用定时任务兜底Scheduled(fixedRate 60000) public void refreshHotProductCache() { ListString hotProductIds getHotProductIds(); for (String id : hotProductIds) { Product product productMapper.selectById(id); redis.opsForValue().set(product: id, JSON.toJSONString(product)); } }Key 永远不过期就从根上杜绝了缓存击穿因为击穿的前提是 Key 过期Key 不过期就不存在击穿。这个方案适合数据变更有明确触发点的场景。比如后台管理员改了商品信息改完顺手刷一下缓存或者数据本身是定时计算的比如排行榜每小时算一次算完直接写入 Redis。但它有一个硬伤如果主动更新的链路出了问题缓存里就是永远的脏数据。比如消息队列丢了一条更新消息或者定时任务某次执行失败了缓存里的数据就一直是旧的而且因为没有过期时间它不会自动被淘汰会一直躺在那里。所以用这个方案的时候通常要配一个兜底的定时全量刷新确保即使某次增量更新失败了数据也不会长期不一致。三个方案到底怎么选不是哪个方案更高级就用哪个是看你的业务能容忍什么。互斥锁数据必须准确宁可慢一点也不能返回旧数据。比如账户余额、库存数量、支付状态。用户查到自己余额是昨天的打客服电话的概率非常高。逻辑过期数据晚几秒更新无所谓但接口绝对不能慢。比如商品详情页、搜索结果列表、内容推荐流。电商场景下页面加载每慢 100 毫秒转化率就会下降响应速度比数据实时性重要得多。永不过期 主动更新数据变更不频繁而且有明确的触发时机。比如系统配置、商品类目树、城市列表、数据字典。这些东西可能一周才改一次没必要设过期时间让它去承受击穿的风险。在实际项目里这三种方案经常混着用。同一个系统里配置数据用永不过期商品详情用逻辑过期库存数据用互斥锁根据业务特点各取所需。说在最后互斥锁方案逻辑上是正确的但它的适用范围比大多数人以为的要窄。在高并发场景下它可能引发的线程堆积、接口超时、服务雪崩等问题比缓存击穿本身还严重。逻辑过期和永不过期不是在故意搞复杂它们本质上是在用一点数据新鲜度换系统的稳定性和响应速度。在大多数业务场景下这笔交易是划算的。如果选方案决解可以先假设一个问题这个数据晚更新 3 秒用户会不会打电话投诉如果不会互斥锁基本不是最优解。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻