掘金 后端 ( ) • 2024-05-06 14:31

对于应用系统来讲,使用缓存提升数据获取效率已经是很常规的手段,本文主要记录对于缓存常用策略模式做总结,方便为后续同类场景设计上提供参考。

Cache-Aside Pattern

关于缓存的使用策略,目前通用解决方案是 Cache-Aside Pattern,该模式详细介绍可参考 Azure 的 Architecture 系列文章中 Cache Aside Pattern 部分(也称为 Lazy Loading)。该模式属于Eventual Consistency(最终一致性)方案。

Cache-Aside Pattern

这里将对缓存的使用分为两种场景展开分析,第一种场景是 数据获取,另一种为 数据变更

数据获取场景

首先是 数据获取 场景,简单说就是先通过缓存,若缓存中有数据则直接返回结果,若缓存中没有数据则去数据库获取数据,并将数据放入缓存且成功后,返回结果。具体流程可参照下图辅助理解: Cache-Aside Query.png

问题

但是,上述场景存在如下两个问题:

  1. 若数据库中不存在数据,多次查询均会穿透缓存造成对数据库的访问压力;
  2. 当并发请求某条在缓存中不存在的数据,均会穿透到数据库访问,并引发缓存的同时更新。 针对上述问题,这里给出可行性解决方案的大体思路:

问题一解决思路

  1. 首先是对数据有效性进行判断,若可以根据一定校验规则检查数据有效性,则可以在应用程序入口处进行拦截,减少后续访问;

  2. 若数据有效性判断失效,并且在数据库中未获取到数据的情况。则需区分对待,若能通过业务确定为无意义数据,可将无意义数据放入缓存中,减少对数据库的访问。

    另一种情况则是数据在业务场景下尚未生效,但是请求先抵达。那么可将待生效数据放入缓存,并设置短期的数据过期时间,当请求抵达时判断是否生效,未生效则返回未查询到数据(这里站在业务角度依然是无效数据,只不过这个无效数据是暂时的,所以增加了过期时间,过期后再次穿透缓存可以从数据库中获取有效业务数据)。

以上过程可参考下图的辅助理解: Query Invalid Data.png

问题二解决思路

  1. 如果是因为设置了缓存中的数据过期时间,导致在某个并发获取的数据不存在而造成的数据库访问压力,可以通过为不同缓存设置随机过期时间,避免大量缓存同时过期造成数据库访问压力;
  2. 针对同一条数据记录并发更新缓存的问题,结合下面 数据变更 场景一同说明;

数据变更场景

接下来看 数据变更 场景,不论是对数据的变更或修改,都是先操作数据,待数据库变更成功后删除缓存数据即可,具体流程可参照下图辅助理解: Cache-Aside Modified.png

为什么要先更新数据库再操作缓存? 主要是为了减少数据不一致的概率。为何采用删除缓存而非更新缓存? 也是为了进一步降低数据不一致的概率,因为删除操作是具有幂等性。采用更新操作易造成数据不一致,具体情况可参照下图辅助理解: Modified Cache Inconsistent.png

问题

即便按照上述方案实施,仍然存在数据不一致的情况。

  • 一种是数据库数据完成变更但缓存未删除: 可基于上述方案增加延迟双删的策略(执行缓存删除操作后,启动延迟异步任务,删除缓存中同样数据);
  • 另一种则相对较复杂,出现在并发场景下,且对同一条数据记录短期内多次修改,同时伴随多次获取操作。具体过程可参照下图辅助理解:

Delete Cache Inconsistent.png

这里参考 Facebook 发表的论文 Scaling Memcache at Facebook 中的实践,通过引入“leases”(这里译为“租约”)机制,来处理该问题。

Leases 机制解决思路

stale sets

要解决缓存数据 “过期” 更新问题,结合 “leases” 机制实现方法大致如下:

当有多个请求抵达缓存时,缓存中并不存在该值时会返回给客户端一个 64 位的 token ,这个 token 会记录该请求,同时该 token 会和缓存键作为绑定,该 token 即为上文中的“leases”,其他请求需要等待这个"leases"过期后才可申请新的“leases”,客户端在更新时需要传递这个 token ,缓存验证通过后会进行数据的存储。

具体过程可参照下图辅助理解:

Leases-Delete Cache Consistent.png

理解了上述过程,回看上文中提到查询场景中尚未解决的第二个问题,也就是查询请求量大导致穿透到数据库的情况,触发的具体过程可参照下图理解:

Query Thundering Herds.png

这一问题场景就是所谓的 Thundering herd problem(惊群问题)。

thundering herd problem

这里结合 leases 机制优化后,可以避免惊群问题 。具体可以结合下图理解 leases 机制在该问题场景下的作用:

Leases-Query Thundering Herds.png

在 Facebook 的论文中,leases 对应的 token 应该是由缓存或缓存的代理层实现,这里为了降低缓存部署节点的计算压力,其实可以将 token 生成规则放到客户端实现,缓存只用来暂存该 token

Leases 机制的 Redis 实现

Redis 为例, 需要结合 Lua 脚本来实现简易的 Leases机制Lua 脚本代码示例如下:

local key = KEYS[1]
local token = ARGV[1]
local value = redis.call('get', key)
if not value then
    redis.replicate_commands()
    local lease_key = 'lease:'..key
    redis.call('set', lease_key, token)
    return {false, token}
else
    return {value, false}
end

为了与业务数据作区分,脚本中将业务存储键增加了 lease: 前缀,后续可以对指定前缀的键值数据作清理,也可以在上述脚本中对 lease: 前缀数据增加过期时间。

这里返回值对于客户端来讲变成了一个数组,需要对数组中的值进行逻辑判断处理,根据 token 有值的情况进行等待与重试处理。同样对于缓存数据的获取,也不能直接使用 Redis 的指令,需要配合 Lua 脚本实现 token 检查机制,Lua 脚本代码示例如下:

local key = KEYS[1]
local token = ARGV[1]
local lease_key = 'lease:'..key
local lease_value = redis.call('get', lease_key)
if lease_value == token then
  local value = redis.call('get', key)
  return {value, true}
else
  return {false, false}
end

通过上述脚本可以发现,以上操作增加了数据处理复杂度。主要表现为缓存模型的变化,需要应用端做适配改造。在实际制定方案时需根据实际情况,判定是否采用上述方案保证严格的一致性。

数据压缩

使用缓存时,通常将业务数据序列化为 JSON 格式字符串进行存储。但在数据量级较大的情况下,最好对数据进行压缩处理。可以降低数据存储空间的占用,提高缓存利用率。具体压缩方案可以使用 MsgPack,或 Protobuf 等。通常为了减少缓存所部属节点的计算压力,建议在客户端对数据压缩与解压进行处理。还可以通过建立缓存专用对象,进一步减少序列化之后的存储容量,即仅保留使用缓存场景的上下文中用到的属性信息即可。

如果利用 Redis 作为缓存,可以针对 Redis 对于集合类型启用压缩列表的特性降低缓存容量的开销。可以将原本保存为 key:value 结构的字符串类型分解为 Hash 结构。具体形式主要为将 key 进行拆分,保留 key 的后几位字符作为 Hash 结构的 key,形式如: key:{hash_key}:value

分级缓存池

即便是在一个业务场景下,对于缓存的使用也不尽相同。即便是同类数据,其变更与访问的频率也存在差异。对于大量极端热点数据,采取数据分片的方式并不能提高性能,因为数据会集中于某个分片中。

通常采用缓存的多副本机制来平衡热点流量。另一种方式与之类似,可以理解为将缓存进行分级池化,对数据的访问频次进行分级,如重点数据缓存,热点数据缓存等。此种方案也可结合不同硬件设施进行差异化部署。当然这部分能力如果沉淀到基础设施平台会降低对业务系统的影响。

但是分级缓存有可能带来另外一个问题,由于特殊业务场景引发,就是一个业务请求中的数据,如果分散到不同的缓存集群会造成多次网络开销,进而降低系统性能。那么针对这类数据如果能够识别,最好将其汇聚到同一缓存池中。一方面降低网络开销,另一方面提高缓存的整体吞吐。

Reference