掘金 后端 ( ) • 2024-04-11 11:00

缓存击穿:指的是在缓存层面发生的一种现象,当一个热点 key(即访问频率高的 key)在缓存中失效(过期)的瞬间,如果有大量并发请求这个 key,这些请求会同时穿透缓存直接打到数据库上,可能导致数据库短时间内承受巨大压力甚至崩溃。

缓存击穿的原因

  1. 热点 key 过期:一个被高频访问的热点 key 在缓存中到期了,没有及时更新。

  2. 缓存不一致:缓存与数据库之间的数据同步存在延迟,或者更新操作导致缓存中的数据暂时失效。

  3. 集中请求:在某个时间点,可能由于特定事件触发(如促销、新闻爆发等),导致大量用户在同一时间请求同一数据。

  4. 缓存策略问题:缓存设置了统一的过期时间,导致热点数据与冷数据同时失效。

缓存击穿的原理:

  1. 系统正常运作,热点 key 存在于缓存中,大量请求通过缓存获取数据,数据库压力较小。

  2. 出于某种原因(如过期、手动清除等),热点 key 从缓存中消失。

  3. 此时,所有针对该热点 key 的请求都会直接查询数据库,而不是从缓存中获取。

  4. 如果请求量很大,数据库可能会因为突然增加的巨大读取压力而性能下降甚至宕机。

  5. 直到热点 key 被重新加载到缓存中,这种高压力状态才会结束。

实际例子:一个商品详情页的缓存过期了,而该商品正处于大促销期间,突然有大量用户涌入查询该商品详情,导致数据库请求量激增。

理论解决方案:

  • 预加载:在缓存过期之前,系统自动预加载数据到缓存中,避免数据突然失效。

  • 缓存降级:在无法访问热点数据时,提供备用的服务逻辑,比如返回默认值、错误页面或从备份服务器读取等,以此避免对数据库的直接访问。

  • 设置热点数据永不过期:对于热点数据,可以设置为永不过期,或者使用逻辑过期,即数据依然存在于缓存中,但标记为过期,后台异步更新缓存。

  • 互斥锁:对于同一个 key 的数据库加载操作,使用互斥锁(或分布式锁)确保同一时间只有一个请求去查询数据库并构建缓存。

  • 限流降级:在系统访问压力增大时,启动限流降级策略,确保核心服务可用。

具体解决例子:

解决方案1:设置热点数据永不过期是一种防止缓存击穿的策略。

这种策略适用于那些始终需要被快速访问的数据,

且这些数据不经常变化或者对数据实时性要求不高的情况。在实际应用中,你可以将这些热点数据设置为永不过期,同时在后台异步更新这些缓存数据以保持数据的新鲜度。

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;

@Service
public class HotspotDataService {

    private final RedisTemplate<String, Object> redisTemplate;

    public HotspotDataService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Object getHotspotData(String key) {
        ValueOperations<String, Object> valueOps = redisTemplate.opsForValue();
        Object data = valueOps.get(key);
        if (data == null) {
            // 如果缓存中没有,从数据库加载数据
            data = loadFromDatabase(key);
            // 设置到缓存中,并设置为永不过期
            valueOps.set(key, data);
        }
        return data;
    }

    private Object loadFromDatabase(String key) {
        // 加载数据的逻辑
        // ...
        return new Object();
    }

    public void refreshHotspotData(String key) {
        // 异步或定时调用此方法来刷新热点数据
        Object newData = loadFromDatabase(key);
        ValueOperations<String, Object> valueOps = redisTemplate.opsForValue();
        valueOps.set(key, newData); // 更新缓存数据
    }
}

解读代码:getHotspotData 方法首先尝试从 Redis 缓存中获取数据。如果缓存中不存在该数据(比如第一次访问),它会从数据库加载数据并将其存入 Redis,同时设置为永不过期。refreshHotspotData 方法可以被异步任务或定时任务调用,以定期刷新缓存中的数据,保持数据的更新。这样,即使在高流量的情况下,请求也不会击穿缓存导致数据库压力过大。

解决方案2:分布式锁:

使用 Redisson 分布式锁来避免缓存击穿。

import org.redisson.api.RedissonClient;
import org.redisson.api.RLock;

public class UserService {
    private final RedissonClient redissonClient;

    public UserService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public User getUserById(Integer id) {
        User user = getUserFromCache(id);
        if (user == null) {
            RLock lock = redissonClient.getLock("user-lock-" + id);
            lock.lock();
            try {
                user = getUserFromCache(id); // Double check locking pattern
                if (user == null) {
                    user = findUserInDatabase(id);
                    if (user != null) {
                        cacheUser(user);
                    }
                }
            } finally {
                lock.unlock();
            }
        }
        return user;
    }
    private User getUserFromCache(Integer id) {
        // 从缓存中获取用户
        // ...
    }
    
    private User findUserInDatabase(Integer id) {
        // 从数据库中获取用户
        // ...
    }
    
    private void cacheUser(User user) {
        // 将用户缓存到 Redis
        // ...
    }
}

解决方案3:使用互斥锁:

当缓存失效时,不是每个请求都去数据库加载数据,而是使用一些同步控制手段(如互斥锁)确保只有一个请求去加载数据。

// 使用ConcurrentHashMap来存储锁  
private ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();  
  
public String getDataWithLock(String key) {  
    String value = cache.get(key);  
    if (value == null) {  
        // 获取互斥锁  
        locks.computeIfAbsent(key, k -> new Object());  
        synchronized (locks.get(key)) {  
            // 双重检查锁定  
            value = cache.get(key);  
            if (value == null) {  
                // 从数据库中获取数据  
                value = database.get(key);  
                // 将数据写入缓存  
                cache.set(key, value, expireTime);  
            }  
        }  
        // 释放互斥锁  
        locks.remove(key);  
    }  
    return value;  
}

这种方法使用了Java的ConcurrentHashMapsynchronized关键字来实现互斥锁。第一个到达的请求会在locks中创建一个锁对象,然后其他到达的请求会等待这个锁释放。一旦数据被加载到缓存中,锁就会被释放,其他请求就可以直接从缓存中获取数据。