C#で扱うRedisのLuaスクリプティング

Redis 2.6からLuaスクリプティングが使えるようになりました。コマンドはEVALです。というわけでC#のRedisライブラリ、BookSleeveで、試してみましょう。RedisやBookSleeveに関しては、以前に私がBuildInsiderで書いたC#のRedisライブラリ「BookSleeve」の利用法を参照ください。

BookSleeveは当然NuGet経由で入れるとして、Windows版のRedisバイナリもNuGetで配布されています。手軽に試してみるなら、Install-Package Redis-64が良いのではないでしょーか。現在の最新は2.6.12.1ということで、Evalにも対応しています。インストールするとpackages\Redis-64.2.6.12.1\toolsにredis-server.exeが転がっているので、それを起動すれば、とりあえず127.0.0.1:6379で動きます。

多重アクセスの検出

HelloWorld!ということで、多重アクセス検知のスクリプトでも書いてみます。ルールとしては、X秒以内にY回アクセスしてきた人間はZ秒アク禁にする。という感じですね。DOSアタック対策的な。LUAスクリプティングを使わないと、キーを2つ用意したりしなけりゃいけなかったり複数コマンド打ったりしたりとか、若干面倒だったり効率悪いのですが、スクリプティング使えば一発で済ませられます。

    public static class RedisExtensions
    {
        public static async Task<bool> DetectAttack(this RedisConnection redis, int db, string key, int limitCount = 10, int durationSecond = 1, int bannedSecond = 300)
        {
            var result = await redis.Scripting.Eval(db, @"
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local count = redis.call('incr', key)
if(count >= limit) then
    local banSec = tonumber(ARGV[3])
    redis.call('EXPIRE', key, banSec)
    return true
else
    local expireSec = tonumber(ARGV[2])
    redis.call('EXPIRE', key, expireSec)
    return false
end", new[] { key }, new object[] { limitCount, durationSecond, bannedSecond }).ConfigureAwait(false);

            // Lua->Redisはtrueの時に1を、falseの時にnullを返す
            return (result == null) ? false
                : ((long)result == 1) ? true
                : false;
        }
    }

こんな感じですね。基本的にはEvalメソッドでスクリプトを渡すだけです、あとKEYS配列とARGV配列を必要ならば。戻り値の扱いなどに若干のクセがありますので、その辺はRedisのEVALのドキュメントを読んでおくといいでしょう。

スクリプトは、まずincrを呼んでカウントを取る。そのカウントが指定数を超えてたらExpireの時間をBanの時間(デフォは300秒=5分)引き伸ばす。超えてなければ、Expireの時間を指定間隔(デフォは1秒)だけ伸ばす。もし1秒以内に連続でアクセスがあれば、Incrのカウントが増えていく。1秒以上経過すればExpireされているので、countは0スタートになる。といった感じです。

利用する場合はこんな具合。

var redis = new RedisConnection("127.0.0.1");
await redis.Open();

var v = await redis.DetectAttack(0, "hogehoge");
Console.WriteLine(v); // false

for (int i = 0; i < 15; i++)
{
    var v2 = await redis.DetectAttack(0, "hogehoge");
    Console.WriteLine(v2); // false,false,...,true,true
}

いい具合ですにぇ?

EVALSHA

BookSleeveのEvalは、正確にはEVALSHAです(更に正しくはデフォルトの、引数のuseCacheがtrueの場合)。

EVALSHAは、事前にスクリプトのSHA1を算出し、初回に登録しておくことで、コマンドの転送をSHA1の転送だけで済ませます。スクリプトを毎回投げていたらコマンド転送に時間がかかるので、それの節約です。この辺をBookSleeveは何も意識しなくても、やってくれるのが非常に楽ちん。素晴らしい。

Increment/DecrementLimit

せっかくなので、もう一つ例を。RedisのIncrementやDecrementはアトミックな操作で非常に使いやすいのですが、上限や下限を設けたい場合があります。例えば、HPは0以下になって欲しくないし、最大HPを超えて回復されても困る、みたいな。それも当然、Luaスクリプティングを使えば簡単に実現可能です。

        public static async Task<long> IncrementWithLimit(this RedisConnection redis, int db, string key, long value, long maxLimit)
        {
            var result = await redis.Scripting.Eval(db, @"
local inc = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local x = redis.call('incrby', KEYS[1], inc)
if(x > max) then
    redis.call('set', KEYS[1], max)
    x = max
end
return x", new[] { key }, new object[] { value, maxLimit }).ConfigureAwait(false);
            return (long)result;
        }

incrbyの結果が指定の値を超えていたら、setで固定する、といった感じです、単純単純。使うときはこんな具合。

var redis = new RedisConnection("127.0.0.1");
await redis.Open();

var v1 = await redis.IncrementWithLimit(0, "hoge", 40, maxLimit: 100);
var v2 = await redis.IncrementWithLimit(0, "hoge", 40, maxLimit: 100);
var v3 = await redis.IncrementWithLimit(0, "hoge", 40, maxLimit: 100);

// 40->80->100
Console.WriteLine(v1 + "->" + v2 + "->" + v3);

楽ちん、これは捗る。

まとめ

というわけで、RedisいいよRedis。いやほんと色々な面で使ってて嬉しいことが多いです。RDBMSだけで頑張ると非常に辛ぽよ、Redisがあるだけで何かと楽になれますので、一家に一台は置いておきたい。

Luaスクリプティングは複数コマンド間で戻り値が扱えるため、利用範囲がグッと広がります。そしてスクリプティング中の動作もまたアトミックである、というのが嬉しい点です(C#コード上で複数コマンドを扱うと、そこの保証がないというのが大きな違い)。と同時に注意しなければならないのは、アトミックなので、スクリプト実行中は完全にブロックされてます。ので、あまりヘヴィなことをLuaスクリプティングでやるのは避けたほうがいいのではないかなー、と思われます。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2023

Twitter:@neuecc GitHub:neuecc

Archive