缓存指南
本文档描述了 GitLab 中使用的各种缓存策略、如何有效实施它们以及各种注意事项。本内容摘自精彩的缓存工作坊。
什么是缓存?
数据的更快存储,它具有以下特点:
- 在计算的许多领域都有使用。
- 处理器有缓存,硬盘有缓存,很多东西都有缓存!
- 通常更接近您希望数据最终到达的位置。
- 数据的更简单存储。
- 临时性的。
什么是快速?
每个网页的目标应该是返回时间在 100 毫秒以内:
- 这是可以实现的,但现代应用程序需要缓存。
- 更大的响应需要更长时间构建,缓存对于保持恒定速度至关重要。
- 缓存读取通常在 1 毫秒以下。几乎所有情况都能从中受益。
- 只在后续页面加载时快速是不够的,因为初次体验也很重要,所以这不是完整的解决方案。
- 用户特定的数据使这变得具有挑战性,并且在重构现有应用程序以满足此速度目标时提出了最大的挑战。
- 用户特定的缓存仍然有效,但它们只会导致比用户间共享的通用缓存更少的缓存命中。
- 我们的目标是始终让页面加载的大部分内容来自缓存。
为什么使用缓存?
- 为了让事情更快!
- 为了避免 IO。
- 磁盘读取。
- 数据库查询。
- 网络请求。
- 避免多次重新计算相同的结果:
- 视图渲染。
- JSON 渲染。
- Markdown 渲染。
- 提供冗余。在某些情况下,缓存可以帮助掩盖其他地方的故障,例如 CloudFlare 的"始终在线"功能
- 减少内存消耗。在 Ruby 中处理更少,只是获取大字符串
- 节省金钱。这在云计算中尤其如此,与 RAM 相比,处理器更昂贵。
关于缓存的疑虑
- 一些工程师反对使用缓存,除非作为最后手段,认为它是一种变通方法,真正的解决方案是改进底层代码使其更快。
- 这可能是源于对缓存过期的恐惧,这是可以理解的。
- 但缓存仍然更快。
- 您必须同时使用这两种技术才能实现真正的性能:
- 例如,如果初始冷写入如此慢以至于超时,那么缓存就没有意义了。
- 但很少有缓存不能提升性能的情况。
- 然而,您完全可以使用缓存作为一种快速的变通方法,这也很酷。 有时"真正的"修复需要几个月,而缓存只需要一天就能实现。
GitLab 中的缓存
尽管 Redis 缓存存在缺点,您仍然应该充分利用 GitLab 应用程序内部和 GitLab.com 上的缓存设置。我们的缓存利用率预测表明我们有充足的余量。
工作流程
方法
- 尽可能靠近最终用户进行缓存。
- 缓存视图渲染是迄今为止最好的性能改进。
- 尝试为尽可能多的用户缓存尽可能多的数据:
- 通用数据可以为所有人缓存。
- 在构建新功能时,您必须牢记这一点。
- 尽可能保留缓存数据:
- 使用嵌套缓存来在过期时尽可能多地保留缓存数据。
- 尽可能减少对缓存的请求次数:
- 这减少了网络问题引起的可变延迟。
- 降低每次读取缓存的开销。
识别什么适合缓存
添加的缓存是否"值得"?这可能难以衡量,但您可以考虑:
- 缓存的数据有多大?
- 这可能会影响您应该使用的缓存存储类型,例如将大型 HTML 响应存储在磁盘上而不是 RAM 中。
- 缓存数据节省了多少 I/O、CPU 和响应时间?
- 如果您的缓存数据很大,但渲染它的时间很短,例如将大块文本转储到页面中,这可能表明缓存它的最佳位置。
- 这个数据被访问的频率如何?
- 缓存频繁访问的数据通常效果更好。
- 这个数据多久更改一次?
- 如果缓存在再次读取之前就轮换了,这个缓存实际上有用吗?
工具
调查
- 性能条是您在本地和生产环境中调查的第一步。 寻找昂贵的查询、过多的 Redis 调用等。
- 生成火焰图:在 URL 中添加
?performance_bar=flamegraph以帮助找到 花费时间的方法。 - 深入研究 Rails 日志:
- 也要仔细查看部分渲染的时间。
- 要单独测量响应时间,您可以使用
jq解析 JSON 日志:tail -f log/development_json.log | jq ".duration_s"tail -f log/api_json.log | jq ".duration_s"
- 在您跟踪
development.log时需要注意的事项的一些提示:tail -f log/development.log | grep "cache hits"tail -f log/development.log | grep "Rendered "
- 当您找到正确的位置后:
- 删除或注释掉代码段,直到找到原因。
- 使用
binding.pry来实时请求中探索。这需要 前台 Web 进程。
验证
- Grafana,特别是以下仪表板:
- 日志
- 对于 Grafana 图表无法覆盖您需要的情况,请改用 Kibana。
- 功能标志:
- 添加缓存时几乎总是值得使用功能标志。
- 打开和关闭它,并观察 Grafana 中的波动线。
- 预期响应时间最初会上升,因为缓存正在预热。
- 直到您以 100% 运行标志时,效果才明显。
- 性能条:
- 在本地使用它,并在 Redis 列表中查找缓存调用。
- 也在生产环境中使用它来验证您的缓存键是否符合预期。
- 火焰图:
- 将
?performance_bar=flamegraph附加到页面
- 将
缓存级别
高级
- HTTP 缓存:
- 使用 ETags 和过期时间来指示浏览器提供自己的缓存版本。
- 这仍然会命中 Rails,但跳过了视图层。
- 反向代理缓存中的 HTTP 缓存:
- 与上面相同,但带有
public设置。 - 这不是指示浏览器,而是指示反向代理(如 NGINX、HAProxy、Varnish)提供缓存版本。
- 后续请求永远不会命中 Rails。
- 与上面相同,但带有
- HTML 页面缓存:
- 将 HTML 文件写入磁盘
- Web 服务器(如 NGINX、Apache、Caddy)直接提供 HTML 文件,跳过 Rails。
- 视图或操作缓存
- Rails 将整个渲染的视图写入其缓存存储并返回。
- 片段缓存:
- 在 Rails 缓存存储中缓存视图的部分。
- 缓存的部分在视图渲染时插入。
低级
- 方法缓存:
- 多次调用相同的方法但只计算一次值。
- 存储在 Ruby 内存中。
@article ||= Article.find(params[:id])strong_memoize_attr :method_name
- 请求缓存:
- 在 Web 请求期间为键返回相同的值。
Gitlab::SafeRequestStore.fetch
- 读写穿透 SQL 缓存:
- 位于数据库前面的缓存。
- Rails 在请求中对相同查询执行此操作。
- 特殊缓存。
- 针对一种用例的超特定缓存。
Rails 内置的缓存助手
这在 Rails 指南中有很好的文档记录
- HTML 页面缓存和操作缓存不再默认包含,但它们仍然有用。
- Rails 指南将 HTTP 缓存称为 条件 GET。
- 对于 Rails 的缓存存储,请记住两个非常重要(且几乎相同)的方法:
- 视图中的
cache,它几乎是以下内容的别名: - 您可以在任何地方使用的
Rails.cache.fetch。
- 视图中的
cache包含一个"模板树摘要",当您修改视图文件时会发生变化。
Rails 缓存选项
expires_in
这为缓存条目设置了生存时间 (TTL),这是最有用 (也是最常用)的缓存选项。这在大多数 Rails 缓存助手中都受支持。
TTL 如果未使用 expires_in 设置,
默认为 8 小时。
考虑使用 8 小时的 TTL 进行一般缓存,因为这匹配一个工作日,意味着用户通常每天只会对相同内容有一次缓存未命中。
写入大量数据时,考虑使用更短的过期时间以减少其对内存使用的影响。
race_condition_ttl
此选项防止同一时间对键进行多个未命中缓存。 第一个发现键过期的进程将 TTL 增加此量,然后 设置新的缓存值。
当缓存键处于非常重的负载下使用时,用于防止多个同时写入, 但应设置为低值,例如 10 秒。
GitLab 上的 Rails 缓存行为
Rails.cache 使用 Redis 作为存储。
GitLab 实例(如 GitLab.com)可以配置 Redis 进行键驱逐。
请参阅 Redis 开发指南。
何时使用 HTTP 缓存
当整个响应可缓存时使用条件 GET 缓存:
- 当您不使用公共缓存时没有隐私风险。您只缓存 用户在浏览器中看到的内容,针对该用户。
- 特别有用于轮询的端点。
- 好的例子:
- 我们轮询更新的讨论列表。使用最后创建条目的
updated_at值作为etag。 - API 端点。
- 我们轮询更新的讨论列表。使用最后创建条目的
可能的缺点
- 用户和 API 库可以忽略缓存。
- 有时 Chrome 对缓存做奇怪的事情。
- 您在开发模式下忘记它的存在,当您的更改没有出现时会感到愤怒。
- 理论上,在所有地方使用条件 GET 缓存都有意义,但实际上有时 可能会导致奇怪的问题。
何时使用视图或操作缓存
这在 Rails 世界中不再非常常用:
- 支持已从 Rails 核心中移除。
- 通常最好查看反向代理缓存或条件 GET 响应。
- 但是,它提供了一种相对简单的方法来模拟 HTML 页面缓存而不 写入磁盘,这在云环境中很有用。
- 在缓存存储中存储相当大的标记块。
- 我们确实在 API 上有一个自定义实现,在那里它更有用,在
cache_action中。
何时使用片段缓存
一直使用!
- 可能是 Rails 中最有用的缓存类型,因为它允许您缓存视图的部分、 整个部分、部分集合。
- 应该设计部分集合的渲染,目标是使用
cached: true。 - 在部分渲染调用周围缓存比在部分内部缓存更快, 但这样您会失去模板树摘要,这意味着当您更新该部分时缓存不会 自动过期。
- 注意不要引入大量缓存调用,例如在循环中放置缓存调用。
- 有时这是不可避免的,但有解决方法,如部分集合缓存。
- 视图渲染和 JSON 生成很慢,应尽可能缓存。
何时使用方法缓存
- 使用实例变量或
StrongMemoize。 - 当在请求中多次需要相同的值时很有用。
- 可用于防止对同一键进行多次缓存调用。
- 可能导致 ActiveRecord 对象出现问题,其中值在您调用 reload 之前不会改变,这往往在测试套件中出现。
何时使用请求缓存
- 使用模式与方法缓存类似,但可用于多个方法。
- 在请求期间存储某物的标准化方式。
- 由于查找类似于缓存查找(在 GitLab 实现中),我们可以使用
相同的键。这就是
Gitlab::Cache.fetch_once的工作原理。
可能的缺点
- 例如,使用
Gitlab::Cache::JsonCache和Gitlab::SafeRequestStore向缓存对象添加新属性 可能导致数据陈旧问题,其中缓存数据没有新属性的适当值 (请参阅此过去的事件)。
何时使用 SQL 缓存
Rails 在请求中自动对相同查询执行此操作,因此对于该用例无需任何操作。
- 然而,使用像
identity_cache这样的 gem 有不同的目的:缓存查询 跨多个请求。 - 避免在单个对象查找上使用,如
Article.find(params[:id])。 - 有时无法使用结果,因为它提供只读对象。
- 它也可以缓存关系,在我们要返回 一系列事物但不关心以不同方式过滤或排序它们的情况下很有用。
何时使用特殊缓存
如果您已经用尽了其他选项,并且必须缓存一些真正棘手的东西, 是时候寻找自定义解决方案了:
- GitLab 中的例子包括
RepositorySetCache、RepositoryHashCache和AvatarCache。 - 在可能的情况下,您应该避免创建自定义缓存实现,因为它增加了 不一致性。
- 可能极其有效。例如,围绕
merged_branch_names的缓存, 使用 RepositoryHashCache。
缓存过期
Redis 如何过期键
简而言之:最旧的东西被新东西替换:
- 一篇有用的文章关于将 Redis 配置为 LRU 缓存。
- 许多不同缓存驱逐策略的选项。
- 您可能需要
allkeys-lru,它在功能上类似于 Memcached。 - 在 Redis 4.0 及更高版本中,提供了 allkeys-lfu, 它相似但不同。
- 我们现在使用
UNLINK而不是DEL来处理所有显式删除,这允许 Redis 在自己的时间内回收内存,而不是立即回收。- 这将键标记为已删除并快速返回成功值, 但实际上稍后会删除它。
Rails 如何过期键
- Rails 倾向于使用 TTL 和缓存键过期而不是使用显式删除。
- 在视图中进行片段缓存时,缓存键默认包含模板树摘要,
这确保对模板的任何更改都会自动使缓存过期。
- 不过,在助手函数中并非如此,请注意。
- Rails 在 ActiveRecord 对象上有两个缓存键方法:
cache_key_with_version和cache_key。 第一个在 5.2 及更高版本中默认使用,并且是之前的标准行为; 它在键中包含updated_at时间戳。
缓存键组件
在 application.log 中找到的示例:
cache(@project, :tag_list)
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29/projects/16-2021031614242546945
2/tag_list- 视图名称和模板树摘要
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29 - 模型名称、ID 和
updated_at值projects/16-20210316142425469452 - 我们传入的符号,转换为字符串
tag_list
注意事项
- 用户特定的数据
- 这是最重要的!
- 这并不总是明显的,特别是在视图中。
- 您必须检查您想要缓存的区域中使用的每个助手方法。
- 时间特定的数据,例如"Billy 8 分钟前发布了这个"。
- 记录正在更新但没有触发
updated_at字段更改 - Rails 助手函数将模板摘要合并到视图中的键中,但这在其他地方不会发生,例如在助手函数中。
Grape::Entity使 API 层的有效缓存变得极其困难。稍后会详细介绍。- 不要在视图中的片段缓存助手函数内部使用
break或return- 它永远不会写入缓存条目。 - 在缓存键中重新排序可能返回旧数据的项:
- 例如有两个可能返回
nil的值并交换它们。 - 使用哈希,如
{ project: nil }。
- 例如有两个可能返回
- Rails 在数组成员上调用
#cache_key来查找键,但它不在哈希值上调用。