Hi~各位读者朋友们,感谢您阅读本文,我是笠泱,本期分享基于Redis的分布式缓存演化之路,引出了分布式锁和缓存一致性问题,以及对应解决方案。
本期导语
先来看这样一类场景:某个电商类应用,维护了一个商品服务,其作用是为用户提供查询各类商品分类、列表、信息服务,它背后直连数据库,假设商品服务需要对外提供每秒1w次查询,但背后的数据库却只能支撑每秒5k次查询,那数据库QPS根本顶不住,会被压垮。
这类大流量查询场景在生产实际中非常常见,比如双十一秒杀和春运抢车票等。那么有没有什么办法在数据库不被压垮的同时,还能让商品服务支持每秒1w次查询呢?最常见且有效可行的办法是引入缓存中间层,将部分数据放入缓存中,加速访问,增加系统吞吐量进一步提升系统性能。
哪些数据适合放入缓存?
- 即时性、数据一致性要求不高的
- 访问量大且更新频率不高的数据(读多,写少)
注意:在开发中,凡是放入缓存中的数据都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程,避免业务崩溃导致的数据永久不一致问题。
本地缓存
我们知道,内存的读写速度要远快于磁盘读写,数据库的数据主要存放在磁盘里,如果能将数据库里的数据放入内存里,查询完全不走磁盘,那必然能大大提升查询性能。我们可以在商品服务的内存中开辟一个空间,以商品ID做key,商品信息数据做value,生成一堆key-value键值对,通过商品ID就能查到对应的商品信息,当用户请求商品服务时发起查询时,优先去查内存,没结果再跑去数据库查询,再将结果顺手放入内存中,下次就又能从内存里快速查询。像这样,放在服务器内部的缓存,就是所谓的本地缓存。
本地缓存可以用map实现,将需要缓存的数据存入map,查询时先判断是否为空,不为空就直接从map中取值,不用查询数据库,为空就需要去查询数据库,并将数据存入map中,下次查询就不用查询数据库。
如果项目是单机部署的,不是分布式,也不考虑缓存大小,那么使用本地缓存没有问题
分布式本地缓存
为了保证系统高可用,通常商品服务不止一个实例,这种情况下,每个服务维持一个缓存,所带来的问题:
(1)缓存不共享
在这种情况下,每个服务都有一个缓存,但是这个缓存并不共享,当请求被负载均衡地调度到另外一个实例,可能它的服务中并不存在这个缓存,因此需要重新查询后端数据库。
(2)缓存一致性问题
在一台实例上的缓存更新后,其他实例上的缓存可能还未更新,这样当从其他实例上获取数据的时候,得到的可能就是未更新的数据。
(3)缓存重复问题
如果每个实例都重复缓存同一份数据在各自本地内存中,那就有些浪费内存资源。
分布式远程缓存
针对分布式本地缓存面临的问题,更好的解决方案是将缓存从本地抽离出来,单独做成一个服务,实现解耦,这就是所谓的分布式远程缓存,也是真正意义上的分布式缓存。
如上图所示,一个服务的不同副本共享同一个缓存空间,缓存放置到缓存中间件中,这个缓存中间件可以是Redis、Memcache等,而且缓存中间件也是可以水平或纵向扩展的,如Redis可以使用Redis集群,它打破了缓存容量的限制,同时能够做到高可用,高性能。
- redis中间件的好处就是可以集群化
- 理论上可以无限扩容redis
- 使用中间件作为缓存就打破了本地缓存的容量限制
分布式远程缓存带来的新问题
并发问题
当多个服务通过网络去读写同一份远程缓存,会存在并发问题,那像Redis这类缓存中间件解决此问题非常简单粗暴且有效,对外不管有多少个网络连接,接收到读写命令后,都统一塞到一个线程上,在一个线程上对map进行读写,同时解决了并发问题和线程切换开销。值得一提的是尽管Redis架构上采用单线程模型,但其性能和并发能力依然高的可怕,其底层原理是内存的读写速度在纳秒级,足够快!其次采用了非阻塞 I/O 多路复用技术(epoll)。
高并发下缓存失效问题
缓存穿透
缓存穿透是指当查询一个一定不存在的数据,由于缓存未命中,将去查询数据库,但是数据库也无此记录,我们如果又没有将此次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,从而失去了缓存的意义。
风险:利用不存在的数据进行攻击,数据库瞬时压力增大,最终导致崩溃。
解决:将数据库查询的null结果也放入缓存并加入短暂过期时间或者采用布隆过滤器(请求先查布隆过滤器,过滤掉非法查询,再查询缓存库、数据库,所以没啥是再引入一层中间层解决不了的问题)
缓存雪崩
缓存雪崩是指在我们设置缓存时key采用了相同的过期时间,导致缓存存在某一时刻同时失效,请求全部转到DB,DB瞬时压力过重导致雪崩。
解决:在原有失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件,其次还可以选择降级熔断技术手段来兜底业务。
缓存击穿
如果某些key可能在某个时间点被超高并发地访问我们称之为热点key,缓存击穿是指对于一些设置了过期时间的热点key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都会打到DB。
解决:最简单的方式是对热点key不设置过期时间;更合适的方案是采用互斥锁,大量并发只让一个请求去查,其它请求等待,查到以后释放锁,其它请求获取到锁,先查缓存,就会有数据,不用去DB。
针对上述三类缓存失效问题简单总结:缓存穿透是指查询一个永不存在的数据;缓存雪崩是值大面积key同时失效问题;缓存击穿是指高频热点key失效问题。
分布式锁
前文提到为了防止热点数据在缓存失效的一瞬间出现高并发查询,需要引入锁机制:
针对缓存击穿问题因为是在分布式系统环境下,这里的互斥锁必然得考虑分布式情况,这个时候就需要分布式锁。
我们可以同时去一个地方“占锁”,如果占到,就执行业务逻辑,否则就必须等待,直到释放锁。占锁可以去Redis,可以去数据库。等待可以让应用程序采用自璇的方式处理。
下面使用redis来实现分布式锁,使用的是SET key value [EX seconds] [PX milliseconds] [NX|XX],https://redis.io/docs/latest/commands/set/
EX
seconds – 设置键key的过期时间,单位时秒。原子性实现加锁和设置锁过期时间,防止死锁。
PX
milliseconds – 设置键key的过期时间,单位时毫秒
NX
– 只有键key不存在的时候才会设置key的值。实现分布式锁
XX
– 只有键key存在的时候才会设置key的值
阶段一:
如果某个服务获取到了锁,刚好执行到删除锁时崩溃了,分布式锁就一直不能释放造成其他服务阻塞,这就是死锁。
问题:setnx占好了位,业务代码异常或者程序在页面过程中宕机,没有执行删除锁逻辑,这就造成了死锁。
解决:设置锁的自动过期,即使没有删除,会自动删除。
阶段二:
问题: setnx设置好,正要去设置过期时间时发生了宕机,又死锁了。
解决: 设置过期时间和占位必须是原子的。redis 支持使用 set nx ex命令
阶段三:
问题: 锁自己过期了,我们直接删除锁,有可能把别人正在持有的锁删除了。
解决: 占锁的时候,值指定为uuid ,每个人匹配是自己的锁才删除。
阶段四:
问题: 如果正好判断是当前值,正要删除锁的时候,锁已经过期,别人已经设置到了新的值。那么我们删除的还是别人的锁
解决: 删除锁必须保证原子性,使用redis+Lua 脚本完成。
阶段五:
保证加锁【占位+过期时间】和删除锁【判断+删除】的原子性。
private Map<String, List<Catelog2Vo>> getCatalogJsonFromDBWithRedisLock() { // 1、占分布式锁,去redis占坑 String uuid = UUID.randomUUID().toString(); Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS); //获取到锁,执行业务 if (lock) { System.out.println("获取分布式锁成功"); //加锁成功,执行业务 // 2、设置过期时间,必须和加锁是同步的,原子的 // stringRedisTemplate.expire("lock", 30, TimeUnit.SECONDS); Map<String, List<Catelog2Vo>> dataFromDB = getDataFromDB(); // 获取值对比+对比成功删除=原子操作 Lua脚本解锁 // String lockValue = redisTemplate.opsForValue().get("lock"); // if (uuid.equals(lockValue)) { // // 删除我自己的锁 // stringRedisTemplate.delete("lock");//删除锁 // } String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; // 删除锁 Long lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid); System.out.println("删除锁返回值:" + lock1); return dataFromDB; } else { System.out.println("获取分布式锁失败,等待重试。。。"); //没获取到锁,等待100ms重试 try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } return getCatalogJsonFromDBWithRedisLock(); } }
使用Redis实现分布式锁总结
- 命令 SET key value NX EX max-lock-time 是一种用Redis来实现分布式锁机制的简单方法
- 加锁和过期时间保证原子性
- 不要设置value为固定的字符串,而是设置为随机的大字符串,如UUID或token
- 解锁(判断+删除)保证原子性
采用Redisson方案
上述设计模式虽然做了优化,但在生产实际中并不推荐,因为某些业务偶有存在执行周期过长,如何保证锁的自动续期成为一个更难的问题,所以更加推荐红锁(the Redlock algorithm)实现,因为这个方法只是复杂一点,但却能保证更好的使用效果。
先看Redisson官网原文(https://redisson.org/docs/overview/)描述:Redisson is the Java Client and Real-Time Data Platform for Redis or Valkey. Providing the most convenient and easiest way to work with Redis or Valkey. Redisson objects provide an abstraction layer between Redis or Valkey and your Java code, which allowing maintain focus on data modeling and application logic.
翻译拓展即:Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid)。充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
简单来说:Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。Redison提供了一个集成到SpringBoot上的starter,对于Java开发来说导入redisson的Maven依赖即可上手使用。
限于笔者技术能力有限,关于Redisson的原理实现和使用细节请读者朋友可参阅Redisson官网https://redisson.org/docs/getting-started
缓存一致性问题
前文通过锁机制解决了缓存击穿问题,确保了读取缓存没问题之后,还有一个问题:缓存里面的数据如何和数据库保持一致,也就是缓存数据一致性。
缓存数据一致性问题的原因是数据库的最后一次更新没有放到Redis缓存中,导致数据库和缓存内容不一致,在生产实际中我们一般只需要确保最终一致性,即放在缓存中的数据,允许读到的最新数据有可接受的延迟。
双写模式
双写模式:在数据库进行写操作的同时对缓存也进行写操作,确保缓存数据与数据库数据的一致性
脏数据问题:由于卡顿等原因,导致写缓存2在最前,写缓存1在后面就出现了不一致脏数据问题
是否满足最终一致性:满足,缓存过期以后,又能得到最新的正确数据读到的最新数据有一定延迟
失效模式
失效模式:在数据库进行更新操作时,删除原来的缓存,再次查询数据库就可以更新最新数据
脏数据问题:当两个请求同时修改数据库,A已经更新成功并删除缓存时又有读数据的请求进来,这时候发现缓存中无数据就去数据库中查询并放入缓存,在放入缓存前第二个更新数据库的请求B成功,这时候留在缓存中的数据依然是A更新的数据
解决:
1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新
2、读写数据的时候(并且写的不频繁),加上分布式的读写锁(读数据要等待写数据整个操作完成)
解决方案
无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
1、如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可
2、如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略);
缓存中间件Canal
Canal是阿里的缓存中间件(https://github.com/alibaba/canal),主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费,Canal将自己伪装成数据库的从服务器,MySQL一有变化,它就会同步更新到redis(当然Canal还能对接其他许多中间件)。
小结
能放入缓存的数据本就不应该对其实时性、一致性要求超高。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。我们不应该过度设计,增加系统的复杂性,遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
本期总结
本期内容简要介绍了分布式缓存的演化,分布式缓存面临的问题以及对应解决方案,引出了分布式锁和对缓存一致性问题探讨,希望为大家在做分布式缓存场景业务时提供参考。
最后,感谢您的阅读!系列文章会同步在微信公众号@云上的喵酱、阿里云开发者社区@云上的喵酱、CSDN@笠泱 更新,您的点赞+关注+转发是我后续更新的动力!