1 背景
一天,老板说「最近公司的用户越来越多了,但是服务器的访问速度越来越差的,阿黄帮我优化下,做好了给你画个饼!」。
阿黄听到老板口中的「画饼」后就非常期待,没有任何犹豫就接下了老板给的这个任务。
阿黄登陆到了系统服务器,一顿操作猛如虎啊,经过一番排查后,确认服务器的性能瓶颈是在数据库。
好家伙,这好办啊,给服务器加上 Redis,让其作为数据库的缓存。这样,在客户端请求数据时,如果能在缓存中命中数据,那就查询缓存,不用在去查询数据库,从而减轻数据库的压力,提高服务器的性能。
阿黄确定使用redis方案之后,就马不停蹄地开整了。
那么问题来了,数据存在两个地方:Redis、MySQL数据库。如何保证这两个地方的数据的一致性?
2 一致性
一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。
强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大。
弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态。
最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型。
导致数据不一致的原因?因为数据库操作和redis操作是两个操作,而非原子操作。
2.1 读数据
- redis中存在数据,直接返回redis中的数据。
- redis中不存在数据,查询数据库后,保存数据到redis。
数据只查询,不修改。缓存到redis中的数据与数据库保持一致,不存在一致性问题。
2.2 写数据
- redis中不存在数据,则直接更新数据库中的数据。
- redis中存在数据,则需要同时更新redis和数据库的数据,保证它们的一致性。
在高并发的业务场景下,数据库大多数情况都是用户并发访问最薄弱的环节。所以,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问MySQL等数据库。
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新,数据库和缓存更新,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
不管是先写数据库,再删除缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。
2.2.1 先删缓存,再写数据库
例如:
前置条件:总共剩余票10张,数据库10张,缓存10张
线程1:买了一张,删除缓存成功
线程2:没读到缓存,去读数据库读还剩10张,并更新到缓存10张
结果:缓存本来应该是9张,而存储的是10张
步骤2在更新数据库时,因网络延迟,导致无法及时更新至数据库,所以当线程2访问此数据时,查到的是旧数据,并更新至缓存中,导致后续线程来查询的数据,都是缓存中的旧数据。
这种方法在高并发下最容易出现长时间的脏数据,不可取。
解决一致性方案:延迟双删(见下面2.2.3.1 )
2.2.2 先写数据库,再删缓存
例如:
前置条件:总共剩余票10张,数据库10张,缓存10张
线程1:买了一张,更新数据库成功,数据库剩余9张
线程2:读缓存10张
结果:缓存存储的是10张
在写完数据库与删缓存期间,线程2查询到的缓存数据是旧数据
先写数据库再删缓存,如果删除缓存失败,并且缓存没有设置过期时间策略,查询缓存得到的永远是旧数据。
删除缓存失败的解决方案:删除重试机制(见下面2.2.3.2)
总结
执行顺序 | 数据问题 | 结果 | 数据一致性问题 |
---|---|---|---|
先删缓存再写数据库 | 删除缓存成功,更新数据库失败 | 请求无法命中缓存,读取数据库旧数据 | 是 |
先写数据库再删缓存 | 更新数据库成功,删除缓存失败 | 请求命中缓存,读取缓存中的旧数据 | 是 |
2.2.3 解决一致性问题方案
2.2.3.1 延迟双删
延迟多少毫秒根据实际业务决定。从线程2查询数据库到到更新缓存到redis的时间决定。
如果在第二次删除缓存失败,读到的缓存数据仍然是旧数据。那如何解决此问题???
2.2.3.2 删除重试机制
如果删除缓存失败,将消息放入消息队列MQ中,重试删除。
异步将需要删除的key发送到消息队列。
缺点:
1)鉴于上述方案对业务代码具有一定入侵性,所以需要一种更加优雅的解决方案,让缓存删除失败的补偿机制运行在背后,尽量少的耦合于业务代码。
2)在高并发场景下,重试最好使用异步方式
解决方法:使用阿里开源框架Canal读取binlog异步删除
2.2.3.3 SpringBoot 整合 Canal + RabbitMQ
通过 Canal + RabbitMQ 实现对 MySQL 数据变动监听,能够应对实际工作直接修改数据库数据后让缓存失效或者刷新的场景。
2.2.3.3.1 Canal工作原理
阿里开源框架Canal主要用途是基于MySQL数据库增量日志解析,提供增量数据订阅与消费。
MySQL主备复制原理
- MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件binary log events,可以通过 show binlog events 进行查看);
- MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log);
- MySQL slave 读relay log中的事件,将数据变更反映到自己的服务上
Canal工作原理
Canal监听到binlog变化时,会通知Canal客户端。
- Canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议;
- MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal );
- Canal 解析 binary log 对象(原始为 byte 流)
2.2.3.3.2 读取binlog异步删除
利用Canal提供的Java客户端,监听Canal通知消息。当收到变化消息时,就完成对缓存的更新。
3 总结
- 这些方式或多或少都有一些弊端,并不完美,实际上也很难有完美的设计。所以在做系统设计的时候,也不要去追求完美,要有一些取舍,找到一种最适合业务场景的方式就行。
- 数据一致性没有绝对的保证,要么牺牲性能加锁,要么串行。强一致性和性能只能自己平衡,在高并发下,这些方案都只能做到优化。从矮个子里面挑高个子。
- 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。我们能放入缓存的数据本就不应该是实时性、一致性要求超高的数据。所以传参数据的时候加上过期时间,保证一定时间内能拿到最新数据即可。
- 我们不应该过度设计,增加系统的复杂性。