数据库与缓存双写一致性

问题 你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?
分析 先做一个说明,从理论上来说,有两种处理思维,一种需保证数据强一致性,这样性能肯定大打折扣;另外我们可以采用最终一致性,保证性能的基础上,允许一定时间内的数据不一致,但最终数据是一致的。
一致性问题是如何产生的? 对于读取过程:

  • 首先,读缓存;
  • 如果缓存里没有值,那就读取数据库的值;
  • 同时把这个值写进缓存中。
双更新模式:操作不合理,导致数据一致性问题
我们来看下常见的一个错误编码方式:
public void putValue(key,value){ // 保存到redis putToRedis(key,value); // 保存到MySQL putToDB(key,value); //操作失败了 }

比如我要更新一个值,首先刷了缓存,然后把数据库也更新了。但过程中,更新数据库可能会失败,发生了回滚。所以,最后“缓存里的数据”和“数据库的数据”就不一样了,也就是出现了数据一致性问题。
数据库与缓存双写一致性
文章图片

你或许会说:我先更新数据库,再更新缓存不就行了?
public void putValue(key,value){ // 保存到MySQL putToDB(key,value); // 保存到redis putToRedis(key,value); }

这依然会有问题。
考虑到下面的场景:操作 A 更新 a 的值为 1,操作 B 更新 a 的值为 2。由于数据库和 Redis 的操作,并不是原子的,它们的执行时长也不是可控制的。当两个请求的时序发生了错乱,就会发生缓存不一致的情况。
数据库与缓存双写一致性
文章图片

放到实操中,就如上图所示:A 操作在更新数据库成功后,再更新 Redis;但在更新 Redis 之前,另外一个更新操作 B 执行完毕。那么操作 A 的这个 Redis 更新动作,就和数据库里面的值不一样了。
那么怎么办呢?其实,我们把“缓存更新”改成“删除”就好了。
不再更新缓存,直接删除,为什么?
  • 业务角度考虑
原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
  • 性价比角度考虑
更新缓存的代价有时候是很高的。如果频繁更新缓存,需要考虑这个缓存到底会不会被频繁访问?
举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。
“后删缓存”能解决多数不一致
因为每次读取时,如果判断 Redis 里没有值,就会重新读取数据库,这个逻辑是没问题的。
唯一的问题是:我们是先删除缓存?还是后删除缓存?
答案是后删除缓存。
1.如果先删缓存 我们来看一下先删除缓存会有什么问题:
public void putValue(key,value){ // 删除redis数据 deleteFromRedis(key); // 保存到数据库 putToDB(key,value); }

数据库与缓存双写一致性
文章图片

就和上面的图一样。操作 B 删除了某个 key 的值,这时候有另外一个请求 A 到来,那么它就会击穿到数据库,读取到旧的值, 然后写入redis,无论操作 B 更新数据库的操作持续多长时间,都会产生不一致的情况。
2.如果后删缓存 而把删除的动作放在后面,就能够保证每次读到的值都是最新的。
public void putValue(key,value){ // 保存到数据库 putToDB(key,value); // 删除redis数据 deleteFromRedis(key); }

这就是我们通常说的Cache-Aside Pattern,也是我们平常使用最多的模式。我们看一下它的具体方式。
先看一下数据的读取过程,规则是“先读 cache,再读 db”,详细步骤如下:
  • 每次读取数据,都从 cache 里读;
  • 如果读到了,则直接返回,称作 cache hit;
  • 如果读不到 cache 的数据,则从 db 里面捞一份,称作 cache miss;
  • 将读取到的数据塞入到缓存中,下次读取时,就可以直接命中。
再来看一下写请求,规则是“先更新 db,再删除缓存”,详细步骤如下:
  • 将变更写入到数据库中;
  • 删除缓存里对应的数据。
大厂高并发,“后删缓存”依旧不一致
这种情况不存在并发问题么?不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
  1. 缓存刚好失效
  2. 请求A查询数据库,得一个旧值
  3. 请求B将新值写入数据库
  4. 请求B删除缓存
  5. 请求A将查到的旧值写入缓存
如果发生上述情况,确实是会发生脏数据。
然而,发生这种情况的概率又有多少呢?
发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的,因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。
这种场景的出现,不仅需要缓存失效且读写并发执行,而且还需要读请求查询数据库的执行早于写请求更新数据库,同时读请求的执行完成晚于写请求。这种不一致场景产生的条件非常严格,一般业务是达不到这个量级的,所以一般公司不去处理这种情况,但高并发业务就非常常见了。
数据库与缓存双写一致性
文章图片

那如果是读写分离的场景下呢?如果按照如下所述的执行序列,一样会出问题:
  1. 请求A更新主库
  2. 请求A删除缓存
  3. 请求B查询缓存,没有命中,查询从库得到旧值
  4. 从库同步完毕
  5. 请求B将旧值写入缓存
如果数据库主从同步比较慢的话,同样会出现数据不一致的问题。事实上就是如此,毕竟我们操作的是两个系统,在高并发的场景下,我们很难去保证多个请求之间的执行顺序,或者就算做到了,也可能会在性能上付出极大的代价。
加锁?
可以采用加锁在写请求中保证“更新数据库&删除缓存”的串行执行为原子性操作(同理也可对读请求中缓存的更新加锁)。加锁势必会导致吞吐量的下降,故采取加锁的方案应该对性能的损耗有所预期。
数据库与缓存双写一致性
文章图片

如何解决高并发的不一致问题? 大家看上面这种不一致情况发生的场景,归根结底还是“删除操作”发生在“更新操作”之前了。
延时双删
假如我有一种机制,能够确保删除动作一定被执行,那就可以解决问题,起码能缩小数据不一致的时间窗口。
常用的方法就是延时双删,依然是先更新再删除,唯一不同的是:我们把这个删除动作,在不久之后再执行一次,比如 5 秒之后。
public void putValue(key,value){ putToDB(key,value); deleteFromRedis(key); // 数秒后重新执行删除操作 deleteFromRedis(key,5); }

这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
这种方案还算可以,只有休眠那一会,可能有脏数据,一般业务也会接受的。
其实在讨论最后一个方案时,我们没有考虑操作数据库或者操作缓存可能失败的情况,而这种情况也是客观存在的。
那么在这里我们简单讨论下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不一致的问题。假设删除缓存失败了呢?此时确实会存在数据不一致的情况。除了设置缓存过期时间这种兜底方案之外,如果我们希望尽可能保证缓存可以被及时删除,那么我们必须要考虑对删除操作进行重试。
删除缓存重试机制
你当然可以直接在代码中对删除操作进行重试,但是要知道如果是网络原因导致的失败,立刻进行重试操作很可能也是失败的,因此在每次重试之间你可能需要等待一段时间,比如几百毫秒甚至是秒级等待。为了不影响主流程的正常运行,你可能会将这个事情交给一个异步线程来执行。
而删除动作也有多种选择:
  • 如果开线程去执行,会有随着 JVM 进程的死亡,丢失更新的风险;
  • 如果放在 MQ 中,会增加编码的复杂性。
所以到了这个时候,并没有一个能够行走天下的解决方案。我们得综合评价很多因素去做设计,比如团队的水平、工期、不一致的忍受程度等。
异步优化方式:消息队列
  1. 写请求更新数据库
  2. 缓存因为某些原因,删除失败
  3. 把删除失败的key放到消息队列
  4. 消费消息队列的消息,获取要删除的key
  5. 重试删除缓存操作
数据库与缓存双写一致性
文章图片

异步优化方式:基于订阅binlog的同步机制
那如果是读写分离场景呢?我们知道数据库(以Mysql为例)主从之间的数据同步是通过binlog同步来实现的,因此这里可以考虑订阅binlog(可以使用canal之类的中间件实现),提取出要删除的缓存项,然后作为消息写入消息队列,然后再由消费端进行慢慢的消费和重试。
数据库与缓存双写一致性
文章图片

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作。
小结 针对 Redis 的缓存一致性问题,我们聊了很多。可以看到,无论你怎么做,一致性问题总是存在,只是几率慢慢变小了。
随着对不一致问题的忍受程度越来越低、并发量越来越高,我们所采用的方案也越来越极端。一般情况下,到了延时双删这一步,就证明你的并发量已经够大了;再往下走,无不是对高可用、成本、一致性的权衡,进入到了特事特办的场景,甚至要考虑基础设施,关于这些每个公司的策略都是不一样的。
除了 Cache-Aside Pattern,一致性常见的还有 Read-Through、Write-Through、Write-Behind 等模式,它们都有自己的应用场景,你可以再深入了解一下。
参考 【数据库与缓存双写一致性】数据库与缓存的双写一致性
数据库与缓存的一致性问题
Redis缓存一致性设计

    推荐阅读