线程 n m 刚释放锁的时刻到达,检测到缓存过期,尝试获取锁并成功拿到锁。但 n 在拿到锁后,没有意识到缓存已经被 m 重建,因为在获取锁之前,n 看到的缓存依然是过期的。

这里我们可以再补充和澄清几个细节,帮助你更全面地理解这个过程:

1. n 拿到锁的时刻

  • m 线程持有锁并重建缓存时,其他线程(包括 n)会检测缓存的状态。
  • m 完成缓存重建并释放锁时,缓存已经被更新。
  • n 可能在缓存重建和锁释放的极短时间内(或者在重建缓存之前),检测到缓存依然过期,因此它会去竞争锁。

2. 为什么 n 没有察觉到缓存重建?

在并发编程中,锁与缓存是分开管理的

  • 锁的作用是防止多个线程同时进行重建操作。

  • 缓存的更新操作是独立的,并且存在短暂的时间差:m 线程完成缓存重建后释放锁,但 n 可能在缓存的最新状态写入 Redis 之前就获取了锁。这就是为什么 n 仍然认为缓存是过期的。

    简单来说,n 看到的缓存状态是在它获取锁之前检测的,这个状态是缓存已经过期。

3. 为什么要二重检查?

这就是为什么代码在 n 拿到锁后,会再次检查缓存的状态(二重检查):

1
2
3
4
5
6
7
8
9
10
11
// 4.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 拿到锁后再检查缓存
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(shopJson)) {
// 此时缓存可能还未重建,才需要重建缓存
// ...
}
}

二重检查的作用:因为在 m 完成缓存重建后,n 拿到锁时缓存可能已经被更新,因此 n 需要重新检查缓存,避免不必要的重复重建。

4. 时间窗口问题

线程 n 在锁释放和缓存被更新之间有一个极小的时间窗口。在这种高并发场景下,时间差是不可避免的:

  • 线程 m 可能重建完缓存的一瞬间释放了锁,此时 n 进入了锁竞争。
  • n 进入锁竞争时,它的视角仍然认为缓存是过期的,所以会尝试重建缓存。
  • 二重检查避免了重复重建:如果缓存已经被重建,n 在获取锁后检查会发现缓存已经更新,因此不会再次重建。

5. 锁竞争和缓存重建的独立性

在高并发环境下,缓存重建和锁竞争是两个独立的流程:

  • 锁竞争:控制多个线程避免同时进行耗时的数据库查询和缓存重建。
  • 缓存重建:锁释放后,其他线程依然有可能拿到锁,而缓存状态可能已经发生变化。这就是为什么在 n 成功获取锁后需要再次检查缓存。

总结:

  • 线程 n m 刚释放锁时获取了锁,并在最初的检测中看到缓存过期,因为它的视角是在锁释放之前建立的。
  • 这就是为什么 n 没有直接意识到缓存已经被 m 重建。
  • 代码中有二重检查机制,确保在 n 拿到锁之后,再次确认缓存状态,避免多余的重建操作。

这种现象是并发操作中的正常现象,使用二重检查就是为了解决这个问题。