源码仓库地址:git@gitee.com:chuangchuang-liu/hm-dingping.git
1、什么是缓存?
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码
1.1、为什么要使用缓存?
添加缓存后,重复的数据可以直接从缓存中获取,一定程度上降低服务器的压力
但缓存带来的问题:
缓存作用 | 使用缓存成本 |
---|---|
降低后端负载 | 数据一致性成本 |
提高读写效率,提升响应能力 | 维护成本 |
1.2、如何使用缓存?
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用
浏览器缓存:主要是存在于浏览器端的缓存
应用层缓存: 可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
数据库缓存: 在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
CPU缓存: 当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
2、添加商铺缓存
- 缓存作用模型
- 核心代码
public Shop queryById(Long id) {
// 从缓存中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (shopJson != null) {
// 如果缓存中有,直接返回
Shop shop = JSON.parseObject(shopJson, Shop.class);
return shop;
}
// 如果缓存中没有,从数据库中查询
Shop shop = this.getById(id);
// 如果数据库中没有,返回null
if (shop == null) {
return null;
}
// 将查询到的数据写入缓存
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, JSON.toJSONString(shop));
return shop;
}
3、缓存更新策略
缓存更新是redis为了节约内存而设计的一种机制,当往redis中插入过多数据,会导致缓存数据过多造成宕机的可能,因此redis会对一些数据进行淘汰。
- 内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
- 超时剔除:当给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
- 主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
/ | 内存淘汰 | 超时剔除 | 主动更新 |
---|---|---|---|
说明 | 不用我们手动维护,当缓存中的数据过多时,利用redis内存淘汰机制,自动剔除部分数据,下次查询会自动更新 | 当数据过期时,会被自动剔除。下次查询时进行自动更新 | 当数据库中的数据进行更改时,手动地对缓存中的数据进行更新 |
一致性 | 低 | 一般 | 好 |
成本 | 无 | 低 | 高 |
业务场景:
低一致性需求:使用内存淘汰机制。如商铺类型数据。
高一致性需求:使用主动更新策略,并以超时剔除作为兜底方案。如商铺信息数据更新。
3.1、缓存与数据库不一致问题
当数据发生更改时,数据库更改了而缓存没有同步更新,或者缓存更新了而数据库没有同步更新,都会造成数据不一致的问题。
3.2、不一致问题解决方案
不一致问题有三种解决方案:
- Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
- Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
- Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
分析:
方案一可以实现,但具有一定的维护成本
方案二仍存在较大的一致性问题
方案三会存在数据丢失问题,如缓存数据还没来得及写入数据库,redis就崩溃了,那么这部分数据没有被持久化而丢失
综上,最终选择方案一作为数据不一致问题的解决方案
方案一如何处理实现,仍有三个问题需要考虑:
- 是删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库让缓存失效,再次查询时更新缓存,实现懒加载效果
- 如何保证缓存操作与数据库操作同时成功或失败?
- 单体系统:使用事务机制保证操作的原子性
- 分布式系统:使用TCC等分布式解决方案来保证操作的原子性
- 先更新数据库再删除缓存,还是先删除缓存再更新数据库?
- 先删除缓存再操作数据库
- 先更新数据库再删除缓存
因为缓存操作是快操作、数据库操作是慢操作。第一种方案在并发情况下更加容易出现数据不一致的问题。
4、实现商铺数据缓存与数据库的同步修改
核心思路:
- 查询商铺数据时,如果缓存没有数据,则查询数据库,同步更新到缓存并设置过期时间;
- 更新数据库数据时,直接删除缓存数据
核心代码
/**
* 案例:给查询商铺的缓存添加超时剔除和主动更新的策略
* (先操作数据库后删除缓存)
* @param id 商铺id
* @return
*/
@Override
public Shop queryById(Long id) {
// 从缓存中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (shopJson != null) {
// 如果缓存中有,直接返回
Shop shop = JSON.parseObject(shopJson, Shop.class);
return shop;
}
// 如果缓存中没有,从数据库中查询
Shop shop = this.getById(id);
// 如果数据库中没有,返回null
if (shop == null) {
return null;
}
// 将查询到的数据写入缓存
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, JSON.toJSONString(shop),
RedisConstants.SHOP_KEY_TTL, TimeUnit.MINUTES);
return shop;
}
@Override
@Transactional // 保证数据库操作和缓存操作的原子性
public boolean updateByShopId(Shop shop) {
if (shop.getId() == null) {
return false;
}
// 更新数据库
updateById(shop);
// 更新缓存
stringRedisTemplate.delete(RedisConstants.SHOP_KEY + shop.getId());
return true;
}
5、缓存穿透
5.1、什么是缓存穿透?
缓存穿透指的是:当请求到达redis和数据库均未命中,且客户端发来大量这种恶意请求,造成数据库服务宕机的问题成为缓存穿透。
5.2、缓存穿透解决方案
常见的缓存穿透解决方案有两种:
- 缓存空对象
- 布隆过滤器
优点 | 缺点 | |
---|---|---|
缓存空对象 | 实现简单 | 造成内存存储大量垃圾数据;存在短期的不一致 |
布隆过滤器 | 内存占用较少,没有多余key | 实现复杂存在误判可能 |
缓存空对象思路分析:当redis和数据库均未命中,则向redis中对应的key缓存一条空对象,当再次查询同一key时,直接返回空对象即可,减少数据库的压力。
布隆过滤器: 布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中,但因为其实通过hash的思想进行判断,会存在误判的可能。
总的来说,如果布隆过滤器判断不存在,那这条数据就是真的不存在;如果判断存在,但数据不一定就真的存在。
5.3、缓存空对象编码实现
- 核心代码
public Shop queryById(Long id) {
// 从缓存中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
// 如果缓存中有,直接返回
Shop shop = JSON.parseObject(shopJson, Shop.class);
return shop;
}
// 判断redis是否缓存空值 shopJson != null,说明其是""空字符串
if (shopJson !=null){
return null;
}
// 如果缓存中未命中,从数据库中查询
Shop shop = this.getById(id);
// 如果数据库中没有,返回null
if (shop == null) {
// 数据库也未命中,缓存null值到redis
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, SHOP_NULL_VALUE,
RedisConstants.SHOP_NULL_VALUE_TTL, TimeUnit.MINUTES);
return null;
}
// 将查询到的数据写入缓存
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, JSON.toJSONString(shop),
RedisConstants.SHOP_KEY_TTL, TimeUnit.MINUTES);
return shop;
}
5.4、补充说明
缓存穿透的解决方案当然不止前面说的这两种,还可以考虑:
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
6、缓存雪崩
6.1、什么是缓存雪崩?
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
6.2、解决方案
- 在设置热点key时,设置不同的TTL,避免热点key同时失效
- 添加redis集群,提供高可用服务
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
7、缓存击穿
7.1、什么是缓存击穿?
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期
逻辑分析:在线程1重建缓存过程的同时,其他大量线程也都查询缓存,然后走查询数据库重建缓存这样的过程,对数据库服务造成巨大压力。
解决方案:
- 互斥锁
只有获取到锁资源的线程可以重建缓存,其他线程则需要一直等待重试,知道缓存中有数据或拿到锁资源。
- 逻辑过期
在设置热点key时不设置TTL,而是在数据本身中设置一个逻辑过期时间,这样在redis中这个数据基本可以认为永不失效。通过代码来判断数据是否失效,当线程1查询数据失效时,尝试获取锁资源,开启独立线程进行缓存重建,而自己则返回旧数据。
同时,其他大量线程查询数据失效时,也尝试获取锁资源。如果失败,则不等待,直接返回旧数据即可!
- 方案优缺点比对
优点 | 缺点 | |
---|---|---|
互斥锁 | 实现简单没有额外内存消耗保证数据一致性 | 性能较差,大量线程需要等待存在死锁风险 |
逻辑过期 | 线程无需等待,服务可用性好 | 不保证一致性;实现复杂;存在额外内存消耗(存储逻辑过期时间) |
7.2、利用互斥锁解决缓存击穿
- 核心思路
- 核心代码
private Shop queryWithMutexLock(Long id) {
// 从缓存中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
// 如果缓存中有,直接返回
Shop shop = JSON.parseObject(shopJson, Shop.class);
return shop;
}
// 判断redis是否缓存空值 shopJson != null,说明其是""空字符串
if (shopJson != null) {
return null;
}
try {
// 获取互斥锁
if (!getMutexLock(id)) {
// 未获取到锁,等待一段时间后重试
Thread.sleep(50);
queryWithMutexLock(id);
}
// Double Check
shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
// 如果缓存中有,直接返回
Shop shop = JSON.parseObject(shopJson, Shop.class);
return shop;
}
// 判断redis是否缓存空值 shopJson != null,说明其是""空字符串
if (shopJson != null) {
return null;
}
// 从数据库中查询,进行缓存重建
Shop shop = this.getById(id);
// 数据库也未命中,缓存null值到redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, SHOP_NULL_VALUE,
RedisConstants.SHOP_NULL_VALUE_TTL, TimeUnit.MINUTES);
return null;
}
// 将查询到的数据写入缓存
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, JSON.toJSONString(shop),
RedisConstants.SHOP_KEY_TTL, TimeUnit.MINUTES);
return shop;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
releaseMutexLock(id);
}
}
/**
* 获取互斥锁
* @param id
* @return
*/
private boolean getMutexLock(Long id) {
String key = "lock:shop" + id;
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 不要直接返回包装类,存在空指针异常问题,需要使用工具类BooleanUtil.isTrue()方法进行校验
return BooleanUtil.isTrue(flag);
}
/**
* 释放互斥锁
* @param id
*/
private void releaseMutexLock(Long id) {
String key = "lock:shop" + id;
stringRedisTemplate.delete(key);
}
7.3、利用逻辑过期解决缓存击穿
- 核心思路
- 核心代码
/**
* 查询商铺信息(缓存击穿-逻辑过期)
*
* @param id
* @return
*/
private Shop queryWithLogicExpireTime(Long id) {
// 从缓存中查询
String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
// 判断是否过期
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
if (StrUtil.isNotBlank(shopJson)) {
// 未过期,返回商铺
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return shop;
}
}
try {
// 尝试获取互斥锁
if (!getMutexLock(id)) {
// 未获取到锁,返回旧商品信息
return shop;
}
// Double Check
shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
// 检查缓存是否存在,且是否过期
shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
// 判断是否过期
redisData = JSONUtil.toBean(shopJson, RedisData.class);
shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
// 未过期,返回商铺
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return shop;
}
}
}
// 从数据库中查询,进行缓存重建
shop = this.getById(id);
// 数据库也未命中,返回null
if (shop == null) {
return null;
}
// 将查询到的数据写入缓存,设置逻辑过期
RedisData newRedisData = new RedisData();
newRedisData.setData(shop);
newRedisData.setExpireTime(LocalDateTime.now().plusMinutes(RedisConstants.SHOP_KEY_TTL));
stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_KEY + id, JSON.toJSONString(newRedisData));
return shop;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
releaseMutexLock(id);
}
}
8、简单封装缓存工具类
@Component
public class CacheUtil {
private final StringRedisTemplate stringRedisTemplate;
public CacheUtil(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, String value, long timeout, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, value, timeout, unit);
}
public String get(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
public void delete(String key) {
stringRedisTemplate.delete(key);
}
}
希望以上内容,可以帮助到大家!