一、背景知识
缓存作为高性能、高可用系统的重要组成部分,在系统架构选型中已经不是一个可选项而是一个必选项。正确使用缓存不仅可以缓解后端服务器CPU和慢速存储设备上的压力,保证海量用户请求下系统稳定性和可用性;同时还可以降低请求响应延时,提升系统性能和用户体验。
进程内缓存和分布式缓存
常用的缓存有进程内和分布式两种。顾名思义,前者和应用程序运行在同一个进程中。进程内缓存只能服务于同一进程的应用程序,缓存内容不能跨进程传播和访问。但是因为没有网络访问操作,所以访问速度更快(相较于分布式缓存)。 正因为进程内缓存访问速度更快的特点,常作为多级缓存中的一级缓存使用。
分布式缓存也可分为复制式和集中式两种,前者因跨节点数据复制的效率和性能问题已很少被使用,因此通常所说的分布式缓存即指集中式缓存,下文均简称分布式缓存。 分布式缓存运行在独立的进程中对外提供缓存服务,应用程序通过网络访问缓存服务。和进程内缓存不同,分布式缓存可以为多个进程的应用程序提供统一的数据缓存服务,但是访问速度没有进程内缓存快。我们常用的Redis和Memcached都属于分布式缓存,而分布式缓存也是我们构建高性能系统的必备组件,更是缓存应用的事实标准。
二、缓存数据一致性问题
通常我们使用分布式缓存的目标是暂存数据以提升读多写少场景数据查询性能,而数据持久化是数据库保证的,也就是说同一份数据既会在缓存中存储又会在数据库中存储。经验告诉我们,同一份数据存储在不同数据源时,不规范的读写操作一定会导致缓存与数据库两者数据不一致的问题。虽然缓存中数据通常并不要求强一致性,但是错误的更新方法连最终一致性都无法保证。例如,用户先从数据库读取某个对象,更新其属性后,将最新值写回数据库和缓存;但如果发生异常(如后续操作执行失败,导致数据库写入回滚而缓存写入成功),就会出现缓存为新值、数据库为旧值的情况,且这种不一致会持续到下次数据写入。发生这种情况会影响业务逻辑的正确性,所以我们要搞清数据不一致问题产生的原因并尽量规避此类问题。
三、常用的缓存更新设计模式
作为一种通用的技术手段,业界已经有相对成熟的分布式缓存更新策略和设计模式,我们无需重复造轮子。根据自己的业务场景选择即可。常用的更新策略有Cache Aside、Read/Write Through和Write Back等。其中Cache Aside作为成本最低、最简单的实现方式,在工程中应用也最为广泛,我们先从它开始介绍。
1. Cache Aside 模式
1.1 读写流程:
- 读数据:首先从缓存中查找数据,如果找到数据就直接返回;否则从数据库查找数据,将找到的数据写入缓存(通常设置过期时间)并返回。
- 写数据:首先更新数据库数据,然后删除缓存并返回。
1.2 并发写-写场景
Cache Aside在写数据的时候,只更新的数据库中数据,并没有更新缓存中数据。因为如果更新缓存中数据,在并发写入的情况下会产生脏写问题,导致缓存中数据不一致。举例说明:
线程(或进程)1
线程(或进程)2
更新数据库中的数据:a=1
更新数据库中的数据:a=2
更新缓存中的数据:a=2
更新缓存中的数据:a=1
(表1)
如果两个线程(或进程)按照表1顺序执行后,数据库中a的值等于2,而缓存中a的值等于1。这种现象就是ACID事务中的脏写问题,即一个事务写入了另一个事务未提交的数据。数据库通过为写入操作加排他锁避免脏写,加锁后,线程2需等待线程1完成数据库和缓存的更新操作后,才能开始执行新一轮更新。但由于我们同时操作数据库和缓存两个数据源,显然无法通过ACID本地事务阻止两个线程交叉执行。所以使用删除缓存操作代替更新缓存操作,即使出现交叉执行,因为缓存删除是幂等操作,也不会产生脏写问题。
线程(或进程)1
线程(或进程)2
更新数据库中数据:a=1
更新数据库中数据:a=2
删除缓存操作
删除缓存操作
(表2)
按照表2顺序执行后,数据库中a的值为2,缓存中无对应数据,后续的读操作会将a=2的值写入缓存。看到这里,你可能已经发现了端倪,如果缓存的读取操作和写入操作穿插执行,是否也有可能产生数据不一致问题呢,我们举例说明。
1.3 并发读-写场景
线程(或进程)1
线程(或进程)2
从缓存中没有读取到数据a的值
从数据库读取数据a=2
更新数据库中数据:a=1
删除缓存中数据a
更新缓存中数据:a=2
(表3)
按照表3流程执行后,数据库中a的值为1,而缓存中a的值为2,确实又产生了数据不一致问题。仔细观察线程1执行步骤,这是一个典型的先读后写流程,如果在读操作和写操作之间有其他线程更改了查询数据的值,就会造成缓存数据不一致现象。除了这种临界条件外,其他情况下均不会产生数据不一致问题,所以说Cache Aside模式产生缓存数据不一致问题的概率较低。
1.4 脏写和覆盖更新
如果你熟悉ACID事务隔离性机制,就会发现表3中的情况就是典型的覆盖更新问题。无论是表1的脏写还是表3的覆盖更新,在RDBMS系统中可以使用事务隔离机制规避。但是在多数据源场景下,无法依靠本地事务隔离性解决问题。如果你对数据一致性有极高的要求,可以通过在读写操作中分别加锁来保证操作串行执行,最大程度规避数据不一致性风险。需要注意的是,加锁行为不仅会增加实现的复杂性和死锁风险,还会大幅降低读写性能,其中利弊需读者根据实际需求权衡。
1.5 原子性更新
读到此处,你可能和我一样,也会有一个疑问。为什么写操作不能先更新缓存,后更新数据库呢?其实在正常流程下,两者的数据更新顺序并不重要,但是一旦发生异常情况就要格外关注了。下面我来详细说明。
场景一:先更新数据库
-
事务开始
-
更新数据库
-
更新(或删除)缓存
- 若更新成功,则提交事务
- 若更新失败,则回滚事务
场景二:先更新缓存
- 事务开始
- 更新(或删除)缓存
- 更新数据库
- 提交事务
这里我对写数据步骤进行了工程化改造,增加了事务开始、事务提交和事务回滚操作。正常情况下,数据库和缓存数据均正常更新,数据保持一致。异常情况下,场景一执行到最后一步更新缓存操作失败后可以通过回滚操作恢复数据库中数据的值,保证缓存和数据库数据一致。而场景二中,若执行到最后一步更新数据库时,事务提交失败导致数据库回滚,已经更新的缓存数据却不受本地事务原子性保护,会出现缓存为新值、数据库为旧值的数据不一致情况。
2. Read/Write Through 模式
2.1 读写流程
- 读数据:首先从缓存中查找数据,如果找到数据就直接返回;否则从数据库查找数据,将找到的数据写入缓存(通常设置过期时间)并返回。
- 写数据:首先更新缓存中数据,然后更新数据库中数据。
它与Cache Aside模式有两点不同:一是Read/Write Through模式在写数据时更新缓存而非删除缓存;二是其先更新缓存,再更新数据库。而读数据步骤与Cache Aside模式完全相同。
2.2 并发写-写场景
正因为该模式采用更新缓存的操作,所以在并发写入场景下会产生脏写问题,读者可以回顾前文1.2小节。更新缓存的方式虽然增加数据不一致可能性,但是后续的查询数据操作避免了一次回库操作,提升了数据查询的性能。因此,若对数据一致性要求不高,而对查询性能要求较高,则可以使用这种模式。
2.3 并发读-写场景
和Cache Aside模式一样,Read/Write Through 模式也会出现覆盖更新问题,其具体流程读者可自行推导,在此不再赘述。
2.4 原子性更新
我在前文1.5小节论述过,如果先更新缓存后更新数据,无法通过本地事务保证更新原子性,因此,若在工程中使用Read/Write Through模式,建议调整数据更新顺序并开启本地事务。
3. Write Back 模式
3.1 读写流程
- 读数据:首先从缓存中查找数据,如果找到数据就直接返回;否则从数据库查找数据,将找到的数据写入缓存(通常设置过期时间)并返回。
- 写数据:更新缓存中数据后立刻返回,异步更新数据库中数据。
它与Read/Write Through模式的核心区别在于:后者是同步更新数据库,而前者是异步更新数据库。异步更新大幅提升了写入性能,但也显著增加了数据不一致的可能性;同时,一旦缓存服务宕机,还会引入数据永久丢失的风险。
3.2 脏写和覆盖更新
由于数据库写入操作为异步操作,大幅增加了脏写和覆盖更新的发生概率,这也是导致数据不一致可能性提高的根本原因。读者可自行梳理临界条件,以验证对该部分内容的掌握程度。
3.3 原子性更新
同样由于数据库更新操作被异步处理,本地事务也无法保障原子性更新。
四、总结
本文我介绍了三种分布式缓存更新模式及其数据一致性问题,以上内容不仅适用于分布式缓存,同样适用于进程内缓存,只不过进程内缓存仅服务于进程内的应用,本身对数据一致性容忍程度更高,所以较少关注更新模式对其影响。我将三种模式下数据一致性问题及适配场景总结为对照表格,供你参考。
脏写
覆盖更新
原子性更新
适配场景
Cache Aside
无
可能性较小
可通过本地事务保证
需要在数据一致性和查询性能之间保持平衡
Read/Write Through
可能性较小
可能性较小
标准流程无法保证,改进流程可通过本地事务保证
数据一致性要求不高,查询性能要求较高
Write Behind / Write Back
可能性较大
可能性较大
无法保证
对写入/读取性能要求最高,对一致性要求最低,且容忍缓存数据丢失
Cache Aside模式作为兼顾性最好、实现最简单的模式,若无特殊要求,建议优先选用。后两种模式可根据实际业务需求选择,且在工程实践中建议直接使用成熟框架。
最后通过本文的介绍,相信你能更深刻地感受到数据一致性和读写性能之间此消彼长的关系。对于分布式系统而言,提升数据一致性要求,往往需要牺牲部分可用性和性能,反之亦然。架构设计中没有绝对的正确方案,只有相对的权衡取舍,我们需时刻牢记CAP原理,结合自身业务场景做出合理的架构选择。
行业拓展
分享一个面向研发人群使用的前后端分离的低代码软件——JNPF。
基于 Java Boot/.Net Core双引擎,它适配国产化,支持主流数据库和操作系统,提供五十几种高频预制组件,内置了常用的后台管理系统使用场景和实用模版,通过简单的拖拉拽操作,开发者能够高效完成软件开发,提高开发效率,减少代码编写工作。
JNPF基于SpringBoot+Vue.js,提供了一个适合所有水平用户的低代码学习平台,无论是有经验的开发者还是编程新手,都可以在这里找到适合自己的学习路径。
此外,JNPF支持全源码交付,完全支持根据公司、项目需求、业务需求进行二次改造开发或内网部署,具备多角色门户、登录认证、组织管理、角色授权、表单设计、流程设计、页面配置、报表设计、门户配置、代码生成工具等开箱即用的在线服务。