点评项目——商户查询缓存
2023.12.7
redis实现商户查询缓存
在企业开发中,用户的访问量动辄成百上千万,如果没有缓存机制,数据库将承受很大的压力。本章我们使用redis来实现商户查询缓存。
原来的操作是根据商铺id直接从数据库查询商铺信息,为了防止频繁地对数据库访问,我们使用redis进行缓存,大致流程图如下:
需要改变的地方就两个:①之前是直接从数据库中查,现在是先尝试从redis中查,没查到再去查数据库。②如果查数据库查到了的话,需要将查到的商铺数据先存到redis中,再将数据返回。 代码如下:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2、判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3、存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4、不存在,根据id查询数据库
Shop shop = getById(id);
//5、数据库没查到数据,返回错误信息
if (shop == null){
return Result.fail("商铺不存在!");
}
//6、数据库查到信息了,写入redis并返回商铺信息
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
}
redis实现商户类型数据缓存
解决商户数据缓存之后,我们趁热打铁也完成一下商户类型数据缓存,即下面这张图中数据的缓存:
而且这个页面数据也不会经常变动,很适合做缓存,需要变更的代码如下:
首先修改 ShopTypeController.java文件,原来是直接从数据库中查数据,这里我们在Controller中自定义一个方法,在service实现类中去编写具体业务代码:
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
@GetMapping("list")
public Result queryTypeList() {
// List<ShopType> typeList = typeService
// .query().orderByAsc("sort").list();
// return Result.ok(typeList);
return typeService.queryList();
}
}
对应的接口需要增加该方法:
public interface IShopTypeService extends IService<ShopType> {
Result queryList();
}
在对应的实现类ShopTypeServiceImpl.java中编写具体业务代码:
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryList() {
//1.尝试从redis中查询商户类型数据
List<String> shopTypes = stringRedisTemplate.opsForList().range(CACHE_SHOP_TYPE_KEY, 0, -1);
//2.在redis中查到数据了,返回ShopType类型数据
if(!shopTypes.isEmpty()){
List<ShopType> list = new ArrayList<>();
for(String shopType : shopTypes){
ShopType bean = JSONUtil.toBean(shopType, ShopType.class);
list.add(bean);
}
return Result.ok(list);
}
//3.在redis中没查到数据,那就去数据库查
List<ShopType> list = query().orderByAsc("sort").list(); //从数据库中按照sort字段升序查询
//3.1 数据库也没查到,返回错误信息
if(list == null){
return Result.fail("店铺类型不存在!");
}
//3.2 数据库查到数据了,存入redis中并返回给用户
for (ShopType shopType : list){
String jsonStr = JSONUtil.toJsonStr(shopType);
shopTypes.add(jsonStr);
}
stringRedisTemplate.opsForList().leftPushAll(CACHE_SHOP_TYPE_KEY,shopTypes);
return Result.ok(list);
}
}
本人新手用的笨方法for-each循环逐个转换,高手可以用stream流来简化代码。
缓存更新策略
由于内存资源比较宝贵,向其插入过多数据的话可能导致内存空间爆满,所以需要某种机制对内存的部分数据进行更新或者移除。下面介绍三种缓存更新数据:
内存淘汰
:Redis自动进行,当Redis内存大到某个阈值时,会自动触发淘汰机制,淘汰掉一些不重要的数据(这个机制可以自定义)超时剔除
:当我们给Redis设置了过期时间TTL之后,Redis会将超时的数据进行删除。主动更新
:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题,该方法一致性较好,但是维护成本高。
业务场景:
- 在低一致性场景下:使用内存淘汰机制,因为该场景下的数据很长一段时间都不需要更新。
- 在高一致性场景下:使用主动更新策略,即自己编写代码实现高一致性,但也不能100%的保证一致性,所以还需要使用超时剔除策略兜底。
数据库与缓存不一致的解决方案
由于我们的缓存数据来自数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步更新,此时存在数据的一致性问题。
有三种解决方案:
- Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
- Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
- Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了
实际开发中,一般还是使用方案一,但是如果我们每次操作完数据库之后,都去更新一下缓存,而此期间并没有人查询数据,那么这个更新动作意义就不大了,所以我们可以把缓存直接删除,等到有人再次查询时,再更新缓存。
还有个问题,我们应该先删缓存还是先更新数据库呢?理论上是都可以,如果先删缓存再更新数据库的话,由于删缓存的速度比更新数据库的速度快很多,所以两个操作之间有一段较长的空档期,此期间如果有其他线程进来查询数据库的话查的就是脏数据了。先更新数据库再删缓存当然也存在安全问题,但是几率会比上述小很多,这里不再细说,结论就是采用先更新数据库再删缓存的策略。
实现商铺缓存与数据库的双写一致
主要需要修改两处地方:
- 根据id查询商铺时,将数据库结果写入缓存时,需要设置超时时间。(超时剔除策略)
- 根据id修改店铺时,先修改数据库,再删除缓存。
在ShopServiceImpl.java代码中设置超时时间:
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
修改店铺操作时,先修改数据库,再删除缓存:
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空!");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
缓存穿透问题及解决办法
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存就形同虚设(只有数据库查到了,才会让redis缓存,但现在的问题是查不到),会频繁的去访问数据库。
常见的解决方案有两种:
- 缓存空对象:如果该数据在缓存和数据库中都不存在,就缓存一个空值到redis中,并且超时时间设置得短一点,如2分钟。
- 布隆过滤:布隆过滤器是处于redis之前的一段过滤器,底层是根据哈希来实现的,客户端的所有请求都会通过该过滤器进行过滤,由于哈希的性质,若该过滤器都查不到数据,则直接返回错误信息;若查到了则放行,但也不一定存在该数据(存在哈希冲突)。
下面使用缓存空对象解决缓存穿透问题,先看一下流程图:
与之前相比需要增加两个操作:
- 数据库也查不到商铺的话,需要将空值写入redis。
- 缓存命中之后可能为空,需要进行判空操作。
代码修改如下:
@Override
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2、判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3、存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if(shopJson != null){
return Result.fail("店铺信息不存在");
}
//4、不存在,根据id查询数据库
Shop shop = getById(id);
//5、数据库没查到数据,返回错误信息
if (shop == null){
//应对缓存穿透问题,将空值写入redis,并且有效期需要设置得短一点。
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("商铺不存在!");
}
//6、数据库查到信息了,写入redis并返回商铺信息
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
缓存雪崩问题及解决办法
缓存雪崩是指在同一时间段,大量缓存的key同时失效,或者Redis服务宕机,导致大量请求到达数据库。
解决方案:
- 给不同的Key的TTL添加随机值,让其在不同时间段分批失效
- 利用Redis集群提高服务的可用性(使用一个或者多个哨兵(
Sentinel
)实例组成的系统,对redis节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 ) - 给缓存业务添加降级限流策略
- 给业务添加多级缓存,可以理解为穿了好几件防弹衣。
缓存击穿问题及解决办法
缓存击穿也叫热点Key问题,一个热点的Key,有大并发集中对其进行访问,突然间这个Key失效了,导致大并发全部打在数据库上,导致数据库压力剧增。
-
常见的解决方案有两种
- 互斥锁
- 如果业务允许的话,对于热点的key可以设置永不过期的key。
互斥锁:只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,当然这样会导致系统的性能变差。
这里放一下使用互斥锁的代码:
public Shop queryWithMutex(Long id){
String key = CACHE_SHOP_KEY + id;
//1、从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2、判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3、存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否是空值
if(shopJson != null){
return null;
}
//4.实现缓存重建
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2判断是否获取成功
if(!isLock){
//4.3 失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
//5、数据库没查到数据,返回错误信息
if (shop == null){
//应对缓存穿透问题,将空值写入redis,并且有效期需要设置得短一点。
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
//6、数据库查到信息了,写入redis并返回商铺信息
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
定义上锁和放锁的代码:
//定义加锁和删锁的操作
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
可以参考以下流程图: