数据库与缓存的一致性

常见方案

  1. 先写缓存,再写数据库
  2. 先写数据库,再写缓存
  3. 先删缓存,再写数据库
  4. 先写数据库,再删缓存

先写缓存,再写数据库

这种方式实际上不可取,比如:

当一个请求先写入了缓存,然后出现了网络异常,导致写入数据库失败。那么这种情况下缓存里缓存的就是无效数据。

先写数据库,再写缓存

这种方式可以解决上一种方式中无效缓存,但是也存在问题,比如:

当某一个缓存已经存在,此时一个请求去更新了该缓存对应的数据库记录,然后尝试写缓存的时候失败了,那么此时缓存与数据库的内容产生了不一致。

也有办法解决,但是仅限于并发度不高的情况,我们可以将这两个操作放在一个事务中,写缓存失败了就回滚事务即可。

问题

当并发度高了后,很容易发生这类情况:

当请求 A 和请求 B 尝试修改同一数据库记录时(并发写)

  1. 请求 A 尝试写数据库
  2. 请求 A 写入数据库完成,尝试写入缓存,此时发生了某种问题(比如网络问题)导致写入缓存操作迟迟没有完成
  3. 请求 B 顺利的写数据库并写入了缓存
  4. 请求 A 写入缓存的操作姗姗来迟,成功完成写入缓存操作

这样的场景下,请求 B 的写入操作是新数据,但是最终缓存中的数据是请求 A 写入的数据,也就是旧数据。新数据被旧数据覆盖了。

先删缓存,再写数据库

对于之前两种需要写入缓存的方式,用删缓存来代替写缓存有一个明显的优势,那就是不需要每次都同时更新数据库和缓存,更加节省系统资源。特别是写多读少的场景,每次都需要写入缓存的开销是非常大的。

本方案也会在并发下产生一个问题:

当请求 A 要写入数据库,而请求 B 要读取数据库时(并发读写)

  1. 请求 A 删缓存,尝试写入数据库时发生了网络问题,导致写入数据库操作迟迟没有完成
  2. 请求 B 尝试读取数据
    1. 先读缓存,发现没有缓存
    2. 读数据库,注意此时请求 A 的写入数据库操作还没有完成,如果请求 A 最终能顺利完成,那么此时读取到的马上就会是旧数据
  3. 请求 B 读取数据成功,并将数据写入缓存
  4. 请求 A 在请求 B 完成操作后成功将数据写入数据库

同样会产生缓存与数据库中的数据不一致的问题。但是可以解决:

延迟双删

删一次不够,那我们就删两次,变成 先删缓存,再写数据库,延迟一定时间后再删缓存

先写数据库,再删缓存

那么这种方案看起来总算近乎“完美”了吧,就算有并发读写,写请求完成前,读请求读取了旧值并将缓存设置成了旧值,但是最终写请求完成后会删除旧的缓存。

这中间短暂的数据库与缓存的不一致通常不被认为是错误,是可容忍的业务延迟。

但是所有的删缓存操作其实都还有个情况,那就是删缓存这个操作是有可能失败的,所以通常需要*重试机制,删缓存失败了我们就重试。

其实本方案有一个极端场景可能会导致数据库与缓存不一致:

并发读写时,读请求先到来,并且原来的缓存正好失效

  1. 读请求到来时,发现没有缓存,尝试读数据库,但是读数据库时因为某种意外导致读数据库非常慢,一直没有完成
  2. 写请求成功在读请求读数据库完成之前写入了数据库,并且删除了缓存(此时本身就没有缓存)
  3. 读请求成功读取完数据库,并写入缓存

在 mysql mvcc 机制下,写请求的成功写入不影响读请求发生时,读请求看到的数据,也就是说读请求最终读出来的是旧数据,那么写入缓存的自然也是旧数据。

但是这种场景是非常极端的,因为我们都知道,同样情况下,读肯定是比写要快的,况且还多了个删缓存的操作,这种场景的发生情况得是读比写还慢,并且比写 + 访问缓存数据库并删除缓存还慢,实属极端。

尝试将数据库与缓存的一致性保障从业务逻辑中抽离出来

“先写数据库,再删缓存” 已经很够用了,因为上面说的那种极端情况非常罕见。

这里想说的是能否有这样一种方式来解决数据库与缓存的一致性:

以 Mysql 为例,Mysql 的 binlog 通常被用来在多个数据库实例之间进行数据同步,那能否利用 binlog 来达到我们的目的?

为此我做了一个 POC (概念验证) cache-killer。下面来讲一下我的实现。

我通过实时监听 mysql 最新的 binlog 日志,并从中解析新的事件,当事件类型为 UPDATE_ROW 或是 DELETE_ROW 时获取 schema, table_ID, Rows[0][0]Rows[0][0]是受影响的记录的第一个列的值,通常来说就是主键了, 而 table_ID会通过寻找 TABLE_MAP 事件找到对应的数据库表,最后会拼装成这样 数据库:表名:主键值这是场景的缓存键的形式,当得到需要删除的缓存键时,接下来要做的就是删除缓存了。大致流程如下:

  1. 实时解析 binlog,并拼装出缓存键
  2. 尝试删除缓存
    1. 删除成功则万事大吉
    2. 因各种原因删除失败则将对应的缓存键放入"死亡名单"
  3. 后台会有一个定时任务,定时检查"死亡名单"
  4. 若"死亡名单"不为空,尝试从中取出缓存键,再次尝试"杀死"
    1. 删除成功则将对应缓存键从"死亡名单"中移除
    2. 删除失败将对应缓存键的计数器加一
  5. 当定时任务尝试指定次数后都没能将缓存杀死,那么会将对应键从"死亡名单"移除,并标记为"不可摧毁"后放入一个通道中
  6. 当"不可摧毁"通道中有内容时,将其中的键取出后通知系统管理员手动处理对应的键

这样的好处就是在代码中不再需要处理删除键的操作,并且也有充分的机制来保证对删除缓存操作的重试。当然,当始终无法"杀死"一个缓存时,通过一定方式(邮件,钉钉,企业微信,飞书等)通知系统管理员来手动处理事件是必要的。