5

数据库与缓存双写一致性

 2 years ago
source link: https://segmentfault.com/a/1190000041576417
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

先做一个说明,从理论上来说,有两种处理思维,一种需保证数据强一致性,这样性能肯定大打折扣;另外我们可以采用最终一致性,保证性能的基础上,允许一定时间内的数据不一致,但最终数据是一致的。

一致性问题是如何产生的?

对于读取过程:

  • 首先,读缓存;
  • 如果缓存里没有值,那就读取数据库的值;
  • 同时把这个值写进缓存中。

双更新模式:操作不合理,导致数据一致性问题

我们来看下常见的一个错误编码方式:

public void putValue(key,value){
    // 保存到redis
    putToRedis(key,value);
    // 保存到MySQL
    putToDB(key,value);//操作失败了
}

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

image.png

你或许会说:我先更新数据库,再更新缓存不就行了?

public void putValue(key,value){
    // 保存到MySQL
    putToDB(key,value);
    // 保存到redis
    putToRedis(key,value);
}

这依然会有问题。

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

image.png

放到实操中,就如上图所示: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);
}

image.png

就和上面的图一样。操作 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)更短,这一情形很难出现。

一般情况下,读取操作都是比写入操作快的,但我们要考虑两种极端情况:

- 一种是这个读取操作 A,发生在更新操作 B 的尾部。(比如写操作执行1s,读操作耗时100ms,读操作在写操作执行到800ms的时候开始执行,在写操作执行到900ms的时候结束,所以实际上读操作仅仅比写操作快了100ms而已)

  • 一种是操作 A 的这个 Redis 的操作时长,耗费了非常多的时间。比如,这个节点正好发生了 STW。(Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾回收器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起)

那么很容易地,读操作 A 的结束时间就超过了操作 B 删除的动作。

这种场景的出现,不仅需要缓存失效且读写并发执行,而且还需要读请求查询数据库的执行早于写请求更新数据库,同时读请求的执行完成晚于写请求。这种不一致场景产生的条件非常严格,一般业务是达不到这个量级的,所以一般公司不去处理这种情况,但高并发业务就非常常见了。

image.png

那如果是读写分离的场景下呢?如果按照如下所述的执行序列,一样会出问题:

  1. 请求A更新主库
  2. 请求A删除缓存
  3. 请求B查询缓存,没有命中,查询从库得到旧值
  4. 从库同步完毕
  5. 请求B将旧值写入缓存

如果数据库主从同步比较慢的话,同样会出现数据不一致的问题。事实上就是如此,毕竟我们操作的是两个系统,在高并发的场景下,我们很难去保证多个请求之间的执行顺序,或者就算做到了,也可能会在性能上付出极大的代价。

可以采用加锁在写请求中保证“更新数据库&删除缓存”的串行执行为原子性操作(同理也可对读请求中缓存的更新加锁)。加锁势必会导致吞吐量的下降,故采取加锁的方案应该对性能的损耗有所预期。

image.png

如何解决高并发的不一致问题?

大家看上面这种不一致情况发生的场景,归根结底还是“删除操作”发生在“更新操作”之前了。

假如我有一种机制,能够确保删除动作一定被执行,那就可以解决问题,起码能缩小数据不一致的时间窗口。

常用的方法就是延时双删,依然是先更新再删除,唯一不同的是:我们把这个删除动作,在不久之后再执行一次,比如 5 秒之后。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);
    // 数秒后重新执行删除操作
    deleteFromRedis(key,5);
}

这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

这种方案还算可以,只有休眠那一会,可能有脏数据,一般业务也会接受的。

其实在讨论最后一个方案时,我们没有考虑操作数据库或者操作缓存可能失败的情况,而这种情况也是客观存在的。

那么在这里我们简单讨论下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不一致的问题。假设删除缓存失败了呢?此时确实会存在数据不一致的情况。除了设置缓存过期时间这种兜底方案之外,如果我们希望尽可能保证缓存可以被及时删除,那么我们必须要考虑对删除操作进行重试。

删除缓存重试机制

你当然可以直接在代码中对删除操作进行重试,但是要知道如果是网络原因导致的失败,立刻进行重试操作很可能也是失败的,因此在每次重试之间你可能需要等待一段时间,比如几百毫秒甚至是秒级等待。为了不影响主流程的正常运行,你可能会将这个事情交给一个异步线程来执行。

而删除动作也有多种选择:

  • 如果开线程去执行,会有随着 JVM 进程的死亡,丢失更新的风险;
  • 如果放在 MQ 中,会增加编码的复杂性。

所以到了这个时候,并没有一个能够行走天下的解决方案。我们得综合评价很多因素去做设计,比如团队的水平、工期、不一致的忍受程度等。

异步优化方式:消息队列

  1. 写请求更新数据库
  2. 缓存因为某些原因,删除失败
  3. 把删除失败的key放到消息队列
  4. 消费消息队列的消息,获取要删除的key
  5. 重试删除缓存操作

image.png

异步优化方式:基于订阅binlog的同步机制

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

image.png

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作。

针对 Redis 的缓存一致性问题,我们聊了很多。可以看到,无论你怎么做,一致性问题总是存在,只是几率慢慢变小了。

随着对不一致问题的忍受程度越来越低、并发量越来越高,我们所采用的方案也越来越极端。一般情况下,到了延时双删这一步,就证明你的并发量已经够大了;再往下走,无不是对高可用、成本、一致性的权衡,进入到了特事特办的场景,甚至要考虑基础设施,关于这些每个公司的策略都是不一样的。

除了 Cache-Aside Pattern,一致性常见的还有 Read-Through、Write-Through、Write-Behind 等模式,它们都有自己的应用场景,你可以再深入了解一下。

数据库与缓存的双写一致性
数据库与缓存的一致性问题
Redis缓存一致性设计


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK