02 商户查询缓存
(0)前期准备
1、接口
- 根据id查询商铺信息;
- 更新商铺信息。
ShopController
@RestController
@RequestMapping("/shop")
public class ShopController {
@Autowired
private ShopService shopService;
/**
* 根据id查询商铺信息
* @param id 商铺Id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
/**
* 更新商铺信息
* @param shop 商铺数据
*/
@PutMapping()
public Result updateShop(@RequestBody Shop shop) {
// 更新数据库,删除redis中缓存
return shopService.update(shop);
}
}
2、数据库表
(1)缓存是什么
- 缓存定义:数据交换的缓冲区(Cache),存贮数据的临时地方,一般读写性能高;
- 缓存使用:如浏览器缓存、应用层缓存、数据库缓存、CPU缓存;
- 缓存作用:降低后端负载,提高服务读写响应速度;
- 缓存成本:开发成本、运维成本、一致性问题。
(2)查询商户信息添加Redis缓存
- 查询商户信息时,先查询Redis缓存,若缓存没有则查询商户信息,若查到则将该数据添加到Redis。
1、key的结构
- 商户信息:key-value:shop缓存标识 + 商户id --- 商户信息;
2、查询商户信息
ShopServiceImpl
/**
* 根据id查询店铺,第一版:先从redis查缓存,若无再查Mysql
*/
public Result queryById(Long id) {
String cacheShopKey = CACHE_SHOP_KEY + id;
// 1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(cacheShopKey);
// 2. 判断缓存是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 缓存存在
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 3. 缓存未命中,根据id查询数据库
Shop shop = this.getById(id);
if (shop == null) {
// 数据库中不存在数据,返回错误
return Result.fail("商铺不存在");
}
// 4. 数据库中存在数据,将商铺信息写入redis,并设置超时时间,避免redis缓存过多数据
stringRedisTemplate.opsForValue().set(cacheShopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 5. 返回商铺信息
return Result.ok(shop);
}
(3)缓存更新策略
(4)结合Redis更新商铺信息
(5)缓存三大问题
1、缓存穿透
- 客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
- 解决方案:
- 缓存空对象:对于不存在的数据也在Redis中建立缓存,值为空,并设置一个较短的TTL时间;
- 优点:实现简单,维护方便;
- 缺点:额外的内存消耗,可能造成短期不一致。
- 布隆过滤:利用布隆过滤算法,在请求进入Redis之前判断是否存在,若不存在则直接拒绝请求(类比hash思想,判断某个数据的多个hash是否匹配上,匹配上则放行,存在有hash冲突,即可能放行不存在的数据);
- 优点:内存占用较少,没有多余的key;
- 缺点:实现复杂,存在误判可能。(可以放行一些不存在的key)
- 缓存空对象:对于不存在的数据也在Redis中建立缓存,值为空,并设置一个较短的TTL时间;
【1】使用空值解决缓存穿透
- 注意:value为空值的key的过期时间应该设置较短,避免长时间的数据不一致。
ShopServiceImpl
/**
* 解决缓存击穿问题
* 根据id查询店铺,第二版:先从redis查缓存,若无再查Mysql,mysql也无则赋空值至redis
*/
public Result queryById(Long id) {
Shop shop = queryWithPassThrough(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}
/**
* 缓存穿透(使用空值)
*/
private Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断缓存中是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,则返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 3. 判断缓存是否是空值
if (shopJson != null) {
return null;
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
if (shop == null) {
// 不存在,则设置空值到redis中,过期时间比正常数据短
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5. 数据库中存在数据,将商铺信息写入redis,并设置超时时间,避免redis缓存过多数据
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 6. 返回
return shop;
}
2、缓存击穿
- 热点Key问题,即热点Key(高并发访问)突然失效,无数请求该key的请求直接打到数据库上。
- 解决方案:
- 互斥锁,进行数据库查询从而缓存重构的过程由一个请求进行;
- 逻辑过期,不设置Redis key的过期时间,而是自己在value中添加一个过期字段,之后若key逻辑过期则让一个请求使用互斥锁去缓存重构,其他请求直接返回旧数据。
【1】使用互斥锁解决缓存击穿
ShopServiceImpl
/**
* 解决缓存击穿问题
* 根据id查询店铺,第三版:使用互斥锁
*/
public Result queryById(Long id) {
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}
/**
* 缓存击穿(使用互斥锁)
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 2. 缓存命中返回数据
return JSONUtil.toBean(shopJson, Shop.class);
}
// 3. 缓存未命中
// 3.1 缓存是否是否是空值
if (shopJson != null) {
return null;
}
// 4. 实现缓存重建
// 4.1 尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
// 4.2 判断是否获取成功
boolean isLock = tryLock(lockKey);
if (isLock) {
try {
// 4.4 成功,根据id查询数据库
shop = getById(id);
// 模拟延迟时间
Thread.sleep(200);
if (shop == null) {
// 不存在,将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 7. 释放互斥锁
unlock(lockKey);
}
} else {
// 4.3 失败,休眠并等待重试
try {
Thread.sleep(50);
return queryWithMutex(id);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
// 返回
return shop;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
【2】使用逻辑过期解决缓存击穿
- 前提:对数据进行预热处理,redis中先保存热点商户信息;
- 封装一个redis value的实体类,其中封装data与过期时间;
- 缓存重构的操作新开一个线程去异步操作,返回的是缓存的旧数据,再次请求时才获得新数据。
封装逻辑过期时间实体类
/**
* 缓存数据:添加过期时间字段
*/
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
热点数据预热
/**
* 作用1:模拟商铺热点数据预热(给使用逻辑过期策略解决缓存击穿问题做前提准备,前提是执行单元测试中的预热方法【见DianPingApplicationTest】)
* 作用2:缓存重建
* 该方法在ShopServiceImpl中
*/
public void saveShopRedis(Long id, Long expireSeconds) throws InterruptedException {
// 1. 查询店铺数据
Shop shop = getById(id);
if (shop == null) {
return;
}
// 模拟缓存重建的时间
Thread.sleep(200);
// 2. 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入redis,不需要设置过期时间(有逻辑过期时间)
String key = CACHE_SHOP_KEY + id;
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 数据预热,测试使用逻辑过期解决缓存击穿前执行
* 该测试方法在DianPingApplicationTest中
*/
@Test
public void test() throws InterruptedException {
shopService.saveShopRedis(1L, 10L);
}
ShopServiceImpl
/**
* 解决缓存击穿问题
* 根据id查询店铺,第四版本:使用逻辑删除
*/
@Override
public Result queryById(Long id) {
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}
/**
* 缓存击穿(使用逻辑过期):需提前导入热点数据
* 思路:如某时间段的热点商品,提前加入热点数据,如数据不存在,直接返回,数据存在,判断是否过期
* 想法:万一该预热商品更新数据时,得考虑是删除缓存还是对商品信息缓存重建。
* 此处针对的是热点数据,不是所有数据
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isBlank(shopJson)) {
// redis中不存在,则返回
return null;
}
// 3. 缓存命中,JSON反序列化成对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断数据是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 数据未过期,返回店铺信息
return shop;
}
// 5. 数据过期,更新缓存数据
// 6. 尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.1 获取成功
if (isLock) {
// 注意:获取锁成功应该再次检测redis缓存是否过期,做DoubleCheck,如果存在则无需重建缓存。
// DoubleCheck:请求1刚刚把Redis数据更新为新数据并释放了锁,请求2查询到旧数据后拿到锁,就得需要再次查询Redis看是否过期,减少与数据库交互的可能次数。
String shopJson1 = stringRedisTemplate.opsForValue().get(key);
if (StringUtil.isBlank(shopJson1)) {
return null;
}
RedisData redisData1 = JSONUtil.toBean(shopJson1, RedisData.class);
LocalDateTime expireTime1 = redisData.getExpireTime();
if (expireTime1.isAfter(LocalDateTime.now())) {
return JSONUtil.toBean((JSONObject) redisData1.getData(), Shop.class);
}
// 7. 开启新线程进行缓存重建(使用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 8. 查询数据库数据
// 9. 更新缓存数据到redis中
saveShopRedis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 10. 释放锁
unlock(lockKey);
}
});
}
// 6.2. 获取失败,返回旧数据
// 返回旧数据
return shop;
}
【3】互斥锁和逻辑过期对比
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 无额外内存消耗;保存一致性;实现简单 | 线程可能需要等待,性能受影响;可能有死锁风险 |
逻辑删除 | 线程无需等待,性能较好(缓存重建可新开一个线程,返回旧数据) | 不保证一致性,有额外内存消耗;实现复杂 |
3、缓存雪崩
- 同一