Redis-应用篇-②缓存一致性问题
Redis-应用篇-②缓存一致性问题
学习核心
- 缓存一致性问题
- 如何保证缓存一致性?(例如mysql&Redis组合)
学习资料
缓存一致性问题
1.什么是缓存一致性问题?
数据一致性
“数据一致”一般指的是:缓存中有数据,缓存的数据值 = 数据库中的值。但根据缓存中是有数据为依据,则”一致“可以包含两种情况:
缓存中有数据,缓存的数据值 = 数据库中的值(需均为最新值,此处将“旧值的一致”归类为“不一致状态”)
缓存中本没有数据,数据库中的值 = 最新值(有请求查询数据库时,会将数据写入缓存,则变为上面的“一致”状态)
”数据不一致“:缓存的数据值 ≠ 数据库中的值;缓存或者数据库中存在旧值,导致其他线程读到旧数据
缓存应用分类
根据是否接收写请求,可以把缓存应用分成只读缓存和读写缓存
- 只读缓存:只在缓存进行数据查找,即使用 “更新数据库+删除缓存” 策略;
- 读写缓存:需要在缓存中对数据进行增删改查,即使用 “更新数据库+更新缓存”策略;
缓存类型 | 一致性主要策略 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
只读缓存 | 更新数据库+删除缓存 | 缓存中一直有数据,数据缓存会更新同步,可降低读请求对于数据库的压力 | 如果更新后的数据之后很少被访问到,会导致缓存中存储的是非热点数据 缓存利用率不高,浪费缓存资源 | 读多写少 |
读写缓存 | 更新数据库+更新缓存 | 只读缓存中保留的是热点数据,缓存利用率高 | 删除缓存导致缓存缺失和再加载的过程: 缓存缺失时,导致大量请求落到数据库,造成巨大压力 | 读写相当 |
缓存方案的选择思路
- 确定缓存类型(读写/只读)
- 确定一致性级别(最终一致性、强一致性)
- 确定同步/异步方式
- 选定缓存流程
- 补充细节
缓存方案设计方向
- 【方向1】只更新MySQL,不管Redis(以过期时间兜底)
- 【方向2】更新MySQL之后,操作Redis
- 【方向3】异步将MySQL的更新同步到Redis
缓存模式的方案设计一般都是以MySQL为主、Redis为辅,主要是担心如果先更新Redis再更新MySQL的过程中,如果Redis出现宕机且此时MySQL数据还没来得及更更新,就会导致数据丢失。而如果采取先更新MySQL后同步Redis,就算期间Redis出现宕机,由于其本身作为辅助缓存,这种数据丢失场景并不是那么重要
结合上述缓存方向分析,针对数据一致性问题产生的原因,可以从两个方面去发散理解:
- 源自部分操作失败(例如MySQL操作成功,而Redis操作失败(例如Redis宕机、网络问题、Redis抖动等))
- 源自并发操作(并发操作的时序性问题:写+读、写+写)
2.缓存方案设计方案解读(一致性策略)
【方向1】只更新MySQL,不管Redis(以过期时间兜底)
方案核心
使用redis的过期时间,mysql更新时,redis不做处理。等到缓存过期失效时,再从mysql拉取缓存
这种方式实现简单,但不一致的时间会比较明显,具体结合业务场景进行配置,如果读请求非常频繁,且过期时间设置较长,则会产生很多脏数据
优缺点
- 优点:
- redis原生接口,开发成本低,易于实现
- 管理成本低,出问题的概率会比较小
- 缺点
- 完全依赖过期时间,时间太短容易造成缓存频繁失效,太长则容易有较长时间出现数据不一致的现象,对编程者的业务能力,有一定要求
【方向2】更新MySQL之后,操作Redis
方案核心
不光通过key的过期时间兜底,还需要在更新mysql时,同时尝试操作redis,这里的操作分两种实现方式:
- 方式1:更新,直接将结果写入Redis,但实际上很少用更新(因为更新容易带来时序性问题)
- 方式2:删除,等待下次访问再加载回来
此外,此处是尝试操作(即尝试删除),这样说是这一步操作是可能失败了(网络问题、redis抖动等都可能导致操作执行失败),就算失败也可以忽略,即不能让删除成为一个关键路径,影响核心流程。因为有key本身的过期时间作为保障,所以最终一致性是一定达成的,主动删除redis数据只是为了缩短不一致的时间。
优缺点
- 优点:
- 对比方案1,其达成最终一致性的延迟更小
- 实现成本低,在方案1的基础上增加了删除逻辑
- 缺点
- 如果mysql更新成功,redis删除却失败(网络问题、redis抖动/redis短时间不可用等都可能导致操作执行失败),该方案退化到方案1(有过期时间兜底)
- 在更新时候需要额外操作Redis,带来了损耗
问题扩展思考
问题1:是否可以引入事务机制?将MySQL和Redis的操作作为一个原子性操作
思路可行,但实际上不需要通过引入事务增加实现的复杂度。回想事务的定义,此处就算在业务逻辑进行控制也无法将redis和mysql两个不同类型的数据库做到一起事务控制,可以理解为此处无法保证整体事务。例如:
- 【步骤1】更新MySQL数据库
- 【步骤2】更新Redis成功
- 【步骤3】提交事务commit 请求失败,导致MySQL回滚,但无法回滚Redis
因此通过加@Transaction的作用可以理解为只是单纯为了MySQL异常时回滚。即然如此,没必要复杂化开发设计,直接先删除缓存然后等下次访问再加载回来即可。
【方向3】异步将MySQL的更新同步到Redis
方案核心
引入异步更新服务来完成对Redis的更新操作,将其与业务代码进行解耦,即业务服务不需要关心Redis是如何更新的,常用的有两种思路:
- 消息方案:采用mysql + 消息队列 + 异步更新服务 + Redis
- binlog方案:采用mysql + cancal + 异步更新服务 + Redis
所谓解耦是指操作redis由slave完成,而不是通过业务代码操作redis。对于业务而言不需要关心redis如何更新,所有的更新操作由这个slave服务进行监听并完成同步
可以理解为另启一个专门用作异步更新的服务,这个服务对Redis的操作策略则由开发设计方案指定:
- 消息方案:订阅MySQL变更消息,消费程序消费消息、更新到redis
- 例如canal作为MySQL binlog增量获取和解析工具,可以将变更记录投递到MQ系统中(例如Kafka/RocketMQ),然后消费程序会从MQ中获取消息,完成缓存更新操作(Redis的更新在消费程序中实现)
- binlog方案:订阅binlog日志、解析日志内容、更新到redis (对比消费方案,其虽然少了MQ消息收发的步骤,但需要对binlog日志进行解析)
- 例如阿里巴巴开源的canal组件,将搭建的消费服务作为mysql的一个slave,订阅mysql的binlog日志、解析日志内容、更新到redis
优缺点
- 优点:
- 和业务完全解耦,在更新mysql时,不需要做额外操作;
- 无时序性问题,可靠性强;
- 缺点:
- 引入了第三方组件消息队列,还要单独搭建一个同步服务,需要额外的维护成本。同步服务如果压力比较大,或者崩溃了,那么在较长时间内,redis中都是老旧数据
方案选型分析
- 首先确认产品上对延迟性的要求,如果要求极高,且数据有可能变化,不建议引入缓存
- 通常来说,过期时间兜底是行之有效的办法,根据实时性期待不一样可以增加个删除逻辑,提升一致性
- 从解耦层面来看,可以使用订阅binog的模式来更新,缺点就是重,比较适合的场景是数据不过期场景
一般场景应用:在项目中使用过期时间来兜底,在更新MySQL(DB)后通过删除缓存待下次访问时更新缓存的方式来缩短数据不一致的时间。此外,在方案设计的时候还考虑到通过订阅binlog的方式来实现异步更新,但这种方案需要依赖于消费服务,维护成本相对较高。主要还是结合业务场景进行选择,看业务对不一致问题的容忍度,然后择选合适的方案。
订阅binlog模式:这种模式更像是同步数据的一种场景,比较适合缓存时间很长时间过期或者不过期的场景。例如视频网站的一些展示视频信息,它们的基本信息流量很大,但是这几个视频的信息基本不会变动,则可选用这种方式。场景适配说明:缓存中的性能衡量指标除了缓存一致性还有缓存命中率,针对流量很高的热点数据对数据的时序性要求比较高,则自然联想到订阅binlog方案来确保数据时序性。其次,这个热点数据不能一直变,如果频繁更新、缓冲命中率不高,自然也不合理。
3.缓存策略方案
针对只读缓存场景(更新数据库+删除缓存)
以数据源为MVSQL、缓存用Redis为例,在数据库场景下,更廉价、高效但可靠性稍低的redis可以给更昂贵、较慢、可靠性强的mysql做缓存。常见有多种缓存模式,此处以常见、最实用的旁路缓存模式为基础,来进行分析。结合缓存的应用场景,理解方案设计的演进。一般场景下针对只读缓存的处理流程分析如下:
新增数据时,直接写入数据库;
更新(修改/删除)数据时,先删除缓存 ;
后续在访问这些增删改的数据时,会发生缓存缺失,进而查询数据库,更新缓存;
(1)旁路缓存策略
从旁路缓存策略,理解上述针对只读缓存方案设计方向的演进和优化。旁路缓存(Cache Aside)策略:分为【读策略】、【写策略】
- 【写策略】
- 更新数据库中的数据
- 删除缓存中的数据
- 【读策略】
- 如果读取的数据命中了缓存,则直接返回数据
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入缓存并返回给用户
扩展问题:为什么是删除缓存?而不是更新缓存?
更新缓存的方式存在时序性问题会导致的数据不一致 =》举个例子:假设a的初始值为2,两台业务服务器在同一时间发出两条请求:【请求1】给a的值加1、【请求2】设置a的值为5,MySQL执行完成并同步操作Redis,由于网络传输本身有延迟,所以无法保证指定的两条Redis更新操作谁先执行,则可能出现mysql和Redis数据不一致的情况:例如假设mysql中先执行【请求1】,再执行【请求2】,则mysql中a的值先变成3,最终为5;若因为一些原因导致Redis先执行【请求2的操作】,再执行【请求1的操作】,则此时Redis的数据就先变成了5,然后在加1变成了6,进而导致数据不一致。相比于数据延迟而言,这更让人疑惑和不能接受,所以一般都选择删除。
时序(原a=2) | 【请求1】给a的值加1 | 【请求2】设置a的值为5 |
---|---|---|
t1 | 更新数据库(a=3) | |
t2 | 更新数据库(a=5) | |
t3 | 更新缓存(a=5) | |
t4 | 更新缓存(a=6) |
此外,系统设计中有一个思想Lazy Loading,适用于那些加载代价大的操作,删除缓存而不是更新缓存,就是懒加载思想的一个应用。
删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。比如商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,这个操作会非常耗时。 从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长时间不被访问,因此,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。
(2)并发操作导致的数据不一致:【写策略】是先删后写还是先写后删?
结合实际的操作场景分析”先删后写“、”先写后删“两种场景在并发情况下会有什么样的表现
- ”先删后写“:先删除缓存,后更新数据库
- ”先写后删“:先更新数据库,后删除缓存
先删后写
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。两个请求执行步骤细化拆解为(假设都对用户进行操作):
- 请求A:删除缓存内容、更新数据库内容
- 请求B:读取缓存内容
- 如果命中直接返回
- 如果未命中则和检索数据库内容并写入缓存,然后返回
步骤 | 请求A(修改操作) | 请求B(读取操作) |
---|---|---|
t1 | 删除缓存内容 | |
t2 | 读取缓存内容,发现未命中 | |
t3 | 请求数据库,读取到age为20 | |
t4 | 将其更新到缓存并返回 =》此时缓存数据:age=20 | |
t5 | 更新数据库内容=》此时数据库数据:age=21 | |
解决方案 | 延时双删:需确保请求A的睡眠时间大于请求B【从数据库中读取+写入缓存】的时间 | |
t6 | 再次删除缓存 |
结合上述分析可以看到,在【读+写】并发场景下,如果采用”先删后写“的方案,会出现数据库和缓存的数据不一致问题。这种方案可以通过延迟双删方案来解决不一致问题。延迟双删伪代码实现分析如下:
# 删除缓存
redis.delKey(X)
# 更新数据库
db.update(X)
# 睡眠
Thread.sleep(N)
# 再删除缓存
redis.delKey(X)
先写后删
继续以【读+写】并发场景分析,如果采用”先写后删“的方案,其流程构建如下
步骤 | 请求A(修改操作) | 请求B(读取操作) |
---|---|---|
t1 | 读取缓存内容,发现未命中 | |
t2 | 请求数据库,读取到age为20 | |
t3 | 更新数据库内容=》此时数据库数据:age=21 | |
t4 | 删除缓存内容 | |
t5 | 将其更新到缓存并返回 =》此时缓存数据:age=20 |
综合上述理论分析,还是会出现数据不一致的问题。但实际上这个问题出现的概率并不高,因为缓存的写入通常要远远快于数据库的写入,因此请求B的读取操作一般场景下会快过请求A的修改操作,而只要确保读取操作快于修改操作,则其流程分析如下:
步骤 | 请求A(修改操作) | 请求B(读取操作) |
---|---|---|
t1 | 读取缓存内容,发现未命中 | |
t2 | 请求数据库,读取到age为20 | |
t3 | 将其更新到缓存并返回 =》此时缓存数据:age=20 | |
t4 | 更新数据库内容=》此时数据库数据:age=21 | |
t5 | 删除缓存内容 |
而基于【读取操作快于修改操作】这一条件,最后的结果是数据库数据正常更新为21,而缓存数据被清除,因此后续的请求会因为缓存没有命中而从数据库中获取最新的数据,因此不会出现数据不一致的场景。因此一般场景中都是选择先更新数据库,后删除缓存,再加上引入过期时间做兜底,计算这段时间真的出现了不一致的情况,也可以通过过期时间兜底来达到最终一致。
(3)部分操作失败导致的数据不一致:【先写后删】如何确保两个操作都能执行成功?
基于上述写策略的选择,一般建议选择”先更新数据库,后删除缓存“的方案来完成,以尽可能达到数据一致。但是这个方案也会出现一个问题,就是这两个操作无法确保都能成功。例如出现更新数据库成功但是删除缓存失败的场景,其流程分析如下(也可以将当前这种场景理解为无并发情况的场景):
步骤 | 请求A(修改操作) | 请求B(读取操作) |
---|---|---|
t1 | 读取缓存内容,发现未命中 | |
t2 | 请求数据库,读取到age为20 | |
t3 | 将其更新到缓存并返回 =》此时缓存数据:age=20 | |
t4 | 更新数据库内容=》此时数据库数据:age=21 | |
t5 | 删除缓存内容 =》 ❌ 此步骤操作失败,缓存中数据为:age=20 |
基于上述流程分析,如果请求A的删除缓存操作失败,则会导致一段时间内数据库和缓存中的数据不一致,由于过期时间的兜底,缓存失效后会重新更新。但是如果是对一些对时序性要求比较敏感的业务,这段时间的数据不一致可能会造成很大的影响。对于这种场景可以有两种解决方案:
- 消息队列 + 重试机制
- 订阅 MySQL binlog,再操作缓存
解决方案1:消息队列 + 重试机制
可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列(例如kafka、RocketMQ),由消费者来操作数据
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制
- 如果重试超过的一定次数,还是没有成功,则需要向业务层发送报错信息了
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试
解决方案2:订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
随后可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用
- 创建更新缓存服务,接收数据变更的 MQ 消息,然后消费消息,更新/删除 Redis 中的缓存数据;
- 使用 Binlog 实时更新/删除 Redis 缓存。利用 Canal,即将负责更新缓存的服务伪装成一个 MySQL 的从节点,从 MySQL 接收 Binlog,解析 Binlog 之后,得到实时的数据变更信息,然后根据变更信息去更新/删除 Redis 缓存;
- MQ+Canal 策略,将 Canal Server 接收到的 Binlog 数据直接投递到 MQ 进行解耦,使用 MQ 异步消费 Binlog 日志,以此进行数据同步;
不管用 MQ/Canal 或者 MQ+Canal 的策略来异步更新缓存,对整个更新服务的数据可靠性和实时性要求都比较高,如果产生数据丢失或者更新延时情况,会造成 MySQL 和 Redis 中的数据不一致。因此,使用这种策略时,需要考虑出现不同步问题时的降级或补偿方案。
针对读写缓存(更新数据库+更新缓存)
读写缓存:增删改在缓存中进行,并采取相应的回写策略,同步数据到数据库中
- 同步直写:使用事务,保证缓存和数据更新的原子性,并进行失败重试(如果 Redis 本身出现故障,会降低服务的性能和可用性)
- 异步回写:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库(没写回数据库前,缓存发生故障,会造成数据丢失) 该策略在秒杀场中有见到过,业务层直接对缓存中的秒杀商品库存信息进行操作,一段时间后再回写数据库。
一致性:同步直写 > 异步回写 因此,对于读写缓存,要保持数据强一致性的主要思路是:利用同步直写,同步直写也存在两个操作的时序问题:更新数据库和更新缓存
(1)无并发情况
执行顺序 | 潜在问题 | 结果 | 是否存在一致性问题 | 解决策略 |
---|---|---|---|---|
先更新缓存,后更新数据库 | 更新缓存成功,更新数据库失败 | 数据库为旧值 | 是 | 消息队列+重试机制 |
先更新数据库,后更新缓存 | 更新数据库成功,更新缓存失败 | 请求命中缓存,读取缓存旧值 | 是 | 消息队列+重试机制 订阅binlog日志 |
(2)高并发情况
数据不一致场景分析
有四种场景可能造成数据不一致,写+读并发、写+写并发,结合这两种场景拆解不同执行顺序可能导致的执行结果:
- 写+读并发:线程A的数据还没回写完成,线程B读取到缓存中的数据和数据库的不一致
- 写+写并发:线程A和线程B同时写入同一个数据,但是回写的时序不同导致数据库不一致(例如先更新数据库的不一定先发消息,先发消息也不一定先执行)
【场景1】写 + 读 并发(先更新缓存,后更新数据库)
时序 | 线程A(更新操作) | 线程B(读取操作) |
---|---|---|
t1 | 更新缓存 | |
t2 | 读取数据,命中缓存,读取到最新值后返回 | |
t3 | 更新数据库 |
虽然线程A还没更新到数据库,数据库和缓存会存在短暂不一致,但在此之前进来的数据都能命中缓存读取到最新值后返回,这种情况对业务的影响较小
【场景2】写 + 读 并发(先更新数据库,后更新缓存)
时序 | 线程A(更新操作) | 线程B(读取操作) |
---|---|---|
t1 | 更新数据库 | |
t2 | 读取数据,假设命中缓存,读取到的是旧值 | |
t3 | 更新缓存 |
线程A更新数据库,此时线程B读取数据如果命中缓存则读取到的是旧值,在线程A未完成更新缓存操作之前,会出现短暂数据不一致的情况
【场景3】写 + 写 并发(先更新缓存,后更新数据库)
时序(原X为0) | 线程A(更新操作)修改X为1 | 线程B(更新操作)修改加5 |
---|---|---|
t1 | 更新缓存 =》X=1 | |
t2 | 更新缓存 =》X=6 | |
t3 | 更新数据库 =》X=5 | |
t4 | 更新数据库 =》X=1 |
此场景中数据库和缓存存在不一致,对业务影响较大
【场景4】写 + 写 并发(先更新数据库,后更新缓存)
时序(原X为0) | 线程A(更新操作)修改X为1 | 线程B(更新操作)修改加5 |
---|---|---|
t1 | 更新数据库 =》X=1 | |
t2 | 更新数据库 =》X=6 | |
t3 | 更新缓存 =》X=5 | |
t4 | 更新缓存 =》X=1 |
此场景中数据库和缓存存在不一致,对业务影响较大
数据不一致场景解决方案
针对场景 1 和 2 的解决方案是:保存请求对缓存的读取记录,延时消息比较,发现不一致后,做业务补偿
针对场景 3 和 4 的解决方案是:对于写请求,需要配合分布式锁使用。写请求进来时,针对同一个资源的修改操作,先加分布式锁,保证同一时间只有一个线程去更新数据库和缓存;没有拿到锁的线程把操作放入到队列中,延时处理。用这种方式保证多个线程操作同一资源的顺序性,以此保证一致性。
分布式锁实现策略
分布式锁策略 | 实现原理 |
---|---|
乐观锁 | 使用版本号、updatetime;缓存中只允许高版本覆盖低版本 |
watch实现Redis乐观锁 | watch监控redisKey的状态值,创建redis事务,key+1,执行事务,key被修改过则回滚 |
setnx | 获取锁:set/setnx;释放锁:del命令/lua脚本 |
Redisson 分布式锁 | 利用Redis的Hash结构作为储存单元,将业务指定的名称作为key,将随机UUID和线程ID作为field,最后将加锁的次数作为value来储存;线程安全 |
强一致性策略
上述策略只能保证数据的最终一致性。 要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。 如果业务层要求必须读取数据的强一致性,可以采取以下策略:
(1)暂存并发读请求
在更新数据库时,先在 Redis 缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性
(2)串行化
读写请求入队列,工作线程从队列中取任务来依次执行
- 【1】修改服务 Service 连接池,id 取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上
- 【2】修改数据库 DB 连接池,id 取模选取 DB 连接,能够保证同一个数据的读写在数据库层面是串行的
(3)使用 Redis 分布式读写锁
将淘汰缓存与更新库表放入同一把写锁中,与其它读请求互斥,防止其间产生旧数据。读写互斥、写写互斥、读读共享,可满足读多写少的场景数据一致,也保证了并发性。并根据逻辑平均运行时间、响应超时时间来确定过期时间。
public void write() {
Lock writeLock = redis.getWriteLock(lockKey);
writeLock.lock();
try {
redis.delete(key);
db.update(record);
} finally {
writeLock.unlock();
}
}
public void read() {
if (caching) {
return;
}
// no cache
Lock readLock = redis.getReadLock(lockKey);
readLock.lock();
try {
record = db.get();
} finally {
readLock.unlock();
}
redis.set(key, record);
}
缓存应用实践
1.大厂如何做MySQL to Redis 同步?
MySQL to Redis 同步方案
场景问题:缓存穿透
缓存穿透:超大规模系统经典问题
构建 Redis 集群,理论上可以通过水平扩容,构建足够大的集群以支持海量并发。但是,因为并发请求的数量基数太大,即使有很小比率的请求穿透缓存,打到数据库上请求的绝对数量仍然不小。加上大促期间的流量峰值,还是存在缓存穿透引发雪崩的风险。
解决思路1:对于已引入消息队列的服务,可以新增消息订阅来完成更新缓存操作
最典型的解决思路是:不让请求穿透缓存 =》反正现在存储也不贵,只要有足够多的服务器,Redis 集群的容量就是无限的。不如考虑把全量的数据都放在 Redis 集群里面,处理读请求的时候,干脆只读 Redis,不去读数据库。这样就完全没有「缓存穿透」的风险了,实际上很多大厂它就是这么干的。
但是在 Redis 中缓存全量的数据,又引发了一个新的问题,那就是,如何来更新缓存中的数据呢?因为取消了缓存穿透的机制,这种情况下,从缓存读到数据可以直接返回,如果没读到数据,那就只能返回错误了!因此,当系统更新数据库的数据之后,必须及时去更新缓存。进而需要考虑怎么保证 Redis 中的数据和数据库中的数据同步更新?
在之前的篇章学习中有用分布式事务来解决数据一致性的问题,但是这些方法都不太适合用来更新缓存,因为分布式事务,对数据更新服务有很强的侵入性。对于单服务来说,如果为了更新缓存增加一个分布式事务,无论用哪种分布式事务,或多或少都会影响下单服务的性能。还有一个问题是,如果 Redis 本身出现故障,写入数据失败,还会导致下单失败,等于是降低了下单服务性能和可用性,从业务角度上看是不行的。
因此可以引入”消息队列+重试机制“来异步处理更新缓存的操作
**对于像订单服务这类核心的业务,可以启动一个更新订单缓存的服务,接收订单变更的 MQ 消息,然后更新 Redis 中缓存的订单数据。**因为这类核心的业务数据,使用方非常多,本来就需要发消息,增加一个消费订阅基本没什么成本,订单服务本身也不需要做任何更改,实现起来很简单,对系统的其他模块完全没有侵入
如果丢消息了怎么办?因为基于现有设计消息是缓存数据的唯一来源,一旦出现丢消息,缓存里缺失的那条数据永远不会被补上。所以,必须保证整个消息链条的可靠性,不过现有的 MQ 集群(例如Kafka、RocketMQ),它都有高可用和高可靠的保证机制,只要正确配置好,是可以满足数据可靠性要求的。
解决思路2:对于未引入消息队列的服务,可以使用binlog实时更新Redis缓存
如果要缓存的数据,本来没有一份数据更新的 MQ 消息可以订阅怎么办?很多大厂都采用的,也是更通用的解决方案是启用一个服务来专门完成更新缓存操作(与业务代码解耦,业务代码无需关心Redis缓存是如何更新的)
业务系统只负责处理业务逻辑、更新 MySQL,完全不用管如何去更新缓存。负责更新缓存的服务,把自己伪装成一个 MySQL 的从节点,从 MySQL 接收 Binlog,解析 Binlog 之后,可以得到实时的数据变更信息,然后根据这个变更信息去更新 Redis 缓存。
这种基于Binlog更新缓存的方案和MQ消息方案的思路大同小异,都是异步订阅实时数据变更信息然后更新Redis。
对于Binlog方案而言其通用性更强,它不要求订单服务发订单消息,订单更新服务也不需要费劲解决发消息失败这种数据一致性问题,整个缓存更新链路上少了一个收发MQ的环节,从MySQL更新到Redis更新的时延更短,这种方案也更加受到青睐。但其缺点也在于如何在订单缓存更新服务中完成对binlog日志的解析(因为它不向MQ收发消息那样有比较强的目的性)
目前也有很多很多开源的项目就提供了订阅和解析 MySQL Binlog 的功能,以比较常用的开源项目 Canal 为例,拆解其如何实时接收 Binlog 更新 Redis 缓存。
Canal 实践
todo : 额外文章(时间篇章)
2.扩展问题思考
多级缓存问题
多级缓存是指在业务中使用了多个缓存,例如本地缓存 + Redis + 数据库的架构
基于上述多级缓存构建的架构,需要考虑三者更新的顺序,其中数据库是主要的数据源,需要优先保证数据库的准确性,其次再考虑本地缓存和Redis的数据一致性问题。一般的更新顺序是:先更新数据库、后更新本地缓存、最后更新Redis,原因分析如下:
数据库是主要的数据源,需要优先保证数据库的准确性,然后再保证缓存的数据一致性
为什么先更新本地缓存?
- 因为本地缓存几乎不会出错,而业务代码也是先查询本地缓存,当本地缓存没有才会进一步查询Redis缓存
- Redis作为第三方组件,如果Redis出错,结合本地缓存、Redis缓存的先后来分析场景问题:
- 先更新本地缓存,后更新Redis缓存:如果Redis出错,业务代码是先查询本地缓存的数据,确保优先拿到最新的数据,Redis出错对其的业务影响并不大
- 先更新Redis缓存,后更新本地缓存:如果Redis出错,当本地缓存失效从Redis中获取数据可能会出现数据不一致问题
如果出现缓存不同步的情况,在负责的业务场景下,该如何降级或者补偿?
设置一个合理的缓存过期时间,这样即使出现缓存不同步,等缓存过期后就会自动恢复(最终一致)。或者通过识别用户手动刷新操作,强制重新加载缓存数据(但要注意防止大量缓存穿透)。还可以在管理员的后台系统中,预留一个手动清除缓存的功能,必要的时候人工干预
全量数据缓存,缓存同步有个时间差,这该如何处理?
就像 MySQL 主从同步时延一样,只能接受它。一般这个时延都是毫米级的,不会对业务有很大影响。
对交易数据进行缓存,单个简单,批量查询如何?
一般批量查询的时候可以用 Redis 的集合数据结构,比如 SET,SET 中的 Value 可以保存交易编号,而不用保存交易数据。