关于redis:首个彻底保证缓存一致性的开源方案

51次阅读

共计 7958 个字符,预计需要花费 20 分钟才能阅读完成。

概述

大量的理论的我的项目中,都会引入 Redis 缓存来缓解数据库的查问压力,此时因为一个数据在 Redis 和数据库两处进行了存储,就会有数据一致性的问题。目前业界尚未见到成熟的可能确保最终一致性的计划,特地是当如下场景产生时,会间接导致缓存数据与数据库数据不统一,可能给利用带来较大问题。

dtm-labs 致力于解决数据一致性问题,在剖析了行业的现有做法后,提出了新解决方案 dtm-labs/dtm+dtm-labs/rockscache,彻底解决了上述问题。另外作为一个成熟计划,该计划还能够防缓存穿透,防缓存击穿,防缓存雪崩,同时也可利用于要求数据强统一的场景。

对于治理缓存的现有计划,本文不再赘述,不太理解的同学能够参考上面这两篇文章

  • 这篇通俗易懂些:聊聊数据库与缓存数据一致性问题
  • 这篇更加深刻:携程最终统一和强一致性缓存实际

乱序产生的不统一

在上述这个时序图中,因为服务 1 产生了过程暂停(例如因为 GC 导致),因而当它往缓存当中写入 v1 时,笼罩了缓存中的 v2,导致了最终的不统一(DB 中为 v2,缓存中为 v1)。

对于上述这类问题该当如何解决?目前现存的计划,全都没有彻底解决该问题,个别都是通过设定稍短的过期工夫兜底。咱们实现的缓存提早删除计划,可能彻底解决这个问题,确保缓存与数据库之间的数据保持一致。解决原理如下:

缓存中的数据是一个 hash,外面有以下几个字段:

  • value: 数据自身
  • lockUtil: 数据锁定到期工夫,当某个过程查问缓存无数据,那么先锁定缓存一小段时间,而后查问 DB,而后更新缓存
  • owner: 数据锁定者 uuid

查问缓存时:

  1. 如果数据为空,且被锁定,则睡眠 1s 后,从新查问
  2. 如果数据为空,且未被锁定,同步执行 ” 取数据 ”,返回后果
  3. 如果数据不为空,那么立刻返回后果,并异步执行 ” 取数据 ”

其中 ” 取数据 ” 的操作定义为:

  1. 判断是否须要更新缓存,上面两个条件满足其一,则须要更新缓存

    • 数据为空,并且未被锁定
    • 数据的锁定已过期
  2. 如果须要更新,则锁定缓存,查问 DB,校验锁持有者无变动,写入缓存,解锁缓存

当 DB 数据更新时,通过 dtm 确保数据更新胜利时,将缓存提早删除(将在前面一节开展具体解说)

  • 提早删除会将数据过期工夫设定为 10s,将锁设置为已过期,触发下一次查问缓存时的“取数据”

在上述的策略下:
如果最初写入数据库的版本为 Vi,最初写入到缓存的版本为 V,写入 V 的 uuid 为 uuidv,那么肯定存在以下事件序列:

数据库写入 Vi -> 缓存数据被标记为删除 -> 某个查问锁定数据并写入 uuidv -> 查询数据库后果 V -> 缓存中的锁定者为 uuidv,写入后果 V

在这个序列中,V 的读取产生在写入 Vi 之后,所以 V 等于 Vi,保障了缓存的数据的最终一致性。

dtm-labs/rockscache 曾经实现了上述办法,可能确保缓存数据的最终一致性。

  • Fetch函数实现了后面的查问缓存
  • DelayDelete函数实现了提早删除逻辑

感兴趣的同学,能够参考 dtm-cases/cache,外面有具体的例子

DB 与缓存操作的原子性

对于缓存的治理,个别业界会采纳写完数据库后,删除 / 更新缓存数据的策略。因为保留到缓存和保留到数据库两个操作之间不是原子的,肯定会有时间差,因而这两个数据之间会有一个不统一的工夫窗口,通常这个窗口不大,影响较小。然而两个两头可能产生宕机,也可能产生各种网络谬误,因而就有可能产生实现了其中一个,然而未实现另一个,导致数据会呈现长时间不统一。

举一个场景来阐明上述不统一的状况,数据用户将数据 A 批改为 B,利用批改完数据库之后,再去删除 / 更新缓存,如果未产生异样,那么数据库和缓存的数据是统一的,没有问题。然而分布式系统中,可能会产生过程 crash、宕机等事件,因而如果更新完数据库,尚未删除 / 更新缓存时,呈现过程 crash,那么数据库和缓存的数据就可能呈现长时间的不统一。

面对这里的长时间不统一的状况,想要彻底解决,并不是一件容易的事,咱们上面分各种利用状况来介绍解决方案。

计划一:较短的缓存工夫

这个计划,是最简略的计划,适宜并发量不大利用。如果利用的并发不高,那么整个缓存零碎,只须要设置了一个较短的缓存工夫,例如一分钟。这种状况下数据库须要承当的负载是:大概每一分钟,须要将拜访到的缓存数据全副生成一遍,在并发量不大的状况下,这种策略是可行的。

上述这种策略非常简单,易于了解和实现,缓存零碎提供的语义是,大多数状况下,缓存和数据库之间不统一的工夫窗口是很短的,在较低概率产生过程 crash 的状况下,不统一的工夫窗口会达到一分钟。

利用在上述束缚下,须要将一致性要求不高的数据读取,从缓存读取;而将一致性要求较高的读,不走缓存,间接从数据库查问。

计划二:音讯队列保障统一

如果利用的并发量很高,缓存过期工夫须要比一分钟更长,而且利用中的大量申请不可能容忍较长时间的不统一,那么这个时候,能够通过应用音讯队列的形式,来更新缓存。具体的做法是:

  • 更新数据库时,同时将更新缓存的音讯写入本地表,随着数据库更新操作的提交而提交。
  • 写一个轮询工作,一直轮询这部分音讯,发给音讯队列。
  • 生产音讯队列中的音讯,更新 / 删除缓存

这种做法能够保障数据库更新之后,缓存肯定会被更新。但这种这种架构计划很重,这几个局部开发保护老本都不低:音讯队列的保护;高效轮询工作的开发与保护。

计划三:订阅 binlog

这个计划实用场景与计划二十分相似,原理又与数据库的主从同步相似,数据库的主从同步是通过订阅 binlog,将主库的更新利用到从库上,而这个计划则是通过订阅 binlog,将数据库的更新利用到缓存上。具体做法是:

  • 部署并配置阿里开源的 canal,让它订阅数据库的 binlog
  • 通过 canal 等工具 监听数据更新,同步更新 / 删除缓存

这种计划也能够保障数据库更新之后,缓存肯定会被更新,然而这种架构计划跟后面的音讯队列计划一样,也十分重。一方面 canal 的学习保护老本不低,另一方面,开发者可能只须要大量数据更新缓存,通过订阅所有的 binlog 来做这个事件,节约了很多资源。

计划四:dtm 二阶段音讯计划

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())
    }))

这种计划的长处:

  • 计划简略易用,代码简短易读
  • dtm 自身是一个无状态的一般利用,依赖的存储引擎 redis/mysql 是常见的基础设施,不须要额定保护音讯队列或者 canal
  • 相干的操作模块化,易保护,不须要像音讯队列或者 canal 在其余中央写消费者的逻辑

从库延时

上述的计划中,假设缓存删除后,服务进行数据查问,总是可能查到最新的数据。然而理论的生产环境中,可能会呈现主从拆散的架构,而主从延时并不是一个可控的变量,那么这时候又要怎么解决?

解决计划两种:一是辨别最终一致性很高和不高的缓存数据,查问数据时,将要求很高的数据必须从主库读取,而把要求不高的数据从从库读取。对于应用了 rockscache 的利用来说,高并发的申请都会在 Redis 这一层被拦挡,对于一个数据,最多只会有一个申请达到数据库,因而数据库的负载已大幅升高,采纳主库读取是一个理论可行的计划。

另一种计划是,主从拆散须要采纳不分叉的单链架构,那么链条开端的从库必然是提早最长的从库,此时采纳监听 binlog 的计划,须要监听链条做末端的从库 binlog,当收到数据变更告诉时,依照上述计划将缓存标记为提早删除。

这两个计划各有优缺点,业务能够依据本人的特点采纳。

防缓存击穿

rockscache 还能够防缓存击穿。当数据变更时,业界现有做法既能够抉择更新缓存,也能够抉择删除缓存,各有优劣。而提早删除综合了两种办法的劣势,并克服了两种办法的劣势:

更新缓存

采取更新缓存策略,那么会为所有的 DB 数据更新生成缓存,不辨别冷热数据,那么会存在以下问题:

  • 内存上,即便一个数据没有被读取,也会保留在缓存里,节约了贵重的内存资源;
  • 在计算上,即便一个数据没有被读取,也可能因为屡次更新,被屡次计算,节约了贵重的计算资源。
  • 上述的乱序不统一产生的概率会较高,当两个邻近的更新中呈现提早,就可能触发。

删除缓存

因为后面的更新缓存做法问题较多,因而大多数的实际采纳的是删除缓存策略,查问时再按需生成缓存。这种做法解决了更新缓存中的问题,然而又带来新问题:

  • 那么在高并发的状况下,如果删除了一个热点数据,那么此时会有大量申请会无奈命中缓存,产生缓存击穿。

为了避免缓存击穿,通用的做法是应用分布式 Redis 锁保障只有一个申请到数据库,等缓存生成之后,其余申请进行共享。这种计划可能适宜很多的场景,但有些场景却不适宜。

  • 例如有一个重要的热点数据,计算代价比拟高,须要 3s 才可能取得后果,那么上述计划在删除一个这种热点数据之后,就会在这个时刻,有大量申请 3s 才返回后果,一方面可能造成大量申请超时,另一方面 3s 没有开释链接,会导致并发连贯数量忽然升高,可能造成零碎不稳固。
  • 另外应用 Redis 锁时,未取得锁的这部分用户,通常会定时轮询,而这个睡眠工夫不好设定。如果设定比拟大的睡眠工夫 1s,那么对于 10ms 就计算出后果的缓存数据,返回太慢了;如果设定的睡眠工夫太短,那么很耗费 CPU 和 Redis 性能

提早删除法的应答策略

后面介绍的 dtm-labs/rockscache 实现的延时删除法也属于删除法,但它彻底解决了删除缓存中的击穿问题,以及击穿带来的附带问题。

  1. 缓存击穿问题:提早删除法中,如果缓存中的数据不存在,那么会锁定缓存中的这条数据,因而防止了多个申请打到后端数据库。
  2. 上述大量申请 3s 才返回数据,以及定时轮询的问题,在延时删除中也不存在,因为热点数据被延时删除时,旧版本的数据还在缓存中,会被立刻返回,无需期待。

咱们来看看不同的数据拜访频率下,提早删除法的体现如何:

  1. 热点数据,每秒 1K qps,计算缓存工夫 5ms,此时提早删除法,大概 5~8ms 左右的工夫里,会返回过期数据,而先更新 DB,再更新缓存,因为更新缓存须要工夫,也会有大概 0~3ms 返回过期数据,因而两者差异不大。
  2. 热点数据,每秒 1K qps,计算缓存工夫 3s,此时提早删除法,大概 3s 的工夫里,会返回过期数据。比照于期待 3s 后再返回数据,那么返回旧数据,通常是更好的行为。
  3. 一般数据,每秒 50 qps,计算缓存工夫 1s,此时提早删除法的行为剖析,相似 2,没有问题。
  4. 低频数据,5 秒拜访一次,计算缓存工夫 3s,此时提早删除法的行为与删除缓存策略根本一样,没有问题
  5. 冷数据,10 分钟拜访一次,此时提早删除法,与删除缓存策略根本一样,只是数据比删除缓存的形式多保留 10s,占用空间不大,没有问题

有一种极其状况是,那就是原先缓存中没有数据,忽然大量申请到来,这种场景对,更新缓存法删除缓存法,提早删除法,都是不敌对的。这种的场景是开发人员须要防止的,须要通过预热来解决,而不该当间接扔给缓存零碎。当然,因为提早删除法曾经把打到数据库的申请量降到最低,因而体现也不弱于任何其余计划。

防缓存穿透与缓存雪崩

dtm-labs/rockscache 还实现了防缓存穿透与缓存雪崩。

缓存穿透是指,缓存和数据库都没有的数据,被大量申请。因为数据不存在,缓存就也不会存在该数据,所有的申请都会间接穿透到数据库。rockscache 中能够设定 EmptyExipire 设定对空后果的缓存工夫,如果设定为 0,那么不缓存空数据,敞开防缓存穿透

缓存雪崩是指缓存中有大量的数据,在同一个工夫点,或者较短的时间段内,全副过期了,这个时候申请过去,缓存没有数据,都会申请数据库,则数据库的压力就会突增,扛不住就会宕机。rockscache 能够设定RandomExpireAdjustment,对过期工夫加上随机值,防止同时过期。

利用是否做到强统一?

下面曾经介绍了缓存一致性的各种场景,以及相干的解决方案,那么是否能够保障应用缓存的同时,还提供强统一的数据读写呢?强统一的读写需要比后面的最终统一的需要场景少,然而在金融畛域,也是有不少场景的。

当咱们在这里探讨强统一时,咱们须要先把一致性的含意做一下明确。

开发者最直观的强一致性很可能了解为,数据库和缓存放弃完全一致,写数据的过程中以及写完之后,无论从数据库间接读,或者从缓存间接读,都可能取得最新写入的后果。对于这种两个独立零碎之间的“强一致性”,能够十分明确的说,实践上是不可能的,因为更新数据库和更新缓存在不同的机器上,无奈做到同时更新,无论如何都会有工夫距离,在这个工夫距离里,肯定是不统一的。

然而应用层的强一致性,则是能够做到的。能够简略思考咱们相熟的场景:CPU 的缓存作为内存的缓存,内存作为磁盘的缓存,这些都是缓存的场景,素来没有产生过一致性问题。为什么?其实很简略,要求所有的数据应用方,只可能从缓存读取数据,而不能同时从缓存和底层存储同时读取数据。

对于 DB 和 Redis,如果所有的数据读取,只可能由缓存提供,就能够很容易的做到强统一,不会呈现不统一的状况。上面咱们来依据 DB 和 Redis 的特点,来剖析其中的设计:

先更新缓存还是 DB

类比 CPU 缓存与内存,内存缓存与磁盘,这两个零碎都是先批改缓存,再批改底层存储,那么到了当初的 DB 缓存场景是否也先批改缓存再批改 DB?

在绝大多数的利用场景下,开发者会认为 Redis 作为缓存,当 Redis 呈现故障时,那么利用须要反对降级解决,仍旧可能拜访数据库,提供肯定的服务能力。思考这种场景,一旦呈现降级,先写缓存再写 DB 计划就有问题,一方面会失落数据,另一方面会产生先读取到缓存中的新版本 v2,再读取到旧版本 v1。因而在 Redis 作为缓存的场景下,绝大部分零碎会采取先写入 DB,再写入缓存的这种设计

写入 DB 胜利缓存失败状况

如果因为过程 crash,导致写入 DB 胜利,然而标记提早删除第一次失败怎么办?尽管距离几秒之后,会重试胜利,但这几秒钟的工夫里,用户去读取缓存,仍旧还是旧版本的数据。例如用户发动了一笔充值,资金曾经进入到 DB,只是更新缓存失败,导致从缓存看到的余额还是旧值。这种状况的解决很简略,用户充值时,写入 DB 胜利时,利用不要给用户返回胜利,而是等缓存更新也胜利了,再给用户返回胜利;用户查问充值交易时,要查问 DB 和缓存是否都胜利了(能够查问二阶段音讯全局事务是否已胜利),只有两者都胜利了,才返回胜利。

在上述的解决策略下,当用户发动充值后,在缓存更新实现之前,用户看到的是,这笔交易还在解决中,后果未知,此时是合乎强统一要求的;当用户看到交易曾经解决胜利,也就是缓存已更新胜利,那么所有从缓存中拿到的数据都是更新后的数据,那么也合乎强统一的要求。

dtm-labs/rockscache 也实现了强统一的读取需要。当关上 StrongConsistency 选项,那么 rockscache 里 Fetch 函数就提供了强统一的缓存读取。其原理与提早删除差异不大,仅做了很小的扭转,就是不再返回旧版本的数据,而是同步期待“取数据”的最新后果

当然这个扭转会带来性能上的降落,比照与最终统一的数据读取,强统一的读取一方面要期待以后“取数据”的最新后果,减少了返回提早,另一方面要期待其余过程的后果,会产生 sleep 期待,消耗资源。

缓存降级降级中的强统一

上述的强统一计划中,阐明了其强统一的前提是:“所有的数据读取,只可能由缓存”。不过如果 Redis 如果产生故障,须要进行降级,那么降级的过程可能很短只有几秒,然而这个几秒内如果不能承受不可拜访,还严苛的要求提供拜访的话,就会呈现读取缓存和读取 DB 混用状况,就不满足这个前提。不过因为 Redis 故障的频率不高,要求强一致性的利用通常装备专有 Redis,因而遇见故障降级的概率很低,很多利用不会在这个中央提出刻薄的要求。

不过 dtm-labs 作为数据一致性畛域的领导者,也深入研究了这个问题,并给出这种刻薄条件下的解决方案。

升降级的过程

当初咱们来思考利用在 Redis 缓存呈现问题的升降级解决。个别状况下这个升降级的开关在配置核心,当批改配置后,各个利用过程会陆续收到降级配置变更告诉,而后在行为上降级。在降级的过程中,会呈现缓存与 DB 混合拜访的状况,这时咱们下面的计划就有可能呈现不统一。那么如何解决才可能保障在这种混合拜访的状况下,仍旧可能让利用获取到强统一的后果呢?

混合拜访的过程中,咱们能够采取上面这个策略,来保障 DB 和缓存混合拜访时的数据一致性。

  • 更新数据时,应用分布式事务,保障以下操作为原子操作

    • 将缓存标记为“锁定中”
    • 更新 DB
    • 将缓存“锁定中”标记去除,标记为提早删除
  • 读取缓存数据时,对于标记为“锁定中”的数据,睡眠期待后再次读取;对于提早删除的数据,不返回旧数据,期待新数据实现再返回。
  • 读取 DB 数据时,间接读取,无需任何额定操作

这个策略跟后面不思考降级场景的强统一计划,差异不大,读数据局部齐全不变,须要变的是更新数据。rockscache 假设更新 DB 是一个业务上可能失败的操作,于是采纳一个 SAGA 事务来保障原子操作,详情参见例子 dtm-cases/cache

升降级的开启敞开有程序要求,不可能同时开启缓存读和写,而是须要在开启缓存读的时候,所有的写操作都曾经确保会更新缓存。

降级的具体过程如下:

  1. 最后状态:

    • 读:混合读
    • 写:DB+ 缓存
  2. 读降级:

    • 读:敞开缓存读。混合读 => 全副 DB 读
    • 写:DB+ 缓存
  3. 写降级:

    • 读:全副 DB 读;
    • 写:敞开缓存写。DB+ 缓存 => 只写 DB

降级的过程与此相反,如下:

  1. 最后状态:

    • 读:全副读 DB
    • 写:全副只写 DB
  2. 写降级:

    • 读:全副读 DB
    • 写:关上写缓存。只写 DB => 写 DB+ 缓存
  3. 读降级:

    • 读:局部读缓存。全副读 DB => 混合读
    • 写:写 DB+ 缓存

dtm-labs/rockscache 已实现了上述强统一的缓存治理办法。

感兴趣的同学,能够参考 dtm-cases/cache,外面有详尽的例子

小结

这篇文章很长,许多的剖析比拟艰涩,最初将 Redis 缓存的应用形式做个总结:

  • 最简略的形式为:较短的缓存工夫,容许大量数据库批改,未同步删除缓存
  • 保障最终统一,并且可防缓存击穿的形式为:二阶段音讯 + 提早删除(rockscache)
  • 强统一:二阶段音讯 + 强统一(rockscache)
  • 一致性要求最严苛的形式为:二阶段音讯 + 强统一(rockscache)+ 升降级兼容

对于后两种形式,咱们都举荐应用 dtm-labs/rockscache 来作为您的缓存计划

欢送拜访 dtm-labs/rockscache 和 dtm-labs/dtm,并 star 反对咱们

正文完
 0