数据库与缓存的一致性
常见方案
- 先写缓存,再写数据库
- 先写数据库,再写缓存
- 先删缓存,再写数据库
- 先写数据库,再删缓存
先写缓存,再写数据库
这种方式实际上不可取,比如:
当一个请求先写入了缓存,然后出现了网络异常,导致写入数据库失败。那么这种情况下缓存里缓存的就是无效数据。
先写数据库,再写缓存
这种方式可以解决上一种方式中无效缓存,但是也存在问题,比如:
当某一个缓存已经存在,此时一个请求去更新了该缓存对应的数据库记录,然后尝试写缓存的时候失败了,那么此时缓存与数据库的内容产生了不一致。
也有办法解决,但是仅限于并发度不高的情况,我们可以将这两个操作放在一个事务中,写缓存失败了就回滚事务即可。
问题:
当并发度高了后,很容易发生这类情况:
当请求 A 和请求 B 尝试修改同一数据库记录时(并发写)
- 请求 A 尝试写数据库
- 请求 A 写入数据库完成,尝试写入缓存,此时发生了某种问题(比如网络问题)导致写入缓存操作迟迟没有完成
- 请求 B 顺利的写数据库并写入了缓存
- 请求 A 写入缓存的操作姗姗来迟,成功完成写入缓存操作
这样的场景下,请求 B 的写入操作是新数据,但是最终缓存中的数据是请求 A 写入的数据,也就是旧数据。新数据被旧数据覆盖了。
先删缓存,再写数据库
对于之前两种需要写入缓存的方式,用删缓存来代替写缓存有一个明显的优势,那就是不需要每次都同时更新数据库和缓存,更加节省系统资源。特别是写多读少的场景,每次都需要写入缓存的开销是非常大的。
本方案也会在并发下产生一个问题:
当请求 A 要写入数据库,而请求 B 要读取数据库时(并发读写)
- 请求 A 删缓存,尝试写入数据库时发生了网络问题,导致写入数据库操作迟迟没有完成
- 请求 B 尝试读取数据
- 先读缓存,发现没有缓存
- 读数据库,注意此时请求 A 的写入数据库操作还没有完成,如果请求 A 最终能顺利完成,那么此时读取到的马上就会是旧数据
- 请求 B 读取数据成功,并将数据写入缓存
- 请求 A 在请求 B 完成操作后成功将数据写入数据库
同样会产生缓存与数据库中的数据不一致的问题。但是可以解决:
延迟双删
删一次不够,那我们就删两次,变成 先删缓存,再写数据库,延迟一定时间后再删缓存。
先写数据库,再删缓存
那么这种方案看起来总算近乎“完美”了吧,就算有并发读写,写请求完成前,读请求读取了旧值并将缓存设置成了旧值,但是最终写请求完成后会删除旧的缓存。
这中间短暂的数据库与缓存的不一致通常不被认为是错误,是可容忍的业务延迟。
但是所有的删缓存操作其实都还有个情况,那就是删缓存这个操作是有可能失败的,所以通常需要*重试机制,删缓存失败了我们就重试。
其实本方案有一个极端场景可能会导致数据库与缓存不一致:
并发读写时,读请求先到来,并且原来的缓存正好失效:
- 读请求到来时,发现没有缓存,尝试读数据库,但是读数据库时因为某种意外导致读数据库非常慢,一直没有完成
- 写请求成功在读请求读数据库完成之前写入了数据库,并且删除了缓存(此时本身就没有缓存)
- 读请求成功读取完数据库,并写入缓存
在 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
事件找到对应的数据库表,最后会拼装成这样 数据库:表名:主键值
这是场景的缓存键的形式,当得到需要删除的缓存键时,接下来要做的就是删除缓存了。大致流程如下:
- 实时解析 binlog,并拼装出缓存键
- 尝试删除缓存
- 删除成功则万事大吉
- 因各种原因删除失败则将对应的缓存键放入"死亡名单"
- 后台会有一个定时任务,定时检查"死亡名单"
- 若"死亡名单"不为空,尝试从中取出缓存键,再次尝试"杀死"
- 删除成功则将对应缓存键从"死亡名单"中移除
- 删除失败将对应缓存键的计数器加一
- 当定时任务尝试指定次数后都没能将缓存杀死,那么会将对应键从"死亡名单"移除,并标记为"不可摧毁"后放入一个通道中
- 当"不可摧毁"通道中有内容时,将其中的键取出后通知系统管理员手动处理对应的键
这样的好处就是在代码中不再需要处理删除键的操作,并且也有充分的机制来保证对删除缓存操作的重试。当然,当始终无法"杀死"一个缓存时,通过一定方式(邮件,钉钉,企业微信,飞书等)通知系统管理员来手动处理事件是必要的。