如何使用 Redis 扩展 Ruby on Rails

了解 Redis 如何在业务应用程序增长时解决性能缓慢问题。
319 位读者喜欢这篇文章。
Consumer Financial Protection Bureau on open source and "growing the pie"

Opensource.com

Ruby on Rails 是一个用于快速构建业务线应用程序的有用框架。但是随着应用程序的增长,我们面临着扩展挑战。我们可以使用各种工具,但向应用程序添加不同的技术会增加复杂性。本文探讨如何使用 Redis 内存数据结构存储作为一种多用途工具来解决不同的问题。

首先,我们需要安装 Redis,这可以使用 brewapt-getdocker 完成。当然,我们还需要安装 Ruby on Rails。作为一个例子,我们将构建一个在线活动管理应用程序。以下是基本模型。

class User < ApplicationRecord
  has_many :tickets
end
class Event < ApplicationRecord
  has_many :tickets
end
class Ticket < ApplicationRecord
  belongs_to :user
  belongs_to :event
end

Redis 作为缓存

应用程序的第一个要求是显示一个活动售出了多少张票以及赚了多少钱。我们将创建这些方法。

class Event < ApplicationRecord
  def tickets_count
    tickets.count
  end
  def tickets_sum
    tickets.sum(:amount)
  end
end

这段代码将对我们的数据库执行 SQL 查询以获取数据。问题是随着规模的扩大,它可能会变得缓慢。为了加速,我们可以缓存这些方法的结果。首先,我们需要为我们的应用程序启用 Redis 缓存。将 gem 'redis-rails' 添加到 Gemfile 并运行 bundle install。在 config/environments/development.rb 中,配置

config.cache_store = :redis_store, {
  expires_in: 1.hour,
  namespace: 'cache',
  redis: { host: 'localhost', port: 6379, db: 0 },
  }

指定 cache 命名空间是可选的,但它有所帮助。此代码还将默认应用程序级别过期时间设置为一小时,届时 Redis 生存时间 (TTL) 将清除陈旧数据。现在我们可以将我们的方法包装在 cache 块中。

class Event < ApplicationRecord
  def tickets_count
    Rails.cache.fetch([cache_key, __method__], expires_in: 30.minutes) do
      tickets.count
    end
  end
  def tickets_sum
    Rails.cache.fetch([cache_key, __method__]) do
      tickets.sum(:amount)
    end
  end
end

Rails.cache.fetch 将检查 Redis 中是否存在特定键。如果该键存在,它将向应用程序返回与该键关联的值,并且不会执行代码。如果该键不存在,Rails 将运行块内的代码并将数据存储在 Redis 中。cache_key 是 Rails 提供的一种方法,它将组合模型名称、主键和上次更新时间戳以创建唯一的 Redis 键。我们将添加 __method__,它将使用特定方法的名称来进一步唯一化键。我们可以选择在某些方法上指定不同的过期时间。Redis 中的数据将如下所示。

{"db":0,"key":"cache:events/1-20180322035927682000000/tickets_count:","ttl":1415,
"type":"string","value":"9",...}

{"db":0,"key":"cache:events/1-20180322035927682000000/tickets_sum:","ttl":3415,
"type":"string","value":"127",...}

{"db":0,"key":"cache:events/2-20180322045827173000000/tickets_count:","ttl":1423,
"type":"string","value":"16",...}

{"db":0,"key":"cache:events/2-20180322045827173000000/tickets_sum:","ttl":3423,
"type":"string","value":"211",...}

在这种情况下,活动 1 售出了 9 张票,总计 127 美元,活动 2 售出了 16 张票,总计 211 美元。

缓存失效

如果在我们缓存此数据后立即售出另一张票怎么办?网站将显示缓存的内容,直到 Redis 使用 TTL 清除这些键。在某些情况下,显示陈旧的内容可能是可以接受的,但我们希望显示准确、最新的数据。这就是使用上次更新时间戳的地方。我们将从子模型 (ticket) 到父模型 (event) 指定一个 touch: true 回调。Rails 将触及 updated_at 时间戳,这将强制为 event 模型创建一个新的 cache_key

class Ticket < ApplicationRecord
  belongs_to :event, touch: true
end
# data in Redis
{"db":0,"key":"cache:events/1-20180322035927682000000/tickets_count:","ttl":1799,
  "type":"string","value":"9",...}
{"db":0,"key":"cache:events/1-20180322035928682000000/tickets_count:","ttl":1800,
  "type":"string","value":"10",...}
...

模式是:一旦我们创建了缓存键和内容的组合,我们就不再更改它。我们使用新键创建新内容,之前缓存的数据保留在 Redis 中,直到 TTL 清除它。这会浪费一些 Redis RAM,但它简化了我们的代码,我们不需要编写特殊的回调来清除和重新生成缓存。

我们需要谨慎选择我们的 TTL,因为如果我们的数据频繁更改且 TTL 较长,我们将存储太多未使用的缓存。如果数据不经常更改但 TTL 太短,即使没有任何更改,我们也会重新生成缓存。以下是我写的一些关于如何平衡这一点的 建议

注意事项:缓存不应是权宜之计。我们应该寻找编写高效代码和优化数据库索引的方法。但有时缓存仍然是必要的,并且可以成为为更复杂的重构争取时间的快速解决方案。

Redis 作为队列

下一个要求是为一个或多个活动生成报告,显示每个活动收到的金额的详细统计信息,并列出销售的个人票据和用户信息。

class ReportGenerator
  def initialize event_ids
  end
  def perform
    # query DB and output data to XLSX
  end
end

生成这些报告可能很慢,因为必须从多个表中收集数据。与其让用户等待响应并下载电子表格,我们可以将其转换为后台作业,并在完成后发送电子邮件,其中包含附件或文件链接。

Ruby on Rails 有一个 Active Job 框架,可以使用各种队列。在本例中,我们将利用 Sidekiq 库,该库将数据存储在 Redis 中。将 gem 'sidekiq' 添加到 Gemfile 并运行 bundle install。我们还将使用 sidekiq-cron gem 来安排定期作业。

# in config/environments/development.rb
config.active_job.queue_adapter = :sidekiq
# in config/initializers/sidekiq.rb
schedule = [
  {'name' => MyName, 'class' => MyJob, 'cron'  => '1 * * * *',  
  'queue' => default, 'active_job' => true }
]
Sidekiq.configure_server do |config|
 config.redis = { host:'localhost', port: 6379, db: 1 }
 Sidekiq::Cron::Job.load_from_array! schedule
end
Sidekiq.configure_client do |config|
 config.redis = { host:'localhost', port: 6379, db: 1 }
end

请注意,我们为 Sidekiq 使用了不同的 Redis 数据库。这不是必需的,但如果我们需要刷新缓存,将缓存存储在单独的 Redis 数据库(甚至在不同的服务器上)中可能很有用。

我们还可以为 Sidekiq 创建另一个配置文件,以指定它应该监视哪些队列。我们不希望有太多队列,但是只有一个队列可能会导致队列被低优先级作业阻塞并延迟高优先级作业的情况。在 config/sidekiq.yml

---
:queues:
  - [high, 3]
  - [default, 2]
  - [low, 1]

现在我们将创建作业并指定低优先级队列。

class ReportGeneratorJob < ApplicationJob
  queue_as :low
  self.queue_adapter = :sidekiq  
  def perform event_ids
    # either call ReportGenerator here or move the code into the job
  end
end

我们可以选择设置不同的队列适配器。Active Job 允许我们在同一应用程序中为不同的作业使用不同的队列后端。我们可能有每天需要运行数百万次的作业。Redis 可以处理这种情况,但我们可能希望使用不同的服务,例如 AWS Simple Queue Service (SQS)。我写了一篇 不同队列选项的比较,可能对您有所帮助。

Sidekiq 利用了许多 Redis 数据类型。它使用列表来存储作业,这使得排队非常快。它使用排序集来延迟作业执行(无论是应用程序专门请求的,还是在重试时进行指数退避时)。Redis 哈希存储有关已执行作业数量及其花费时间的统计信息。

定期作业也存储在哈希中。我们可以使用普通的 Linux cron 来启动作业,但这会在我们的系统中引入单点故障。使用 Sidekiq-cron,计划存储在 Redis 中,并且运行 Sidekiq worker 的任何服务器都可以执行作业(该库确保只有一个 worker 会在计划的时间抓取特定作业)。Sidekiq 还有一个很棒的 UI,我们可以在其中查看各种统计信息,并暂停计划作业或按需执行它们。

Redis 作为数据库

最后一个业务需求是跟踪每个活动页面的访问次数,以便我们可以确定它们的受欢迎程度。为此,我们将使用排序集。我们可以直接创建 REDIS_CLIENT 以调用原生 Redis 命令,或者使用 Leaderboard gem,它提供了额外的功能。

# config/initializers/redis.rb
REDIS_CLIENT = Redis.new(host: 'localhost', port: 6379, db: 1)
# config/initializers/leaderboard.rb
redis_options = {:host => 'localhost', :port => 6379, :db => 1}
EVENT_VISITS = Leaderboard.new('event_visits', Leaderboard::DEFAULT_OPTIONS, redis_options)

现在我们可以从控制器的 show 操作中调用它

class EventsController < ApplicationController
  def show
    ...
    REDIS_CLIENT.zincrby('events_visits', 1, @event.id)
    # or
    EVENT_VISITS.change_score_for(@event.id, 1)
  end
end
# data in Redis
{"db":1,"key":"events_visits","ttl":-1,"type":"zset","value":[["1",1.0],...,["2",4.0],["7",22.0]],...}

当我们有数百万个排序集成员时,向排序集中添加项目最终会变慢,但是对于大多数用例来说,Redis 已经足够快了。我们现在可以使用此排序集来确定每个事件的 rankscore。或者我们可以使用 REDIS_CLIENT.zrange('events_visits', 0, 9) 显示前 10 个事件。

由于我们使用 Redis 存储非常不同类型的数据(缓存、作业等),因此我们需要小心不要耗尽 RAM。Redis 会自行驱逐键,但它无法区分保存陈旧缓存的键与对我们的应用程序重要的键。

更多信息

我希望这篇文章对在 Ruby on Rails 应用程序中为各种目的使用 Redis 进行了有用的介绍。如果您想了解更多信息,请查阅以下链接

标签
User profile image.
Oracle Cloud 高级软件开发工程师。此处表达的观点仅代表我个人,不一定代表我的雇主。构建出色软件最重要的事情不是如何做,而是客户的需求是什么以及为什么存在这种需求。我与客户、高管和其他工程师密切合作,回答这些问题,然后构建出色的软件。

2 条评论

很有趣。我一直认为 Redis 是一个共享会话存储(我用它来做这个)。它基本上是将其用作一个非常基础的数据库,尽管您可以使用哈希来构建共享状态)。我认为在 2018 年拥抱单体架构不是我们鼓励的,但是如果您想横向扩展 RoR 而不是纵向扩展,这是一个非常好的模式。

Creative Commons License本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.