一、缓存问题
Redis经常被用作缓存,关于Redis的使用,我们已经在《Redis快速入门全指南》讲到过了,而缓存在使用的过程中存在很多问题需要解决。例如:
缓存的数据一致性问题
缓存穿透
缓存雪崩
缓存击穿
1.1 缓存一致性问题
缓存一致性问题的核心在于确保缓存数据与数据库数据的同步,以避免数据不一致对业务产生负面影响。在实际应用中,由于缓存与数据库是两个独立的数据存储系统,数据更新操作可能在不同时间、不同顺序发生,从而导致数据不一致。例如,数据库中的数据已经更新,但缓存中的数据仍然是旧值,这可能导致用户获取到错误的信息。
缓存一致性解决方案的三种模式
我们先看下目前企业用的最多的缓存模型。缓存的通用模型有三种:
(一)Cash Aside 模式
Cash Aside 模式是由业务开发者在更新数据库的同时,手动更新缓存,以确保两者数据一致。这种模式的优点在于能够紧密控制数据库操作与缓存操作的一致性,使两者能够同时成功或失败,从而保证数据的强一致性。然而,其缺点是对业务代码有一定的侵入性,需要在原有的数据库操作代码基础上添加缓存操作逻辑。
在读取数据时,优先查询 Redis。若首次查询未命中缓存,则从数据库中获取数据,并将其缓存到 Redis 中,同时设置 TTL(Time To Live)过期时间。这样,即使后续更新缓存失败,过期时间也能确保缓存数据最终被清除,下次查询时会重新从数据库获取最新数据,从而实现数据的一致性。在写入数据时,先对数据库进行增删改操作,成功后再删除 Redis 中的相应缓存数据。需要注意的是,对于数据库的新增操作,Redis 无需立即新增缓存,可在数据被首次查询时再建立缓存,以避免不必要的内存占用。对于修改操作,建议直接删除 Redis 中的缓存数据,待下次查询时重新缓存最新数据,同样可减少内存浪费。
在 Cash Aside 模式中,删除 Redis 数据与数据库增删改操作的顺序至关重要。若先删除 Redis 数据再更新数据库,可能会出现并发安全问题。如图,在多线程环境下,一个线程删除 Redis 数据后尚未更新数据库时,另一个线程进行查询操作,由于 Redis 未命中,该线程会从数据库获取旧数据并缓存到 Redis 中,导致数据不一致。因此,推荐先操作数据库,再删除 Redis 数据。尽管这种方式仍可能存在极低概率的并发安全问题(如缓存过期且在写入缓存数据时恰有其他线程修改数据库并删除缓存),但由于其发生条件极为苛刻,且缓存过期机制可最终保证数据同步,所以这种先操作数据库再删 Redis 的方案成为 Cash Aside 模式的最佳实践。部分企业为进一步提高一致性可靠性,会采用延时双删策略,即在操作完数据库并删除一次 Redis 数据后,延迟一段时间再次删除,以降低出现安全问题的可能性,但鉴于问题发生概率极低,延时双删并非普遍必要。
(二)Read Write Through 模式
Read Write Through 模式将数据库与缓存整合为一个服务,由该服务内部维护两者的一致性。对于业务开发者而言,只需调用此服务对外暴露的接口,即可实现数据的增删改查操作,无需关心一致性的具体实现细节。这种模式极大地简化了业务开发流程,使开发者能够专注于业务逻辑本身。然而,该模式的缺点是需要自行开发这样一套整合服务,开发成本较高,且目前市场上缺乏现成的通用解决方案,因此在实际应用中相对较少使用。
(三)Write Behind Caching 模式
Write Behind Caching 模式与前两种模式不同,它在进行数据的增删改操作时,直接操作缓存,而不立即更新数据库。缓存中的数据会通过异步任务定期持久化到数据库中,以实现最终一致性。这种模式的优势明显,由于所有操作都直接针对缓存,无需等待数据库操作完成,大大提升了系统的整体性能,减少了数据库的写操作次数,降低了数据库压力,从而显著提高了系统的并发能力。
例如,在高并发写入数据到 Redis 的场景下,数据先写入缓存,异步任务会在一段时间后检查缓存中的数据更新情况,并将其同步到数据库中。但这种模式也存在一定的局限性,由于数据更新并非实时同步到数据库,缓存与数据库之间可能会出现短暂的数据不一致,无法保证强一致性。因此,该模式适用于对性能要求极高、对数据一致性要求相对较低的业务场景,如某些实时性要求不高的统计数据或日志数据的处理。
1.2 缓存穿透
缓存穿透是指客户端请求的数据在数据库中原本就不存在,导致请求无法在缓存中命中,进而穿透缓存直接访问数据库的现象。按照常规的 Cache Aside 查询流程,系统在接收到查询请求时,首先会尝试在缓存中查找数据。若缓存未命中,则会转向数据库进行查询。当查询的数据在数据库中也不存在时,缓存自然无法构建有效数据,后续相同的查询请求依然会重复这一过程,即不断地穿透缓存访问数据库。
在恶意攻击场景下,攻击者可能故意发起大量针对不存在数据的请求,这些请求将如洪水般直接冲击数据库,造成数据库承受巨大压力,甚至可能导致数据库系统崩溃,严重影响业务的正常运行。
常见的解决方案有两种:1、缓存空对对象。2、布隆过滤。
(一)缓存空对象方案
缓存空对象方案的核心思路较为直观,即当系统查询到数据库中不存在请求的数据时,以该请求的参数作为键(Key),在 Redis 中缓存一个特殊的空值。这个空值可以是 NULL,也可以是自定义的特定标记,用于表示数据不存在。这样,当下次接收到相同参数的查询请求时,系统在缓存中就能命中该空值,通过简单的判断即可确定数据不存在,从而避免了再次查询数据库。
这种方案的显著优势在于其简单性和易理解性,开发者能够快速实施。然而,它也存在明显的弊端,即会导致额外的内存消耗。由于每一个不存在的数据请求都会在缓存中创建一个空对象缓存,如果此类请求数量众多,将占用大量的 Redis 内存空间,而这些空对象缓存实际上并无实际业务价值。
为了缓解这一问题,我们可以为缓存的空对象设置相对较短的过期时间,例如几十秒甚至十几秒。在缓存有效期内,能够有效阻挡针对相同不存在数据的请求直达数据库,减轻数据库压力。一旦过期,空对象缓存将自动从 Redis 中清除,释放内存资源。若后续再次收到相同请求,系统可再次缓存空对象,如此循环往复,在一定程度上平衡了内存消耗与数据库保护的需求。由于其简单方便的特性,缓存空对象方案成为当前解决缓存穿透问题较为常见的方法之一。
(二)布隆过滤方案
布隆过滤是一种高效的数据统计算法,专门用于判断一个元素是否存在于特定的数据集合中。在缓存穿透问题的语境下,它的作用是在查询操作前对数据是否可能存在于数据库中进行预先判断,从而避免不必要的数据库查询操作。
其工作原理基于将元素映射到一个极长的二进制数位序列上。具体操作步骤如下:
首先,初始化一个长度极长的二进制数位序列,默认所有位均为 0。例如,我们可以构建一个包含 30 位(实际应用中往往更长,可达成千上万位)的二进制序列,并对每一位进行编号,从 0 到 29。
其次,准备 n 个不同算法的哈希函数。这些哈希函数将用于对数据集合中的元素进行运算。
然后,针对数据集合中的每个元素,分别使用这 n 个哈希函数进行计算,每个元素将得到 n 个哈希结果。将这些结果作为二进制数位序列的下标,将对应下标的位置标记为 1。例如,对于元素 "hello",经过三个哈希函数计算得到结果为 1、5 和 12,则将二进制序列的第 1、5 和 12 位标记为 1。依此类推,对数据集合中的所有元素完成标记操作。
当需要判断一个新元素是否存在于数据集合中时,同样使用上述 n 个哈希函数对该元素进行计算,得到 n 个哈希值,并查看对应的二进制数位序列中的位是否为 1。若所有对应位均为 1,则该元素可能存在于数据集合中;若存在任何一位为 0,则可确定该元素一定不存在。
值得注意的是,布隆过滤并非通过存储数据集合中的所有元素来进行判断,而是仅通过标记位来记录元素的存在信息,因此其内存占用极低。例如,对于一个包含 1000 万个元素的数据集合,若使用三个哈希函数进行运算,总共仅需约 3000 万个比特位的存储空间。换算后,仅需约 3.57 兆字节即可存储 1000 万个元素的标记信息。即使数据量大幅增加,如达到 30M、300M 甚至更大,其所需内存也相对可控,能够存储海量元素的标记,内存利用率极高。
然而,布隆过滤并非完美无缺。由于其基于哈希函数和标记位的判断机制,存在一定的误判可能性。当判断一个元素存在时,实际上该元素可能并不真正存在于数据集合中,只是由于哈希计算结果的巧合,导致对应标记位均为 1。但当判断一个元素不存在时,其结果是绝对准确的。不过,通过合理设置二进制数位序列的长度(如 Redis 中提供的布隆过滤器的二进制数位可达 2 的 32 次方,即约 40 多亿位)以及选用优秀的哈希函数算法,可以将误判的概率降低到极低水平,使其在实际应用中仍然具有极高的可靠性。
综上所述,布隆过滤方案在解决缓存穿透问题时,具有内存占用少的显著优势,但同时也面临着实现复杂(好在有许多第三方组件可供使用)以及存在误判可能性的挑战。
在实际的 Redis 缓存应用开发中,开发者需要根据具体的业务场景、数据特性以及性能需求,综合权衡缓存空对象和布隆过滤两种方案的优劣,选择最适合的缓存穿透解决方案,以确保系统的高效稳定运行,为用户提供优质可靠的服务体验。
1.2 缓存雪崩
缓存雪崩是指在特定时间段内,大量的缓存数据同时失效,或者 Redis 服务突然发生宕机故障,致使大量原本应由缓存处理的请求直接涌向数据库,从而给数据库带来巨大压力的现象。这一现象可细分为两种关键场景:
(一)大量缓存同时失效
在正常的缓存机制运行下,由于 Redis 缓存的存在,用户查询数据时多数能够在缓存中找到对应数据并直接返回,仅有少数缓存未命中的数据请求才会穿透至数据库进行查询,从而有效减轻了数据库的负载压力。然而,当大量缓存键(Key)的过期时间设置不当,导致它们在同一时刻集体过期时,Redis 中的缓存数据将瞬间清空。此时,用户的查询请求无法在缓存中命中,只能大规模地转向数据库获取数据,数据库瞬间面临海量请求的冲击,极有可能因不堪重负而崩溃,进而严重影响整个系统的正常运行。
为有效应对大量缓存同时失效引发的雪崩问题,可采用为不同缓存键(Key)的过期时间添加随机值的策略。在实际应用中,例如在项目启动初期进行缓存预热时,通常会批量从数据库查询数据并写入缓存,以提升系统运行后的缓存命中率。若此时为所有缓存键设置相同的过期时间,如统一设定为 30 分钟,则极有可能导致这些缓存数据在 30 分钟后同时过期,从而引发缓存雪崩。
改进的方法是,在设置缓存键过期时间时,在原本固定的 30 分钟过期时间基础上,额外添加一个 1 至 10 分钟不等的随机数值。如此一来,在缓存预热过程中批量导入的缓存数据,其过期时间将分散在 30 至 40 分钟这一时间段内,极大地降低了大量缓存同时失效的概率,从而有效预防因缓存集体过期而引发的雪崩风险。
(二)Redis 服务宕机
更为严峻的情况是 Redis 服务直接宕机。这意味着整个缓存层瞬间失效,数据库直接暴露于用户请求之下。所有原本依赖缓存的请求均无法命中缓存,全部直接抵达数据库,这种全量请求的压力对于数据库而言几乎是毁灭性的,极有可能导致数据库服务瞬间瘫痪,使整个业务系统陷入停滞状态。
避免 Redis 服务宕机的最佳实践是构建 Redis 集群架构。例如采用主从集群结合哨兵模式的部署方案。哨兵机制能够实时监控主从集群的运行状态,一旦主节点发生宕机故障,哨兵可迅速启动重新选主流程,确保集群的整体高可用性,最大程度降低因单点故障导致的服务中断风险。
尽管采用集群架构能够显著提升 Redis 的可用性,但仍无法绝对保证其 100% 稳定运行。因此,在面对可能的服务宕机情况时,还需配备一系列有效的后备处理手段:
降级限流策略
在缓存查询业务逻辑中引入降级限流机制。限流策略旨在限制并发请求的数量,即使在 Redis 服务宕机、查询业务被迫转向数据库的情况下,通过限制请求并发量,能够有效控制数据库的负载压力,避免数据库因瞬间涌入过多请求而崩溃。同时,降级策略允许在特定情况下直接拒绝部分非关键请求,甚至暂时熔断整个查询业务,阻止请求直接访问数据库。待 Redis 缓存服务恢复正常后,再解除熔断状态,恢复业务正常访问。这种降级限流熔断机制在关键时刻能够牺牲部分业务可用性,以换取整个系统和数据库的稳定性,确保核心业务功能不受影响,从而提升系统的整体容错能力。
多级缓存架构
构建多级缓存体系也是应对 Redis 服务宕机的有效手段。一个请求从客户端发起,在到达服务器并最终访问数据库的过程中,会经历多个环节,如先经过 Nginx 反向代理,再通过网关转发至微服务。利用这一请求处理流程,可以在不同层级设置缓存:
浏览器本地缓存:主要用于缓存静态资源,如图片、JavaScript 文件、CSS 样式表等,能够有效减少对服务器资源的请求,提升页面加载速度,但对于需从数据库动态获取的数据处理能力有限。
Nginx 缓存:在 Nginx 层面可建立缓存,不过由于其缓存更新相对复杂,通常适用于存储一致性要求较低、更新频率极低的数据。对于更新频繁且一致性要求较高的数据,Nginx 缓存可能无法有效处理,但它作为多级缓存的第一道防线,仍能拦截大量低频请求,减轻后端压力。
微服务 JVM 本地缓存:在微服务内部,利用编程方式构建 JVM 本地缓存,例如采用类似于 Map 的键值结构在内存中存储数据。多数高频请求数据能够在本地缓存中命中,进一步减少对 Redis 和数据库的访问。只有当本地缓存未命中时,才会考虑向 Redis 查询数据,若 Redis 也未命中,则最终转向数据库获取数据。
通过构建这样的多级缓存架构,在数据库前端形成了多层防御屏障。即使 Redis 这一层级缓存出现宕机故障,其他层级的缓存仍能够处理大量请求,从而大幅减少直接到达数据库的请求数量,使数据库能够在可承受的压力范围内稳定运行,确保整个系统的持续可用性。
综上所述,缓存雪崩问题对系统性能和稳定性影响重大,在实际开发过程中,需综合运用多种解决方案,从缓存过期时间设置、集群架构搭建到降级限流策略实施以及多级缓存体系构建等多个维度进行全面考量和精心设计,以确保系统在面对各种复杂情况时能够稳健运行,为用户提供持续可靠的服务体验。
1.3 缓存击穿
缓存击穿问题的核心在于热点 Key 的特殊性质。所谓热点 Key,需满足两个关键特征:一是遭受高并发访问,例如在电商促销活动中,某热门商品的缓存 Key,可能在瞬间面临海量用户的查询请求;二是其缓存重建业务复杂。在实际开发场景中,缓存数据往往并非简单地直接取自数据库并存储于 Redis,而是可能涉及从多个数据库表进行复杂查询、表关联运算等操作,以构建最终的缓存数据。这一过程可能耗时数十毫秒甚至数百毫秒。
当这样的热点 Key 缓存过期时,由于其高并发访问特性,大量请求会同时发现缓存未命中,进而尝试进行缓存重建。然而,由于缓存重建业务复杂耗时,在重建完成之前,Redis 中该 Key 的缓存处于空缺状态。在此期间,后续的大量并发请求将持续无法命中缓存,只能转向数据库获取数据。如此一来,数据库在短时间内承受巨大压力,极有可能导致数据库性能急剧下降甚至崩溃,严重影响整个系统的稳定性和响应能力。
为更直观地理解这一过程,可通过时序图进行展示。当热点 Key 到期失效后,首个线程查询时未命中,随即启动缓存重建流程,该流程包括从数据库查询数据、进行复杂的业务处理以构建缓存数据,最后将数据写入缓存。但在写入缓存之前,由于业务复杂耗时,后续其他线程的查询请求陆续到来,均因缓存未命中而尝试重建缓存。由于缺乏有效的协调机制,这些线程可能会重复执行相同的重建流程,导致大量请求在缓存重建期间不断冲击数据库,形成缓存击穿的严峻局面。
常见的解决方案有两种:1、互斥锁。2、逻辑过期。
(一)互斥锁方案
互斥锁方案基于一种简单而直接的思路:通过加锁机制,确保在热点 Key 缓存重建期间,只有一个线程能够执行重建操作,其他线程则等待锁的释放。
具体而言,当一个线程查询热点 Key 未命中时,它首先尝试获取互斥锁。若获取锁成功,则该线程负责执行缓存重建任务,包括从数据库查询数据、构建缓存数据并最终写入缓存,完成后释放锁。在该线程执行重建过程中,若其他线程前来查询同一热点 Key 且未命中,它们也会尝试获取锁,但由于锁已被占用,获取锁的操作将失败。此时,这些线程通常会进入休眠状态,并在一段时间后重试获取锁,再次查询缓存是否已被重建。若缓存已重建成功,则直接返回缓存数据;若仍未命中,则继续重复上述等待、重试的过程,直至缓存重建完成并能够命中缓存。
这种方案的优点显著:
首先,在内存消耗方面,相较于其他方案(如逻辑过期方案),互斥锁方案无需额外存储逻辑过期时间等信息,因此不存在因存储额外数据结构而导致的内存占用增加问题,内存消耗相对较小。
其次,互斥锁方案能够保证数据的强一致性。由于在缓存未命中时,线程会等待缓存重建完成并获取最新数据,而非直接使用可能不一致的旧数据,所以能够确保数据库与缓存之间的数据一致性。只要线程获取到数据,该数据必定是最新的,从而有效避免了因数据不一致而引发的业务逻辑错误。
最后,从实现难度来看,互斥锁方案相对简单。仅需在缓存查询和重建的关键逻辑处添加加锁、解锁操作,代码实现逻辑较为直观,开发人员能够快速上手并实施,降低了开发成本和维护难度。
然而,互斥锁方案也存在明显的弊端:
其核心问题在于线程等待机制对性能的影响。当缓存重建时间较长(例如达到数百毫秒)时,大量并发线程在等待锁的过程中将处于阻塞状态,无法进行其他有效操作。这不仅导致系统响应时间延长,降低了系统的整体性能,还可能引发线程资源耗尽等问题。此外,在复杂的多缓存操作业务场景中,互斥锁的使用可能引发死锁风险。例如,当一个业务线程持有一个缓存 Key 的锁,并尝试获取另一个缓存 Key 的锁,而该锁恰好被其他业务线程持有,且双方均在等待对方释放锁时,死锁便会发生,导致系统陷入僵局,严重影响系统的可用性和稳定性。
(二)逻辑过期方案
逻辑过期方案采用了一种创新性的思路来应对缓存击穿问题,其核心在于引入逻辑过期时间概念,从根本上改变了缓存过期的处理方式。
在逻辑过期方案中,当向 Redis 存储数据时,不再设置传统的 TTL(Time To Live)过期时间,而是在缓存数据的 Value 中额外添加一个逻辑过期时间字段。该字段的值通过在当前时间基础上加上预设的过期时长(如 30 分钟)计算得出。从表面上看,缓存数据似乎永不过期,因为 Redis 不会基于 TTL 自动清除该数据。但实际上,通过逻辑过期时间的判断,系统能够知晓数据是否已在逻辑上过期。
当线程查询热点 Key 时,首先检查其逻辑过期时间。若发现逻辑过期,说明数据可能已陈旧,需要进行更新。此时,为避免多个线程同时进行更新操作导致的冲突,线程同样需要获取互斥锁。与传统互斥锁方案不同的是,获取锁成功的线程并不直接执行耗时的缓存重建操作,而是启动一个独立的新线程来负责执行从数据库查询数据、构建缓存数据并写入缓存的完整流程,同时重置逻辑过期时间。而原线程在启动新线程后,直接返回当前的旧数据,以确保系统的可用性和响应速度。
在新线程执行缓存重建期间,其他线程查询该热点 Key 时,同样会发现逻辑过期并尝试获取互斥锁。若获取锁失败,这些线程不再像传统互斥锁方案那样等待锁的释放,而是直接返回当前已查询到的旧数据,继续处理业务逻辑。直到新线程完成缓存重建并释放锁后,后续查询该热点 Key 的线程才能获取到最新的缓存数据。
逻辑过期方案的优势主要体现在性能和并发处理能力方面:
由于线程在发现缓存逻辑过期时无需等待缓存重建完成,而是直接返回旧数据并继续处理业务,避免了大量线程因等待而阻塞的情况,大大提高了系统的并发处理能力和整体性能。在高并发场景下,系统能够快速响应用户请求,减少用户等待时间,提升用户体验。
然而,逻辑过期方案也存在一些不足之处:
首先,在数据一致性方面,由于线程可能返回逻辑过期的旧数据,这就导致了缓存数据与数据库数据在一定时间内存在不一致性。虽然这种不一致性在某些业务场景下可能是可接受的,但对于对数据一致性要求极高的应用来说,可能会引发业务风险。
其次,由于需要在缓存数据中额外存储逻辑过期时间字段,这无疑增加了内存的占用。在大规模缓存数据的场景下,这种额外的内存消耗可能会对系统的内存资源造成一定压力,需要开发者在设计系统时充分考虑内存容量和缓存数据结构的优化。
最后,从代码实现复杂度来看,逻辑过期方案相对较高。开发者需要在缓存操作逻辑中精心维护逻辑过期时间的计算、判断以及与互斥锁的协同工作,涉及到多线程编程、数据结构设计等复杂技术点,增加了代码的编写难度和维护成本。
综上所述,互斥锁方案和逻辑过期方案在解决缓存击穿问题上各有优劣。互斥锁方案侧重于保证数据一致性,但以牺牲性能和可能面临死锁风险为代价;逻辑过期方案则优先考虑性能和并发处理能力,但在数据一致性和内存消耗方面存在一定挑战。在实际应用中,开发者需要根据具体的业务需求、系统性能要求以及数据一致性敏感度等因素,权衡利弊,选择最适合的解决方案,以确保 Redis 缓存系统在面对热点 Key 过期时能够稳定、高效地运行,为整个业务系统提供可靠的缓存支持。
评论