Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

RedisでEVALを使うとこんなにお得!GunosyでのEVAL活用例

この記事はGunosy Advent Calendar 202013日目の記事です。昨日は大曽根さんの不確実性と向き合うデータ分析でした。

Gunosyでネットワーク広告系のプロダクトを扱っているeastです。今回はRedisでEVALを使うことの優位性を、具体的な事例を交えて紹介できればと思います。

RedisのEVALとは?

RedisのEVALとは、Redisで独自のLua scriptを実行させることができる機能です。ざっくり言うと、自作のRedisコマンドを作成するような感じですね。

EVAL

どんな時に使うのか

基本的には標準のRedisコマンドでは対応できないような状況で、止む無く使うことが多いですね。 以下は簡単なRubyでのサンプルになります。

指定されたkeyのvalueが条件に合った場合のみ、setを実行すると言うようなスクリプトになっています。

require 'redis'

script=<<EOS
if redis.call('get',KEYS[2]) == ARGV[2] then
  redis.call('set', KEYS[1], ARGV[1])
end
EOS

redis = Redis.new
redis.set('fuga', 1)

redis.eval(script, keys: ['hoge', 'fuga'], argv: ['hello', 2]) # ここでは書き込まれない

redis.eval(script, keys: ['hoge', 'fuga'], argv: ['hello', 1]) # ここでは書き込まれる

ここでは2回EVALを実行してます。1回目は、EVALに渡しているkeys[1]に該当するkeyである"fuga"のvalueが、argv[1]で指定されている2と一致しないため、set(hoge -> hello)は実行されません。2回目は一致するのでsetが実行されます。

実際にはEVALではなくEVALSHAを使う

先ほどEVALを使った例を示しましたが、実際のプロダクトではEVALよりもEVALSHAを使う方が良いでしょう。EVALだと実行する度にLuaスクリプトをRedisに送る必要がありますが、EVALSHAを使えばRedis側にスクリプトをキャッシュさせることが出来るので、送信コストを抑えることができます。

以下は、先ほどのサンプルをEVALSHAで書き換えたものです。

require 'redis'

script=<<EOS
if redis.call('get',KEYS[2]) == ARGV[2] then
  redis.call('set', KEYS[1], ARGV[1])
end
EOS

redis = Redis.new
sha = redis.script(:load, script)
redis.set('fuga', 1)

redis.evalsha(sha, keys: ['hoge', 'fuga'], argv: ['hello', 2]) # ここでは書き込まれない

redis.evalsha(sha, keys: ['hoge', 'fuga'], argv: ['hello', 1]) # ここでは書き込まれる

ただ注意点として、Redis側のスクリプトキャッシュが消えてしまうと当然EVALSHAは実行できなくなってしまうので、この辺りカバーする処理を追加する必要があります。Redis側がクラスタ化されてる場合は、そこも考慮しないといけませんね。

また、言語やライブラリによっても違うかもしれませんが、EVALSHAはpipelineに載せることも可能です。

redis.pipelined do
  redis.eval(sha, keys: ['hoge', 'fuga'], argv: ['hello', 1])
  redis.eval(sha, keys: ['hage', 'fuga'], argv: ['world', 1])
end

何故EVALを使うのか?

一般的なwebサービスだと、EVALを使いたくなるようなユースケースはあまりないかもしれません。 ですが、例えば私のチームが扱っているような広告配信システムだと、毎秒数万リクエスト、月間だと数百億リクエストを扱います。このようなシステムの裏側で動いているRedisでは、当然これ以上のオペレーションが実行されるため、それなりに大きい負荷になります。また、Redisとの間でデータをやりとりする回数が増えるとその分時間がかかるので、アプリケーション側のスループットを上げるため、出来ればやりとりする回数も最小限に抑えたいです。

具体的な活用例

それでは、具体的なEVALの活用例をいくつか紹介したいと思います。これらはいずれも本番環境で実際に活用している方法です。

例1: 複数のRedisコマンドをまとめる

これはとてもシンプルな活用法ですね。複数のオペレーションを一つにまとめることで、処理効率を上げます。 例えば、私は日常的にhincrbyやhsetを使うことが多いですが、これらのコマンドはexpireを同時に設定できないため、後からexpireを設定すると言う処理をよく書きます。

redis = Redis.new

redis.hincrby('key', 'field', 1)
redis.expire('key', 60 * 60 * 24)

これを一つのLuaスクリプトにまとめて、EVALSHAで呼び出すように変更したのが以下です。

script=<<EOS
redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])
redis.call('expire', KEYS[1], ARGV[3])
EOS
redis = Redis.new
sha = redis.script(:load, script)

redis.evalsha(sha, keys: ['key'], argv: ['field', 1, 60 * 60 * 24])

これでどのくらい効果があるのでしょうか?試しに簡単なベンチマークを取ってみました。hincrbyとexpireを行う方法と、EVALSHAを使う方法でそれぞれ10万回オペレーションを実行し、実行時間を比較しました。以下が結果になります。

             user     system      total        real
before   3.166335   0.817735   3.984070 (  3.990830)
after    1.935333   0.401556   2.336889 (  2.368636)

おおよそ6割ほどの実行時間に改善されてます。参考までに、検証に使ったコードは以下になります。

require 'redis'
require 'benchmark'

DURATION_DAY = 60 * 60 * 24

script=<<EOS
redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])
redis.call('expire', KEYS[1], ARGV[3])
EOS

redis = Redis.new
sha = redis.script(:load, script)

sample = 100000
Benchmark.bm 10 do |r|
  r.report 'before' do
    redis.pipelined do
      sample.times do |n|
        key = "key-#{rand(1000)}-1"
        field = "field-#{rand(100)}"
        redis.hincrby(key, field, 1)
        redis.expire(key, DURATION_DAY * 7)
      end
    end
  end
  r.report 'after' do
    redis.pipelined do
      sample.times do |n|
        key = "key-#{rand(1000)}-2"
        field = "field-#{rand(100)}"
        redis.evalsha(sha, keys: [key], argv: [field, 1, DURATION_DAY * 7])
      end
    end
  end
end

例2: 複数key指定に対応してないコマンドを複数keyに対応させる

hgetallは、指定したhashの全fieldとvalueを取得するコマンドですが、これを複数keyに対応させたmhgetallのようなコマンドは存在しません。複数keyのhashを取得したいと言う状況は割とあるので、mhgetall相当のスクリプトがあればRedisとのやりとりを減らして一発で取ってこれます。

require 'redis'

script=<<EOS
local result = {}
for i, key in ipairs(KEYS) do
  result[i] = redis.call('HGETALL', key)
end
return result
EOS

redis = Redis.new
sha = redis.script(:load, script)

redis.hset('fuga', 'a', 1)
redis.hset('fuga', 'b', 2)
redis.hset('hoge', 'c', 3)
redis.hset('hoge', 'd', 4)


r = redis.evalsha(sha, keys: ['fuga', 'hoge']).map { |m| Hash[*m] }
p r # => [{"c"=>"3", "d"=>"4"}, {"a"=>"1", "b"=>"2"}]

例3: 書き込みの重複排除

redisでincr等を使って、何らかの集計値をカウントすることはよくあると思います。広告系のシステムだと、インプレッション数をカウントしたり、ウェブメディアだったらページビュー数とか。 ですが、例えば広告系のシステムだとインプレッションのリクエストが重複して発生すると言うことがしばしば起こります。一般的なWebサービス等でページビュー数を数える場合でも、同一ユーザーが短時間に何度もアクセスする場合はカウントさせたくないといった要望が発生するかもしれません。

このように重複リクエスト(あるいは重複と処理したいリクエスト)を排除してRedisでの集計を行いたい場合は、単純にやると以下のように書くことができます。

require 'redis'

redis = Redis.new

unique_id = 'hogehoge' # UserIDや広告ImpressionIDなど、一意になるID
# 同じunique_idでは10秒間に重複して書き込みはできない
100.times do
  if redis.get(unique_id).to_i != 1
    redis.incrby('page_1234', 1)
    redis.setex(unique_id, 10, 1)
  end
end

p redis.get('page_1234') # => "1"

リクエストごとにユニークになるであろうID(UserIDや広告ImpressionID)をkeyにして短時間書き込んでおき、次回書き込み前にチェックすることで同一IDでの重複書き込みを防止しています。

ですがこの方法だと、redisから取得してきた値を評価して使うためタイムロスが発生してしまいます。また、アトミックな処理になっていないので、Redisに書き込むサーバーを複数台に分散させた場合に、重複書き込みが発生してしまうリスクがあります。pipeline化するのも難しいですね。

これらの問題を解決するためにEVALを使って書き換えたものが以下になります。

require 'redis'

script=<<EOS
if redis.call('exists',KEYS[2]) ~= 1 then
  redis.call('incrby', KEYS[1], ARGV[1])
  redis.call('setex', KEYS[2], 1, 1)
end
EOS

redis = Redis.new
sha = redis.script(:load, script)

unique_id = 'hogehoge' # UserIDや広告ImpressionIDなど、一意になるID
# 同じunique_idでは10秒間に重複して書き込みはできない
100.times do
  redis.evalsha(sha, keys: ['page_1234', unique_id], argv: [1])
end

p redis.get('page_1234') # => "1"

これならifでの重複チェックと書き込みをRedis側で一貫して行うので、サーバー -> Redis間のやりとりは一度で完結しますし、処理効率も良いです。また、アトミックな処理になるので、書き込み側のサーバーを増やしても重複が発生するリスクはありません。pipeline化することもできるので、ハイトラフィックな環境でも問題なく動作します。

まとめ

と言うわけで、今回はいくつかの具体例を通してRedisのEVAL活用方を紹介しました。EVALって聞くと、ついつい本能的に避けてしまう人もいるかもしれませんが、適切に使えばとても強力な武器になります。みんな使いましょう!

明日はgumigumi4fが何か書いてくれます!