Redis和MySQL的数据一致性问题思考
最近有在反思自己工作。因为自己这边是面向业务的,而且是和商品数据相关的。所以我平时工作中涉及到的最多的就是MySQL和Redis的数据存储。像我们配置商品是把商品配置到MySQL,但是对外toC接口都是直接读取Redis的。所以自然而然就涉及到MySQL和Redis的数据一致性问题。下面就是聊聊我自己对于这个问题的一个思考吧。有问题或者有更好方案的朋友也希望可以在评论里点出。
在互联网搜索这个问题很容易就看到概念性的经典方案,比如下面的三个经典缓存模式,我之前是没在意过这些的,但是在学习思考的过程中确实觉得有些概念或者有些名词大家可以了解一下。我这里是简单的总结了一下,具体的内容可以参考我看的那篇帖子https://juejin.cn/post/6964531365643550751,他总结的很详情
呐就是下面的三个经典缓存模式
三个经典缓存模式
- Cache-Aside(旁路缓存)
即读取缓存、读取数据库和更新缓存的操作都在应用系统来完成。(业务最常用的缓存策略)- 读流程:服务读取数据先读缓存,缓存命中的话,直接返回数据。没命中,读库写回缓存并返回数据。
- 写流程:服务写数据先写数据库,再删除缓存。
- Read- Through/Write- Through(读写穿透)
即服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。- 读流程(Read- Through):服务读取数据先读缓存,缓存命中的话,直接返回数据。没命中,读库写回缓存并返回数据。
这里的读流程可以说是和上面一模一样了!那搁这还说啥呢??
其实这里强调的是在网关层和存储层之间增加了一个缓存层,也就是Read-Through实际只是在Cache-Aside之上进行了一层封装
就是上面的是应用程序->缓存->MySQL
下面的是应用程序->Cache Provider -> 缓存->MySQL - 写流程(Write-Through):当发生写请求时,也是由缓存抽象层完成数据源和缓存数据的
应用程序->Cache Provider -> 缓存 && Cache Provider->MySQL
- 读流程(Read- Through):服务读取数据先读缓存,缓存命中的话,直接返回数据。没命中,读库写回缓存并返回数据。
- Write behind (异步缓存写入)
即服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,通过抽象缓存层完成,其中写操作是只更新缓存,不直接更新数据库,对于数据库的更新采用批量异步方式来更新数据库。
Write behind跟Read-Through/Write-Through都是由Cache Provider来负责缓存和数据库的读写。
不同
Read/Write Through是同步更新缓存和数据的
Write Behind则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。
这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。但是它适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。
Redis和MySQL一致性的两种场景和解决方案
Redis和MySQL的数据一致性我认为还是要结合业务需求,我这里分为两个场景
- 第一种情况是接口获取数据以MySQL数据为主,这种情况就是指接口数据获取时先读Redis,如果Redis中数据获取失败就要去读MySQL这种情况。(读写以MySQL数据为主)
- 第二种情况是接口获取数据以Redis数据为主,这种情况就是指接口获取数据时只读Redis,如果Redis中数据不存在,那么就返回获取数据失败。(写以MySQL为主,读以Redis数据为主)
第一种情况:读写以MySQL数据为主
这种业务场景下一般使用Cache-Aside模式。就是读先读Redis、再读MySQL;写先写MySQL,再删除Redis。
注意这里是删除Redis。原因就是考虑到多个线程写的时候先更新MySQL的线程后更新了Redis,导致Redis中的数据是旧数据(脏数据),个人认为主要是为了解决这种问题。当然了删除Redis就意味着我们选择的是延迟加载的方式。所以对于更新Redis就还存在下面另外个优点:就是延迟加载在下一次有读请求时才会执行更新操作,如果更新Redis的计算是复杂逻辑会在写多读少的情况下减少更新频率,节省了性能损耗。
上面对于Redis和MySQL双写的顺序是先写MySQL再写Redis,如果我想先写Redis呢?
当然也可以先操作Redis,但是这样的话就可能存在多线程下,一个写线程先删了Redis,另一个读线程在这个情况下没读到Redis,就把MySQL中的数据写入到Redis中了。然后线程A写入MySQL中新数据,所以这样MySQL和Redis就数据不一致了。。。。对于这种就要考虑延迟双删机制。
解决方案:
- 同步:
- 同步双写。Redis和MySQL的更新操作放在同一事务中,整个成功整个失败,保证双写完全一致,此时先更新MySQL和先更新Redis都可以。不过一般以MySQL为主的话还是会优先先更新MySQL中的数据
- 异步(这里虽然是同步去触发更新,但是不放在一个事务内,不能保证原子性,就归属于异步更新,当然触发也可以具体选择同步触发还是异步触发的方式)
- Cache-Aside(旁路缓存)。先写MySQL,再删Redis
- 缓存延迟双删。先删Redis,再写MySQL,再删Redis(这里二次删除Redis的过程应该采用sleep休眠一会再删除或者使用延迟队列进行延迟删除缓存)。
- 监听binlog。通过监听MySQL的binlog触发异步删除(如使用阿里的canal将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性。)
- 补偿
- 缓存失败重试机制
写请求更新数据库,缓存因为某些原因删除失败,把删除失败的key放到消息队列,消费消息队列的消息,获取要删除的key,重试删除缓存操作
- 缓存失败重试机制
第二种情况:写以MySQL为主,读以Redis数据为主
这种业务情况是指写操作以MySQL为主,即写操作的时候先写MySQL再写Redis,读数据以Redis中的结果为主,如果Redis中有接口就返回数据,如果Redis中不存在数据,就直接返回数据不存在。所以这个情况下就不能删除Redis了,应该采用更新Redis。更新Redis可以保证有效数据在Redis中是永远存在的。那么这种情况下MySQL如何保证和Redis数据一致性呢?
解决方案:
- 同步:
- 同步双写。Redis和MySQL的更新操作放在同一事务中,整个成功整个失败,保证双写完全一致。(本地事务或分布式事务)
- 异步(这里虽然是同步去触发更新,但是不放在一个事务内,不能保证原子性,就归属于异步更新,当然触发也可以具体选择同步触发还是异步触发的方式)
- MQ消息。MySQL更新完成后直接发送异步MQ消息,然后通过消费消息实现Redis数据变更
- 监听binlog。通过监听MySQL的binlog触发异步更新(如使用阿里的canal将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,更新缓存,保证数据缓存一致性。)
- 数据库触发器。在MySQL中设置触发器,当数据库发生变化时触发相应的操作,将变化的数据同步到Redis中。通过在MySQL中设置触发器,可以在数据发生变化时立即同步更新Redis。
- 数据变更日志。记录数据变更日志,将数据变更操作写入日志文件或者数据库中,然后通过定时任务或者实时监控方式将变更的数据同步更新到Redis中。(这个方法实时监控类似监听binlog方式,定时任务的方式就类似于下面提到的补偿机制)
- 补偿(在上面异步触发更新的方式中,存在更新完MySQL,但是更新Redis失败的情况,这种情况下,就要考虑补偿更新)
- 缓存失败重试机制。
- 更新Redis失败后同步执行重试,如果重试成功即更新成功,失败可以采用领起线程重试或下面其他方案。
- 更新失败也则可采用另起一个线程,再进行有限次数的重试,重试成功即更新成功,失败则写入MQ或log表记录等。
- 更新Redis失败后把更新失败的key放到消息队列,通过消费消息队列要更新的key,达到重试更新Redis操作。
- 更新Redis失败后把更新失败的数据记录到日志中,日志可以是日志文件或数据库表,然后通过监控方式再补偿更新。
- 定时任务定时补偿机制。
- 定时将MySQL中的全量数据更新到Redis中。这里的定时可以采用全量定时和增量定时两种定时更新方式。
- 更新Redis失败后把更新失败的数据记录到日志中,定时任务扫描实现失败数据补偿更新。
- 缓存失败重试机制。