技术的出现都是有特定的背景的,我们摸清了技术的发展脉络,也就能更好的掌握这门技术,也能理解未来的发展趋势。所以我在Java架构杂谈
公众号以及IT宅(itzhai.com)
中写的一些技术文章有可能会顺便梳理一下发展脉络。如:架构演变之路:为何要搞微服务架构?, 三万长文50+趣图带你领悟web编程的内功心法
为了同步缓存和数据库的数据,我们也先来看看传统的缓存策略。常见的有以下几种更新策略:
1、Cache-Aside策略
我们学过操作系统的缓存之后,知道无论是LLC还是page cache,我们都不会显示的去维护它,而是在操作系统内部直接集成了这些缓存。
而Redis的缓存是独立于应用程序的,我们要使用Redis缓存,必须手动在程序中添加缓存操作代码,所以我们把这种使用缓存的方式叫旁路缓存(Cache-Aside)[^20] (把Redis和数据库数据同步逻辑封装好下沉到持久层的此类场景不算是Cache-Aside)。
以下是Cache-Aside的查询数据的图示:
这也是最常用的缓存方法,缓存位于侧面(Aside),应用程序直接与缓存和数据库通信。具体交互:
- 应用程序首先检查缓存
- 如果缓存命中,直接返回数据给客户端
- 如果缓存没有命中,则查询数据库以读取数据,并将其返回给客户端以及存储在缓存中。
一般写数据流程如下图:
- 更新数据库中的缓存
- 让缓存失效
1.1、优缺点
优点:
- Cache-Aside很适合读取繁重的场景,使用Cache-Aside使系统对缓存故障具有弹性,如果缓存集群宕机了,系统仍然可以通过直接访问数据库来运行。不过响应时间可能变得很慢,在最坏的情况下,数据库可能会停止工作;
- 这种方式,缓存的数据模型和数据库中的数据模型可以不同,比如,把数据库中多张表的数据组合加工之后,再放入缓存。
缺点:
- 使用Cache-Aside的时候,写数据之后,很容易导致数据不一致。可以给缓存设置一个TTL,让其自动过期,如果要保证数据的实时性,那么必须手动清除缓存。
1.2、数据不一致问题
并发写导致的脏数据
有些写缓存的代码会按如下逻辑编写:
- 更新数据
- 更新缓存
这种方案,可能会因为并发写导致脏数据,如下图:
线程1设置a为12,线程2设置a为14,由于执行顺序问题。最终,数据库中的值是14,而缓存中的值是12,导致缓存数据和数据库中的数据不一致。
为此,我们一般不写完数据库之后立刻更新缓存。
读写并发导致的脏数据
即使是写完数据之后,我们直接删掉缓存,也是有可能导致缓存中出现脏数据的,如下图:
数据库被线程2更新为了12,但是缓存最终又加载到了老的值10。
不过这个场景概率很低,因为一方面一般读操作比写操作只需快得多,并且另一方面还需要读操作必须在写操作之前就进入数据库查询,才能导致这种场景的出现。
**我们最常用的兜底策略是设置一个缓存过期时间,好让这种极端场景产生的脏数据能够定时被淘汰。**当然,用2PC或者Paxos协议来保证一致性也可以,不过实现起来太复杂了。
2、Read/Write Through策略
前面的Cache-Aside需要应用程序参与整个缓存和数据库的同步过程,而Read/Write Throught策略则不需要应用程序参与,而是让缓存自己来代理缓存和数据的同步,在应用层看来,后端就是一个单一的存储介质,至于存储内部的缓存机制,应用层无需关注。
就像操作系统的缓存,无论是LLC还是page cache,我们都不会显示的去维护它,而是在操作系统内部直接集成了这些缓存。
接下来看看这个策略。
Read-Through
Read-Through,缓存与数据库保持一致,当缓存未命中的时候,从数据库中加载丢失的缓存,填充缓存,并将其返回给应用程序。加载缓存过程对应用透明:
这种策略要保证数据库和缓存中的数据模型必须相同。
Write-Through
Write-Through,当更新数据的时候,如果没有命中缓存,则直接更新数据库,然后返回。如果命中了缓存,则直接更新缓存,缓存内部触发自动更新数据库,都更新完成之后,再返回。
缓存和数据库保持一致,写入总是通过缓存到达数据库。如下图:
Write-Back
Write-Back,更新数据的时候,只更新缓存,不立刻更新数据库,而是异步的批量更新数据到数据库中。
这个策略与Write-Through相比,写入的时候,避免了要同步写数据库,让写入的速度有了很大的提高,但是确定也很明显:缓存和数据库中的数据不是强一致性的,还有可能会导致数据丢失。
Write-Back策略适用于写入繁重的场景,通过与Read-Through配合使用,可以很好的适用于读写都和频繁的场景。
我们可以稍微来总结一下缓存策略的选用:
缓存策略 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
Read-Through | 读效率高 | 缓存和数据库的数据可能不一致 | 适合读取繁重的场景 |
Write-Through | 保证数据一致性,避免缓存失效,保证缓存数据是最新的 | 写效率低 | 写入不是很频繁,对数据一致性要求比较高的场景,如资讯网站,博客 |
Write-Back | 写效率高,保证缓存数据是最新的 | 数据库可能丢数据 | 适合写入繁重但是数据可靠性要求不是很严格的场景,如评论系统,消息通知系统 |
Cache-Aside | 缓存和数据库的数据模型不要求一致,可根据业务灵活组织,应用不强依赖缓存,缓存实时性高 | 编码复杂,缓存与数据库可能不一致 | 数据模型复杂的业务系统 |
Read-Through的缓存和数据库数据不一致解决方案在于写入策略,只要我们配合合理的写入策略,就更好的保证缓存和数据库数据的一致性。
在实际的使用场景中,我们会关注使用的缓存要不要求实时更新。根据实时性,Redis缓存更新可以分为两种策略:实时策略,异步策略。这两种策略都是离不开以上介绍的几种的缓存策略的思想。
实时策略
实时策略,是最常用的缓存策略。
类似Cache-Aside策略的实现就是实时策略,
**读取:**先从缓存读取数据,如果缓存没有命中,则从数据库中读取,读取到了则放到缓存中。如果缓存命中,则直接从缓存中读取数据。
**写入:**写入的时候,先把数据写入到数据库中,然后在让缓存失效,缓存下次读取的时候,从数据库中加载数据。
这种方案会存在数据不一致问题,在Cache-Aside策略小节已经有讲过了。
对于缓存与数据库的数据一致性要求高的场景,建议使用实时策略;如果对缓存与数据库一致性要求不高,则可以使用异步策略。接下来讲讲异步策略。
异步策略
所谓的异步,我们可以做成读写都是异步的:
-
**读取:**当从缓存中读取不到数据的时候,不直接从数据库加载,而是返回一个fallback数据,然后往消息队列中放入一个加载数据的消息,通过异步消费消息去加载数据。可以避免因为缓存失效,导致高并发大流量一起请求到数据库,从而对数据库造成压力。
-
**写入:**总是先写入数据库或者缓存,然后异步更新另一个:
- 先更新数据库:生成消息异步更新缓存,优点:数据持久性可以得到保证,缺点:缓存实时性差
- 先更新缓存:异步刷新缓存到数据库,相当于把缓存当成了数据库在用,优点:完全使用缓存,IO效率高,缺点:可能丢数据
定时策略
针对读写并发量过大的场景,我们可以进一步降级,定时的把缓存中的数据刷到数据库,可以在缓存中对数据进行整合,然后在刷新到数据库中。
优点:IO效率高,比异步策略更高,缺点:可能丢数据。
其实MySQL的Change Buffer就是采用异步策略,同时又使用Redo Log来实现数据的不丢失。进一步阅读:洞悉MySQL底层架构:游走在缓冲与磁盘之间[1]
来总结对比下这几种方案:
策略 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
实时策略 | 实时性高 | 缓存更新困难,容易导致数据不一致 | 金融,交易系统等业务数据实时性要求高,数据可靠性要求高的场景 |
异步策略-先更新数据库 | 数据持久性可以得到保证 | 缓存实时性差 | 产品详情,文章详情等不要求实时展现,但要求不丢数据 |
异步策略-先更新缓存 | 完全使用缓存,IO效率高 | 可能丢数据 | 评论系统,消息通知 |
定时策略 | IO效率高,比异步策略更高 | 比异步策略-先更新缓存更容易丢数据 | 评论系统,消息通知 |
其实,在使用实时策略的时候,我们关注的是如何进一步降低丢数据的风险,有两种处理方式:
- 用2PC或者Paxos协议来保证一致性也可以,不过实现起来太复杂了;
- 通过各种其他五花八门的骚操作,来进一步降低实时策略丢数据的概率。
降低丢数据的概率的常用措施有哪些?
-
实时策略场景:
- 缓存双删,可以在第二次删除之前休眠一小段时间。进一步降低了数据不一致的概率,但是也不能避免数据不一致;
- 增加组件订阅binglog,完成缓存的更新,适合缓存结构和数据库结构一致的场景。如果缓存结构复杂,也不好写成通用的组件;
- 通过分布式事务,把缓存操作和数据库操作通过一个事务完成,但由于Redis并不支持类似MySQL的事务,所以在分布式处理过程中,还是可能导致其他客户端读取到中间数据,导致脏读问题。
-
异步策略场景:
- 优先修改缓存数据,通过队列异步写到请求到数据库中;(异步直写),如果消息队列可靠,则可以保证数据最终写入数据库。