思维导图
本文的思维导图如下:

一、什么是双写一致性?
所谓双写一致性,就是当我们修改了数据库的数据,同时也要更新缓存的数据,作到缓存与数据库的数据保持一致。
二、如何保证双写一致?
问题1:先更新数据库,还是先更新缓存?
先更新数据库,再更新缓存
比如「请求 A」和「请求 B」两个请求,同时更新「同一条」数据,可能出现这样的顺序:

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

A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了,将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。
此时,数据库中的数据是1,而缓存中的数据却是 2,出现了缓存和数据库中的数据不一致的现象。
所以,无论是先更新数据库,还是先更新缓存,都会存在并发问题。
当两个请求并发地更新同一条数据的时候,可能会出现数据不一致的现象。
解决方案:旁路缓存策略
为了解决上面的问题,我们在更新数据时,不更新缓存,而是删除缓存中的数据。
然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
这个策略就是 Cache Aside 策略 ,中文是旁路缓存策略。
该策略又可以细分为「读策略」和「写策略」。

写策略
● 更新数据库中的数据
● 删除缓存中的数据
读策略
● 如果读取的数据命中了缓存,则直接返回数据
● 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
在「写策略」的时候,该选择哪种顺序呢?
● 先删除缓存,再更新数据库;
● 先更新数据库,再删除缓存。
问题2:先更新数据库,还是先删除缓存?
先更新数据库,再删除缓存
| 正常情况 | 2. 异常情况 |
|---|---|
![]() |
![]() |
正常情况:
初始情况:
● 缓存:10
● 数据库:10
线程2:
1.把数据库的 10 更新为 20
2.删除缓存的 10
线程1:
查询缓存的 10,未命中 → 查询数据库,查到数据库的 20
将数据库的 20 写入缓存
最终结果:
● 缓存:20
● 数据库:20
但是,因为线程是可以交替进行的,所以我们再看异常情况:
初始情况:
● 缓存:10
● 数据库:10
线程1:
1.查询缓存的 10,未命中 → 查询数据库,查到数据库的 10
线程2:
把数据库的 10 更新为 20
删除缓存的 10
线程1:将数据库的 10 写入缓存
最终结果:
● 缓存:10
● 数据库:20
先删除缓存,再更新数据库
| 正常情况 | 异常情况 |
|---|---|
![]() |
![]() |
正常情况:
初始情况:
● 缓存:10
● 数据库:10
线程1:
1.删除缓存的 10
2.更新数据库为 20
线程2:
查询缓存的 10,未命中
查到数据库的 20,并将其写入缓存
最终结果:
● 缓存:20
● 数据库:20
但是,因为线程是可以交替进行的,所以我们再看异常情况。
初始情况:
● 缓存:10
● 数据库:10
线程1:
1.删除缓存的 10
线程2:
查询缓存的 10,未命中
查到数据库的 10 → 并将其写入缓存
线程1:
- 在不知道刚刚删除的缓存的 10 被线程 2 恢复的情况下,更新数据库为 20
线程 1 的内心:你个老 6,我刚删的 10,你怎么又去写上了?!
最终结果:
● 缓存:10
● 数据库:20
结论: 无论是先更新数据库,还是先删除缓存,都会存在脏读。
当两个请求并发地更新同一条数据的时候,可能会出现数据不一致的现象。
解决方案:延时双删
读操作

- 客户端发送请求要查询数据
- 在 Redis 里查询数据:
- 命中,就直接返回结果;
- 未命中,再查询数据库 → 数据库查询到结果 → 把数据写入Redis → 把数据返回给客户
写操作:延迟双删

- 客户端发送请求要更新数据
- 先删除缓存 → 修改数据库中的数据 → 过一会儿再删除缓存
在数据更新时,先更新数据库,还是先更新缓存?
为什么要删除两次缓存呢?
为什么要延时删除?
问题3:为什么要删除两次缓存?
因为无论是先删除缓存,还是先修改数据库,都会存在脏读情况。
所以我们要删除两次缓存:
- 第一次删:防止写期间命中脏缓存。
- 第二次删:干掉「读线程在写窗口内回填的旧值」。
问题4:为什么要延时删除?

因为我们的数据库可能是主从架构的,需要等待一会让两个数据库同步。
但这个延迟时间毕竟不好控制,所以这种方案还是有脏数据的风险。
增强手段
读写锁

代码实现
Redisson已经为我们提供了读写锁。
读操作:

写操作:

优点: 强一致
缺点: 低性能
异步通知
在允许稍微延迟的业务情况下,我们会通过异步通知来保证数据的最终一致性。
基于 MQ
我们可以基于 MQ 中间件来实现异步通知:

基于 Canal
我们还可以基于 Canal 中间件来实现异步通知:

Canal 伪装成 MySQL 的从节点,来读取 BINLOG。
二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。
三、相关面试题
如何保证缓存与数据库的数据一致性?
等价问题:Redis 作为缓存,MySQL 的数据如何与 Redis 进行同步?

回答这个问题一定要结合自身业务前提,首先应该要介绍自己的业务背景。
1)业务允许延迟一致性又是另一种方案
【介绍自己简历上的业务】允许延迟的业务:
嗯,就说我最近做的这个项目,++我们当时是把文章的热点数据存入到了缓存中++,虽然是热点数据,但是实时要求性并没有那么高。所以,我们当时采用的是异步的方案同步的数据
【介绍你的方案】介绍 Redisson 读写锁的方案:
允许延时一致的业务,采用异步通知:
- 我们使用 MQ 中间件,更新数据之后,通知缓存删除。
- 我们使用的是阿里的 canal 中间件,不需要修改业务代码,部署一个 canal 服务,伪装成 mysql 的一个从节点,当 mysql 数据更新以后,canal 会通过读取 binlog 数据更新缓存
2)业务一致性要求高是一种方案
【介绍自己简历上的业务】要求强一致的业务:
嗯,就说我最近做的这个项目,++我们当时是把抢券的库存存入到了缓存中++,这个需要数据库与 Redis 保持高度一致。
为了保证数据的强一致性,我们当时采用的是 Redisson 提供的读写锁来保证数据的同步。
【介绍你的方案】介绍 Redisson 读写锁的方案:
- 在读的时候添加共享锁:
- 可以保证读读不互斥,读写互斥。
- 加锁之后,其他线程可以共享读操作
- 在跟新数据的时候,添加排他锁:
- 它是读写、读读都排斥
- 加锁之后,阻塞其他线程读写操作
- 这样就能保证在写数据的同时不会让其他线程读数据,从而避免了仓数据。
追问:排他锁是如何保证读写、读读互斥的呢?
回答:其他排他锁底层使用也是 setnx,保证了同一时刻只能有一个线程操作锁住的方法。
追问:为什么不用延时双删?
回答:延时双删。如果时写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不好缺点,且再延时的过程中也可能会出现脏数据,并不能保证强一致性,所以没有采用它。



