大量的实际的项目中,都会引入 Redis 缓存来缓解数据库的查询压力,此时由于一个数据在 Redis 和数据库两处进行了存储,就会有数据一致性的问题。目前业界尚未见到成熟的能够确保最终一致性的方案,特别是当如下场景发生时,会直接导致缓存数据与数据库数据不一致,可能给应用带来较大问题。
dtm-labs 致力于解决数据一致性问题,在分析了行业的现有做法后,提出了新解决方案dtm-labs/dtm+dtm-labs/rockscache,彻底解决了上述问题。另外作为一个成熟方案,该方案还可以防缓存穿透,防缓存击穿,防缓存雪崩,同时也可应用于要求数据强一致的场景。
关于管理缓存的现有方案,本文不再赘述,不太了解的同学可以参考下面这两篇文章
在上述这个时序图中,由于服务 1 发生了进程暂停(例如由于 GC 导致),因此当它往缓存当中写入 v1 时,覆盖了缓存中的 v2 ,导致了最终的不一致( DB 中为 v2 ,缓存中为 v1 )。
对于上述这类问题应当如何解决?目前现存的方案,全都没有彻底解决该问题,一般都是通过设定稍短的过期时间兜底。我们实现的缓存延迟删除方案,能够彻底解决这个问题,确保缓存与数据库之间的数据保持一致。解决原理如下:
缓存中的数据是一个 hash ,里面有以下几个字段:
查询缓存时:
其中"取数据"的操作定义为:
当 DB 数据更新时,通过 dtm 确保数据更新成功时,将缓存延迟删除(将在后面一节展开详细讲解)
在上述的策略下: 假如最后写入数据库的版本为 Vi ,最后写入到缓存的版本为 V ,写入 V 的 uuid 为 uuidv ,那么一定存在以下事件序列:
数据库写入 Vi -> 缓存数据被标记为删除 -> 某个查询锁定数据并写入 uuidv -> 查询数据库结果 V -> 缓存中的锁定者为 uuidv ,写入结果 V
在这个序列中,V 的读取发生在写入 Vi 之后,所以 V 等于 Vi ,保证了缓存的数据的最终一致性。
dtm-labs/rockscache已经实现了上述方法,能够确保缓存数据的最终一致性。
Fetch
函数实现了前面的查询缓存DelayDelete
函数实现了延迟删除逻辑感兴趣的同学,可以参考dtm-cases/cache,里面有详细的例子
对于缓存的管理,一般业界会采用写完数据库后,删除 /更新缓存数据的策略。由于保存到缓存和保存到数据库两个操作之间不是原子的,一定会有时间差,因此这两个数据之间会有一个不一致的时间窗口,通常这个窗口不大,影响较小。但是两个中间可能发生宕机,也可能发生各种网络错误,因此就有可能发生完成了其中一个,但是未完成另一个,导致数据会出现长时间不一致。
举一个场景来说明上述不一致的情况,数据用户将数据 A 修改为 B ,应用修改完数据库之后,再去删除 /更新缓存,如果未发生异常,那么数据库和缓存的数据是一致的,没有问题。但是分布式系统中,可能会发生进程 crash 、宕机等事件,因此如果更新完数据库,尚未删除 /更新缓存时,出现进程 crash ,那么数据库和缓存的数据就可能出现长时间的不一致。
面对这里的长时间不一致的情况,想要彻底解决,并不是一件容易的事,我们下面分各种应用情况来介绍解决方案。
这个方案,是最简单的方案,适合并发量不大应用。如果应用的并发不高,那么整个缓存系统,只需要设置了一个较短的缓存时间,例如一分钟。这种情况下数据库需要承担的负载是:大约每一分钟,需要将访问到的缓存数据全部生成一遍,在并发量不大的情况下,这种策略是可行的。
上述这种策略非常简单,易于理解和实现,缓存系统提供的语义是,大多数情况下,缓存和数据库之间不一致的时间窗口是很短的,在较低概率发生进程 crash 的情况下,不一致的时间窗口会达到一分钟。
应用在上述约束下,需要将一致性要求不高的数据读取,从缓存读取;而将一致性要求较高的读,不走缓存,直接从数据库查询。
假如应用的并发量很高,缓存过期时间需要比一分钟更长,而且应用中的大量请求不能够容忍较长时间的不一致,那么这个时候,可以通过使用消息队列的方式,来更新缓存。具体的做法是:
这种做法可以保证数据库更新之后,缓存一定会被更新。但这种这种架构方案很重,这几个部分开发维护成本都不低:消息队列的维护;高效轮询任务的开发与维护。
这个方案适用场景与方案二非常类似,原理又与数据库的主从同步类似,数据库的主从同步是通过订阅 binlog ,将主库的更新应用到从库上,而这个方案则是通过订阅 binlog ,将数据库的更新应用到缓存上。具体做法是:
这种方案也可以保证数据库更新之后,缓存一定会被更新,但是这种架构方案跟前面的消息队列方案一样,也非常重。一方面 canal 的学习维护成本不低,另一方面,开发者可能只需要少量数据更新缓存,通过订阅所有的 binlog 来做这个事情,浪费了很多资源。
dtm 里的二阶段消息模式,非常适合这里的修改数据库之后更新 /删除缓存,主要代码如下:
msg := dtmcli.NewMsg(DtmServer, gid).
Add(busi.Busi+"/UpdateRedis", &Req{Key: key1})
err := msg.DoAndSubmitDB(busi.Busi+"/QueryPrepared", db, func(tx *sql.Tx) error {
// update db data with key1
})
这段代码,DoAndSubmitDB 会进行本地数据库操作,进行数据库的数据修改,修改完成后,会提交一个二阶段消息事务,消息事务将会异步调用 UpdateRedis 。假如本地事务执行之后,就立刻发生了进程 crash 事件,那么 dtm 会进行回查调用 QueryPrepared ,保证本地事务提交成功的情况下,UpdateRedis 会被最少成功执行一次。
回查的逻辑非常简单,只需要 copy 类似下面这样的代码即可:
app.GET(BusiAPI+"/QueryPrepared", dtmutil.WrapHandler(func(c *gin.Context) interface{} {
return MustBarrierFromGin(c).QueryPrepared(dbGet())
}))
这种方案的优点:
上述的方案中,假定缓存删除后,服务进行数据查询,总是能够查到最新的数据。但是实际的生产环境中,可能会出现主从分离的架构,而主从延时并不是一个可控的变量,那么这时候又要怎么处理?
处理方案两种:一是区分最终一致性很高和不高的缓存数据,查询数据时,将要求很高的数据必须从主库读取,而把要求不高的数据从从库读取。对于使用了 rockscache 的应用来说,高并发的请求都会在 Redis 这一层被拦截,对于一个数据,最多只会有一个请求到达数据库,因此数据库的负载已大幅降低,采用主库读取是一个实际可行的方案。
另一种方案是,主从分离需要采用不分叉的单链架构,那么链条末尾的从库必定是延迟最长的从库,此时采用监听 binlog 的方案,需要监听链条做末端的从库 binlog ,当收到数据变更通知时,按照上述方案将缓存标记为延迟删除。
这两个方案各有优缺点,业务可以根据自己的特点采用。
rockscache 还可以防缓存击穿。当数据变更时,业界现有做法既可以选择更新缓存,也可以选择删除缓存,各有优劣。而延迟删除综合了两种方法的优势,并克服了两种方法的劣势:
采取更新缓存策略,那么会为所有的 DB 数据更新生成缓存,不区分冷热数据,那么会存在以下问题:
因为前面的更新缓存做法问题较多,因此大多数的实践采用的是删除缓存策略,查询时再按需生成缓存。这种做法解决了更新缓存中的问题,但是又带来新问题:
为了防止缓存击穿,通用的做法是使用分布式 Redis 锁保证只有一个请求到数据库,等缓存生成之后,其他请求进行共享。这种方案能够适合很多的场景,但有些场景却不适合。
前面介绍的dtm-labs/rockscache实现的延时删除法也属于删除法,但它彻底解决了删除缓存中的击穿问题,以及击穿带来的附带问题。
我们来看看不同的数据访问频率下,延迟删除法的表现如何:
有一种极端情况是,那就是原先缓存中没有数据,突然大量请求到来,这种场景对,更新缓存法删除缓存法,延迟删除法,都是不友好的。这种的场景是开发人员需要避免的,需要通过预热来解决,而不应当直接扔给缓存系统。当然,由于延迟删除法已经把打到数据库的请求量降到最低,因此表现也不弱于任何其他方案。
dtm-labs/rockscache还实现了防缓存穿透与缓存雪崩。
缓存穿透是指,缓存和数据库都没有的数据,被大量请求。由于数据不存在,缓存就也不会存在该数据,所有的请求都会直接穿透到数据库。rockscache 中可以设定EmptyExipire
设定对空结果的缓存时间,如果设定为 0 ,那么不缓存空数据,关闭防缓存穿透
缓存雪崩是指缓存中有大量的数据,在同一个时间点,或者较短的时间段内,全部过期了,这个时候请求过来,缓存没有数据,都会请求数据库,则数据库的压力就会突增,扛不住就会宕机。rockscache 可以设定RandomExpireAdjustment
,对过期时间加上随机值,避免同时过期。
上面已经介绍了缓存一致性的各种场景,以及相关的解决方案,那么是否可以保证使用缓存的同时,还提供强一致的数据读写呢?强一致的读写需求比前面的最终一致的需求场景少,但是在金融领域,也是有不少场景的。
当我们在这里讨论强一致时,我们需要先把一致性的含义做一下明确。
开发者最直观的强一致性很可能理解为,数据库和缓存保持完全一致,写数据的过程中以及写完之后,无论从数据库直接读,或者从缓存直接读,都能够获得最新写入的结果。对于这种两个独立系统之间的“强一致性”,可以非常明确的说,理论上是不可能的,因为更新数据库和更新缓存在不同的机器上,无法做到同时更新,无论如何都会有时间间隔,在这个时间间隔里,一定是不一致的。
但是应用层的强一致性,则是可以做到的。可以简单考虑我们熟悉的场景:CPU 的缓存作为内存的缓存,内存作为磁盘的缓存,这些都是缓存的场景,从来没有发生过一致性问题。为什么?其实很简单,要求所有的数据使用方,只能够从缓存读取数据,而不能同时从缓存和底层存储同时读取数据。
对于 DB 和 Redis ,如果所有的数据读取,只能够由缓存提供,就可以很容易的做到强一致,不会出现不一致的情况。下面我们来根据 DB 和 Redis 的特点,来分析其中的设计:
类比 CPU 缓存与内存,内存缓存与磁盘,这两个系统都是先修改缓存,再修改底层存储,那么到了现在的 DB 缓存场景是否也先修改缓存再修改 DB ?
在绝大多数的应用场景下,开发者会认为 Redis 作为缓存,当 Redis 出现故障时,那么应用需要支持降级处理,依旧能够访问数据库,提供一定的服务能力。考虑这种场景,一旦出现降级,先写缓存再写 DB 方案就有问题,一方面会丢失数据,另一方面会发生先读取到缓存中的新版本 v2 ,再读取到旧版本 v1 。因此在 Redis 作为缓存的场景下,绝大部分系统会采取先写入 DB ,再写入缓存的这种设计
假如因为进程 crash ,导致写入 DB 成功,但是标记延迟删除第一次失败怎么办?虽然间隔几秒之后,会重试成功,但这几秒钟的时间里,用户去读取缓存,依旧还是旧版本的数据。例如用户发起了一笔充值,资金已经进入到 DB ,只是更新缓存失败,导致从缓存看到的余额还是旧值。这种情况的处理很简单,用户充值时,写入 DB 成功时,应用不要给用户返回成功,而是等缓存更新也成功了,再给用户返回成功;用户查询充值交易时,要查询 DB 和缓存是否都成功了(可以查询二阶段消息全局事务是否已成功),只有两者都成功了,才返回成功。
在上述的处理策略下,当用户发起充值后,在缓存更新完成之前,用户看到的是,这笔交易还在处理中,结果未知,此时是符合强一致要求的;当用户看到交易已经处理成功,也就是缓存已更新成功,那么所有从缓存中拿到的数据都是更新后的数据,那么也符合强一致的要求。
dtm-labs/rockscache也实现了强一致的读取需求。当打开StrongConsistency
选项,那么 rockscache 里Fetch
函数就提供了强一致的缓存读取。其原理与延迟删除差别不大,仅做了很小的改变,就是不再返回旧版本的数据,而是同步等待“取数据”的最新结果
当然这个改变会带来性能上的下降,对比与最终一致的数据读取,强一致的读取一方面要等待当前“取数据”的最新结果,增加了返回延迟,另一方面要等待其他进程的结果,会产生 sleep 等待,耗费资源。
上述的强一致方案中,说明了其强一致的前提是:“所有的数据读取,只能够由缓存”。不过如果 Redis 如果发生故障,需要进行降级,那么降级的过程可能很短只有几秒,但是这个几秒内如果不能接受不可访问,还严苛的要求提供访问的话,就会出现读取缓存和读取 DB 混用情况,就不满足这个前提。不过因为 Redis 故障的频率不高,要求强一致性的应用通常配备专有 Redis ,因此遇见故障降级的概率很低,很多应用不会在这个地方提出苛刻的要求。
不过 dtm-labs 作为数据一致性领域的领导者,也深入研究了这个问题,并给出这种苛刻条件下的解决方案。
现在我们来考虑应用在 Redis 缓存出现问题的升降级处理。一般情况下这个升降级的开关在配置中心,当修改配置后,各个应用进程会陆续收到降级配置变更通知,然后在行为上降级。在降级的过程中,会出现缓存与 DB 混合访问的情况,这时我们上面的方案就有可能出现不一致。那么如何处理才能够保证在这种混合访问的情况下,依旧能够让应用获取到强一致的结果呢?
混合访问的过程中,我们可以采取下面这个策略,来保证 DB 和缓存混合访问时的数据一致性。
这个策略跟前面不考虑降级场景的强一致方案,差别不大,读数据部分完全不变,需要变的是更新数据。rockscache 假定更新 DB 是一个业务上可能失败的操作,于是采用一个 SAGA 事务来保证原子操作,详情参见例子dtm-cases/cache
升降级的开启关闭有顺序要求,不能够同时开启缓存读和写,而是需要在开启缓存读的时候,所有的写操作都已经确保会更新缓存。
降级的详细过程如下:
升级的过程与此相反,如下:
dtm-labs/rockscache已实现了上述强一致的缓存管理方法。
感兴趣的同学,可以参考dtm-cases/cache,里面有详尽的例子
这篇文章很长,许多的分析比较晦涩,最后将 Redis 缓存的使用方式做个总结:
对于后两种方式,我们都推荐使用dtm-labs/rockscache来作为您的缓存方案
欢迎访问dtm-labs/rockscache和dtm-labs/dtm,并 star 支持我们
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.