Alex Holubenko
Posted on August 22, 2020
Pretty interesting title of the article, isn’t it?🤔
My name is Alex and I’m Senior Software Engineer at Mrsool.co
Before start, for the last 6 years in Ruby / Ruby on Rails, all that I knew about caching in Rails was that it somehow uses Redis, that’s all.
But some time ago on our project, we faced the problem: #delete_matched
, who work on high load projects know what it means…
Pain, pain for CPU and Redis Cluster
(to be clear, for cluster it’s not… because in Redis Cluster
it’s scan only one node /we have 3 for example/)
So, just to clarify what for we need #delete_matched
: let’s imagine we have some SQL table banks
and it has many branches
and we need to show nearest branches
of some bank
by the current location
of the user, but we have hundreds of thousands of users in many cities and countries, and we don’t want overload our DB each time for 2–3–4–100 users that locates in few meters from each other.
What we should do in this way? Right, caching it:
def nearest_brances(id, latitude, longitude)
bank = ... # get bank by id
Rails.cache.fetch("nearest_brances_#{id}_#{latitutde}_#{longitude}") do
# some logic to get nearest open branches
end
end
On the other hand, if any of this branch is, for example, decided to be closed for some time? In this case, we need to update the cache to avoid confusion of users that decided to visit the closed branch:
def clear_nearest_bank_branches(id)
Rails.cache.delete_matched("nearest_brances_#{id}_")
end
Pros:
- It removes all the values for the given pattern
Cons:
- High CPU load for because it needs to scan all the keys from Redis.
- In case if you use Redis Cluster with a few nodes it will scan only for one of them.
So.. is it worth it? - Definitely NO.
But, how to solve it?
Answer: “Redis Hashes”, you can read more about it here: https://redis.io/topics/data-types.
It’s very similar to Ruby hashes:
{ key1 => { key2 => value } }
From this structure we can see the answer to our question of how to solve this problem:
key1 = "nearest_brances_#{id}"
key2 = "#{latitude}_#{longitude}"
key3 = "#{latitude2}_#{longitude2}"
value = some_list_of_branches
value2 = some_another_list_of_branches
So now we can code it in such a way:
redis = Rails.cache.redis
redis.hset(key1, key2, value)
redis.hset(key1, key3, value2)
After, when we will need to clear cache for some bank, all that we need to do is:
redis.del(key1)
But, it’s not that simple: How to store objects in the hash (by default it’s possible to store only strings)? Like ActiveRecord in our case.
I will try to show you.
First of all, we need some service(I decided to use lib instead but it’s up to you) to work with our new “cache”:
# lib/redis_hash_store.rb
module RedisHashStore
extend self
def write(key1, key2, value)
redis.hset(key1, key2, value)
end
def read(key1, key2)
redis.hget(key1, key2)
end
def delete(key1, key2)
redis.hdel(key1, key2)
end
def delete_hash(key)
redis.del(key)
end
private
def redis
Rails.cache.redis
end
end
Now it’s a bit easier to work with hashes:
RedisHashStore.write(key1, key2, value)
RedisHashStore.read(key1, key2)
RedisHashStore.delete(key1, key2)
RedisHashStore.delete_hash(key1)
But we still can’t store objects… Let’s change it!
After some investigations of ActiveSupport source I decided to use some light version of their implementation:
class Entry
attr_reader :value
def initialize(value, expires_in:)
@value = value
@created_at = Time.now.to_f
@expires_in = expires_in
end
def expired?
@expires_in && @created_at + @expires_in <= Time.now.to_f
end
end
You might ask: “What for we need expired? logic here?” — good question!
Let me try to explain: Unfortunately Redis doesn’t have expire for Redis Hashes…(There are a lot of discussions about it: https://github.com/redis/redis/issues/1042 )
That’s why we need it here.
Let’s move back to our RedisHashStore, now it has such look:
module RedisHashStore
extend self
class Entry
attr_reader :value
def initialize(value, expires_in:)
@value = value
@created_at = Time.now.to_f
@expires_in = expires_in
end
def expired?
@expires_in && @created_at + @expires_in <= Time.now.to_f
end
end
def write(key1, key2, value)
redis.hset(key1, key2, value)
end
def read(key1, key2)
redis.hget(key1, key2)
end
def delete(key1, key2)
redis.hdel(key1, key2)
end
def delete_hash(key)
redis.del(key)
end
private
def redis
Rails.cache.redis
end
end
But wait a minute.. something missed here? Right!
Now we need somehow to convert Entry to string and then safely move it back to the object. For this purpose we need Marshal. (You can read more about it here: https://ruby-doc.org/core-2.6.3/Marshal.html).
In our case we need 2 methods: #dump
and #load
, let’s add it to our RedisHashStore:
module RedisHashStore
extend self
class Entry
attr_reader :value
def initialize(value, expires_in:)
@value = value
@created_at = Time.now.to_f
@expires_in = expires_in
end
def expired?
@expires_in && @created_at + @expires_in <= Time.now.to_f
end
end
def write(key1, key2, value, **options)
entry = Entry.new(value, expires_in: options[:expires_in])
redis.hset(key1, key2, serialize_value(entry))
entry.value
end
def read(key1, key2)
entry = deserialize_value(redis.hget(key1, key2))
return if entry.blank?
if entry.expired?
delete(key1, key2)
return nil
end
entry.value
end
def delete(key1, key2)
redis.hdel(key1, key2)
end
def delete_hash(key)
redis.del(key)
end
private
def serialize_value(value)
Marshal.dump(value)
end
def deserialize_value(value)
return if value.nil?
Marshal.load(value)
end
def redis
Rails.cache.redis
end
end
About benchmarks, some preparations:
indexes = 1..1_000_000
indexes.each do |index|
Rails.cache.write("some_data_#{index}", index)
RedisHashStore.write("some_data", index, index)
end
..and let's go!
Benchmark.bm do |x|
x.report("delete_matched") { Rails.cache.delete_matched("some_data_*") }
x.report("delete_hash") { RedisHashStore.delete_hash('some_data') }
end
user system total real
delete_matched 0.571040 0.244962 0.816002 (3.791056)
delete_hash 0.000000 0.000225 0.000225 (0.677891)
Nise result, isn’t it? 😜
To avoid thoughts like: “But first one was running on Redis with 1_000_001 records and second with only 1!”
Oh, got it! Let’s try in a different order:
Benchmark.bm do |x|
x.report("delete_hash") { RedisHashStore.delete_hash('some_data') }
x.report("delete_matched") { Rails.cache.delete_matched("some_data_*") }
end
user system total real
delete_hash 0.000478 0.000000 0.000478 (0.648363)
delete_matched 0.744732 0.187648 0.932380 (3.957141)
So as you can see, there are almost the same result here.
Enjoy 😜
That’s it 🤗 🎉
That’s my first article but I tried to describe it as much clear and understandable as it’s possible, thank you if you read it to the end 🙇
Posted on August 22, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.