Redis,分布式缓存演化之路

简介: 本文介绍了基于Redis的分布式缓存演化,探讨了分布式锁和缓存一致性问题及其解决方案。首先分析了本地缓存和分布式缓存的区别与优劣,接着深入讲解了分布式远程缓存带来的并发、缓存失效(穿透、雪崩、击穿)等问题及应对策略。文章还详细描述了如何使用Redis实现分布式锁,确保高并发场景下的数据一致性和系统稳定性。最后,通过双写模式和失效模式讨论了缓存一致性问题,并提出了多种解决方案,如引入Canal中间件等。希望这些内容能为读者在设计分布式缓存系统时提供有价值的参考。感谢您的阅读!

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@笠泱 更新,您的点赞+关注+转发是我后续更新的动力!

目录
打赏
0
6
6
1
152
分享
相关文章
分布式爬虫框架Scrapy-Redis实战指南
本文介绍如何使用Scrapy-Redis构建分布式爬虫系统,采集携程平台上热门城市的酒店价格与评价信息。通过代理IP、Cookie和User-Agent设置规避反爬策略,实现高效数据抓取。结合价格动态趋势分析,助力酒店业优化市场策略、提升服务质量。技术架构涵盖Scrapy-Redis核心调度、代理中间件及数据解析存储,提供完整的技术路线图与代码示例。
分布式爬虫框架Scrapy-Redis实战指南
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
本文介绍了从单机锁到分布式锁的演变,重点探讨了使用Redis实现分布式锁的方法。分布式锁用于控制分布式系统中多个实例对共享资源的同步访问,需满足互斥性、可重入性、锁超时防死锁和锁释放正确防误删等特性。文章通过具体示例展示了如何利用Redis的`setnx`命令实现加锁,并分析了简化版分布式锁存在的问题,如锁超时和误删。为了解决这些问题,文中提出了设置锁过期时间和在解锁前验证持有锁的线程身份的优化方案。最后指出,尽管当前设计已解决部分问题,但仍存在进一步优化的空间,将在后续章节继续探讨。
488 131
【📕分布式锁通关指南 02】基于Redis实现的分布式锁
|
1月前
|
Springboot使用Redis实现分布式锁
通过这些步骤和示例,您可以系统地了解如何在Spring Boot中使用Redis实现分布式锁,并在实际项目中应用。希望这些内容对您的学习和工作有所帮助。
187 83
Redis--缓存击穿、缓存穿透、缓存雪崩
缓存击穿、缓存穿透和缓存雪崩是Redis使用过程中可能遇到的常见问题。理解这些问题的成因并采取相应的解决措施,可以有效提升系统的稳定性和性能。在实际应用中,应根据具体场景,选择合适的解决方案,并持续监控和优化缓存策略,以应对不断变化的业务需求。
62 29
Redis应用—8.相关的缓存框架
本文介绍了Ehcache和Guava Cache两个缓存框架及其使用方法,以及如何自定义缓存。主要内容包括:Ehcache缓存框架、Guava Cache缓存框架、自定义缓存。总结:Ehcache适合用作本地缓存或与Redis结合使用,Guava Cache则提供了更灵活的缓存管理和更高的并发性能。自定义缓存可以根据具体需求选择不同的数据结构和引用类型来实现特定的缓存策略。
Redis应用—8.相关的缓存框架
Redis分布式锁如何实现 ?
Redis分布式锁主要依靠一个SETNX指令实现的 , 这条命令的含义就是“SET if Not Exists”,即不存在的时候才会设置值。 只有在key不存在的情况下,将键key的值设置为value。如果key已经存在,则SETNX命令不做任何操作。 这个命令的返回值如下。 ● 命令在设置成功时返回1。 ● 命令在设置失败时返回0。 假设此时有线程A和线程B同时访问临界区代码,假设线程A首先执行了SETNX命令,并返回结果1,继续向下执行。而此时线程B再次执行SETNX命令时,返回的结果为0,则线程B不能继续向下执行。只有当线程A执行DELETE命令将设置的锁状态删除时,线程B才会成功执行S
Redis缓存设计与性能优化
Redis缓存设计与性能优化涵盖缓存穿透、击穿、雪崩及热点key重建等问题。针对缓存穿透,可采用缓存空对象或布隆过滤器;缓存击穿通过随机设置过期时间避免集中失效;缓存雪崩需确保高可用性并使用限流熔断组件;热点key重建利用互斥锁防止大量线程同时操作。此外,开发规范强调键值设计、命令使用和客户端配置优化,如避免bigkey、合理使用批量操作和连接池管理。系统内核参数如vm.swappiness、vm.overcommit_memory及文件句柄数的优化也至关重要。慢查询日志帮助监控性能瓶颈。
49 9
【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性
本文介绍了如何通过Lua脚本在Redis中实现分布式锁的原子性操作,避免并发问题。首先讲解了Lua脚本的基本概念及其在Redis中的使用方法,包括通过`eval`指令执行Lua脚本和通过`script load`指令缓存脚本。接着详细展示了如何用Lua脚本实现加锁、解锁及可重入锁的功能,确保同一线程可以多次获取锁而不发生死锁。最后,通过代码示例演示了如何在实际业务中调用这些Lua脚本,确保锁操作的原子性和安全性。
89 6
【📕分布式锁通关指南 03】通过Lua脚本保证redis操作的原子性
缓存与数据库的一致性方案,Redis与Mysql一致性方案,大厂P8的终极方案(图解+秒懂+史上最全)
缓存与数据库的一致性方案,Redis与Mysql一致性方案,大厂P8的终极方案(图解+秒懂+史上最全)
|
1月前
|
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理
本文深入探讨了基于Redis实现分布式锁时遇到的细节问题及解决方案。首先,针对锁续期问题,提出了通过独立服务、获取锁进程自己续期和异步线程三种方式,并详细介绍了如何利用Lua脚本和守护线程实现自动续期。接着,解决了锁阻塞问题,引入了带超时时间的`tryLock`机制,确保在高并发场景下不会无限等待锁。最后,作为知识扩展,讲解了RedLock算法原理及其在实际业务中的局限性。文章强调,在并发量不高的场景中手写分布式锁可行,但推荐使用更成熟的Redisson框架来实现分布式锁,以保证系统的稳定性和可靠性。
51 0
【📕分布式锁通关指南 04】redis分布式锁的细节问题以及RedLock算法原理