掘金 后端 ( ) • 2024-04-10 10:42

Redis 缓存穿透、缓存击穿和缓存雪崩是三种常见的缓存问题,它们可能导致系统性能下降甚至崩溃。

缓存穿透:是指请求查询缓存系统中不存在的数据,由于缓存不命中,请求会继续查询数据库

缓存穿透的原因通常有以下几点:

  1. 非法请求:攻击者故意构造不存在的请求(如使用随机生成的或异常的键值),试图绕过缓存层,直接对数据库进行攻击。

  2. 系统缺陷:系统设计时没有考虑到或没有正确处理查询不存在数据的情况。

  3. 数据缺失:合法请求查询的数据确实不存在,比如已删除或未生成的数据,但系统没有相应的缓存策略来处理这种情况。

原理

缓存穿透的原理可以通过以下步骤来解释:

  1. 客户端发起一个查询请求,请求中携带的键(key)用于在缓存中查找数据。

  2. 缓存系统接收到请求后,尝试根据提供的键查找对应的值。

  3. 如果缓存中不存在该键对应的值,缓存不命中,请求就会继续传递到数据库层。

  4. 数据库尝试查找请求的数据,但由于数据不存在,返回空结果。

  5. 系统可能会将空结果返回给客户端,但通常不会将这个空结果写入缓存。

  6. 如果有大量此类查询,每次查询都会穿透缓存直接对数据库进行,导致数据库压力增大。 实际例子:一个恶意攻击者不断地使用随机生成的用户 ID 来请求用户信息,因为这些用户 ID 不存在,所以每次请求都会直接查询数据库。

理论解决方案:

  • 布隆过滤器:使用布隆过滤器(Bloom Filter)来检查请求的数据是否可能存在。如果布隆过滤器不存在,那么就可以直接返回,不需要查询数据库。

  • 缓存空值:即使数据在数据库中不存在,也可以将一个特殊的空值或者标记存入缓存,以避免重复查询数据库。

  • 接口限流:对接口进行限流操作,限制每个用户的请求频率。

具体例子:

解决方案1:布隆过滤器

假设我们有一个用户服务,我们可以使用 Google 的 Guava 库中的布隆过滤器来避免缓存穿透。

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class UserService {
    private final BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 1000);

    public User getUserById(Integer id) {
        if (!bloomFilter.mightContain(id)) {
            return null; // 如果布隆过滤器判断不存在,直接返回 null
        }
        // 正常的缓存逻辑
        // ...
    }

    public void createUser(User user) {
        // 创建用户的同时将用户 ID 加入布隆过滤器
        bloomFilter.put(user.getId());
        // 正常的创建用户逻辑
        // ...
    }
}

解决方案2:缓存空对象

import org.springframework.cache.annotation.Cacheable;

public class UserService {

    @Cacheable(value = "users", key = "#id", unless = "#result == null")
    public User getUserById(Integer id) {
        User user = findUserInDatabase(id);
        if (user == null) {
            // 如果数据库中也不存在,缓存一个空对象或特殊标记,设置较短的过期时间
            cacheNullObject(id);
            return null;
        }
        return user;
    }
  private void cacheNullObject(Integer id) {
        // 缓存一个空对象或特殊标记
        // ...
    }
}

解决方案3:接口限流:使用 Google Guava RateLimiter 实现接口限流的 Java 示例代码。

import com.google.common.util.concurrent.RateLimiter;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public class RateLimiterUtil {
    private static final ConcurrentMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();

    // 获取限流器,如果不存在则创建一个新的
    public static RateLimiter getRateLimiter(String key, double permitsPerSecond) {
        return limiters.computeIfAbsent(key, k -> RateLimiter.create(permitsPerSecond));
    }
}

接口中使用这个限流器来控制请求:

import com.google.common.util.concurrent.RateLimiter;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    // 假设每秒允许每个用户进行5次请求
    private static final double PERMITS_PER_SECOND = 5.0;

    @GetMapping("/user/{id}")
    public User getUser(@PathVariable Integer id) {
        RateLimiter rateLimiter = RateLimiterUtil.getRateLimiter("user_" + id, PERMITS_PER_SECOND);

        // 检查是否能够立即获取令牌
        if (!rateLimiter.tryAcquire()) {
            throw new RateLimitExceededException("Rate limit exceeded. Please try again later.");
        }

        // 正常的业务逻辑
        return findUserById(id);
    }
      private User findUserById(Integer id) {
        // 查找用户的逻辑
        return new User();
    }
}

public class RateLimitExceededException extends RuntimeException {
    public RateLimitExceededException(String message) {
        super(message);
    }
}

public class User {
    // 用户类的实现
  private long id;
  private String userName;
  private string address;
}

上面的代码中,我们为每个用户 ID 创建了一个单独的 RateLimiter 实例。当请求到达时,我们会尝试从限流器获取一个令牌。如果不能立即获取到令牌(表示请求太频繁),则抛出 RateLimitExceededException 异常。