Rails Cache with Redis Hashes
Tags: ruby, rails, redis, webdev
Published: 2020-08-22 12:02:57

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:
```ruby
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:
```ruby
def clear_nearest_bank_branches(id)
  Rails.cache.delete_matched("nearest_brances_#{id}_")
end
```
**Pros:**
1. It removes all the values for the given pattern

**Cons:**
1. High CPU load for because it needs to scan all the keys from Redis.
2. 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:
```ruby
{ key1 => { key2 => value } }
```
From this structure we can see the answer to our question of how to solve this problem:
```ruby
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:
```ruby
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:
```ruby
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”:
```ruby
# 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:
```ruby
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:
```ruby
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:
```ruby
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:
```ruby
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:

```ruby
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!
```ruby
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:
```ruby
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 🙇