news 2026/9/11 5:31:11

Redis缓存击穿解决方案:双重判定锁原理与SpringBoot实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis缓存击穿解决方案:双重判定锁原理与SpringBoot实践

做后端开发这几年,Redis 相关的“三兄弟”问题——穿透、击穿、雪崩,几乎是每个团队都要正面撞上一次的。尤其是缓存击穿,平时看着温温吞吞,一到活动高峰,热点 key 刚过期那一瞬间,几万请求同时打到数据库上,CPU 直接拉满,慢查询一条接一条,整个服务像被人掐住了脖子。今天想聊的这套方案,就是专门针对缓存击穿的“双重判定锁”。很多文章叫它双重检测锁,也有叫双检锁的,本质上是同一套思路:用 Redis 的互斥锁,配合“加锁后再次检查缓存”的二次判定,把并发打到数据库的请求压到只剩一个。

这套方案的适用范围很明确:缓存里是热点数据,更新不频繁但读取量极大,业务上可以接受短时间内的锁等待。比如商品详情、活动配置、用户画像这类场景,都非常适合。如果你正在用 SpringBoot 整合 Redis 做缓存治理,或者面试前想搞懂 Redis 分布式锁到底怎么落地,这篇内容应该能给你一个可以直接抄作业的答案。

1. 缓存击穿问题剖析

1.1 穿透、击穿、雪崩,到底怎么区分

很多新手把这三个概念混在一起,实际排查问题时才发现方向完全错了。我习惯用一句话区分:穿透是查了一个缓存和数据库里都不存在的数据,击穿是缓存里本来有,但恰好在某个时间点过期了,雪崩是大量 key 在同一时间段集体过期。

穿透的核心问题是“不存在”,所以布隆过滤器、缓存空值是主流解法;击穿的核心问题是“热点 key 过期瞬间的高并发”,所以互斥锁、逻辑过期是主流解法;雪崩的核心问题是“过期时间设置不合理或宕机”,所以过期时间加随机值、多级缓存、高可用是主流解法。

三者的表现也很不一样。穿透的攻击特征很明显,请求的 key 千奇百怪,Redis 命中率骤降,数据库出现大量空查询;击穿则集中在某一个或某几个 key 上,数据库的 QPS 曲线会在 key 过期后出现一个尖锐的峰值;雪崩是整个 Redis 缓存的命中率断崖式下跌,数据库所有表都感受到压力。

我之前接手过一个线上事故,现象是单条 SQL 的慢查询飙升,排查了半天,发现就是商品详情页的某个热点 key 设置了固定的 30 分钟过期,一到整点大家同时来刷,就把数据库打穿了。这种事故有个特点:Redis 本身没问题,数据库连接池却被打满了,很多不懂的人会先去优化 SQL,方向完全错了。

1.2 为什么击穿最难搞

穿透和雪崩都有比较成熟的“无脑”解法,击穿麻烦在于“热点 key”三个字。首先你很难提前预判哪个 key 会突然变成热点,等线上告警响了再处理,流量早就把数据库压垮了;其次热点 key 的并发量级往往远超预期,一个 key 承载整个集群的读流量,一旦失效,压力是瞬间叠加的。

网上常见几种方案,各有各的坑。缓存空值能解决穿透,但对击穿几乎无效,因为击穿场景下 key 本来就有值,只是过期了,空值缓存根本不会生效。布隆过滤器也一样,它是拦截“不存在”的 key,对“存在但过期”的 key 毫无办法。所以击穿场景几乎只能靠锁或者“提前续期”的思路来兜底。

还有一个容易被忽略的点:击穿和穿透经常会叠加出现。比如某个 key 查询数据库时确实没有数据,你又没有做空值缓存,那么这个 key 就会在缓存层和数据库层反复穿透,而这种 key 一旦被打上“热点”标签,就会演变成击穿问题。所以成熟的方案里,双重判定锁往往会搭配空值缓存一起使用,一层防击穿,一层防穿透,两个问题一起解决。

2. 双重判定锁的核心原理与设计取舍

2.1 “双重判定”到底指哪两次判定

双重判定锁这个名字,我第一次听的时候也觉得有点绕,其实看代码就一目了然。它借鉴了 Java 单例模式里双重检测锁(DCL)的思路,只不过把对象实例换成了缓存中的 key。

第一次判定发生在加锁之前:请求进来后先查一次 Redis,如果缓存命中了,直接返回,锁都不用碰;如果没命中,说明缓存可能过期了,这时候才去尝试获取互斥锁。第二次判定发生在成功拿到锁之后:再次查一次 Redis,看看缓存是不是已经被其他线程重建好了。

为什么要多查这一次?因为在高并发场景下,可能出现“多个线程同时发现缓存未命中,同时尝试加锁,但只有一个线程成功拿到锁”的情况。如果拿到锁的线程去数据库查询并回填了缓存,那么其他正在等待锁的线程,在拿到锁之后其实已经没有必要再去查询数据库了。如果少了第二次判定,所有线程拿锁后都会重复查库,锁就形同虚设。

这个细节我见过太多人写错。很多人以为加了锁就万事大吉,结果压测一打,数据库 QPS 还是冲上了天。原因就是没有做二次判定,每个线程拿到锁后都老老实实去查了一次库,虽然并发被锁串行化了,但重复查询的问题依然存在。

2.2 两种主流变体:缓存空值与逻辑过期

双重判定锁在落地的时候,会根据业务对一致性和性能的要求,演变成两种风格。

第一种是“缓存空值 + 互斥锁”。请求过来,先查 Redis,如果命中且值不为空,直接返回;如果值为空,加锁后再查一次 Redis,若仍然为空,则查数据库。数据库查到了就回填缓存,查不到就回填一个特殊空值并设置短过期时间。这种方案实现最简单,数据一致性最好,但有一个代价:热点 key 过期瞬间,拿不到锁的请求需要短暂等待,也就是“牺牲一点响应时间换数据库安全”。

第二种是“逻辑过期 + 互斥锁”。这种方案下,Redis 缓存的值是一个包装对象,里面除了业务数据,还带一个逻辑上的过期时间。请求读缓存时,如果发现逻辑时间还没到,直接返回数据;如果发现逻辑时间已经到了,会尝试加锁,加锁成功后再查一次缓存,如果还是过期的状态,就另起一个线程去重建缓存,当前线程则先返回旧值。这种方案的好处是响应速度快,请求基本不会阻塞,缺点是数据一致性偏弱,极端情况下用户可能看到很短一段时间的旧数据。

两种方案没有绝对的好坏。我个人的选择标准很简单:如果业务对一致性敏感,比如库存、金额相关,用互斥锁方案;如果是商品标题、描述这种允许短暂不一致的内容,用逻辑过期方案,用户体验更好。但无论哪种,双重判定的核心逻辑都跑不掉:加锁后必须二次检查,否则并发控制就是空谈。

2.3 和普通 Redis 分布式锁相比,差在哪

有朋友会问,这不就是一个分布式锁吗?为什么单独起个名字叫双重判定锁?区别在于,一般讨论 Redis 分布式锁时,重点是“互斥”和“可靠性”,比如防止死锁、防止误删、保证原子性;而双重判定锁的重点是“在互斥的基础上,减少不必要的数据库访问”。

你可以这么理解:普通分布式锁解决的是“并发下同一资源只能被一个线程操作”的问题,双重判定锁解决的是“缓存失效瞬间大量请求穿透到数据库”的问题。后者是在前者之上,叠加了“缓存二次检查”这个缓存治理专属的步骤。所以你单独用 Redisson 的 RLock 也能做互斥,但如果没有二次判定,锁的性能收益会大打折扣。

另外,双重判定锁的锁粒度没必要做得很重。普通分布式锁可能要考虑可重入、看门狗续期、公平锁这些特性,双重判定锁通常只需要最基础的 SETNX 就能满足需求。因为锁的持有时间极短,就是一次数据库查询加缓存回填的时间,只要确保不会死锁,业务就能跑得很稳。

3. SpringBoot 完整落地实现

3.1 环境准备与 RedisTemplate 配置

先说环境。我用的是 SpringBoot 2.7 + Redis 7.0,连接工具用的 Redis Desktop Manager,平时排查 key 很方便。如果你是在 Windows 本机开发,Redis 官方没有 Windows 版本,可以下载微软维护的发行版或者用 Docker 起一个 redis 镜像,我个人更推荐 Docker 方式,和 Linux 环境行为一致,不容易踩坑。

引入依赖时,SpringBoot 的spring-boot-starter-data-redis就够了。这里要注意一个序列化问题。很多新手用 RedisTemplate 直接存字符串,结果 key 变成\xac\xed\x00\x05t\x00...这种乱码,就是因为默认的 JdkSerializationRedisSerializer。在缓存场景下,我建议直接用 StringRedisTemplate,或者手动把 RedisTemplate 的 key 和 value 序列化器都改成 StringRedisSerializer。

实操时还有一个坑:RedisTemplate 的setIfAbsent方法在不同版本里签名不一样,低版本叫setIfAbsent(K key, V value),高版本重载了setIfAbsent(K key, V value, long timeout, TimeUnit unit)。如果你想要加锁的同时设置过期时间,一定要用带过期参数的版本,否则还得额外调一次expire方法,两次操作不具备原子性,锁就存在永久不释放的风险。

3.2 用 SETNX 实现双重判定锁完整代码

下面这份代码是我比较常用的互斥锁版本,核心流程完整,可以直接跑。为了清晰,我把加锁、解锁、缓存读取都单独拆开。

首先定义一个简单的数据对象,模拟商品:

public class Product { private Long id; private String name; private BigDecimal price; // getter / setter 省略 }

然后写核心的服务方法:

@Service public class ProductService { private static final String CACHE_KEY_PREFIX = "product:"; private static final String LOCK_KEY_PREFIX = "lock:product:"; private static final long CACHE_TTL_SECONDS = 600; private static final long LOCK_EXPIRE_SECONDS = 5; private static final long EMPTY_VALUE_TTL_SECONDS = 60; @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private ObjectMapper objectMapper; public Product getProductById(Long id) { String cacheKey = CACHE_KEY_PREFIX + id; String lockKey = LOCK_KEY_PREFIX + id; // 第一次判定:直接查缓存 String cached = stringRedisTemplate.opsForValue().get(cacheKey); if (cached != null) { // 处理缓存空值的情况 if ("NULL_VALUE".equals(cached)) { return null; } return deserialize(cached); } // 尝试加锁,只在获取到锁的线程里执行重建逻辑 String requestId = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 第二次判定:拿到锁后再查一次缓存 cached = stringRedisTemplate.opsForValue().get(cacheKey); if (cached != null) { if ("NULL_VALUE".equals(cached)) { return null; } return deserialize(cached); } // 缓存仍然不存在,才去数据库查询 Product product = queryFromDatabase(id); if (product == null) { // 缓存空值,防止缓存穿透 stringRedisTemplate.opsForValue() .set(cacheKey, "NULL_VALUE", EMPTY_VALUE_TTL_SECONDS, TimeUnit.SECONDS); return null; } stringRedisTemplate.opsForValue() .set(cacheKey, serialize(product), CACHE_TTL_SECONDS, TimeUnit.SECONDS); return product; } finally { // 释放锁时必须校验 value,且保证原子性 releaseLock(lockKey, requestId); } } // 没拿到锁的线程,自旋重试 for (int i = 0; i < 3; i++) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } cached = stringRedisTemplate.opsForValue().get(cacheKey); if (cached != null) { if ("NULL_VALUE".equals(cached)) { return null; } return deserialize(cached); } } // 重试后仍未拿到缓存,回源数据库(兜底策略,避免请求失败) Product product = queryFromDatabase(id); return product; } private void releaseLock(String lockKey, String requestId) { // 使用 Lua 脚本保证“校验 value + 删除 key”的原子性 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); stringRedisTemplate.execute(redisScript, Collections.singletonList(lockKey), requestId); } private Product queryFromDatabase(Long id) { // 模拟慢查询 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 实际场景从 Mapper 查库 return new Product(id, "测试商品-" + id, new BigDecimal("99.00")); } private String serialize(Product product) { try { return objectMapper.writeValueAsString(product); } catch (JsonProcessingException e) { throw new RuntimeException("序列化失败", e); } } private Product deserialize(String json) { try { return objectMapper.readValue(json, Product.class); } catch (JsonProcessingException e) { throw new RuntimeException("反序列化失败", e); } } }

这段代码有几个细节值得展开讲。

requestId是每个线程加锁时生成的唯一标识,释放锁时先判断 Redis 里的 value 是否等于当前线程的 requestId,再执行删除。这个判断必须放在 Lua 脚本里做,因为“判断 + 删除”不是原子操作的话,会出现一个线程把另一个线程刚获取的锁误删的情况。我见过不少人用两步操作写释放锁,压测时全靠运气没出事,但迟早会出问题。

自旋重试的Thread.sleep(50)需要根据场景调整。50 毫秒是比较保守的值,如果你对响应时间不敏感,可以放到 100 甚至 200 毫秒;如果业务要求高并发下的低延迟,50 毫秒内的等待很多人是可以接受的。注意不要用while(true)无限自旋,万一数据库挂了,所有请求都会堆积在锁等待上,直接把连接池拖垮。

最后那个“兜底策略”是个人习惯。当线程重试几次后还是没读到缓存,说明可能出现了异常情况,比如 Redis 服务不稳定,或者持有锁的线程还没回填。这时候直接查一次数据库兜底,至少保证请求不失败。代价是极端情况下数据库会多承受部分查询压力,但比接口直接报错要好得多。

3.3 逻辑过期方案的核心实现

如果你的业务对响应时间敏感,不想让任何请求阻塞等待,逻辑过期方案会更合适。先定义一个包装类:

public class RedisData<T> { private T data; private LocalDateTime expireTime; // getter / setter 省略 }

然后实现读取逻辑:

public Product getProductWithLogicalExpire(Long id) { String cacheKey = CACHE_KEY_PREFIX + id; String cached = stringRedisTemplate.opsForValue().get(cacheKey); if (cached == null) { // 缓存不存在,不阻塞,直接查库重建(也可用互斥锁兜底) return queryFromDatabase(id); } RedisData<Product> redisData = deserializeRedisData(cached); // 逻辑时间未到,直接返回旧值 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { return redisData.getData(); } // 逻辑时间到期,尝试获取锁 String lockKey = LOCK_KEY_PREFIX + id; String requestId = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 双重判定:拿到锁后再查一次缓存 cached = stringRedisTemplate.opsForValue().get(cacheKey); if (cached != null) { RedisData<Product> latestData = deserializeRedisData(cached); if (latestData.getExpireTime().isAfter(LocalDateTime.now())) { return latestData.getData(); } } // 另起线程重建缓存,当前线程先返回旧数据 Product product = queryFromDatabase(id); RedisData<Product> newData = new RedisData<>(); newData.setData(product); newData.setExpireTime(LocalDateTime.now().plusSeconds(600)); stringRedisTemplate.opsForValue().set(cacheKey, serializeRedisData(newData), 12, TimeUnit.HOURS); return product; } finally { releaseLock(lockKey, requestId); } } // 获取不到锁,直接返回当前缓存里的旧数据 return redisData.getData(); }

这种方案最大的特点是:拿不到锁的线程不需要等待,直接返回旧数据,所以接口响应时间非常稳定。但需要注意两个问题:一是缓存里的物理过期时间要设得比逻辑过期时间长很多,防止逻辑过期还没到,物理 key 先被 Redis 淘汰了;二是如果业务数据更新频繁,逻辑过期方案会导致用户读到旧值的时间窗口变长,需要业务方能够接受。

3.4 锁的过期时间怎么定

锁的过期时间是最容易拍脑袋又最容易出事的地方。设短了,数据库查询慢一点锁就自动失效,其他线程趁虚而入,锁形同虚设;设长了,万一持有锁的线程发生异常,其他请求会长时间阻塞,等待重试的线程会越积越多。

常规做法是预估“数据库查询 + 缓存回填”的最长耗时,然后在这个基础上乘一个 3 到 5 倍的保险系数。比如数据库查询加上网络耗时平均 50 毫秒,极端情况 1 秒内能完成,那把锁的过期时间设为 5 秒已经足够。千万不要在锁里面执行 RPC 调用或者大批量数据操作,否则锁注定要超时。

这里可以套一个简单的公式:锁过期时间 = 业务预估最大耗时 × 3,然后向上取整到一个常见的秒数。如果业务耗时极不稳定,建议升级方案,使用 Redisson 这类带看门狗自动续期的分布式锁,而不是死磕 SETNX。

4. 实际操作中的问题与排查技巧实录

4.1 锁误删问题:没有原子性校验的代价

有一次压测,我故意把释放锁的逻辑写成了“先 get 判断 value,再 del”,结果在高并发下真的复现了误删:线程 A 拿到锁后处理时间较长,锁过期了;线程 B 拿到锁并设置了自己的 value;线程 A 处理完准备释放锁,它读到 Redis 里的 value 已经不是自己的 requestId,但因为在“判断”和“删除”之间发生了线程切换,最终它把线程 B 的锁给删了。线程 C 趁虚而入,再次拿到锁,并发控制彻底失效。

之后我所有释放锁的操作都强制要求用 Lua 脚本,或者直接用 Redisson 的unlock()方法。判断和删除必须是一个原子操作,没有任何商量余地。

4.2 获取锁失败后重试策略不当

自旋重试如果不加次数限制,在热点 key 失效的瞬间会形成死循环风暴。比如 1 万个请求同时进来,1 个请求拿到锁去查库,剩下 9999 个请求全部在while(true)里循环读缓存,对 Redis 的读压力本身就变成了一种二次冲击。更糟的是,如果数据库查询时间较长,这些自旋请求会把 Redis 连接池耗尽,导致整个应用不可用。

我的办法是限制自旋次数,并且在每次自旋后 sleep 一个很小的随机时间,避免所有请求在同一个时间点同时重试。你可以把 sleep 时间设置成 30 到 80 毫秒之间的随机值,效果比固定 50 毫秒要好很多,因为固定值容易造成“惊群”。

4.3 空值缓存引发的数据短暂不一致

缓存空值能防止穿透,但也会带来一个副作用:如果数据库里新插入了一条数据,空值缓存还没过期,用户会一直读到空结果。常规做法是把空值缓存的过期时间设得很短,比如 30 到 60 秒,同时配合主动删除:业务侧插入数据时,顺便把对应的空值缓存删掉。如果你使用 Redis 的发布订阅或者 Canal 同步数据变更,也可以做到更实时的缓存更新。

4.4 问题排查速查表

现象可能原因排查方向
缓存击穿仍然发生加锁后没有二次判定检查拿到锁之后是否重新查询缓存
锁一直不释放,请求大量堆积锁过期时间设置过长,或释放锁代码没有在 finally 中执行检查 finally 块,确认异常情况下锁也会释放
锁被误删释放锁时未校验 value,或校验与删除非原子操作改用 Lua 脚本释放锁
自旋请求打爆 Redis无限循环重试增加重试次数上限,sleep 加随机值
数据库短暂出现压垮性查询空值缓存时间过长,缓存删除后旧值未失效缩短空值 TTL,同步删除缓存
主从切换后锁失效锁写入了旧主节点考虑 Redisson + RedLock,或对锁追加 Watchdog 续期

5. 延伸:从单体锁到 Redis 生产环境可靠性

5.1 单机锁到分布式锁的演进

双重判定锁用到 Redis,本质上是因为多个应用节点之间需要共享同一个互斥状态。如果你的服务只有单节点,用 JVM 内部的synchronized或者ReentrantLock就能完成互斥,根本不需要引入 Redis。但现在的业务系统基本都是多实例部署,请求经过负载均衡分发到不同节点,JVM 锁只能锁住当前进程,跨节点就失效了。

这也是为什么 Redis 分布式锁在缓存治理中如此重要。SETNX 的语义很简单:只有当 key 不存在时才能设置成功,谁能成功设置,谁就获得了锁。这个操作天然就是跨节点共享的,任何一个应用节点执行setIfAbsent(lockKey, requestId)都遵循同一套规则,所以可以实现全局互斥。

不过要记住一个长期被争论的点:普通 Redis 主从架构下,如果 master 节点宕机,锁数据还没来得及同步到 slave,新的 master 不会有这把锁的记录,其他节点就能重复加锁,这就是经典的锁丢失问题。官方给的建议是使用 RedLock,但 RedLock 本身也有争议,生产环境是否采用,取决于你对锁的敏感度。对于大多数缓存击穿场景,锁丢失导致的后果只是多几个请求穿透到数据库,影响可控,所以我在大部分项目里都没上 RedLock,够用就行。

5.2 部署形态对锁可靠性的影响

开发环境我用 Docker 直接跑 redis 镜像,一条命令就能起来,反正不用考虑数据持久化。生产环境就需要重视部署形态了。如果是主从哨兵模式,Redis Sentinel 负责故障自动切换,但切换过程存在短暂的不可用窗口,双重判定锁在这个窗口内可能不可用。如果是 Redis Cluster 集群模式,锁 key 会被哈希到某个 slot,加锁操作只会落在对应的主节点上,整体可用性更高,但仍然要注意主从切换导致的锁丢失。

另一个容易忽略的点是 Redis 持久化。锁 key 虽然生命周期很短,但如果在没开启持久化的情况下 Redis 重启,所有锁都会丢失。曾有人问过我:Redis 重启后锁全丢了,是不是双重判定锁就废了?我的回答是:如果 Redis 已经重启,缓存大概率也没了,这时候系统要处理的是缓存雪崩,而不是单一 key 的击穿,双重判定锁本来就该配合缓存预热、空值缓存、多级缓存一起使用,单靠一把锁扛不住这种极端场景。

5.3 热点场景下的落地建议

以商品详情页为例,我见过的比较稳的搭配是:一级本地缓存(Caffeine)挡掉大部分读流量,二级 Redis 缓存存商品数据,三级数据库兜底。热点 key 的过期时间在基础值上加上随机偏移量,避免所有商品同时过期形成雪崩。本地缓存失效后,请求走到 Redis,Redis 失效后再走双重判定锁回源数据库。

秒杀场景又是另一套玩法。秒杀商品的库存数据对一致性要求极高,通常不会让请求直接穿透到数据库,而是把库存预加载到 Redis 里,用 Redis 的原子命令(比如decr或 Lua 脚本)扣减库存,扣减成功后再异步落库。这时候双重判定锁用不上的原因很简单:库存不是“过期后回填”的缓存数据,而是实时变更的数据源,锁的作用是保护数据库不被并发写击穿,方向完全不同。

所以我在实操中的建议是:不要试图用一个方案解决所有缓存问题。双重判定锁是缓存击穿场景下的“银弹”,但它只解决“热点 key 过期瞬间的并发读穿透”这一个问题。搭配缓存空值解决穿透、搭配随机过期时间解决雪崩、搭配本地缓存提升性能,组合起来才是完整的缓存治理方案。

6. 我踩过的坑和给你的一些建议

做这个方案从前到后踩了不少坑,最值得说的有三个。第一个是二次判定,这个是最容易犯的错误,代码写完一眼扫过去觉得没问题,压测时才发现数据库压力没有预期下降,排查半天才意识到拿到锁之后没有再查一次缓存。第二个是释放锁的原子性,不用 Lua 脚本之前,我总觉得自己不会遇到误删,直到亲手复现了一次,才彻底老实了。第三个是锁过期时间的设定,一开始我给了一个看起来很长的 30 秒,想着肯定够用,结果有一次数据库慢查询真的把锁给撑过期了,后面所有请求全部穿到了数据库,那一次真的把我吓出一身冷汗。

如果你准备在项目里落地双重判定锁,我建议先做一个最小验证:写一个模拟热点 key 的接口,用 JMeter 开 1000 个并发线程同时请求,观察 Redis 的命中率和数据库的 QPS 曲线。这个实验能直观地看到双重判定的效果,也能帮你调整锁的过期时间和重试策略。等到参数调稳了,再推广到核心业务,风险会小很多。

最后再分享一个小技巧:不要在锁里面做“先查数据库,再调外部接口,再更新缓存”这种长链路操作。双重判定锁的正确姿势是锁内只做最必要的操作,链路越短,锁持有的时间越短,系统的并发能力就越高。如果确实需要做长链路,建议把锁拆成多个短锁,或者直接用异步更新的方式,让请求先拿旧数据返回,后台再慢慢重建缓存。这也是逻辑过期方案在互联网大厂里如此流行的原因,取舍之间,全是业务方的真实诉求。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 5:30:29

异步消息驱动架构改造实战:从同步网关到高可用消息链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 5:29:36

render-middleware:为 Remix 路由注入请求级渲染器的中间件包解析

render-middleware&#xff1a;为 Remix 路由注入请求级渲染器的中间件包解析 【免费下载链接】remix The fully-stacked web framework 项目地址: https://gitcode.com/GitHub_Trending/re/remix remix-run/render-middleware 是 Remix 全栈框架中负责"请求级响应…

作者头像 李华
网站建设 2026/9/11 5:26:53

ESP32双屏GIF稳定播放的SPI与LVGL协同设计

1. 为什么“能播放”不等于“能扛住八小时”&#xff1a;一个被低估的嵌入式显示稳定性陷阱刚把GIF在ESP32双屏上跑起来那会儿&#xff0c;我拍着桌子跟同事说&#xff1a;“成了&#xff01;”——主屏滚动文字&#xff0c;副屏循环播放一个12864像素的齿轮转动GIF&#xff0c…

作者头像 李华
网站建设 2026/9/11 5:23:46

智能体系统生存指南:隔离、集成与治理三位一体架构

1. 这不是又一个“架构图PPT”&#xff0c;而是一套能落地的智能体系统生存指南“智能体系统架构&#xff1a;隔离、集成与治理的综合调研”——看到这个标题&#xff0c;你脑子里是不是立刻浮现出几张叠满箭头的分层框图、几个带阴影的云朵图标&#xff0c;再配上“高内聚、低…

作者头像 李华