一篇文章解决所有问题:通用缓存策略设计

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

Cache-Aside Pattern

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

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

数据获取场景

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

问题

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

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

问题一解决思路

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

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

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

以上过程可参考下图的辅助理解:

问题二解决思路

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

数据变更场景

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

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

问题

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

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

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

Leases 机制解决思路

stale sets

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

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

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

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

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

thundering herd problem

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

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

Leases 机制的 Redis 实现

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

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 脚本代码示例如下:

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

相关推荐
间彧6 分钟前
Kubernetes的Pod与Docker Compose中的服务在概念上有何异同?
后端
间彧10 分钟前
从开发到生产,如何将Docker Compose项目平滑迁移到Kubernetes?
后端
间彧15 分钟前
如何结合CI/CD流水线自动选择正确的Docker Compose配置?
后端
间彧16 分钟前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧18 分钟前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧22 分钟前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧27 分钟前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang1 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构
睡前要喝豆奶粉1 小时前
在.NET Core Web Api中使用redis
redis·c#·.netcore
草明2 小时前
Go 的 IO 多路复用
开发语言·后端·golang