💨

Goの分散排他処理パッケージRedsyncを使ってみる

2022/06/14に公開

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するようです。
https://github.com/go-redsync/redsync/blob/52511c81d0a572cb8b849331f9a3ecb69a5651ea/redsync.go#L33-L35

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すると、nilerrorが返ってきます。
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