多线程下使用缓存+锁Lock, 出现“锁失效” + “缓存未命中竞争”的缓存击穿情况,双重检查缓存解决问题

多线程情况下,想通过缓存+同步锁的机制去避免多次重复处理逻辑,尤其是I/0操作,但是在实际的操作过程中发现多次访问的日志

2025-06-05 17:30:27.683 [ForkJoinPool.commonPool-worker-3] INFO Rule - [vagueNameMilvusReacll,285] - embedding time-consuming:503 
2025-06-05 17:30:29.693 [ForkJoinPool.commonPool-worker-3] INFO Rule - [vagueNameMilvusReacll,314] - milvus time-consuming:2010 
2025-06-05 17:30:29.701 [ForkJoinPool.commonPool-worker-3] INFO Rule - [vagueNameMilvusReacll,358] - vagueName time-consuming:2534 

2025-06-05 17:30:30.135 [ForkJoinPool.commonPool-worker-11] INFO Rule - [vagueNameMilvusReacll,285] - embedding time-consuming:434 
2025-06-05 17:30:30.363 [ForkJoinPool.commonPool-worker-11] INFO Rule - [vagueNameMilvusReacll,314] - milvus time-consuming:228 
2025-06-05 17:30:30.369 [ForkJoinPool.commonPool-worker-11] INFO Rule - [vagueNameMilvusReacll,358] - vagueName time-consuming:3202 

2025-06-05 17:30:30.750 [ForkJoinPool.commonPool-worker-8] INFO Rule - [vagueNameMilvusReacll,285] - embedding time-consuming:381 
2025-06-05 17:30:31.021 [ForkJoinPool.commonPool-worker-8] INFO Rule - [vagueNameMilvusReacll,314] - milvus time-consuming:270 
2025-06-05 17:30:31.022 [ForkJoinPool.commonPool-worker-8] INFO Rule - [vagueNameMilvusReacll,358] - vagueName time-consuming:3855

代码如下:

public final static Map<String, Lock> keyLockMap = new ConcurrentHashMap<>();


Rule cacheRule = (Rule) CacheMap.get(nodeValue);
if (cacheRule != null) {
    // 返回缓存
}

Lock lock = keyLockMap.computeIfAbsent(nodeValue, k -> new ReentrantLock());
lock.lock();
try {

}finally {
   lock.unlock();
   // 释放锁资源,避免 map 持有无用锁对象太久
   keyLockMap.remove(nodeValue);
}

实际的问题:
在加锁之前做了第一次缓存检查(没问题),但在加锁之后没有再次检查缓存是否被其他线程填充过!

这就导致多个线程可能都进入了 lock.lock() 后的代码块,并且都执行了实际查询逻辑。

解决方案:双重检查缓存(Double-Checked Caching)

Rule cacheRule = (Rule) CacheMap.get(nodeValue);
if (cacheRule != null) {
    rule.setKey(cacheRule.getKey());
    rule.setValue(cacheRule.getValue());
    return;
}

Lock lock = keyLockMap.computeIfAbsent(nodeValue, k -> new ReentrantLock());
lock.lock();
try {
    // 【关键】第二次检查缓存
    cacheRule = (Rule) CacheMap.get(nodeValue);
    if (cacheRule != null) {
        rule.setKey(cacheRule.getKey());
        rule.setValue(cacheRule.getValue());
        return;
    }

    // 真正执行 Milvus 请求...
    // ...
    // 最后更新缓存
    CacheMap.put(nodeValue, rule);
} finally {
    lock.unlock();
    keyLockMap.remove(nodeValue); // 可选释放锁对象
}

在这里插入图片描述
这个可能出现锁失效的情况
keyLockMap.remove(nodeValue); // 可选释放锁对象
当T1 进入的时候处理完逻辑后,放入缓存,然后删除锁
sleep(xxx)
当T2 进入的时候处理逻辑,发现没有锁,上锁,访问缓存

发现问题了,如果finally 及时删除锁,可能会出现下一个线程重新建立锁对象,然后多了查询缓存的性能消耗。
为了避免这种情况存在

建立LockManager 类管理锁对象,同时对锁进行ttl 保留时间定期任务删除对应的key


import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;

@Slf4j
public class LockManager {
    private final Map<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
    private final Map<String, Long> lastAccessTime = new ConcurrentHashMap<>();
    private static final long TTL = TimeUnit.MINUTES.toMillis(5); // 锁保留5分钟

    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

    public LockManager() {
        startCleanupTask();
    }

    // 获取锁,并更新最后访问时间
    public ReentrantLock getLock(String key) {
        lastAccessTime.put(key, System.currentTimeMillis());
        return lockMap.computeIfAbsent(key, k -> new ReentrantLock());
    }

    // 清理任务:扫描并移除超时的锁对象
    private void startCleanupTask() {
        scheduler.scheduleAtFixedRate(() -> {
            log.info("------------start check expired lock---------------");
            long now = System.currentTimeMillis();
            lastAccessTime.forEach((key, timestamp) -> {
                if (now - timestamp > TTL) {
                    lockMap.remove(key);
                    lastAccessTime.remove(key);
                    log.info("Removed expired lock for key: {}", key);
                }
            });
        }, 1, 1, TimeUnit.MINUTES); // 每分钟执行一次清理
    }

    public void shutdown() {
        scheduler.shutdownNow();
    }
}

@Configuration
public class AppConfig {

    @Bean(destroyMethod = "shutdown")
    public LockManager lockManager() {
        return new LockManager();
    }
}

注入使用

        Lock lock = lockManager.getLock(nodeValue);

在这里插入图片描述

### 关于缓存击穿的概述 缓存击穿是指某个热点键(key)在高并发情况失效,导致大量的请求穿透到数据库,从而引发数据库的压力激增甚至崩溃的情况。为了避免这种情况的发生,通常采用预热缓存、设置过期时间和分布式等方式来缓解压力[^1]。 以下是针对缓存击穿的一个典型用例图描述以及解决方案示例: --- ### 缓存击穿的用例图说明 #### 场景描述 假设有一个电商网站,在大促销活动期间,某些商品的价格信息会被频繁访问。如果该价格信息对应的缓存突然失效,可能会导致大量请求直接打到数据库上,造成数据库负载过高。 #### 用例图的关键角色 - **用户**:发起对商品价格的查询请求。 - **缓存层**:负责存储高频访问的数据,减少对数据库的直接调用。 - **数据库**:提供最终的真实数据来源。 - **分布式机制**:用于控制多个线程或进程在同一时刻只允许一个实例去加载缓存。 #### 流程描述 1. 用户发送请求获取某商品的价格信息。 2. 如果缓存命中,则直接返回结果给用户;否则进入下一步。 3. 当发现缓存未命中时,尝试通过分布式定当前 key 的加载过程。 4. 若成功获得,则从数据库读取数据并重新填充至缓存中。 5. 若未能获得,则等待其他线程完成加载后再重试。 --- ### 示例代码实现 以下是一个简单的 Java 实现示例,展示如何利用 Redis 和分布式防止缓存击穿: ```java import redis.clients.jedis.Jedis; public class CacheService { private Jedis jedis; private String lockKeyPrefix = "lock:"; public Object getFromCache(String key) { // 尝试从缓存中获取数据 String cachedValue = jedis.get(key); if (cachedValue != null) { return cachedValue; // 缓存命中 } // 使用分布式保护缓存重建逻辑 boolean acquiredLock = tryAcquireDistributedLock(lockKeyPrefix + key); if (!acquiredLock) { // 如果未能获取,则稍后重试或者降级处理 System.out.println("Failed to acquire lock, retrying..."); return null; } try { // 定成功后,从数据库加载数据 String dbValue = loadFromDatabase(key); if (dbValue != null && !dbValue.isEmpty()) { // 更新缓存 jedis.setex(key, 60 * 5, dbValue); // 设置缓存有效期为5分钟 } return dbValue; } finally { releaseDistributedLock(lockKeyPrefix + key); // 确保释放 } } private String loadFromDatabase(String key) { // 模拟从数据库加载数据的过程 return "Price from DB"; } private boolean tryAcquireDistributedLock(String lockKey) { // 尝试获取分布式 long result = jedis.setnx(lockKey, "locked"); if (result == 1L) { jedis.expire(lockKey, 10); // 设置的有效时间为10秒 return true; } return false; } private void releaseDistributedLock(String lockKey) { // 删除 jedis.del(lockKey); } } ``` --- ### 解决方案总结 为了有效应对缓存击穿问题,可以采取以下措施: 1. 对热点数据进行预热,确保其始终存在于缓存中[^2]。 2. 引入随机过期时间,避免大批量缓存在同一时间点失效。 3. 利用分布式限制同时重建缓存的线程数量,降低数据库压力[^3]。 --- ### 示意图解 下面是一幅简化版的缓存击穿防护流程图: ``` +-------------------+ | User Request | +--------+----------+ | v +-------------------+ | Check Cache | ----> Hit? Yes -> Return Data +--------+----------+ | v No +-------------------+ | Acquire Distributed Lock| +--------+----------+ | v Success? Yes / No | | v v Retry or Fallback +-------------------+ | Load From Database| +--------+----------+ | v +-------------------+ | Update Cache and Release Lock| +-----------------------------+ ``` --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IT_Octopus

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值