细说的话可以扯很多,但是我觉得其实没太大必要(小声 BB:很多解决方案我也没太弄明白)。我个人觉得引入缓存之后,如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。
下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。
Cache Aside Pattern 中遇到写请求是这样的:更新数据库,然后直接删除缓存 。
如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:
- 缓存失效时间变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
- 增加缓存更新重试机制(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。虽然说多引入了一个消息队列,但其整体带来的收益还是要更高一些。
相关文章推荐:缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹。
Redis MySQL 更新一致性
一致性问题还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。前提是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。
首先必须要保持下面观点一致:
- 缓存必须要有过期时间
- 保证数据库跟缓存的最终一致性即可,不必追求强一致性
为什么必须要有过期时间?首先对于缓存来说,当它的命中率越高的时候,我们的系统性能也就越好。如果某个缓存项没有过期时间,而它命中的概率又很低,这就是在浪费缓存的空间。而如果有了过期时间,且在某个缓存项经常被命中的情况下,我们可以在每次命中的时候都刷新一下它的过期时间,这样也就保证了热点数据会一直在缓存中存在,从而保证了缓存的命中率,提高了系统的性能。
设置过期时间还有一个好处,就是当数据库跟缓存出现数据不一致的情况时,这个可以作为一个最后的兜底手段。也就是说,当数据确实出现不一致的情况时,过期时间可以保证只有在出现不一致的时间点到缓存过期这段时间之内,数据库跟缓存的数据是不一致的,因此也保证了数据的最终一致性。
那么为什么不应该追求数据强一致性呢?这个主要是个权衡的问题。数据库跟缓存,以 Mysql 跟 Redis 举例,毕竟是两套系统,如果要保证强一致性,势必要引入 2PC 或 Paxos 等分布式一致性协议,或者是分布式锁等等,这个在实现上是有难度的,而且一定会对性能有影响。而且如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?直接读写数据库不是更简单吗?那究竟如何做到数据库跟缓存的数据强一致性呢?这是个比较复杂的问题,本文会在最后稍作展开。
数据库和缓存的读写顺序
总共大概有四种可能的选项(你不可能把数据库删了吧…):
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
先更新缓存,再更新数据库
我们都知道不管是操作数据库还是操作缓存,都有失败的可能。如果我们先更新缓存,再更新数据库,假设更新数据库失败了,那数据库中就存的是老数据。当然你可以选择重试更新数据库,那么再极端点,负责更新数据库的机器也宕机了,那么数据库中的数据将一直得不到更新,并且当缓存失效之后,其他机器再从数据库中读到的数据是老数据,然后再放到缓存中,这就导致先前的更新操作被丢失了,因此这么做的隐患是很大的。
从数据持久化的角度来说,数据库当然要比缓存做的好,我们也应当以数据库中的数据为主,所以需要更新数据的时候我们应当首先更新数据库,而不是缓存。
先更新数据库,再更新缓存
原因一(线程安全角度)
假设线程 A(或者机器 A,道理是一样的)和线程 B 需要更新同一个数据,A 先于 B 但时间间隔很短,那么就有可能会出现:
- 线程 A 更新了数据库
- 线程 B 更新了数据库
- 线程 B 更新了缓存
- 线程 A 更新了缓存
按理说线程 B 应该最后更新缓存,但是可能因为网络等原因,导致线程 B 先于线程 A 对缓存进行了更新,这就导致缓存中的数据不是最新的。
原因二(业务场景角度)
有如下两点:
(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
先删除缓存,再更新数据库
这个方案的问题也是很明显的,假设现在有两个请求,一个是写请求 A,一个是读请求 B,那么可能出现如下的执行序列:
- 请求 A 删除缓存
- 请求 B 读取缓存,发现不存在
- 请求 B 从数据库中读取到旧值
- 请求 A 将新值写入数据库
- 请求 B 将旧值写入缓存
这样就会导致缓存中存的还是旧值,在缓存过期之前都无法读到新值,即脏读。
同样,MySQL 数据库使用读写分离架构。请求 B 去从库取数据,但是还没有完成主从同步,查到旧值。
先更新数据库,再删除缓存
一个请求 A 做查询操作,一个请求 B 做更新操作,那么会有如下情形产生
(1)缓存刚好失效
(2)请求 A 查询数据库,得一个旧值
(3)请求 B 将新值写入数据库
(4)请求 B 删除缓存
(5)请求 A 将查到的旧值写入缓存
但是概率不高。如果出现这种情况,那么步骤 (3) 要快于步骤 (2) 的执行时间,才可以 (4) 在 (5) 之前发生。然而,数据库的写操作一般比读操作要慢。(读写分离框架就是因为数据库读操作快于写操作,耗资源少)
尽可能防止脏读数据
延时双删策略。先删除缓存,再更新数据库,休眠 1 秒,再次删除缓存。
如此,休眠 1 秒就可以将请求 A 在更新数据期间可能出现的脏数据删除。具体 1 秒由开发人员自行估计,读数据业务逻辑的耗时基础上,加几百 ms 即可。
MySQL 采用读写分离框架时,睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。
为了防止吞吐量降低,则将第二次删除作为异步的。自己起一个线程,异步删除。
缓存删除失败解决
方案一:

(1) 缓存删除失败后,将其添加到消息队列中
(2) 自己消费消息,获得需要删除的 key
(3) 继续重试删除操作,直到成功
缺点
对业务线代码造成大量的侵入。
方案二

流程如下图所示:
(1)更新数据库数据
(2)数据库会将操作信息写入 binlog 日志当中
(3)订阅程序提取出所需要的数据以及 key
(4)另起一段非业务代码,获得该信息
(5)尝试删除缓存操作,发现删除失败
(6)将这些信息发送至消息队列
(7)重新从消息队列中获得该数据,重试操作。
备注说明:
上述的订阅 binlog 程序在 mysql 中有现成的中间件叫 canal,可以完成订阅 binlog 日志的功能。至于 oracle 中,博主目前不知道有没有现成中间件可以使用。另外,重试机制,博主是采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。
强一致性
强一致性是指,完全一致,没有任何错误。
上一致性协议当然是可以的,虽然成本也是非常客观的。2PC 甚至是 3PC 本身是存在一定程度的缺陷的,所以如果要采用这个方案,那么在架构设计中要引入很多的容错,回退和兜底措施。那如果是上 Paxos 和 Raft 呢?那么你首先至少要看过这两者的相关论文,并且调研清楚目前市面上有哪些开源方案,并做好充分的验证,并且能够做到出了问题自己有能力修复… 对了,我还没提到性能问题呢。
我们先回到”先更新数据库,再删除缓存”这个方案本身上来,从字面上来看,这里有两步操作,因此在数据库更新之前,到缓存被删除这段时间之内,读请求读取到的都是脏数据。如果要实现这两者的强一致性,只能是在更新完数据库之前,所有的读请求都必须要被阻塞直到缓存最终被删除为止。如果是读写分离的场景,则要在更新完主库之前就开始阻塞读请求,直到主从同步完毕,且缓存被删除之后才能释放。
这个思路其实就是一种串行化的思路,写请求一定要在读请求之前完成,才能保证最新的数据对所有读请求来说是可见的。说到这里是不是让你想起了什么?比如 volatile,内存屏障,ReadWriteLock,或者是数据库的共享锁,排他锁…当前场景可能不同,但是要面对的问题都是相似的。
现在回到问题本身,我们要怎么实现这种阻塞呢?可能有同学已经发现了,我们需要的其实是一种 分布式读写锁。对于写请求来说,在更新数据库之前,必须要先申请写锁,而其他线程或机器在读取数据之前,必须要先申请读锁。读锁是共享的,写锁是排他的,即如果读锁存在,可以继续申请读锁但无法申请写锁,如果写锁存在,则无论是读锁还是写锁都无法申请。只有实现了这种分布式读写锁,才能保证写请求在完成数据库和缓存的操作之前,读请求不会读取到脏数据。
注意,这里用到的分布式读写锁并没有解决缓存击穿的问题,因为从读请求的视角来看,如果发生了更新数据库的情况,读请求要么被阻塞,要么就是缓存为空,需要从数据库读取数据再写入缓存。为了防止因缓存失效或被删除导致大量请求直接打到数据库上导致数据库崩溃,你只能考虑加锁甚至是加分布式锁,具体参见缓存击穿这一章节。
那么说到分布式读写锁,其实现一样有一定的难度。如果确定要使用,我建议使用 Curator 提供的 InterProcessReadWriteLock,或者是 Redisson 提供的 RReadWriteLock。对分布式读写锁的讨论超出了本文的范围,这里就不做过多展开了。