马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
x
目录
1 为啥要缓存捏?
2 基本流程(以查询商铺信息为例)
3 实现数据库与缓存双写一致
3.1 内存淘汰
3.2 超时剔除(半自动)
3.3 主动更新(手动)
3.3.1 双写方案
3.3.2 读写穿透方案
3.3.3 写回方案
4 缓存穿透的解决方案
1)缓存空对象
2)布隆过滤
(缓存空对象)解决缓存穿透问题流程
5 缓存雪崩解决方案
6 缓存击穿的解决方案
6.1 基于互斥锁解决缓存击穿问题
6.2 基于逻辑过期解决缓存击穿问题
7 缓存工具封装
1 为啥要缓存捏?
速度快,好用
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大低沉用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几万万,这么大数据量,假如没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术
2 基本流程(以查询商铺信息为例)
3 实现数据库与缓存双写一致
起首我们需要明确数据一致性问题的主要原因是什么,从主要原因入手才是解决问题的关键!数据一致性的根本原因是 缓存和数据库中的数据不同步,那么我们该如何让 缓存 和 数据库 中的数据尽大概的即时同步?这就需要选择一个比较好的缓存更新计谋了
常见的缓存更新计谋:
3.1 内存淘汰
利用Redis的内存淘汰机制实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不敷时,会根据肯定的计谋自动淘汰部分数据
这种计谋模子长处在于没有维护成本,但是内存不敷这种无法预定的情况就导致了缓存中会有很多旧的数据,数据一致性差。
Redis中常见的淘汰计谋:
1 noeviction(默认):当到达内存限制而且客户端尝试执行写入操作时,Redis 会返回错误信息,拒绝新数据的写入,包管数据完整性和一致性
2 allkeys-lru:从所有的键中选择最近最少利用(Least Recently Used,LRU)的数据进行淘汰。即优先淘汰最长时间未被访问的数据
3 allkeys-random:从所有的键中随机选择数据进行淘汰
4 volatile-lru:从设置了过期时间的键中选择最近最少利用的数据进行淘汰
5 volatile-random:从设置了过期时间的键中随机选择数据进行淘汰
6 volatile-ttl:从设置了过期时间的键中选择剩余生存时间(Time To Live,TTL)最短的数据进行淘汰
3.2 超时剔除(半自动)
手动给缓存数据添加TTL,到期后Redis自动删除缓存
这种计谋数据一致性一样平常,维护成本有但是较低,一样平常用于兜底方案~
3.3 主动更新(手动)
手动编码实现缓存更新,在修改数据库的同时更新缓存
这种计谋数据一致性就是最高的(毕竟自己动手,丰衣足食),但同时维护成本也是最高的。
3.3.1 双写方案
1)读取(Read):当需要读取数据时,起首检查缓存是否存在该数据。假如缓存中存在,直接返回缓存中的数据。假如缓存中不存在,则从底层数据存储(如数据库)中获取数据,并将数据存储到缓存中,以便以后的读取操作可以更快地访问该数据。
2)写入(Write):当进行数据写入操作时,起首更新底层数据存储中的数据。然后,根据详细情况,可以选择直接更新缓存中的数据(使缓存与底层数据存储保持同步),大概是简单地将缓存中与修改数据相干的条目标记为无效状态(缓存失效),以便下一次读取时重新加载最新数据
!在更新数据的情况下 优先选择删除缓存模式 其次是更新缓存模式
问题:操作时,先操作数据库还是先操作缓存捏?
答案:先操作数据库,再删缓存
假如先操作缓存:先删缓存,再更新数据库
当线程1删除缓存到更新数据库之间的时间段,会有其它线程进来查询数据,由于没有加锁,且前面的线程将缓存删除了,这就导致请求会直接打到数据库上,给数据库带来巨大压力。这个变乱发生的概率很大,因为缓存的读写速度块,而数据库的读写较慢。
这种方式的不敷之处:存在缓存击穿问题,且概率较大
假如先操作数据库:先更新数据库,再删缓存
当线程1在查询缓存且未命中,此时线程1查询数据,查询完准备写入缓存时,由于没有加锁线程2乘虚而入,线程2在这期间对数据库进行了更新,此时线程1将旧数据返回了,出现了脏读,这个变乱发生的概率很低,因为先是需要满意缓存未命中,且在写入缓存的那段时间内有一个线程进行更新操作,缓存的读写和查询很快,这段清闲时间很小,所以出现脏读征象的概率也很低
这种方式的不敷之处:存在脏读征象,但概率较小
要包管两个操作同时操作 在单体项目中可以放在同一个事务中
3.3.2 读写穿透方案
将读取和写入操作起首在缓存中执行,然后再流传到数据存储
1)读取穿透(Read Through):当进行读取请求时,起首检查缓存。假如所请求的数据在缓存中找到,直接返回数据。假如缓存中没有找到数据,则将请求转发给数据存储以获取数据。获取到的数据随后存储在缓存中,然后返回给调用者。
2)写入穿透(Write Through):当进行写入请求时,起首将数据写入缓存。缓存立即将写操作流传到数据存储,确保缓存和数据存储之间的数据保持一致。这样包管了后续的读取请求从缓存中返回更新后的数据。
3.3.3 写回方案
调用者只操作缓存,其他线程去异步处理惩罚数据库,实现终极一致
1)读取(Read):先检查缓存中是否存在数据,假如不存在,则从底层数据存储中获取数据,并将数据存储到缓存中。
2)写入(Write):先更新底层数据存储,然后将待写入的数据放入一个缓存队列中。在适当的时机,通过批量操作或异步处理惩罚,将缓存队列中的数据写入底层数据存储
- 主动更新计谋中三种方案的应用场景:
- 双写方案 较适用于读多写少的场景,数据的一致性由应用程序主动管理
- 读写穿透方案 适用于数据实时性要求较高、对一致性要求严格的场景
- 写回方案 适用于追求写入性能的场景,对数据的实时性要求相对较低、可靠性也相对低
- 更新计谋的应用场景:
- 对于低一致性需求,可以利用内存淘汰机制。例如店肆类型数据的查询缓存
- 对于高一致性需求,可以接纳主动更新计谋,并以超时剔除作为兜底方案。例如店肆详情数据查询的缓存
4 缓存穿透的解决方案
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会见效,这些请求都会打到数据库。
常看法决缓存穿透的解决方案:
1)缓存空对象
就是给redis缓存一个空对象并设置TTL存活时间
长处:实现简单,维护方便
缺点:额外的内存斲丧,大概造成短期的不一致
2)布隆过滤
通俗的说,就是中心件~
长处:内存占用较少,没有多余key
缺点:实现复杂,存在误判大概(有穿透的风险),无法删除数据
上面两种方式都是被动的解决缓存穿透方案,此外我们还可以接纳主动的方案预防缓存穿透,比如:增强id的复杂度避免被猜测id规律、做好数据的底子格式校验、加强用户权限校验、做好热门参数的限流
(缓存空对象)解决缓存穿透问题流程
5 缓存雪崩解决方案
缓存雪崩是指在同一时段大量的缓存key同时失效大概Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
就是说,一群设置了有效期的key同时消失了,大概说redis罢工了,导致所有的大概说大量的请求会给数据库带来巨大压力叫做缓存雪崩~
- 缓存雪崩的常看法决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流计谋,比如快速失败机制,让请求尽大概打不到数据库上
- 给业务添加多级缓存
6 缓存击穿的解决方案
缓存击穿问题也叫热门Key问题,就是一个被高并发访问而且缓存重修业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
大概击穿流程:
第一个线程,查询redis发现未命中,然后去数据库查询并重修缓存,这个时候因为在缓存重修业务较为复杂的情况下,重修时间较久,又因为高并发的情况下,在线程1重修缓存的时间内,会有其他的大量的其他线程进来,发现查找缓存仍未命中,导致继承重修,如此死循环。
缓存击穿的常看法决方案:
互斥锁(时间换空间)
长处:内存占用小,一致性高,实现简单
缺点:性能较低,容易出现死锁
逻辑过期(空间换时间)
长处:性能高
缺点:内存占用较大,容易出现脏读
两者相比较,互斥锁更加易于实现,但是容易发存亡锁,且锁导致并行变成串行,导致系统性能下降,逻辑过期实现起来相较复杂,且需要耗费额外的内存,但是通过开启子线程重修缓存,使原来的同步壅闭变成异步,提高系统的相应速度,但是容易出现脏读
6.1 基于互斥锁解决缓存击穿问题
就是当线程查询缓存未命中时,尝试去获取互斥锁,然后在重修缓存数据,在这段时间里,其他线程也会去尝试获取互斥锁,假如失败就休眠一段时间,并继承,不断重试,等到数据重修乐成,其他线程就可以命中数据了。这样就不会导致缓存击穿。这个方案数据一致性是绝对的,但是相对来说会捐躯性能。
这里我们获取互斥锁可以利用redis中string类型中的setnx方法 ,因为setnx方法是在key不存在的情况下才可以创建乐成的,所以我们重修缓存时,利用setnx来将锁的数据加入到redis中,而且通过判断这个锁的key是否存在,假如存在就是获取锁乐成,失败就是获取失败,这样刚好可以实现互斥锁的效果。
开释锁就更简单了,直接删除我们存入的锁的key来开释锁。
-
- //获取锁
- public Boolean tryLock(String key){
- Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
- return BooleanUtil.isTrue(flag);
- }
-
- //释放锁方法
- public void unlock(String key){
- stringRedisTemplate.delete(key);
- }
复制代码
6.2 基于逻辑过期解决缓存击穿问题
给redis缓存字段中添加一个过期时间,然后当线程查询缓存的时候,先判断是否已颠末期,假如过期,就获取互斥锁,并开启一个子线程进行缓存重修任务,直到子线程完成任务后,开释锁。在这段时间内,其他线程获取互斥锁失败后,并不是继承等待重试,而是直接返回旧数据。这个方法虽然性能较好,但也捐躯了数据一致性。
所谓的逻辑过期,雷同于逻辑删除,并不是真正意义上的过期,而是新增一个字段,用来标记key的过期时间,这样能可以或许避免key过期而被自动删除,这样数据就永不外期了,从根本上解决因为热门key过期导致的缓存击穿。一样平常搞运动时,比如抢优惠券,秒杀等场景,请求量比较大就可以利用逻辑过期,等运动一过利市动删除逻辑过期的数据
逻辑过期肯定要先进行数据预热,将我们热门数据加载到缓存中
逻辑过期时间根据详细业务而定,逻辑过期过长,会造成缓存数据的堆积,浪费内存,过短造成频繁缓存重修,低沉性能,所以设置逻辑过期时间时需要实际测试和评估不同参数下的性能和资源斲丧情况,可以通过观察系统的表现,在业务需求和性能要求之间找到一个平衡点
7 缓存工具封装
调用者
- /**
- * 根据id查询商铺数据
- *
- * @param id
- * @return
- */
- @Override
- public Result queryById(Long id) {
- // 调用解决缓存穿透的方法
- // Shop shop = cacheClient.handleCachePenetration(CACHE_SHOP_KEY, id, Shop.class,
- // this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
- // if (Objects.isNull(shop)){
- // return Result.fail("店铺不存在");
- // }
-
- // 调用解决缓存击穿的方法
- Shop shop = cacheClient.handleCacheBreakdown(CACHE_SHOP_KEY, id, Shop.class,
- this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
- if (Objects.isNull(shop)) {
- return Result.fail("店铺不存在");
- }
-
- return Result.ok(shop);
- }
复制代码 工具类
- @Component
- @Slf4j
- public class CacheClient {
- private final StringRedisTemplate stringRedisTemplate;
- public CacheClient(StringRedisTemplate stringRedisTemplate) {
- this.stringRedisTemplate = stringRedisTemplate;
- }
- /**
- * 将数据加入Redis,并设置有效期
- *
- * @param key
- * @param value
- * @param timeout
- * @param unit
- */
- public void set(String key, Object value, Long timeout, TimeUnit unit) {
- stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
- }
- /**
- * 将数据加入Redis,并设置逻辑过期时间
- *
- * @param key
- * @param value
- * @param timeout
- * @param unit
- */
- public void setWithLogicalExpire(String key, Object value, Long timeout, TimeUnit unit) {
- RedisData redisData = new RedisData();
- redisData.setData(value);
- // unit.toSeconds()是为了确保计时单位是秒
- redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(timeout)));
- stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), timeout, unit);
- }
- /**
- * 根据id查询数据(处理缓存穿透)
- *
- * @param keyPrefix key前缀
- * @param id 查询id
- * @param type 查询的数据类型
- * @param dbFallback 根据id查询数据的函数
- * @param timeout 有效期
- * @param unit 有效期的时间单位
- * @param <T>
- * @param <ID>
- * @return
- */
- public <T, ID> T handleCachePenetration(String keyPrefix, ID id, Class<T> type,
- Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
- String key = keyPrefix + id;
- // 1、从Redis中查询店铺数据
- String jsonStr = stringRedisTemplate.opsForValue().get(key);
- T t = null;
- // 2、判断缓存是否命中
- if (StrUtil.isNotBlank(jsonStr)) {
- // 2.1 缓存命中,直接返回店铺数据
- t = JSONUtil.toBean(jsonStr, type);
- return t;
- }
- // 2.2 缓存未命中,判断缓存中查询的数据是否是空字符串(isNotBlank把null和空字符串给排除了)
- if (Objects.nonNull(jsonStr)) {
- // 2.2.1 当前数据是空字符串(说明该数据是之前缓存的空对象),直接返回失败信息
- return null;
- }
- // 2.2.2 当前数据是null,则从数据库中查询店铺数据
- t = dbFallback.apply(id);
- // 4、判断数据库是否存在店铺数据
- if (Objects.isNull(t)) {
- // 4.1 数据库中不存在,缓存空对象(解决缓存穿透),返回失败信息
- this.set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
- return null;
- }
- // 4.2 数据库中存在,重建缓存,并返回店铺数据
- this.set(key, t, timeout, unit);
- return t;
- }
- /**
- * 缓存重建线程池
- */
- public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
- /**
- * 根据id查询数据(处理缓存击穿)
- *
- * @param keyPrefix key前缀
- * @param id 查询id
- * @param type 查询的数据类型
- * @param dbFallback 根据id查询数据的函数
- * @param timeout 有效期
- * @param unit 有效期的时间单位
- * @param <T>
- * @param <ID>
- * @return
- */
- public <T, ID> T handleCacheBreakdown(String keyPrefix, ID id, Class<T> type,
- Function<ID, T> dbFallback, Long timeout, TimeUnit unit) {
- String key = keyPrefix + id;
- // 1、从Redis中查询店铺数据,并判断缓存是否命中
- String jsonStr = stringRedisTemplate.opsForValue().get(key);
- if (StrUtil.isBlank(jsonStr)) {
- // 1.1 缓存未命中,直接返回失败信息
- return null;
- }
- // 1.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
- RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
- // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
- JSONObject data = (JSONObject) redisData.getData();
- T t = JSONUtil.toBean(data, type);
- LocalDateTime expireTime = redisData.getExpireTime();
- if (expireTime.isAfter(LocalDateTime.now())) {
- // 当前缓存数据未过期,直接返回
- return t;
- }
- // 2、缓存数据已过期,获取互斥锁,并且重建缓存
- String lockKey = LOCK_SHOP_KEY + id;
- boolean isLock = tryLock(lockKey);
- if (isLock) {
- // 获取锁成功,开启一个子线程去重建缓存
- CACHE_REBUILD_EXECUTOR.submit(() -> {
- try {
- // 查询数据库
- T t1 = dbFallback.apply(id);
- // 将查询到的数据保存到Redis
- this.setWithLogicalExpire(key, t1, timeout, unit);
- } finally {
- unlock(lockKey);
- }
- });
- }
- // 3、获取锁失败,再次查询缓存,判断缓存是否重建(这里双检是有必要的)
- jsonStr = stringRedisTemplate.opsForValue().get(key);
- if (StrUtil.isBlank(jsonStr)) {
- // 3.1 缓存未命中,直接返回失败信息
- return null;
- }
- // 3.2 缓存命中,将JSON字符串反序列化未对象,并判断缓存数据是否逻辑过期
- redisData = JSONUtil.toBean(jsonStr, RedisData.class);
- // 这里需要先转成JSONObject再转成反序列化,否则可能无法正确映射Shop的字段
- data = (JSONObject) redisData.getData();
- t = JSONUtil.toBean(data, type);
- expireTime = redisData.getExpireTime();
- if (expireTime.isAfter(LocalDateTime.now())) {
- // 当前缓存数据未过期,直接返回
- return t;
- }
- // 4、返回过期数据
- return t;
- }
- /**
- * 获取锁
- *
- * @param key
- * @return
- */
- private boolean tryLock(String key) {
- Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
- // 拆箱要判空,防止NPE
- return BooleanUtil.isTrue(flag);
- }
- /**
- * 释放锁
- *
- * @param key
- */
- private void unlock(String key) {
- stringRedisTemplate.delete(key);
- }
- }
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |