缓存一致性

概述

在实际的项目中,当 QPS 变高,通常会引入 Redis 缓存来缓解数据库的查询压力。但是一旦引入了缓存,那么一个数据在 Redis 和数据库两处进行了存储,就会有数据一致性的问题。 DTM 致力于解决数据一致性问题,在分析了行业的现有做法后,提出了新架构方案,本文将详细叙述

我们将从最简单的单个数据库方案,逐步到我们的新架构方案

单数据库方案

当业务访问量不大时,我们可以在服务中,直接访问数据库,这样的解决方案简单直接,没有问题。

当业务量增大时,数据库很容易成为瓶颈,此时最便捷的解决办法是,升级数据库配置,让数据库直接提供更高的 qps 来应对业务增长。此方法优点是,不需要任何开发工作,升级配置就可以支撑更高的业务量,但是有以下缺点:

  • 单机数据库配置有上限,磁盘的 IOPS ,单机内存,单机 CPU 都有上限,到了一定程度,无法再往上升级
  • 配置升级很昂贵,这些配置的升级一倍,需要的成本可能是4倍,甚至10倍,这种方案硬件成本上升过快,难以应对大用户量的应用

开发影响: 这种方案,应用不需要任何修改。

最终一致数据库方案

上述升级配置的方式,对开发人员非常友好,但是成本过于昂贵,缺点非常明显,那么有没有办法做横向扩展,让成本只是线性上升呢?

绝大多数的互联网应用都是读多写少的应用,采用数据库的主从复制,进行读写分离,可以比较好的应对用户量上涨带来的问题,对比上述升级配置的方案,主从分离具备一下特点

  • 随着用户增长,可以通过增加更多的从库,分担数据库的访问压力,这个成本是线性增加,不是指数增加,比前面的垂直升级配置方案更好
  • 理论上一个主库可以增加大量的从库,这种扩展的上限比前面垂直升级方案上限要高很多

但是这里的方案,已经是弱一致性,数据从主库到从库,需要一定的时间。应用在主库上写完之后,立即去从库读,可能读取不到最新数据,因此这个时候是弱一致性。

开发影响: 在这种弱一致性方案下,开发就需要针对应用做修改,开发需要区分出一致性要求强的读请求,和一致性要求低的读请求,将强一致的调度到主库,弱一致的调度到从库。

Redis 缓存方案

上述的数据库方案在每用户成本上,是很高的。在当今的互联网应用中,用户量非常大,纯数据库方案,会导致成本居高不下,此时其他技术方案会成为更佳的选择。

使用 Redis 将数据缓存在内存中,是当今非常通用的数据查询方案,对比上述的数据库方案, Redis 方案具备以下特点:

  • 数据存储在内存,访问速度极快,单机 Redis 通常比单机数据库提供高达 10~100 倍的访问支持
  • Redis 也支持主从复制,分片等,可以很轻松的提供应用所需要的 qps

在并发访问方面, Redis 完美的提供了支持,但是也带来了不少的问题:

  • Redis 的存储形式是内存,与数据库的数据形式不一样,需要开发人员手动维护
  • Redis 与数据库没有前面数据库主从的这种方式的自动数据同步,相关数据的一致性需要开发者手动维护

开发影响: 开发者需要手动维护跟数据库格式不同的缓存数据,另外还需要解决 Redis 缓存与数据库数据不一致的问题。

缓存更新为什么会会不一致

引入了 Redis 缓存之后,数据会同时存储在数据库和 Redis。一般业界会采用写完数据库后,删除/更新缓存数据的策略。由于保存到缓存和保存到数据库两个操作之间不是原子的,一定会有时间差,因此这两个数据之间会有一个不一致的时间窗口,通常这个窗口不大。但是两个中间可能发生宕机,也可能发生各种网络错误,因此就有可能发生完成了其中一个,但是未完成另一个,导致数据会出现较长时间不一致。

举一个场景来说明上述不一致的情况,数据用户将数据 A 修改为 B ,应用修改完数据库之后,再去修改缓存,如果未发生异常,那么数据库和缓存的数据都是 B,没有问题。但是分布式系统中,可能会发生进程crash、宕机等事件,因此如果更新完数据库,尚未更新缓存时,出现进程crash,那么数据库和缓存的数据就可能出现较长时间的不一致。

面对这里的较长时间不一致的情况,想要彻底解决,并不是一件容易的事,我们下面分各种应用情况来介绍解决方案。

方案一:较短的缓存时间

这个方案,是最简单的方案,适合并发量不大应用。如果应用的并发不高,那么整个缓存系统,只需要设置了一个较短的缓存时间,例如一分钟。这种情况下数据库需要承担的负载是:大约每一分钟,需要将访问到的缓存数据全部生成一遍,在并发量不大的情况下,这种策略是可行的。

上述这种策略非常简单,易于理解和实现,缓存系统提供的语义是,大多数情况下,缓存和数据库之间不一致的时间窗口是很短的,在较低概率发生进程crash的情况下,不一致的时间窗口会达到一分钟。

应用在上述约束下,需要将一致性要求不高的数据读取,从缓存读取;而将一致性要求较高的读,不走缓存,直接从数据库查询。

方案二:消息队列保证一致

假如应用的并发量很高,缓存过期时间需要比一分钟更长,而且应用中的大量请求不能够容忍较长时间的不一致,那么这个时候,可以通过使用消息队列的方式,来更新缓存。具体的做法是:

  • 更新数据库时,同时将更新缓存的消息写入本地表,随着数据库更新操作的提交而提交。
  • 写一个轮询任务,不断轮询这部分消息,发给消息队列。
  • 消费消息队列中的消息,更新/删除缓存

这种做法可以保证数据库更新之后,缓存一定会被更新。但这种这种架构方案很重,这几个部分开发维护成本都不低:消息队列的维护;高效轮询任务的开发与维护。

方案三:订阅 binlog

这个方案适用场景与方案二非常类似,原理又与数据库的主从同步类似,数据库的主从同步是通过订阅binlog,将主库的更新应用到从库上,而这个方案则是通过订阅binlog,将数据库的更新应用到缓存上。具体做法是:

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

这种方案也可以保证数据库更新之后,缓存一定会被更新,但是这种架构方案跟前面的方案一样,也非常重。一方面 canal 的学习维护成本不低,另一方面,开发者可能只需要少量数据更新缓存,通过订阅所有的 binlog 来做这个事情,比较浪费。

方案四: dtm 二阶段消息方案

dtm 里的二阶段消息模式,非常适合这里的修改数据库之后更新/删除缓存,主要代码如下:

  1. msg := dtmcli.NewMsg(DtmServer, gid).
  2. Add(busi.Busi+"/UpdateRedis", &Req{Key: key1})
  3. err := msg.DoAndSubmitDB(busi.Busi+"/QueryPrepared", db, func(tx *sql.Tx) error {
  4. // update db data with key1
  5. })

这段代码,DoAndSubmitDB会进行本地数据库操作,进行数据库的数据修改,修改完成后,会提交一个二阶段消息事务,消息事务将会异步调用 UpdateRedis。假如本地事务执行之后,就立刻发生了进程 crash 事件,那么 dtm 会进行回查调用 QueryPrepared ,保证本地事务提交成功的情况下,UpdateRedis 会被最少成功执行一次。

回查的逻辑非常简单,只需要copy类似下面这样的代码即可:

  1. app.GET(BusiAPI+"/QueryPreparedB", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
  2. return MustBarrierFromGin(c).QueryPrepared(dbGet())
  3. }))

这种方案的优点:

  • 方案简单易用,代码简短易读
  • dtm 本身是一个无状态的普通应用,依赖的存储引擎 redis/mysql 是常见的基础设施,不需要额外维护消息队列或者 canal
  • 相关的操作模块化,易维护,不需要像消息队列或者 canal 在其他地方写消费者的逻辑

更新还是删除缓存?

当数据变更时,可以选择更新缓存,也可以选择删除缓存,各有优劣,我们下面尝试分析他们的优缺点,并提出新的做法:

删除缓存

如果我们采取删除缓存策略,查询时再按需生成缓存,那么在高并发的情况下,如果删除了一个热点数据,那么此时会有大量请求会无法命中缓存。这是一个头疼的问题

为了防止缓存击穿,通用的做法是使用分布式 Redis 锁保证只有一个请求到数据库,等缓存生成之后,其他请求进行共享。这种方案能够适合很多的场景,但有些场景却不适合。例如有一个重要的热点数据,计算代价比较高,需要3s才能够获得结果,那么上述方案在删除一个这种热点数据之后,就会在这个时刻,有大量请求3s才返回结果,一方面可能造成大量请求超时,另一方面3s没有释放链接,会导致并发连接数量突然升高,可能造成系统不稳定。

另外使用 Redis 锁时,未获得锁的这部分用户,通常会定时轮询锁,而这个睡眠时间不好设定。如果设定比较大的睡眠时间1s,那么对于200ms就计算出结果的缓存数据,返回太慢了;如果设定的睡眠时间太短,那么很消耗 CPU 和 Redis 性能

更新缓存

如果我们采取更新缓存策略,那么存在的问题是:每一个数据修改,都会重新生成缓存,没有区分冷热数据,冷数据会浪费相关的存储资源和计算资源。

延迟删除

那么有没有两全其美的办法呢?既不浪费存储和计算资源,还能够解决缓存击穿问题?有的!延迟删除法,延迟删除法的工作原理如下:

  1. 当数据变更时,在 Redis 中查找数据,如果数据不存在,什么都不做;如果数据存在,那么将该数据标记为删除,并且将该数据的过期时间设置为10s。
  2. 当从缓存读取数据时,如果发现数据被标记为删除,或者锁定已经过期,那么将数据标记为被锁定到now+4s,然后去数据库中读取数据,然后写入缓存,清除标记。
  3. 当从缓存读取数据时,如果发现数据被标记为已锁定,那么返回旧的数据,如果没有旧数据,则sleep 1s后再次尝试

这种延迟删除法,具备以下优点:

  1. 冷数据不会占用大量存储和计算资源,只有真实被请求的数据,才会生成缓存,占用存储空间
  2. 不会出现上述缓存击穿的情况。当更新了热点数据缓存时,在这3s计算缓存数据时,会返回旧数据,避免了请求超时等情况

我们来看看各种情况下,延迟删除法是否会出现问题:

  1. 热点数据,每秒1K qps,计算缓存时间5ms,此时延迟删除法,大约5ms左右的时间里,会返回过期数据,而先更新DB,再更新缓存,也会有大约1~10ms返回过期数据,因此两者差别不大。
  2. 热点数据,每秒1K qps,计算缓存时间3s,此时延迟删除法,大约3s的时间里,会返回过期数据。对比于等待3s后再返回数据,那么返回旧数据,通常是更好的行为。
  3. 普通数据,每秒50 qps,计算缓存时间1s,此时延迟删除法的行为分析,类似2,没有问题。
  4. 低频数据,5秒访问一次,计算缓存时间3s,此时延迟删除法的行为与删除缓存策略基本一样,没有问题
  5. 冷数据,10分钟访问一次,此时延迟删除法,与删除缓存策略基本一样,只是数据比删除缓存的方式晚删除10s,没有问题

有一种极端情况是,那就是原先缓存中没有数据,突然大量请求到来,这种场景对删除缓存法,延迟删除法,更新缓存法,都是不友好的。这种的场景是开发人员需要避免的,需要通过预热来解决,而不应当直接扔给缓存系统。

延迟删除能够很好的处理缓存中的各类情况,原理稍微复杂一写,需要通过lua脚本来实现,但好处是,可以实现为一个sdk,充分复用,无需每个开发人员都做。

dtm-labs/rockscache实现了这里所说的延迟删除的方法。这个库具备以下特性:

  • 保证缓存数据的最终一致
  • 提供了强一致接口
  • 防缓存击穿
  • 防缓存穿透
  • 防缓存雪崩

感兴趣的同学,可以参考dtm-cases/cache,里面有详细的例子。

乱序产生的不一致

前面所说的不一致,主要是不能同时更新数据库和更新缓存导致的,不一致的窗口为更新两者之间的时间差。然后还有一种数据不一致,有一定的概率发生,下面我们来看看这种情况的时序图:

cache-version

在上述这个时序图中,由于服务1发生了进程暂停(例如由于GC导致),因此当它往缓存当中写入v1时,覆盖了缓存中的v2,导致了最终的不一致(DB中为v2,缓存中为v1)。

对于上述这类问题应当如何解决?目前现存的方案,全都没有彻底解决该问题,一般都是通过设定稍短的过期时间兜底。我们实现的缓存延迟删除方案,能够彻底解决这个问题,确保缓存与数据库之间的数据保持一致。解决原理如下:

缓存中的数据有以下几个字段:

  • 数据锁定时间:当某个进程想要更新缓存,那么先锁定缓存一小段时间,然后查询DB,然后更新缓存
  • 数据锁定者uuid
  • 数据是否被标记为删除

查询缓存时:

  1. 如果缓存数据未被锁定,并且数据为空或被标记为删除 a. 将数据锁定n秒,锁定者为请求的uuid b. 从数据库中获取数据 c. 判断数据的锁定者是否为当前请求uuid,如果是,更新缓存;如果否,不更新缓存
  2. 如果缓存中的数据被锁定,并且数据为空 a. 睡眠1s后重新查询缓存
  3. 其他数据情况 a. 直接返回缓存中的数据(不管数据是否已经被标记删除)

当DB数据更新时,通过dtm保证将缓存延迟删除

  • 延迟删除中,将数据标记为延迟删除,并清空数据锁定时间,清空锁定者uuid

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

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

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

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

感兴趣的同学,可以参考dtm-cases/cache,里面有详细的例子

从库延时

上述的方案中,假定缓存删除后,服务进行数据查询,总是能够查到最新的数据。但是实际的生产环境中,会出现主从分离的架构,而主从延时并不是一个可控的变量,那么这时候又要怎么处理?

处理方案两种:一是区分最终一致性很高和不高的缓存数据,查询数据时,将要求很高的数据必须从主库读取,而把要求不高的数据从从库读取。

另一种方案是,主从分离需要采用不分叉的单链架构,那么链条末尾的从库必定是延迟最长的从库,监听这个从库的binlog,当收到数据变更通知时,按照上述方案将缓存标记为删除。

这两个方案各有优缺点,业务可以根据自己的特点采用。

应用能否做到强一致?

上面已经介绍了缓存一致性的各种场景,以及相关的解决方案,那么是否可以保证使用缓存的同时,还提供强一致的数据读写呢?

当我们在这里讨论强一致时,我们需要先把一致性的含义做一下明确。

开发者最直观的强一致性很可能理解为,数据库和缓存保持完全一致,写数据的过程中以及写完之后,无论从数据库直接读,或者从缓存直接读,都能够获得最新写入的结果。对于这种的“强一致性”,可以非常明确的说,理论上是不可能的,因为更新数据库和更新缓存在不同的机器上,无法做到同时更新,无论如何都会有时间间隔,在这个时间间隔里,一定是不一致的。

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

对于DB和Redis,只要我们要求所有的数据读取,都经过缓存,那么就是强一致,不会出现不一致的情况。下面我们来根据DB和Redis的特点,来分析其中的设计:

先更新缓存还是DB

类比CPU缓存与内存,内存缓存与磁盘,这两个系统都是先修改缓存,再修改底层存储,那么到了现在的DB缓存场景是否也修改缓存再修改DB?

在绝大多数的应用场景下,开发者会认为Redis作为缓存,当Redis出现故障时,那么应用需要支持降级处理,依旧能够访问数据库,提供一定的服务能力。考虑这种场景,一旦出现降级,先写缓存再写DB方案就有问题,就会发生先读取到新版本v2,在读取到旧版本v1。因此在Redis作为缓存的场景下,大部分系统会采取先写入DB,再写入缓存的这种设计

写入DB成功缓存失败情况

假如因为进程crash,导致写入DB成功,但是标记延迟删除第一次失败怎么办?虽然间隔几秒之后,会重试成功,但这几秒钟的时间里,用户去读取缓存,依旧还是旧版本的数据。例如用户发起了一笔充值,资金已经进入到DB,只是更新缓存失败,导致从缓存看到的余额还是旧值。这种情况的处理很简单,用户充值时,写入DB成功时,应用不要给用户返回成功,而是等缓存更新也成功了,再给用户返回成功;用户查询充值交易时,要查询DB和缓存是否都成功了(可以查询二阶段消息全局事务是否已成功),只有两者都成功了,才返回成功。

在上述的处理策略下,当用户发起充值后,在缓存更新完成之前,用户看到的是,这笔交易还在处理中,结果未知,那么依旧是符合强一致的要求。

降级升级的处理

现在我们来考虑应用在Redis缓存出现问题的升降级处理。一般情况下这个升降级的开关在配置中心,当修改配置后,各个应用进程会陆续收到降级配置变更通知,然后在行为上降级。在降级的过程中,会出现缓存与DB混合访问的情况,这时我们上面的方案就有可能出现不一致。那么如何处理才能够保证在这种混合访问的情况下,依旧能够让应用获取到强一致的结果呢?

混合访问的过程中,我们可以采取下面这个策略,来保证DB和缓存混合访问时的数据一致性。

  • 更新数据时,使用分布式事务,保证以下操作为原子操作
    • 将缓存标记为“更新中”
    • 更新DB
    • 将缓存“更新中”标记去除,标记为延迟删除
  • 读取缓存数据时,对于标记为“更新中”的数据,睡眠等待后再次读取;对于延迟删除的数据,不返回旧数据,等待新数据完成再返回。
  • 读取DB数据时,直接读取,无需任何额外操作

降级的详细过程如下:

  1. 最初状态:全部读缓存,写DB+缓存
  2. 打开读降级:部分读缓存,部分读DB,写DB+缓存
  3. 读降级完成:全部读DB,写DB+缓存
  4. 打开写降级:全部读DB,部分写DB,部分写DB+缓存
  5. 写降级完成:全部读DB,全部写DB

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

  1. 最初状态:全部读DB,全部写DB
  2. 打开写升级:全部读DB,部分写DB,部分写DB+缓存
  3. 写升级完成:全部读DB,全部写DB+缓存
  4. 打开读升级:部分读缓存,部分读DB,全部写DB+缓存
  5. 读升级完成:全部读缓存,全部写DB+缓存

dtm-labs/rockscache已实现了上述强一致的缓存管理方法。

感兴趣的同学,可以参考dtm-cases/cache,里面有详尽的例子

小结

这篇文章很长,许多的分析比较晦涩,最后将Redis缓存的使用方式做个总结:

  • 最简单的方式为:较短的缓存时间,允许少量数据库修改,未同步删除缓存
  • 保证最终一致,并且可防缓存击穿的方式为:二阶段消息+延迟删除(rockscache)
  • 一致性要求最严苛的方式为:二阶段消息+延迟删除(rockscache)+升降级兼容

对于后两种方式,我们都推荐使用dtm-labs/rockscache来作为您的缓存方案