Goの分散排他処理パッケージRedsyncを使ってみる
Go言語で排他処理を行う場合、sync.Mutex
等を用いるのが一般的と思います。
しかし、Webアプリケーションの負荷分散などを目的として、複数のサーバインスタンスを並列に稼働させる場合、複数のサーバインスタンスを跨いでの排他制御はできません。
上記のような場合に、使えそうなパッケージRedsyncを見つけたので、検証してみました。
Redsync
Redisを経由して、複数のインスタンスが共有できるMutex(っぽいもの)を作成できるパッケージ。
以下が基本的な使い方。
(Redisが必要なので、試したい場合はDockerdocker run --rm -d -p6379:6379 redis
で立てましょう。)
package main
import (
goredislib "github.com/go-redis/redis/v8"
"github.com/go-redsync/redsync/v4"
"github.com/go-redsync/redsync/v4/redis/goredis/v8"
)
func main() {
// redisクライアント設定
client := goredislib.NewClient(&goredislib.Options{
Addr: "localhost:6379",
})
pool := goredis.NewPool(client) // or, pool := redigo.NewPool(...)
// mutex設定
rs := redsync.New(pool)
mutexname := "my-global-mutex"
mutex := rs.NewMutex(mutexname)
// Mutex取得
if err := mutex.Lock(); err != nil {
panic(err)
}
// Mutex開放
if ok, err := mutex.Unlock(); !ok || err != nil {
panic("unlock failed")
}
}
検証
使い方を誤れば予期せぬバグを引き起こしそうなので、プロダクトに使用するにあたり、検証してみました。
検証1. redis上の表現
Mutexの実態はredis上のレコードです。どのようなレコードが作成されるのか確認します。
key
まずkeyですが、NewMutex
時に指定した名前がそのままredisのkeyとなるようです。
上記のサンプルコードの場合は"my-global-mutex"
がkeyとなっています。
> keys *
1) "my-global-mutex"
value
デフォルトではランダムなBase64エンコードされた文字列が入るようです。まぁMutexとして使う分にはあまり関係ないですね。
> get my-global-mutex
"FuwheoWcH97OlWcN0HNU1g=="
> get my-global-mutex
"PIzLCLV4I9r1OA5mlwXI9Q=="
オプションで変更可能です。
mutex := rs.NewMutex(mutexname, redsync.WithValue("hoge"))
ttl
redis上のttlは、デフォルトで8秒になっているようです。
mutex := rs.NewMutex(mutexname)
if err := mutex.Lock(); err != nil {
panic(err)
}
fmt.Printf("ttl: %v\n", client.TTL(context.TODO(), mutexname).Val())
$ go run main.go
ttl: 8s
mutexを作成する際のoptionで変更可能
mutex := rs.NewMutex(mutexname, redsync.WithExpiry(60*time.Second))
$ go run main.go
ttl: 1m0s
Unlockしなくてもttlを超えるとmutexが解放されるので、長い時間がかかる処理の場合はそれよりも長い時間を設定する必要がありそうです。
検証2: Lock時の挙動
lock待ち
subのgo routineでLockしたMutexを取りに行く場合。
log.SetFlags(log.Ltime | log.Lmicroseconds)
go func() {
log.Println("[sub] try mutex lock")
if err := mutex.Lock(); err != nil {
panic(err)
}
log.Println("[sub] mutex locked")
time.Sleep(time.Millisecond * 3500)
ok, err := mutex.Unlock()
if err != nil {
panic(err)
}
log.Println("[sub] mutex unlocked:", ok)
}()
time.Sleep(time.Millisecond * 100) // ちょっと待つ
log.Println("[main] try mutex lock")
if err := mutex.Lock(); err != nil {
panic(err)
}
log.Println("[main] mutex locked")
ok, err := mutex.Unlock()
if err != nil {
panic(err)
}
log.Println("[main] mutex unlocked:", ok)
$ go run main.go
17:12:33.524642 [sub] try mutex lock
17:12:33.530443 [sub] mutex locked
17:12:33.629070 [main] try mutex lock
17:12:37.033996 [sub] mutex unlocked: true
17:12:37.123408 [main] mutex locked
17:12:37.124889 [main] mutex unlocked: true
この例では、subのGoroutineがUnlockしてから90msくらいしてからLockをmainがLockしています。
デフォルトだと、50〜250msの間のランダムなスパンでRetryするようです。
Mutex.Lockの試行 = redisへのアクセスとなります。
オプションで設定することができるので、redisへの負荷と即応性のトレードオフを考慮して決めると良いと思います。
mutex := rs.NewMutex(mutexname, redsync.WithRetryDelay(time.Millisecond*500))
// or
mutex := rs.NewMutex(mutexname, redsync.WithRetryDelayFunc(func(tries int) time.Duration { return time.Duration(tries*100) * time.Millisecond }))
lockされていない状態でのunlock
lockされていない状態でUnlockすると、nil
とerror
が返ってきます。
ttlを超えてからUnlockした場合などが想定されますね。
rs := redsync.New(pool)
mutexname := "my-global-mutex"
mutex := rs.NewMutex(mutexname)
ok, err := mutex.Unlock()
if err != nil {
panic(err)
}
fmt.Printf("ok: %v\n", ok)
$ go run main.go
ok: false
検証3: 予期せぬプログラム終了
panicやシグナル受信など予期せぬプログラム終了があった場合にはどうなるのでしょうか。
panic
mutex := rs.NewMutex(mutexname)
if err := mutex.Lock(); err != nil {
panic(err)
}
panic("予期せぬpanic")
> ttl my-global-mutex
(integer) 7
上記の例では、mutex.Lockした直後にpanicが起きプログラムが終了した場合には、プログラムが終了しても、redisのレコードは残り続けています。
シグナル受信(SIGINT)
シグナルを受信した場合(全部試すのは面倒なのでSIGINT
のみ)にも、redisのレコードは残っていました。
panicやシグナル受信で、予期せぬエラーでGoルーチンやプロセス自体が終了してしまった場合にはmutexはLockされたままになり、mutex自体に適切なタイムアウトをかけておかないと、Mutexを再取得することできなくなると予想されるので、そこは気をつけておいた方が良さそうです。
まとめ
便利で普通に使うには問題なさそうですが、以下の点に気をつけたいと感じました。
- ttlが設定されている(時間が経つと自動的にUnlockされる)
- panicやシグナルで強制終了された場合にはMutexを確保したままになる
処理時間やタイムアウト時間程度の最低限のttlを設定することにさえ気をつければ、問題なく使えそうだなとい印象を受けました。
Discussion