📚

RedisとLuaでSliding window logを実装してGolangから呼ぶ

2023/12/20に公開

この記事は GENDA Advent Calendar 2023 20日目の記事です。
https://qiita.com/advent-calendar/2023/genda

本当は

「俺の全プログラミングスキルを集結し、麻雀の上がり判定と点数計算を1Sec未満で行う(前編)」

という記事を書き上げたかったのですが、師走に敗北しました。
株式会社GENDA テックリードにしがみです

TL;DR

Sliding window log は Rate limiting のアルゴリズム。
Redis と Lua を使うと Sliding window log が簡単に実装できる。
Lua で実装することで Lua サポートされているRedisライブラリがある言語なら、
どの言語でも簡単に利用できる。

Sliding window logとは?

Rate limiting アルゴリズムの一つ。

Wikipedia によると他にはこのようなアルゴリズムがあるようです。

  • Token bucket
  • Leaky bucket
  • Fixed window counter
  • Sliding window counter

C.f. https://en.wikipedia.org/wiki/Rate_limiting#:~:text=A rate limiting algorithm is,code 429%3A Too Many Requests.

Sliding window log は他に比べるとこのような特徴があります。

  • n回 / m分 を他と比べ正確に実現できる
  • Sliding window counter や Fixed window counter と比べ、期間分のタイムスタンプを保持管理するため、実装コスト、ストレージ(メモリ)コストがかかる。

前置き

今回は理由があって Redis を選定したわけではなく、

Redis を使うことが先に決まっていて、Rate limiting をやってみようという順番でした。

Sliging window log に決めた理由は、実装的に勉強になると思ったからです。

結果として Redis を使うと Sliding window log を楽に実現できたためとても満足しています。

もちろん RDBMS でも NoSQL でも他の In memory store でも実現することはできると思います。

Luaの有無について

Lua の使用有無にはメリットデメリットあったのですが今回は Lua を使いました

メリット

Lua を使うと Atomic 性が保証される(Pipelining や Transaction の手間が省ける)
呼び出し言語側がロジックを持たずに済む

デメリット

Lua の学習コスト
テスト、デバッグ容易性の低さ

最終的には勉強になるし Lua でやってみようと決めました。
すごくシンプルな Lua スクリプトで実装できたので Lua の学習コストというデメリットは結果的に少なかったです。

GoのRedis ライブラリについて

次の二つが Go で有名な Redis ライブラリかなと思います。

https://github.com/redis/go-redis
https://github.com/gomodule/redigo

redigo の方は使ってないのでわからないですが、go-redis の方は Redis 公式のライブラリだけあって、Lua サポートがしっかりされていると感じました。

Redis には Lua の script を cache する機能もあるみたいですが、go-redis がよしなにやってくれるので全く気にせずに済みました。

コード

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

func connectRedis() *redis.Client {
	return redis.NewClient(&redis.Options{
		Addr:     ":8080",
		Password: "",
		DB:       0,
	})
}

func main() {
	rdb := connectRedis()
	ratelimiter := NewSlidingWindowStaticKey(rdb, 4, 2, "your-key") // 4秒に2回まで
	ctx := context.Background()

	loop := 10 * time.Second
	count := 0
	execCount := 0
	fmt.Printf("設定:%d 回 / %.0f 秒\n", ratelimiter.allowedMaxCount, ratelimiter.window.Seconds())
	for begin := time.Now(); time.Since(begin) < loop; {
		count++
		ok := callProcWithRateLimit(ctx, ratelimiter, "your_key")
		if ok {
			execCount++
		}
	}

	fmt.Printf("実行 / 呼び出し = %d / %d\n", execCount, count)
}

func callProcWithRateLimit(ctx context.Context, ratelimit *SlidingWindow, key string) bool {
	allowed, err := ratelimit.Allow(ctx, time.Now())
	if err != nil {
		panic(err)
	}

	if !allowed {
		return false
	}

	proc()
	return true
}

func proc() {
	fmt.Printf("%s: heavy calc\n", time.Now().Format(time.TimeOnly))
}

var ErrEmptyKey = errors.New("key is empty")

type (
	SlidingWindow struct {
		window          time.Duration
		allowedMaxCount int64
		keyGen          KeyGenFunc
		rdb             redis.UniversalClient
	}
)

type KeyGenFunc func() string

func NewSlidingWindow(rdb redis.UniversalClient, windowSeconds, allowedMaxCount int64, keyGen KeyGenFunc) *SlidingWindow {
	return &SlidingWindow{
		rdb:             rdb,
		window:          time.Duration(windowSeconds) * time.Second,
		allowedMaxCount: allowedMaxCount,
		keyGen:          keyGen,
	}
}

func NewSlidingWindowStaticKey(rdb redis.UniversalClient, windowSeconds, allowedMaxCount int64, key string) *SlidingWindow {
	return NewSlidingWindow(rdb, windowSeconds, allowedMaxCount, func() string {
		return key
	})
}

func (sw SlidingWindow) Allow(ctx context.Context, now time.Time) (bool, error) {
	return allow(ctx, sw.rdb, sw.keyGen(), sw.window, sw.allowedMaxCount, now)
}

func allow(ctx context.Context, rdb redis.UniversalClient, key string,
	window time.Duration, limit int64, now time.Time,
) (bool, error) {
	if key == "" {
		return false, ErrEmptyKey
	}
	sec := window.Seconds()
	if sec <= 0 {
		panic("SlidingWindow: second must be positive")
	}
	if limit <= 0 {
		panic("SlidingWindow: limit must be positive")
	}
	ts := now.UnixMicro()
	if ts <= 0 {
		panic("SlidingWindow: now must be positive")
	}

	return slidingWindow.Run(ctx, rdb, []string{key}, sec, limit, ts).Bool() // nolint:wrapcheck
}

// (now-window, now].
var slidingWindow = redis.NewScript(`
	local windowMicro = tonumber(ARGV[1]) * 1000000
	local current_time = redis.call('TIME')
	local ts = tonumber(current_time[1]) * 1000000 + tonumber(current_time[2])
	redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ts - windowMicro)
	if redis.call('ZCARD', KEYS[1]) < tonumber(ARGV[2]) then
		redis.call('ZADD', KEYS[1], ts, current_time[1] .. current_time[2])
		redis.call('EXPIRE', KEYS[1], ARGV[1])
		return 1
	end
	return 0
`)

実行

go mod init example.com
go mod tidy
// Redis起動

go run main.go

// 以下実行結果のstdout
設定:2 回 / 4 秒
11:01:11: heavy calc
11:01:11: heavy calc
11:01:15: heavy calc
11:01:15: heavy calc
11:01:19: heavy calc
11:01:19: heavy calc
実行 / 呼び出し = 6 / 7229

ちゃんと4秒に2回まで許可されていますね

Discussion