Redis缓存击穿(互斥锁、逻辑过期)问题

前言 缓存击穿也叫热点 Key 问题,是指一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问指向数据库,使得数据库压力瞬间增大。 解决方案 常见的解决方案有两种: 互斥锁 逻辑过期

前言

缓存击穿也叫热点 Key 问题,是指一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问指向数据库,使得数据库压力瞬间增大。

解决方案

常见的解决方案有两种:

  • 互斥锁

  • 逻辑过期

互斥锁:本质就是让所有线程在缓存未命中时,需要先获取互斥锁才能从数据库查询并重建缓存,而未获取到互斥锁的,需要不断循环查询缓存、未命中就尝试获取互斥锁的过程。因此这种方式可以让所有线程返回的数据都一定是最新的,但响应速度不高。

逻辑过期:本质就是让热点 key 在 redis 中永不过期,而通过过期字段来自行判断该 key 是否过期,如果未过期,则直接返回;如果过期,则需要获取互斥锁,并开启新线程来重建缓存,而原线程可以直接返回旧数据;如果获取互斥锁失败,就代表已有其他线程正在执行缓存重建工作,此时直接返回旧数据即可。

两者的对比:

方案

优点

缺点

互斥锁

没有额外的内存消耗

保证一致性

实现简单

线程需要等待,性能受影响

可能有死锁风险

逻辑过期

线程无需等待,性能较好

不保证一致性

有额外内存消耗

实现复杂

代码示例

实体类

@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 商铺名称
     */
    private String name;

    /**
     * 商铺类型的id
     */
    private Long typeId;

    /**
     * 商铺图片
     */
    private String images;

    /**
     * 商圈
     */
    private String area;

    /**
     * 地址
     */
    private String address;

    /**
     * 经度
     */
    private Double x;

    /**
     * 维度
     */
    private Double y;

    /**
     * 均价,取整数
     */
    private Long avgPrice;

    /**
     * 销量
     */
    private Integer sold;

    /**
     * 评论数量
     */
    private Integer comments;

    /**
     * 评分,1~5分
     */
    private Integer score;

    /**
     * 营业时间
     */
    private String openHours;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;


    @TableField(exist = false)
    private Double distance;
}

常量

public class RedisConstants {
    public static final String CACHE_SHOP_KEY = "cache:shop:";
    public static final String LOCK_SHOP_KEY = "lock:shop:";
    public static final Long LOCK_SHOP_TTL = 10L;
    public static final String EXPIRE_KEY = "expire";
    public static final Long CACHE_SHOP_TTL = 100L;
}

工具类

public class ObjectMapUtils {

    // 将对象转为 Map
    public static Map<String, String> obj2Map(Object obj) throws IllegalAccessException {
        Map<String, String> result = new HashMap<>();
        Class<?> clazz = obj.getClass();
        Field[] fields = clazz.getDeclaredFields();
        for (Field field : fields) {
            // 如果为 static 且 final 则跳过
            if (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) {
                continue;
            }
            field.setAccessible(true); // 设置为可访问私有字段
            Object fieldValue = field.get(obj);
            if (fieldValue != null) {
                result.put(field.getName(), field.get(obj).toString());
            }
        }
        return result;
    }

    // 将 Map 转为对象
    public static Object map2Obj(Map<Object, Object> map, Class<?> clazz) throws Exception {
        Object obj = clazz.getDeclaredConstructor().newInstance();
        for (Map.Entry<Object, Object> entry : map.entrySet()) {
            Object fieldName = entry.getKey();
            Object fieldValue = entry.getValue();
            Field field = clazz.getDeclaredField(fieldName.toString());
            field.setAccessible(true); // 设置为可访问私有字段
            String fieldValueStr = fieldValue.toString();
            // 根据字段类型进行转换
            fillField(obj, field, fieldValueStr);

        }
        return obj;
    }

    // 将 Map 转为对象(含排除字段)
    public static Object map2Obj(Map<Object, Object> map, Class<?> clazz, String... excludeFields) throws Exception {
        Object obj = clazz.getDeclaredConstructor().newInstance();
        for (Map.Entry<Object, Object> entry : map.entrySet()) {
            Object fieldName = entry.getKey();
            if(Arrays.asList(excludeFields).contains(fieldName)) {
                continue;
            }
            Object fieldValue = entry.getValue();
            Field field = clazz.getDeclaredField(fieldName.toString());
            field.setAccessible(true); // 设置为可访问私有字段
            String fieldValueStr = fieldValue.toString();
            // 根据字段类型进行转换
            fillField(obj, field, fieldValueStr);
        }
        return obj;
    }

    // 填充字段
    private static void fillField(Object obj, Field field, String value) throws IllegalAccessException {
        if (field.getType().equals(int.class) || field.getType().equals(Integer.class)) {
            field.set(obj, Integer.parseInt(value));
        } else if (field.getType().equals(boolean.class) || field.getType().equals(Boolean.class)) {
            field.set(obj, Boolean.parseBoolean(value));
        } else if (field.getType().equals(double.class) || field.getType().equals(Double.class)) {
            field.set(obj, Double.parseDouble(value));
        } else if (field.getType().equals(long.class) || field.getType().equals(Long.class)) {
            field.set(obj, Long.parseLong(value));
        } else if (field.getType().equals(String.class)) {
            field.set(obj, value);
        } else if(field.getType().equals(LocalDateTime.class)) {
            field.set(obj, LocalDateTime.parse(value));
        }
    }

}

结果返回类

@Data
@NoArgsConstructor
@AllArgsConstructor
@Repository
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

控制层

@RestController
@RequestMapping("/shop")
public class ShopController {

    @Resource
    public IShopService shopService;

    /**
     * 根据id查询商铺信息
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryShopById(id);
    }

}

服务层

@Service
public class IShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Resource
    private ShopMapper shopMapper;

    @Resource
    private RedisTemplate redisTemplate;

    @Override
    public Result queryShopById(Long id) {
        Shop shop = queryWithMutex(id);
        if(shop == null) {
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

    // 互斥锁解决缓存击穿
    public Shop queryWithMutex(Long id) {
        String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
        boolean flag = false;
        try {
            do {
                // 从 redis 查询
                Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
                // 缓存命中
                if(!entries.isEmpty()) {
                    try {
                        // 刷新有效期
                        redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
                        Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class);
                        return shop;
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }
                // 缓存未命中,尝试获取互斥锁
                flag = tryLock(id);
                if(flag) { // 获取成功,进行下一步
                    break;
                }
                // 获取失败,睡眠后重试
                Thread.sleep(50);
            } while(true); //未获取到锁,休眠后重试
            // 查询数据库
            Shop shop = this.getById(id);
            if(shop == null) {
                // 不存在,直接返回
                return null;
            }
            // 存在,写入 redis
            try {

                // 测试,延迟缓存重建过程
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

                redisTemplate.opsForHash().putAll(shopKey, ObjectMapUtils.obj2Map(shop));
                redisTemplate.expire(shopKey, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
            return shop;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            if(flag) { // 获取了锁需要释放
                unlock(id);
            }
        }

    }


    // 尝试加锁
    private boolean tryLock(Long id) {
        Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(RedisConstants.LOCK_SHOP_KEY + id,
                "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(isLocked);
    }

    // 解锁
    private void unlock(Long id) {
        redisTemplate.delete(RedisConstants.LOCK_SHOP_KEY + id);
    }

    // 重建缓存
    private void rebuildCache(Long id, Long expireTime) throws IllegalAccessException {
        Shop shop = this.getById(id);
        Map<String, String> map = ObjectMapUtils.obj2Map(shop);
        // 添加逻辑过期时间
        map.put(RedisConstants.EXPIRE_KEY, LocalDateTime.now().plusMinutes(expireTime).toString());
        redisTemplate.opsForHash().putAll(RedisConstants.CACHE_SHOP_KEY + id, map);
    }
}

互斥锁方案:

测试:

查询5000次 id 为 10001 的商户

结果:

控制台显示只有一条sql记录:

逻辑过期方案:

采用逻辑过期的方式时,key 是不会过期的,而这里由于是热点 key,我们默认其是一定存在于 redis 中的(可以做缓存预热事先加入 redis),因此如果 redis 没命中,就直接返回空。

服务层代码:

public Result queryShopById(Long id) {
    // 逻辑过期解决缓存击穿
    Shop shop = queryWithLogicalExpire(id);
    if (shop == null) {
        return Result.fail("店铺不存在");
    }
    return Result.ok(shop);
}

// 逻辑过期解决缓存击穿
private Shop queryWithLogicalExpire(Long id) {
    String shopKey = RedisConstants.CACHE_SHOP_KEY + id;
    // 从 redis 查询
    Map<Object, Object> entries = redisTemplate.opsForHash().entries(shopKey);
    // 缓存未命中,返回空
    if(entries.isEmpty()) {
        return null;
    }
    try {
        Shop shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class, RedisConstants.EXPIRE_KEY);
        LocalDateTime expire = LocalDateTime.parse(entries.get(RedisConstants.EXPIRE_KEY).toString());
        // 判断缓存是否过期
        if(expire.isAfter(LocalDateTime.now())) {
            // 未过期则直接返回
            return shop;
        }
        // 过期需要先尝试获取互斥锁
        if(tryLock(id)) {
            // 获取成功
            // 双重检验
            entries = redisTemplate.opsForHash().entries(shopKey);
            shop = (Shop) ObjectMapUtils.map2Obj(entries, Shop.class, RedisConstants.EXPIRE_KEY);
            expire = LocalDateTime.parse(entries.get(RedisConstants.EXPIRE_KEY).toString());
            if(expire.isAfter(LocalDateTime.now())) {
                // 未过期则直接返回
                unlock(id);
                return shop;
            }
            // 通过线程池完成重建缓存任务
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    rebuildCache(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(id);
                }
            });
        }
        return shop;
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

// 尝试加锁
private boolean tryLock(Long id) {
    Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(RedisConstants.LOCK_SHOP_KEY + id,
            "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(isLocked);
}

// 解锁
private void unlock(Long id) {
    redisTemplate.delete(RedisConstants.LOCK_SHOP_KEY + id);
}

// 重建缓存
private void rebuildCache(Long id, Long expireTime) throws IllegalAccessException {
    Shop shop = this.getById(id);
    Map<String, String> map = ObjectMapUtils.obj2Map(shop);
    // 添加逻辑过期时间
    map.put(RedisConstants.EXPIRE_KEY, LocalDateTime.now().plusMinutes(expireTime).toString());
    redisTemplate.opsForHash().putAll(RedisConstants.CACHE_SHOP_KEY + id, map);
}

测试:

这里先预热,将 id 为 1001 的数据加入,并且让过期字段为过去的时间,即表示此数据已过期

然后将数据库中对应的 name 由 “张亮麻辣烫” 改为 “张亮麻辣烫2”

可以看到部分结果返回的旧数据,而部分结果返回的是新数据

且 redis 中的数据也已经更新,控制台只有一条记录

Comment