缓存(9)- 热点数据缓存前置

最后更新:2019-11-20

1. 热点查询

当百万的 QPS 属于不同用户时,因缓存是集群化的,所有到达业务后台的请求会根据一定路由规则(如 Hash),分散到请求缓存集群中的某一个节点,具体架构如下图所示:

假设一个节点最大能够支撑 10W QPS,我们只需要在集群中部署 10 台节点即可支持百万流量。但当百万 QPS 都属于同一用户时,即使缓存是集群化的,同一个用户的请求都会被路由至集群中的某一个节点,整体架构如图所示:

即使此节点的机器配置非常好,当前能够支持住百万 QPS。但随着流量上涨,它也无法满足未来的流量诉求。原因有 2 点:

  • 单台机器无法无限升级;

  • 缓存程序本身也是有性能上限的。

此类并发次数非常大、数据完全相同的请求称为热点查询。

有哪些热点查询场景

  • 微博热点吃瓜事件,百万用户同一时间查询某条微博内容。对此条微博的查询就是热查询。

  • 电商里的秒杀或者低价薅羊毛活动,为了第一时间抢到心仪的商品,成百上千万的用户会不断地刷新商品页面,等待秒杀倒计时。对此商品的查看就是热查询。

2. 主从复制进行垂直扩容

虽然单机的机器配置和程序的性能是有上限的,但我们可以利用节点间的主从复制功能来进行节点间的扩容。主从复制开启后,一个主节点可以挂一至多个从。升级后的架构如下图所示

在查询时,将应用内的缓存客户端开启主从随机读。此时,包含一个从的分片的并发能力,可以提升至原来的一倍。随着从节点的增加,单分片的并发性能会不断翻倍。这对于所有请求只会命中某一个固定单分片的热点查询能够很好地应对。

但此方案存在一个较大的问题,就是浪费资源。

主从复制除了有应对热点的功能,另外一个主要作用是为了高可用。当集群中的某一个主节点发生故障后,集群高可用模块会自动对该节点进行故障迁移,从该节点所属分片里选举一个从节点为主节点。为了高可用模块在故障转移时的逻辑能够简单清晰并做到统一,会将集群的从节点数量设置为相同数量。

相同从节点数量也带来了较大的资源浪费。为了应对热点查询,需要不断扩容从分片。但热点查询只会命中其中一个分片,这就导致所有其他分片的从节点全部浪费了。为了节约资源,可以对高可用模块进行改造,不强制所有分片的从节点必须相同,但这个代价也是非常高昂的。另外,热点查询很多时候是随时出现的,并不能提前预测,所以提前扩容某一个分片意义并不大。

总的来说,主从复制能够解决一定流量的热点查询且实施起来较简单。但不具备扩展性,在应对更大流量的热点时会有些吃力。

3. 应用内的前置缓存

热点查询是对相同的数据进行不断重复查询的一种场景。特点是次数多,但需要存储的数据少,因为数据都是相同的。

针对此类业务特性,我们可以将热点数据前置缓存在应用程序内来应对热点查询,并解决前一小节里主从复制方案的扩展性问题。使用了前置缓存的架构如下图所示:

应用内的缓存存储的均是热点数据。当应用扩容后,热点缓存的数量也随之增加。在采用了前置缓存后,在面对热查询时只需扩容应用即可。因为所有应用内均存储了所有的热点数据,且前端负载均衡器(如 Nginx 等)会将所有请求平均地分发到各应用中去。

使用应用内的前置缓存应对热点查询时,仍有以下几个问题需要重点关注。

首先是应用内缓存需要设置上限

应用所属宿主机的内存是有限的,且其内存还要支持业务应用使用。固在使用应用内的前置缓存时,必须设置容量的上限且设置容量满时的逐出策略。逐出策略可以是 LRU,将最少使用的缓存在容量满时清理掉,因为热点缓存需要存储的是访问次数多的数据。

此外,前置缓存也需要设置过期时间,毕竟太久无访问的缓存也肯定是非热点数据,所以可以及时清理掉,提前释放内存空间。

其次是根据业务对待延迟的问题

前置缓存的延迟问题要么采用定期刷新,要么采用主动刷新。

如果业务上可以容忍一定时间的延迟,可以在缓存数据上设置一个刷新时间即可。实现起来非常简单。

如果想要实时感知变化,可以采用 Binlog 的方式,在变更时主动刷新。但前置缓存的主动感知不能在前置缓存的应用里实现,因为应用代码也运行在此机器上,通过 MQ 感知变更会消耗非常多的 CPU 和内存资源。另外,前置缓存里数据很少,很多变更消息都会因不在前置缓存中而被忽略掉。为了实现前置缓存的更新,可以将前置缓存的数据异构一份出来用作判断,升级的方案如下图所示:

通过异构前置缓存用作判断,可以过滤出需要处理的数据,并实时调用对应机器更新即可。此方案实现起来较复杂且异构本来也导致了延迟,实际上大部分场景设置刷新时间即可满足。

再者要把控好瞬间的逃逸流量

应用初始化时,前置缓存是空的。假设在初始化时,瞬间出现热点查询,所有的热点请求都会逃逸到后端缓存里。可能这个瞬间热点就会把后端缓存打挂。

其次,如果前置缓存采用定期过期,在过期时若将数据清理掉,那么所有的请求都会逃逸至后端加载最新的缓存,也有可能把后端缓存打挂。这两种情况对应的流程图如下图所示:

对于这两种情况,可以对逃逸流量进行前置等待或使用历史数据的方案。不管是初始化还是数据过期,在从后端加载数据时,只允许一个请求逃逸。这样最大的逃逸流量为部署的应用总数,量级可控。架构如下图所示:

对于数据初始化为空时,其他非逃逸的请求可以等待前置缓存的数据并设置一个超时时间。对于数据过期需要更新时,并不主动清理掉数据。其他非逃逸请求使用历史脏数据,而逃逸的那一个请求负责把数据取回来并刷新前置缓存。

最后如何发现热点缓存并前置

除了需要应对热点缓存,另外一个重点就是如何发现热点缓存。对于发现热点有两个方式,一种是被动发现,另外一种是主动发现。

被动发现是借助前置缓存有容量上限实现的。在被动发现的方案里,读服务接受到的所有请求都会默认从前置缓存中获取数据,如不存在,则从缓存服务器进行加载。因为前置缓存的容量淘汰策略是 LRU,如果数据是热点,它的访问次数一定非常高,因此它一定会在前置缓存中。借助前置缓存的容量上限和淘汰策略,即实现了热点发现。

但此方式也存在一个问题——所有的请求都优先从前置缓存获取数据,并在未查询到时加载服务端数据到本地的前置缓存里,此方式也会把非热点数据存储至前置缓存里,导致非热点数据产生非必要的延迟性。

主动发现则需要借助一些外部计数工具来实现热点的发现。外部计数工具的思路大体比较类似,都是在一个集中的位置对于请求进行计数,并根据配置的阈值判断某请求是否会命中数据。对于判定为热点的数据,主动的推送至应用内的前置缓存即可。

采用主动发现的架构后,读服务接受到请求后仍然会默认的从前置缓存获取数据,如获取到即直接返回。如未获取到,会穿透去查询后端缓存的数据并直接返回。但穿透获取到的数据并不会写入本地前置缓存。数据是否为热点且是否要写入前置缓存,均由计数工具来决定。此方案很好地解决了因误判断带来的延迟问题。

4. 参考资料

《23讲搞定后台架构实战 》

Edgar

Edgar
一个略懂Java的小菜比