使用 Redis 处理 Rails Model 缓存

Model 层的缓存常常都会被忽略,甚至是经验丰富的码农。当你对视图层做缓存时,你不需要进行底层缓存,这是一个非常常见的误解。虽然在 Rails 里大部分的瓶颈在于视图层,但是总有个别情况不是这样的。

底层缓存是非常灵活的,可以工作于任何一个应用程序。在本教程中,我将演示如何使用 Redis 来缓存你的 model 层。

缓存是如何工作的?
过去,访问磁盘的成本已经非常高了。而且从磁盘访问数据经常会对性能产生不利的影响。为了解决这个问题,我们可以在应用程序和数据库服务器之间加上缓存层。

缓存层在初始化时是没有任何数据的。当它接收到数据请求时,它会调用数据库并将结果存储在内存中(缓存)。所有后续的请求将从缓存层直接读取数据,所以可以避免不重复往返的访问数据库服务器,从而提高性能。

为什么使用 Redis?
Redis 是一个基于内存、Key-Value 存储系统。它的速度极快,几乎是瞬间完成数据检索。Redis 支持先进的数据结构,如链表,哈希表,集合,并能持续保存到磁盘。

虽然大多数码农更喜欢使用 Memcache 和 Dalli 去处理他们的缓存化的需求,但我发现 Redis 非常容易的安装和方便管理。另外,如果你使用的是 resque 或 Sidekiq 管理你的队列,你很可能已经安装了 Redis 了。对于那些有兴趣了解何时使用 Redis 的朋友们,可以到这个 讨论 里了解更多相关信息。

前提
我假设你的项目正在使用 Rails,文章中的例子是使用 Rails 4.2.rc1,使用 haml 渲染视图和 MongoDB 作为数据库,但是本教程的片段应该适用于任何版本的 Rails。

在开始之前,你需要安装和运行 Redis。进入你的应用程序目录,并执行以下命令:

1
2
3
4
$ wget http://download.redis.io/releases/redis-2.8.18.tar.gz
$ tar xzf redis-2.8.18.tar.gz
$ cd redis-2.8.18
$ make

这个命令将需要一段时间才能完成。一旦完成了,就可以开启 Redis 服务了:

1
2
$ cd redis-2.8.18/src
$ ./redis-server

使用 gem “rack-mini-profiler” 可以测量性能提升,这个 gem 可以帮助我们正确的体现出性能的改善。

# 开始

例如,让我们构建一个虚拟的在线故事书阅读书店。这个书店有各种各样的书籍和语言。首先,让我们创建模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# app/models/category.rb

class Category
include Mongoid::Document
include Mongoid::Timestamps
include Mongoid::Paranoia

include CommonMeta
end

# app/models/language.rb

class Language
include Mongoid: :Document
include Mongoid::Timestamps
include Mongoid::Paranoia

include CommonMeta
end

# app/models/concerns/common_meta.rb

module CommonMeta
extend ActiveSupport::Concern
included do
field :name, :type => String
field :desc, :type => String
field :page_title, :type => String
end
end

这里包括了一个 seed 数据文件。只要复制粘贴到你的 seeds.rb 和运行 rake seed 任务,数据就会加载到我们的数据库中。

1
rake db:seed

现在,让我们创建一个简单的 Category 列表页面,该页面显示了所有 Categories 的描述和标记信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# app/controllers/category_controller.rb

class CategoryController < ApplicationController
include CategoryHelper
def index
@categories = Category.all
end
end

# app/helpers/category_helper.rb

module CategoryHelper
def fetch_categories
@categories = Category.all
end
end

# app/views/category/index.html.haml

%h1
Category Listing
%ul#categories
- @categories.each do |cat|
%li
%h3
= cat.name
%p
= cat.desc

# config.routes.rb

Rails.application.routes.draw do
resources :languages
resources :category
end

当你打开浏览器并将其地址指向 /category 时,你会发现 mini-profiler benchmarking 显示在后端执行每一个动作的时间。这些正确的数据是告诉你,你的应用程序哪部分比较缓慢和应该如何优化它们。本页面执行了两条 SQL 语句并且使用了 5ms 的时间完成查询。

虽然起初看起来好像 5ms 是无关紧要的,特别是在需要更多时间去渲染视图时,但在一个生产级别的应用程序中有多次数据库查询时,它们可以明显的降低软件的性能。

由于元数据模型是不太可能发生改变的,这样就可以避免不必要的数据库切换。这一点也是底层缓存的用武之地。

# 安装 Redis

使用 Redis 基于 Ruby 的客户端来帮助我们非常方便的链接 Redis 实例:

1
2
3
4
gem 'redis'
gem 'redis-namespace'
gem 'redis-rails'
gem 'redis-rack-cache'

一但安装好这些 gem 后,就可以配置 Rails 使用 Redis 来作为缓存存储:

1
2
3
4
5
# config/application.rb

#...........
config.cache_store = :redis_store, 'redis://localhost:6379/0/cache', { expires_in: 90.minutes }
#.........

使用 redis-namespace gem 可以让我们创建一个更好的 Redis 命名空间:

1
2
3
# config/initializers/redis.rb 

$redis = Redis::Namespace.new("site_point", :redis => Redis.new)

现在所有的 Redis 功能都可以通过 $redis 进行全局使用了。以下的一个例子是体现如何访问在 redis 服务器上的值(运行于 Rails console):

1
$redis.set("test_key", "Hello World!")

这个命令创建了一个 key:“test_key” 和 value:“Hello World” 保存在 Redis 中。要取这个值,只做:

1
$redis.get("test_key")

现在,我们有了基础知识,让我们开始重写我们的 helper 方法:

1
2
3
4
5
6
7
8
9
10
11
12
# app/helpers/category_helper.rb

module CategoryHelper
def fetch_categories
categories = $redis.get("categories")
if categories.nil?
categories = Category.all.to_json
$redis.set("categories", categories)
end
@categories = JSON.load categories
end
end

在第一次执行这部分代码时,内存 / 缓存中是没有任何东西的。所以我们请求 Rails 把数据从数据库推送到 Redis 中。注意到 to_json 的调用了吗?当要写对象进 Redis,我们多种方式。一种选择是遍历对象中的每个属性,然后将它们保存为一个哈希函数,但是这种方式较为缓慢。最简单的方法是将它们保存为一个 JSON 编码的字符串。解码,只需使用 JSON.load

然而,这有一个意想不到的副作用。当我们正在检索这个值时,一个简易的对象符号不工作。我们需要更新视图并使用哈希语法来显示该类型:

1
2
3
4
5
6
7
8
9
10
11
# app/views/category/index.html.haml

%h1
Category Listing
%ul#categories
- @categories.each do |cat|
%li
%h3
= cat["name"]
%p
= cat["desc"]

重新启动浏览器,并看看性能是否有所不同。首次访问,我们仍然访问数据库,但随后的重新加载将不在访问数据库了。以后所有的请求都将直接从缓存中读取。这个简单的变化非常有效。

# 管理缓存

我刚发现一个关于 categories 的错误。让我们先解决它:

1
2
3
4
5
$ rails c

c = Category.find_by :name => "Famly and Frends"
c.name = "Family and Friends"
c.save

重新加载并查看该更新是否显示在视图中:

很遗憾,我们的视图上并没有体现出这个变化。因为我们并没有访问数据库,所有的数据都直接从缓存中读取。唉,现在的缓存已经过期,直到 Redis 重启前被更新都数据都无法使用。这个对于大多数应用程序来说真是一个破坏者啊。我们偶尔可以使用缓存到期来解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# app/helpers/category_helper.rb

module CategoryHelper
def fetch_categories
categories = $redis.get("categories")
if categories.nil?
categories = Category.all.to_json
$redis.set("categories", categories)
# Expire the cache, every 3 hours
$redis.expire("categories",3.hour.to_i)
end
@categories = JSON.load categories
end
end

缓存将会在每 3 个小时就失效。虽然这对大多数情况下工作,缓存中的数据将滞后于现在数据库。这种工作方式很可能不抬适合你。如果你喜欢保持缓存的更新,我们可以使用 after_save 这个回调:

1
2
3
4
5
6
7
8
9
10
11
# app/models/category.rb

class Category
#...........
after_save :clear_cache

def clear_cache
$redis.del "categories"
end
#...........
end

每次模型的更新,我们都将通知 Rails 去清除缓存。这样可以确保缓存是最新的。Yay!

你应该使用类似 cache_observers 在生产环境中,为了保持简洁,我们在这里坚持使用 after_save。如果你不知道哪种方法最适合你,这里的讨论可能会对你有所启发。

# 结论

底层缓存是非常简单的,如果使用得当,它是非常有价值的。它可以在你花费最小的努力下瞬间提高你的系统的性能。在这篇文章中所有的代码片断可以在 GitHub 上找到。