August 6, 2019
Recyclable cache keys or cache versioning was introduced in Rails 5.2. Large applications frequently need to invalidate their cache because cache store has limited memory. We can optimize cache storage and minimize cache miss using recyclable cache keys.
Recyclable cache keys is supported by all cache stores that ship with Rails.
Before Rails 5.2, cache_key
's format was {model_name}/{id}-{update_at}. Here
model_name
and id
are always constant for an object and updated_at
changes
on every update.
>> post = Post.last
>> post.cache_key
=> "posts/1-20190522104553296111"
# Update post
>> post.touch
>> post.cache_key
=> "posts/1-20190525102103422069" # cache_key changed
In Rails 5.2, #cache_key
returns {model_name}/{id} and new method
#cache_version
returns {updated_at}.
>> ActiveRecord::Base.cache_versioning = true
>> post = Post.last
>> post.cache_key
=> "posts/1"
>> post.cache_version
=> "20190522070715422750"
>> post.cache_key_with_version
=> "posts/1-20190522070715422750"
Let's update post
instance and check cache_key
and cache_version
's
behaviour.
>> post.touch
>> post.cache_key
=> "posts/1" # cache_key remains same
>> post.cache_version
=> "20190527062249879829" # cache_version changed
To use cache versioning feature, we have to enable
ActiveRecord::Base.cache_versioning
configuration. By default
cache_versioning
config is set to false for backward compatibility.
We can enable cache versioning configuration globally as shown below.
ActiveRecord::Base.cache_versioning = true
# or
config.active_record.cache_versioning = true
Cache versioning config can be applied at model level.
class Post < ActiveRecord::Base
self.cache_versioning = true
end
# Or, when setting `#cache_versioning` outside the model -
Post.cache_versioning = true
Let's understand the problem step by step with cache keys before Rails 5.2.
1. Write post
instance to cache using
fetch
api.
>> before_update_cache_key = post.cache_key
=> "posts/1-20190527062249879829"
>> Rails.cache.fetch(before_update_cache_key) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">
2. Update post
instance using
touch
.
>> post.touch
(0.1ms) begin transaction
Post Update (1.6ms) UPDATE "posts" SET "updated_at" = ? WHERE "posts"."id" = ? [["updated_at", "2019-05-27 08:01:52.975653"], ["id", 1]]
(1.2ms) commit transaction
=> true
3. Verify stale cache_key
in cache store.
>> Rails.cache.fetch(before_update_cache_key)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">
4. Write updated post
instance to cache using new cache_key
.
>> after_update_cache_key = post.cache_key
=> "posts/1-20190527080152975653"
>> Rails.cache.fetch(after_update_cache_key) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
5. Cache store now has two copies of post
instance.
>> Rails.cache.fetch(before_update_cache_key)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">
>> Rails.cache.fetch(after_update_cache_key)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
cache_key and its associated instance becomes irrelevant as soon as an instance is updated. But it stays in cache store until it is manually invalidated.
This sometimes result in overflowing cache store with stale keys and data. In applications that extensively use cache store, a huge chunk of cache store gets filled with stale data frequently.
Now let's take a look at the same example. This time with cache versioning to understand how recyclable cache keys help optimize cache storage.
1. Write post
instance to cache store with version
option.
>> ActiveRecord::Base.cache_versioning = true
>> post = Post.last
>> cache_key = post.cache_key
=> "posts/1"
>> before_update_cache_version = post.cache_version
=> "20190527080152975653"
>> Rails.cache.fetch(cache_key, version: before_update_cache_version) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
2. Update post
instance.
>> post.touch
(0.1ms) begin transaction
Post Update (0.4ms) UPDATE "posts" SET "updated_at" = ? WHERE "posts"."id" = ? [["updated_at", "2019-05-27 09:09:15.651029"], ["id", 1]]
(0.7ms) commit transaction
=> true
3. Verify stale cache_version
in cache store.
>> Rails.cache.fetch(cache_key, version: before_update_cache_version)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
4. Write updated post
instance to cache.
>> after_update_cache_version = post.cache_version
=> "20190527090915651029"
>> Rails.cache.fetch(cache_key, version: after_update_cache_version) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 09:09:15">
5. Cache store has replaced old copy of post
with new version automatically.
>> Rails.cache.fetch(cache_key, version: before_update_cache_version)
=> nil
>> Rails.cache.fetch(cache_key, version: after_update_cache_version)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 09:09:15">
Above example shows how recyclable cache keys maintains single, latest copy of an instance. Stale versions are removed automatically when new version is added to cache store.
Rails 6 added #cache_versioning
for ActiveRecord::Relation
.
ActiveRecord::Base.collection_cache_versioning
configuration should be enabled
to use cache versioning feature on collections. It is set to false by default.
We can enable this configuration as shown below.
ActiveRecord::Base.collection_cache_versioning = true
# or
config.active_record.collection_cache_versioning = true
Before Rails 6, ActiveRecord::Relation
had cache_key
in format
{table_name}/query-{query-hash}-{count}-{max(updated_at)}
.
In Rails 6, cache_key is split in stable part cache_key
-
{table_name}/query-{query-hash}
and volatile part cache_version
-
{count}-{max(updated_at)}
.
For more information, check out blog on ActiveRecord::Relation#cache_key in Rails 5.
>> posts = Post.all
>> posts.cache_key
=> "posts/query-00644b6a00f2ed4b925407d06501c8fb-3-20190522172326885804"
>> ActiveRecord::Base.collection_cache_versioning = true
>> posts = Post.all
>> posts.cache_key
=> "posts/query-00644b6a00f2ed4b925407d06501c8fb"
>> posts.cache_version
=> "3-20190522172326885804"
Cache versioning works similarly for ActiveRecord::Relation
as
ActiveRecord::Base
.
In case of ActiveRecord::Relation
, if number of records change and/or
record(s) are updated, then same cache_key
is written to cache store with new
cache_version
and updated records.
Previously, cache invalidation had to be done manually either by deleting cache or setting cache expire duration. Cache versioning invalidates stale data automatically and keeps latest copy of data, saving on storage and performance drastically.
Check out the pull request and commit for more details.
If this blog was helpful, check out our full blog archive.