Skip to content

缓存实现及其问题

认识缓存

缓存就是数据交换的缓冲区,是存贮数据的临时地方,一般读写性能较高。

缓存的作用:

  1. 降低后端负载
  2. 提高读写效率,降低响应时间

缓存的成本:

  1. 数据一致性成本
  2. 代码维护成本
  3. 运维成本

添加缓存

缓存作用模型

查询商铺缓存的流程

添加缓存业务代码

java
@Override
public List<UserDTO> getUserlist() {
    Gson gson = new Gson();
    // 1. 查询redis缓存
    String cache = redisTemplate.opsForValue().get(CACHE_LIST_PRE);
    // 2.1. 存在缓存
    if (StrUtil.isNotBlank(cache)) {
        // 3. 反序列化
        return gson.fromJson(cache, new TypeToken<List<UserDTO>>() {}.getType());
    }
    // 2.2. 不存在缓存
    // 3. 查询数据库
    List<User> userList = list();
    // 4. 信息脱敏
    ArrayList<UserDTO> userDTOList = new ArrayList<>();
    for (User user : userList) {
        UserDTO dto = new UserDTO();
        BeanUtil.copyProperties(user, dto);
        dto.setPhone(DesensitizedUtil.mobilePhone(dto.getPhone()));
        userDTOList.add(dto);
    }
    // 5. 保存到Redis
    redisTemplate.opsForValue()
        .set(CACHE_LIST_PRE, gson.toJson(userDTOList), 2, TimeUnit.MINUTES);
    return userDTOList;
}

缓存更新

缓存更新策略

内存淘汰超时剔除主动更新
说明利用Redis的内存淘汰机制,内存不足时自动淘汰部分数据。给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。编写业务逻辑,在修改数据库的同时,更新缓存。
一致性一般
维护成本
  • 低一致性需求:使用内存淘汰机制
  • 高一致性需求:主动更新 + 超时剔除

主动更新策略

  • 读操作:
    • 缓存命中则直接返回
    • 缓存未命中则查询数据库,并写入缓存,设定超时时间
  • 写操作:
    • 先操作数据库,然后再删除缓存
    • 要确保数据库与缓存操作的原子性(事物/分布式事物)

缓存穿透

什么是缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

缓存空对象

优点:实现简单,维护方便

缺点:额外的内存消耗、可能造成短期的不一致

业务实现

java
// 缓存空对象
@Override
public UserDTO getInfoById(Long id) {
    // 缓存查询
    String userString = redisTemplate.opsForValue()
        .get(CACHE_USER_PRE + id);
    if (StrUtil.isNotBlank(userString)) {
        // 有缓存 => 真实数据
        return gson.fromJson(userString, UserDTO.class);
    }
    if (userString != null) {
        // 有缓存 => 空对象
        throw new BusinessException(404, "用户不存在");
    }
    // 数据库查询
    User user = getById(id);
    if (user == null) {
        // 缓存空对象
        redisTemplate.opsForValue()
            .set(CACHE_USER_PRE + id, "", 2, TimeUnit.MINUTES);
        throw new BusinessException(404, "用户不存在");
    }
    // 信息脱敏
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // 缓存真实数据
    redisTemplate.opsForValue()
        .set(CACHE_USER_PRE + id, gson.toJson(userDTO), 2, TimeUnit.MINUTES);
    return userDTO;
}

布隆过滤器

优点:内存占用较少,没有多余key

缺点:实现复杂、存在误判可能

image-20240117202714944

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  1. 给不同的Key的TTL添加随机值
  2. 利用Redis集群提高服务的可用性
  3. 给缓存业务添加降级限流策略
  4. 给业务添加多级缓存

缓存击穿/热点Key

什么是缓存击穿

缓存击穿问题也叫热点Key问题:一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

互斥锁

优点:没有额外的内存消耗、保证一致性、实现简单

缺点:线程需要等待,性能受影响、可能有死锁风险

互斥锁流程图

互斥锁业务代码

java
// 热点key-互斥锁
@Override
public List<UserDTO> getUserlist() throws InterruptedException {
    // 1. 查询redis缓存
    String cache = redisTemplate.opsForValue().get(CACHE_LIST_PRE);
    // 2.1. 存在缓存
    if (StrUtil.isNotBlank(cache)) {
        // 3. 反序列化
        return gson.fromJson(cache, new TypeToken<List<UserDTO>>() {}.getType());
    }
    // ⭐️ 获取互斥锁
    String lock = REDIS_LOCK_PRE + "userlist";
    Boolean flag = redisTemplate.opsForValue()
        .setIfAbsent(lock, "1", 30, TimeUnit.SECONDS);
    if (BooleanUtil.isFalse(flag)) {
        // ⭐️ 获取锁失败了 => 休眠 + 递归
        Thread.sleep(200);
        return getUserlist();
    }

    ArrayList<UserDTO> userDTOList = new ArrayList<>();
    try {
        // 2.2. 不存在缓存
        // 3. 查询数据库
        List<User> userList = list();
        log.info("查询数据库");
        // 4. 信息脱敏
        for (User user : userList) {
            UserDTO dto = new UserDTO();
            BeanUtil.copyProperties(user, dto);
            dto.setPhone(DesensitizedUtil.mobilePhone(dto.getPhone()));
            userDTOList.add(dto);
        }
        // 5. 保存到Redis
        redisTemplate.opsForValue()
            .set(CACHE_LIST_PRE, gson.toJson(userDTOList), 10, TimeUnit.SECONDS);
    } catch (Exception ignored) {
    } finally {
        // ⭐️ 释放锁
        redisTemplate.delete(lock);
    }
    return userDTOList;
}

逻辑过期

优点:线程无需等待,性能较好

缺点:不保证一致性、有额外内存消耗、实现复杂

逻辑过期流程图

逻辑过期业务代码

1、LogicalExpiration逻辑过期实体类,使用泛型使其通用化

java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LogicalExpiration<T> {
    private T value;
    private Date date;
}

2、核心业务代码

java
public UserDTO getUserById(Long id) {
    // 1. 查询缓存
    String userString = redisTemplate.opsForValue().get(CACHE_USER_PRE + id);
    if (StrUtil.isBlank(userString)) {
        // 没有缓存 -> 查询数据
        User user = getById(id);
        // 没有数据 -> 报错
        if (user == null) {
            throw new BusinessException(500, "用户不存在");
        }
        // 数据脱敏
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        // 新建缓存
        Date date = new Date();
        date.setTime(System.currentTimeMillis() + 2 * 60 * 1000);
        redisTemplate.opsForValue().set(CACHE_USER_PRE + id, gson.toJson(
            new LogicalExpiration<>(userDTO, date)
        ));
        // 返回数据
        return userDTO;
    }
    // 存在缓存 => 反序列化拿到对象
    LogicalExpiration<UserDTO> logicalExpiration = gson.fromJson(
        userString, new TypeToken<LogicalExpiration<UserDTO>>() {}.getType());
    // 判断缓存是否过期
    if (logicalExpiration.getDate().before(new Date())) {
        // 已经过期 => 新建线程进行更新缓存
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        executorService.execute(() -> {
            UserDTO userDTO = BeanUtil.copyProperties(getById(id), UserDTO.class);
            Date date = new Date();
            // 设置TTL为2min
            date.setTime(System.currentTimeMillis() + 2 * 60 * 1000);
            redisTemplate.opsForValue().set(CACHE_USER_PRE + id, gson.toJson(
                new LogicalExpiration<>(userDTO, date)
            ));
        });
    }
    // 返回数据
    return logicalExpiration.getValue();
}

Released under the MIT License.