【Redis教程0x0C】数据库与缓存的一致性保证

1. 引言

当我们在实现业务的过程中,如果发现服务器的性能瓶颈在数据库时,就要考虑加上Redis,让它作为数据库的缓存了。这样,客户端请求数据时,如果能在缓存命中,就不用去查数据库了,这大大减轻了数据库的压力,提高了服务器性能。
那么这里就产生了个问题,我们在数据更新的时候,既需要更新数据库,也需要更新缓存。那么怎么确定它们两者的先后顺序呢?

2. 先更新数据库,还是先更新缓存?

首先我们给出结论,无论是先更新哪个,后更新另一个,都会存在并发问题,导致缓存和数据库中数据不一致的现象。为什么呢?往下看!

2.1 先更新数据库,再更新缓存

举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
image.png
A 请求先将数据库的数据更新为 1,然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。
此时,数据库中的数据是 2,而缓存中的数据却是 1,出现了缓存和数据库中的数据不一致的现象。

2.2 先更新缓存,再更新数据库

假设「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
image.png
A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了, 将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。
此时,数据库中的数据是 1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象
既然两种更新方式都有问题,那也没有解决方法呢?
有是有,但势必影响了性能,我们后面再总结里会说。

3. 先更新数据库,还是先删除缓存?

既然无论先更新谁都会导致缓存和数据库的数据不一致,那么我们索性就不更新缓存了,直接把缓存中的旧数据删除,只更新数据库。这样一来,后续去读缓存发现没数据,再从数据库中读取数据,并更新到缓存中,保证数据一致性。

为什么选择删而不是更新呢?
删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。这时候去更新需要多表查询,开销太大了。
这也是一种Lazy Loading懒加载的思路,适用于加载代价大的操作。

这种策略叫做Cache Aside策略旁路缓存策略)。
该策略可以细分为读策略写策略
image.png
写策略

  1. 更新数据库中的数据;
  2. 删除缓存中的数据;

读策略

  1. 如果读取的数据命中缓存,则直接返回数据;
  2. 如果未命中缓存,则从数据库中读,然后回写到缓存,并且返回给用户;

对于写策略,我们不禁又有思考,是先更新后删,还是先删后更新呢?我们来分析一下!

3.1 先删除缓存,再更新数据库(写中读会导致不一致)

来看这样一个场景:
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
image.png
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。
可以看到,先删除缓存,再更新数据库,写中读(写操作的过程中有读操作)的时候,还是可能会出现缓存和数据库的数据不一致的问题。
那有没有解决方法呢?
有。叫做
延迟双删

伪代码:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。
所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。
但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

3.2 先更新数据库,再删除缓存(读中写会导致不一致,但实际上很难发生!)

继续用「读 + 写」请求的并发的场景来分析。
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
image.png
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。
从上面的理论上分析,先更新数据库,再删除缓存,在读中写(读操作的过程中写)也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高
为缓存的写入通常要远远快于数据库的写入
,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况
而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的
为了保证万无一失,我们还可以给缓存数据加上过期时间,这样就算在这期间存在缓存数据不一致,有过期时间兜底,最终也能达到一致。

4. 如何保证两个操作都能成功?

之前我们说到,要想保证数据一致性,最建议的方案就是先更新数据库,再删除缓存,并且给缓存数据加上过期时间。
但这仍可能存在这样一个问题:明明更新了数据,但是要过一段时间才生效。
为什么呢?
这是因为更新数据库和删除缓存是两个操作,如果删除缓存(第二步操作)的时候失败了,导致缓存中的数据是旧值,而数据库是新值。不过好在加了过期时间,这样在旧值过期后,还是能去数据库读到新值。
image.png
那我们应该怎样保证两步操作都成功呢?
有两种方法:

  1. 重试机制;
  2. 订阅MySQL binlog,再操作缓存。

4.1 重试机制

我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

举个例子,来说明重试机制的过程。
image.png

4.2 订阅MySQL binlog,再操作缓存

先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是 Canal 的工作原理:
image.png
所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。

5. 总结

首先明确一点,在一般业务场景中,最推荐的保证数据库和缓存一致性的方案还是先更新数据库,再删除缓存。并且给缓存设置一个过期时间。
当然,上面的方案在每次更新数据的时候,都会删缓存,这势必会影响缓存的命中率。所以如果在对缓存命中率很高的场景下,可以采用更新数据库+更新缓存的方案。当然这个方案会导致在两个线程并发更新时数据不一致的问题,为了避免,可以有以下两种方案:(提高了缓存命中率,但降低了性能)

  1. 在更新缓存前加个分布式锁,保证同一时间只运行一个请求更新缓存,会对性能造成影响;
  2. 在更新缓存时,设置一个较短的过期时间,这样即使缓存不一致,数据也会很快过期,对业务还是能接收的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值