业界标杆:分布式缓存与 DB 秒级一致的灵活设计实践 - 今日头条

本文由 简悦 SimpRead 转码, 原文地址 www.toutiao.com

爆款项目是 2020 年携程的一个新项目,目标是将全品类、高性价比的旅行商品统一集合在一个频道供用户选购。

作者介绍

大卫, 携程服务端开发经理,对应用架构设计、云原生、代码整洁之道有浓厚兴趣。

一、前言

爆款项目是 2020 年携程的一个新项目,目标是将全品类、高性价比的旅行商品统一集合在一个频道供用户选购。出于这样的业务定位,项目有三个特点:

  • 高流量
  • 部分商品会成为热卖商品
  • 承担下单职能

那么在系统设计之初,就必须考虑下面两个点:

  • 如何应对高 QPS(包括整体高 QPS 和个别商品的高 QPS),高流量,保障 C 端用户体验?
  • 在满足第一点的情况下,如何保障信息的时效性,让用户尽可能看到最新的信息,避免下单时的信息和看到的信息不一致?

很显然,要想较好的应对高 QPS,高流量的前端请求,需要借助缓存(我们使用了公司推荐的 Redis,后文不再做特别说明)。但是怎么使用好缓存解决上面两个问题,这是需要考虑的。我们对比了本文讨论的方案,和另外几个传统方案的优缺点,见下:

https://p26.toutiaoimg.com/origin/pgc-image/e2d18ec1394b453aa343c3cf8ec9b272?from=pc

综上,本方案除了第一次实现有较高的复杂度外,带来的其他优势都是很可观的。目前线上运行下来,数据访问层应用相关的数据如下:

QPS: 近万 QPS

性能: 平均耗时个位数毫秒

缓存数据更新延时: 秒级

Redis 集群的请求量: 进行热点 key 处理后,减少原本 1/4 的请求量

Redis 集群内存占用: 按需进行缓存,内存占用为传统方案的 1/10

Redis 集群 CPU 使用情况: 整体平稳,未出现局部机器 CPU 异常

本文将从应用架构、缓存访问组件、缓存更新平台几方面介绍本方案。

二、应用架构

本方案的应用架构大致如下:

https://p26.toutiaoimg.com/origin/pgc-image/ea9f969e1d3f4f218cd70440acbb7185?from=pc

其中,浅绿色部分由业务实现,深绿色部分是本方案实现的。后文会详细介绍缓存访问组件和缓存更新平台的设计思路。

三、缓存访问组件

该组件的存在主要是为了封装缓存的访问。主要做了两件事:

  • 按需异步将缓存中需要增、删、改的键值对通过消息传递给缓存更新平台,让其进行实际的缓存更新操作。
  • 对热点 key 进行本地缓存与更新,避免对某个 key 的大量请求直接打到缓存导致缓存雪崩。

3.1 为什么要异步操作缓存?

这里,可能大家会有一个疑问,为什么要将简单的缓存操作由传统方案中的同步操作变为基于消息机制的异步操作呢?

这是由于我们的业务场景要求 DB 数据与缓存数据能够快速最终一致而决定的。如果采取传统的同步操作,那么极端情况下,可能会出现下面这样的多线程执行时序:

https://p26.toutiaoimg.com/origin/pgc-image/a0166656564d48b6ae1f3239e45c86b5?from=pc

可以发现,这样的时序执行完后,缓存中 key 对应的 value 是过期的 v1 而不是数据库中最新的 v2,这就会导致严重的用户体验问题,并且这个问题很难被发现。

那么我们是不是可以采用 Redis 的 SETNX 命令来解决这个问题呢?其实也是不行的。比如,上面的时序变为线程 1 先执行完,线程 3 再执行完,那么实际上缓存中的数据依然会是过期的 v1。因为线程 3 在采用 SETNX 命令设置缓存时,发现 key 已经有对应的值了,所以线程 3 最终的 SETNX 命令不会执行成功,也就导致了该更新的缓存反而没有更新。

不难看出,这类问题就是由于我们会有大量的并行操作同一个 key 导致的。所以,这里引入消息机制来异步执行缓存操作就是为了使同一个 key 的并行操作变为串行操作。

异步操作带来的问题

由于缓存操作由传统方案中的同步操作变为异步操作,那么引入了两个新问题:

  • 如果投递消息失败了怎么办?
  • 业务希望数据更新成功后缓存务必更新成功,也就是说希望 DB 数据更新和缓存更新近乎在一个事务里面,这该怎么办?

在这个组件中,我们通过引入一张存放于业务的 DB 的消息记录表来解决上述两个问题。它相当于是一个容灾方案,只要消息进入这张表,缓存更新平台就保证这条消息必然会被消费。

3.2 关于热点 key 的处理

该组件还有一大功能就是对热点 key 的处理。众所周知,缓存热点 key 在很多业务中都存在。例如若页面中存在长列表 / 瀑布流,那么第一屏的产品的访问量肯定比第二屏的产品访问量要高很多;又例如某些商品做活动,那么这类商品肯定要比没做活动的商品访问量高很多。

而爆款业务在可预见的未来,肯定也会出现热点 key 的问题。若热点 key 的问题不及时解决,当对单一 key 的请求量足够大时,可能导致缓存集群中存储该 key 的机器性能严重下降,从而导致缓存雪崩。所以在系统设计上,我们需要为解决热点 key 预留可扩展性。

目前组件内部热点 key 的处理流程如下:

https://p26.toutiaoimg.com/origin/pgc-image/084645ba96664df48853694adef1c336?from=pc

通过上述流程可以看到,组件内部解决热点 key 主要是要解决下面三个问题:

  • 如何判断是热点 key?
  • 热点 key 如何存储?
  • 热点 key 的内容如何更新?

1)如何判断是热点 key?

首先我们需要知道哪些 key 是热点 key 才能解决热点 key 的问题,识别热点 key 采取下面两个方案互补:

  • 动态识别热点 key:主要针对部分 key 的访问流量增长相对平稳没那么陡的场景,使应用有能力应对线上一些无法预知的突发情况。
  • 预设热点 key:主要针对定点开始的活动(比如电商的秒杀),这类流量增长通常会非常陡且高峰很短暂。如果这种场景也采取方案 1 来主动识别通常就会导致滞后性,其实最终不会起到任何作用。所以我们就需要预设热点 key。

由于爆款业务处于起步阶段,场景 1 的问题尚不紧急,所以目前方案 1 我们计划在未来的迭代实现,这里不做过多讨论。

对于方案 2,业务目前可以主动将可以判定为热点的 key 灌给缓存访问组件。组件收到这类 key 后,当它在从缓存拿到这类 key 的内容后会主动将内容存入本地内存。后续所有的访问,都会从本地内存读取,从而大幅降低对远端缓存服务器的访问。

2)热点 key 如何存储?

在前文已经提到,针对热点 key,我们选择将其内容存放于应用服务器的内存中,这样做基于下面两个原因:

  • 应用服务器本身一般都是以集群来部署,可以弹性缩扩容;
  • 应用服务器的内存基本上可用空间都在 50% 以上;

这样做带来的好处是:应用服务器在基于流量变化进行横向缩扩容时,热点 key 的内存与并发量的支持也跟着一起调整了,避免了多余的维护成本。

缓存访问组件在进行本地缓存时,考虑到热点 key 的访问流量通常是增长快下降也快,而且极端情况下可能出现本地缓存内容和数据库中的内容不一致,所以我们选择在本地进行一个很短时间的缓存,便于其能够应对突发的流量增长的同时也能在极端情况下快速与数据保持一致。

3)热点 key 的内容如何更新?

前文有提到,我们希望尽可能快的将数据库中最新的数据反映到缓存,热点 key 的本地缓存也不例外。所以,我们需要建立一个广播机制,让本地缓存能够知晓远端缓存的内容变化了。

这里,我们借助了缓存更新平台。由于所有的缓存更新都是发生在缓存更新平台(见后文),所以其可以将发生变化的缓存 key 通过消息队列广播给所有缓存访问组件,组件消费到这条消息后,若 key 是热点 key,则进行本地缓存的更新。极端情况下,可能会出现组件消费消息失败从而未更新的问题。针对这种情况,前文有提到,我们采取了很短时间的本地缓存,所以即便出现这个问题,也只会在较短时间有问题,最大程度保障了用户体验。

四、缓存更新平台

缓存更新平台主要有下面两大功能:

  • 执行实际的缓存增、删、改命令;
  • 缓存内容发生了变更后通知业务方;

由于缓存更新平台汇总了所有的缓存更新操作,所以它能够在缓存发生变更后,通过广播消息及时通知业务方,业务方拿到该消息后可以判断是否要做处理。目前这个功能主要用于解决热点 key 的内容更新问题,这在前文热点 key 处理的相关章节已做了详细说明,后文不再赘述。

后文主要介绍该平台的第一点功能。

前文有讲到,我们为了规避并行操作同一个 key 导致缓存中存储旧值而非最新值的问题,从而引入消息机制将缓存操作串行化。该缓存更新平台就用于串行的从消息队列消费缓存操作消息。

所以我们的核心需求是:单线程处理同一个 key 的缓存操作消息且不让旧的缓存覆盖新的缓存。

基于上面的需求,产生了四个问题:

  • 怎么判断多个消息属于同一个 key 的缓存消息?
  • 缓存操作的消息量级非常大(峰值情况下几十万条 / 分钟),怎么快速消费完?
  • 怎么知道缓存内容是新还是旧,是否该对该消息进行处理?
  • 由于基于消息,如何保障消息一定会被处理?

4.1 怎么判断多个消息是属于同一个 key 的缓存消息?

针对这个问题,我们通过在消息中携带缓存的 key 来解决这个问题,这样做带来了几个好处:

  • 将业务和缓存更新平台解耦,key 的内容由业务全权决定;
  • 通过首先计算 key 的 hash 值,然后对其取模,可以将相同的 key 分配到相同的线程处理(见后文);
  • 可扩展性强,针对后续热点 key 的分析和自动化加载热点 key 也起到了关键作用(后续迭代计划的功能);

4.2 怎么快速消费消息?

由于核心需求是单线程的处理同一类 key 的消息,所以不同 key 的消息由不同的线程处理既能很好的解决性能问题,又不会产生逻辑问题。

我们采取了如下架构去消费产生的消息:

https://p26.toutiaoimg.com/origin/pgc-image/0683debe04a649b5a6ac79cd25cf7f27?from=pc

同一类 key 通过计算其 hash 值,然后再对结果进行取模,可以保证它们进到同一个内存队列和线程,从而规避并行操作同一个 key 的问题。通过这个架构,如果某个 key 的消息消费过慢,也不会影响其他 key 的消费进度,从而既保障了消费速度也满足了需求。

实践下来,目前我们仅用了两台机器就能做到每分钟消费几十万条消息,且远未遇到瓶颈。

4.3 怎么知道缓存内容是新还是旧,是否该对该消息进行处理?

虽然我们做到了同一类 key 的单线程处理,并且,我们使用的公司的消息队列能保障消息的有序性。但依然有个问题没解决,那就是旧的缓存可能会覆盖新的缓存,因为我们没法保障新的缓存消息一定在旧的缓存消息产生之后再产生。考虑下面这个场景:

https://p26.toutiaoimg.com/origin/pgc-image/38211db5b3a24bb282b18b674f8c448e?from=pc

从上面可以看到,由于线程 3 的 key<->v2 消息先产生,所以它会被先消费,此时缓存的数据会变为 v2, 然后缓存更新平台再处理 key<->v1 这条消息,从而导致 v1 覆盖缓存中的 v2,出现旧值覆盖新值的问题。

在这里,我们引入了缓存版本的概念来解决这个问题,我们认为每条缓存的数据都应该有一个版本号(业务提供,例如可以是修改数据的时间戳,只要满足单调递增即可)。基于此,缓存的增、删、改操作全部基于这个版本号来进行判断是否执行操作。具体的判断逻辑,在后文介绍。

  • 缓存的增、删、改流程
  • 删除缓存流程

先看下面流程图:

https://p26.toutiaoimg.com/origin/pgc-image/3fa04cda93674ecf85ed484d4aa186fc?from=pc

我们整个流程上是基于消息通知,这个消息生产的时机是只要业务删除了数据库中的数据就可以向缓存更新平台发送一条删除缓存消息。

从流程上可以看到,针对该消息的处理,流程里面并不是简单的删除一个 key,而是将删除的内容标记一下存入缓存。这样做带来了如下的好处:

  • 能够避免缓存穿透;
  • 能够避免缓存 “复活” 已经删除的数据;

如果我们简单的删除缓存中的内容而不是将被删除的内容标记起来存入缓存,那么当出现下面这个场景时,缓存中就会长期存在已经删除的数据,从而导致数据使用方误认为该数据仍然有效。

首先假设现在某个 key 在缓存中不存在。线程先消费了删除该 key 的消息且删除的数据版本是 v1,然后消费了存储缓存 key<->v1 的消息,这个时候就会将 key<->v1 写入缓存,但其实这个数据已经被删除了。

但即便将删除的内容放入缓存,考虑极端情况,仍然可能会有问题,考虑下面这个场景:

有两条邻近产生的消息:

消息 1:删除 key<->v1 的缓存消息

消息 2:新增 key<->v1 的缓存消息

假设消费完消息 1 后,因为某种原因(如平台宕机或者消息队列出问题等等),消息 2 过了很久(缓存 key 已经过期)才被消费到,这时在缓存中存入该消息也会导致被删除的数据 “复活”。所以针对这类情况,有两种措施:

  • 缓存永久有效
  • 超过一定时间未处理的消息就不处理了(我们采取的方案)

关于删除缓存消息中的版本,前文有提到,我们认为每条缓存数据都是有版本的。所以即便业务要删一条数据,那么被删的数据肯定也是有版本号的,而这个版本就是该条消息的版本。我们借助这个版本,就知道缓存中的数据是否是更新的版本,是否可以被覆盖并且被标记为删除了。

新增 & 修改缓存流程

新增缓存消息的处理流程和修改缓存消息的处理流程一致,见下:

https://p26.toutiaoimg.com/origin/pgc-image/ce10abefa0e543c58a9f1226c561acaa?from=pc

首先,消息的生产时机是:

  • 新增缓存消息:
  • 业务往数据库中插入数据
  • 业务流程因为缓存缺失导致直接访问到数据库的数据
  • 修改缓存消息:
  • 业务修改了数据库中的数据

流程上可以看到,当缓存更新平台收到新增 / 修改缓存消息时,拿着消息中的 key 去查缓存,如果没有,则直接存入缓存;如果缓存中存在,则拿着缓存中的数据版本与消息的数据版本进行对比,如果消息中的数据版本更高(即更新),那么就可以安全覆盖缓存中的数据;反之,则不应该覆盖。通过这个流程,就可以很好的避免传统缓存更新里面经常出现的低版本数据覆盖缓存中高版本的数据。

新增 & 修改缓存流程与删除缓存流程大体一致,仅有一个区别点,如下:

新增 & 修改缓存:

https://p26.toutiaoimg.com/origin/pgc-image/3166156a87114744b83ca50939fcf13b.png?from=pc

删除缓存:

https://p26.toutiaoimg.com/origin/pgc-image/20adadb36ed542ca84d91418b856aa2f.png?from=pc

删除流程中关心的是消息中的版本是否大于等于缓存中的版本,而新增 & 修改缓存流程只关心消息中的版本是否大于缓存中的版本,为什么删除流程要关心版本相同的情况而新增 & 修改流程不关心呢?

针对删除,假设删除的数据对应的版本是 3,而缓存中正好也有这个数据且数据版本也是 3,这说明删除操作其实针对的是最新的数据,所以可以将缓存标记为删除态。

针对新增 & 修改,假设某条数据修改后,数据版本为 3。此时缓存里面正好也有版本为 3 的数据,那么缓存中的这条数据会有下面两种情况:

  • 该数据在缓存中被标记为删除态了(即被业务删除了该数据)

若此时写入缓存,会导致删除态数据重新 “复活”

  • 该数据处于正常状态

若此时写入缓存,没有任何意义。

综上,无论针对上述哪种情况,只要不对这条消息进行处理,就不会有任何问题。所以,修改流程只有当消息中的版本高于缓存中的版本时才设置缓存。

4.4 如何保障消息一定会被处理?

由于整个平台依赖于消息队列中间件,那么如果消息队列中间件出了问题(如宕机 / 网络问题 / 消息投递失败等等)导致消费变得很慢或漏掉消息,怎么办?

前文提到,我们提供的缓存访问组件内部会将每条消息记录到业务 DB。缓存更新平台通过业务提供的接口增量轮询该表,确保所有消息都被及时消费掉。通过这样的容错措施,确保不会因为单点故障导致缓存来不及更新。

五、小结

可以看到,通过上述的缓存访问组件和缓存更新平台,可以做到缓存与数据库数据的快速一致,从而既保障了性能同时又最大程度的降低了用户看到过期数据的可能性。

接下来,我们将继续迭代,解决 1)减少缓存访问组件在业务代码上的侵入性;2)在缓存更新平台引入缓存 key 的分析机制,可以自动判定是否是热点 key 等问题。

作者丨大卫

来源丨公众号:携程技术(ID:ctriptech)

dbaplus 社群欢迎广大技术人员投稿,投稿邮箱:[email protected]

关注公众号【dbaplus 社群】,获取更多原创技术文章和精选工具下载

Gdevops 广州站:解答 2021 运维、数据库、金融科技亟待抉择的三大问题