Help us learn about your current experience with the documentation. Take the survey.

Redis 开发指南

Redis 实例

GitLab 使用 Redis 来实现以下不同目的:

  • 缓存(主要通过 Rails.cache)。
  • 作为使用 Sidekiq 的作业处理队列。
  • 管理共享的应用状态。
  • 存储 CI trace 块。
  • 作为 ActionCable 的 Pub/Sub 队列后端。
  • 速率限制状态存储。
  • 会话。

在大多数环境(包括 GDK)中,所有这些功能都指向同一个 Redis 实例。

在 GitLab.com 上,我们使用独立的 Redis 实例。 有关我们设置的更多详细信息,请参阅 Redis SRE 指南

每个应用程序进程都配置为使用相同的 Redis 服务器,因此它们可用于进程间通信,特别是在 PostgreSQL 不太适用的情况下。例如,临时状态或写入频率远高于读取频率的数据。

如果启用了 Geo,每个 Geo 站点都有自己的独立 Redis 数据库。

我们有关于添加新 Redis 实例的开发文档

键命名

Redis 是一个扁平的命名空间,没有层次结构,这意味着我们必须注意键名以避免冲突。通常我们使用冒号分隔的元素来在应用级别提供结构感。例如可能是 projects:1:somekey

尽管我们按用途将 Redis 使用分为不同的类别,并且在像 GitLab.com 这样的高可用配置中,这些类别可能映射到独立的 Redis 服务器,但默认的 Omnibus 和 GDK 设置共享一个 Redis 服务器。这意味着键应该始终在所有类别中全局唯一。

在 Redis 键名中使用不可变标识符通常是更好的选择 - 例如使用项目 ID 而不是完整路径。如果使用完整路径,当项目被重命名时,该键将不再被查询。如果键的内容因名称更改而失效,最好包含一个使该条目过期的钩子,而不是依赖键的变化。

多键命令

GitLab 支持 cache-related workloads 类型的 Redis Cluster,这是在 epic 878 中引入的。

这给命名带来了额外的限制:当 GitLab 执行需要多个键位于同一 Redis 服务器上的操作时 - 例如,比较两个存储在 Redis 中的集合 - 应该通过将可变部分放在花括号中来确保这一点。例如:

project:{1}:set_a
project:{1}:set_b
project:{2}:set_c

set_aset_b 保证位于同一个 Redis 服务器上,而 set_c 则不是。

目前,我们在开发和测试环境中使用 RedisClusterValidator 来验证这一点,该验证器为 cacheshared_state Redis 实例启用。

强烈建议开发者在适当的地方使用 hash-tags 以促进未来在更多 Redis 类型中采用 Redis Cluster。例如,Namespace 模型为其配置缓存键使用 hash-tags。

要执行多键命令,开发者可以使用 .pipelined 方法,该方法将命令分割并发送到每个节点并聚合回复。但是,这不适用于事务,因为 Redis Cluster 不支持跨槽事务。

对于 Rails.cache,我们通过补丁处理 read_multi_get 中的 MGET 命令以使用 .pipelined 方法。管道的最小大小设置为 1000 个命令,可以通过使用 GITLAB_REDIS_CLUSTER_PIPELINE_BATCH_LIMIT 环境变量进行调整。

结构化日志中的 Redis

对于 GitLab 团队成员:有 基础 进阶 视频,展示了如何在 GitLab.com 上使用 Redis 结构化日志字段。

我们对 Web 请求和 Sidekiq 作业的结构化日志包含每个 Redis 实例的持续时间、调用次数、写入字节数和读取字节数字段,以及所有 Redis 实例的总计。对于特定请求,这可能是:

字段
json.queue_duration_s 0.01
json.redis_cache_calls 1
json.redis_cache_duration_s 0
json.redis_cache_read_bytes 109
json.redis_cache_write_bytes 49
json.redis_calls 2
json.redis_duration_s 0.001
json.redis_read_bytes 111
json.redis_shared_state_calls 1
json.redis_shared_state_duration_s 0
json.redis_shared_state_read_bytes 2
json.redis_shared_state_write_bytes 206
json.redis_write_bytes 255

由于所有这些字段都被索引,因此在生产环境中调查 Redis 使用情况就变得简单直接。例如,要查找从缓存读取数据最多的请求,我们可以按 redis_cache_read_bytes 降序排序。

慢日志

一个视频展示了如何查看慢日志(GitLab 内部) 在 GitLab.com 上

在 GitLab.com 上,Redis 慢日志的条目在 pubsub-redis-inf-gprd* 索引中可用,带有 redis.slowlog 标签。 这显示了执行时间较长且可能存在性能问题的命令。

fluent-plugin-redis-slowlog 项目负责从 Redis 获取 slowlog 条目并将其传递给 Fluentd(最终传递到 Elasticsearch)。

分析整个键空间

Redis Keyspace Analyzer 项目包含工具,用于转储 Redis 实例的完整键列表和内存使用情况,然后分析这些列表,同时从结果中消除可能敏感的数据。它可以用于找到最频繁的键模式,或使用最多内存的模式。

目前这不会自动为 GitLab.com Redis 实例运行,而是根据需要手动运行。

N+1 调用问题

RedisCommands::Recorder 是一个用于从测试中检测 Redis N+1 调用问题的工具。

Redis 通常用于缓存目的。通常,缓存调用是轻量级的,无法产生足够的负载来影响 Redis 实例。但是,仍然可能在不知情的情况下触发昂贵的缓存重新计算。使用此工具分析 Redis 调用,并为它们定义预期的限制。

创建测试

它被实现为一个 ActiveSupport::Notifications 仪器。

你可以创建一个测试来验证可测试的代码只进行一次 Redis 调用:

it 'avoids N+1 Redis calls' do
  control = RedisCommands::Recorder.new { visit_page }

  expect(control.count).to eq(1)
end

或者验证特定 Redis 调用数量的测试:

it 'avoids N+1 sadd Redis calls' do
  control = RedisCommands::Recorder.new { visit_page }

  expect(control.by_command(:sadd).count).to eq(1)
end

你也可以提供一个模式来捕获特定的 Redis 调用:

it 'avoids N+1 Redis calls to forks_count key' do
  control = RedisCommands::Recorder.new(pattern: 'forks_count') { visit_page }

  expect(control.count).to eq(1)
end

你也可以使用特殊的匹配器 exceed_redis_calls_limitexceed_redis_command_calls_limit 来定义 Redis 调用数量的上限:

it 'avoids N+1 Redis calls' do
  control = RedisCommands::Recorder.new { visit_page }

  expect(control).not_to exceed_redis_calls_limit(1)
end
it 'avoids N+1 sadd Redis calls' do
  control = RedisCommands::Recorder.new { visit_page }

  expect(control).not_to exceed_redis_command_calls_limit(:sadd, 1)
end

这些测试可以帮助识别与 Redis 调用相关的 N+1 问题,并确保修复按预期工作。

另请参阅

缓存

Rails.cache 使用的 Redis 实例可以配置键驱逐策略,通常是 LRU,当达到内存限制时,“最近最少使用"的缓存项会被驱逐(删除)。

请参阅 GitLab.com 的键驱逐配置,用于其 Rails.cache 使用的 Redis 实例 redis-cluster-cache。这个 Redis 实例不应达到其最大内存限制,因为在达到最大内存时进行键驱逐会带来延迟成本,详情请参见此问题。请参阅 redis-cluster-cache 的当前内存使用情况

由于此缓存中的数据可能比其设置的过期时间更早消失,因此 Rails.cache 应用于真正具有缓存性质和短暂性的数据。

对于应该可靠地存储在 Redis 而不是缓存的数据,你可以使用 Gitlab::Redis::SharedState

工具类

我们有一些额外的类来帮助处理特定用例。这些主要用于精细控制 Redis 使用,因此它们不会与 Rails.cache 包装器结合使用:我们要么使用 Rails.cache,要么使用这些类和直接的 Redis 命令。

我们更倾向于使用 Rails.cache,这样我们可以获得未来 Rails 优化带来的好处。Ruby 对象在写入 Redis 时会被序列化,因此我们必须注意既不存储巨大的对象,也不存储不受信任的用户输入。

通常,只有在以下至少一个条件为真时,我们才会使用这些类:

  1. 我们想要操作非缓存 Redis 实例上的数据。
  2. Rails.cache 不支持我们想要执行的操作。

Gitlab::Redis::{Cache,SharedState,Queues}

这些类包装了 Redis 实例(使用 Gitlab::Redis::Wrapper), 以便方便地直接使用它们。典型用法是在类上调用 .with,它接受一个块,该块产生 Redis 连接。例如:

# 从共享状态(持久化)Redis 获取 `key` 的值
Gitlab::Redis::SharedState.with { |redis| redis.get(key) }

# 检查 `value` 是否是集合 `key` 的成员
Gitlab::Redis::Cache.with { |redis| redis.sismember(key, value) }

Gitlab::Redis::Cache Rails.cache 共享同一个 Redis 实例, 因此如果配置了键驱逐策略,它也会有键驱逐策略。对于真正具有缓存性质且如果缺失可以重新生成的数据,请使用此类。

使用此类时,请确保始终为键设置 TTL,因为它不像 Rails.cache 那样设置默认 TTL,其默认 TTL为 8 小时。考虑为一般缓存使用 8 小时的 TTL,这匹配一个工作日,意味着用户通常每天只会对相同内容遇到一次缓存未命中。

当你预计要向缓存添加大量工作负载,或对其生产影响有疑问时,请联系 #g_durability

Gitlab::Redis::SharedState 不会配置键驱逐策略。 对于无法重新生成且预期会持续存在直到其设置过期时间的数据,请使用此类。它也不为键设置默认 TTL,因此使用此类时几乎总是应该为键设置 TTL。

Gitlab::Redis::Boolean

在 Redis 中,每个值都是一个字符串。 Gitlab::Redis::Boolean 确保布尔值被一致地编码和解码。

Gitlab::Redis::HLL

Redis 的 PFCOUNTPFADDPFMERGE 命令在 HyperLogLogs 上操作,这是一种数据结构,允许以低内存使用量估计唯一元素的数量。有关更多信息, 请参阅 Redis 中的 HyperLogLogs

Gitlab::Redis::HLL 为在 HyperLogLogs 中添加和计数值提供了便捷的接口。

Gitlab::SetCache

在需要高效检查某个项目是否在一组项目中的情况下,我们可以使用 Redis 集合。 Gitlab::SetCache 提供了一个 #include? 方法,该方法使用 SISMEMBER 命令,以及 #read 来获取集合中的所有条目。

这被 RepositorySetCache 使用,以提供一种便捷的方式来使用集合来缓存诸如分支名称等存储库数据。

后台迁移

基于 Redis 的迁移涉及使用 SCAN 命令扫描整个 Redis 实例以查找特定的键模式。 对于大型 Redis 实例,迁移可能会超过常规或部署后迁移的时间限制RedisMigrationWorker 作为后台迁移执行长时间运行的 Redis 迁移。

要通过创建类来执行后台迁移:

module Gitlab
  module BackgroundMigration
    module Redis
      class BackfillCertainKey
        def perform(keys)
        # 实现清理或回填键的逻辑
        end

        def scan_match_pattern
        # 定义 `SCAN` 命令的匹配模式
        end

        def redis
        # 定义具体的 Redis 实例
        end
      end
    end
  end
end

要通过部署后迁移触发 worker:

class ExampleBackfill < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  MIGRATION='BackfillCertainKey'

  def up
    queue_redis_migration_job(MIGRATION)
  end
end