sync.Mutexkill -9を生き延びない。OOM、デプロイのロールアウト、ノードのリブートも同様だ。プロセスが終了した瞬間、ロックは消える。そのロックがスケジュールされたジョブ、データ移行、リーダー選出を守っていたなら、今や2つのプロセスが自分だけが動いていると確信している状況になっている。

これはミューテックスのバグではない。カテゴリーの誤りだ。プロセスローカルなロックは、クラスター全体のリソースを守ることはできない。

解決策は分散リースだ。Time-To-Live、一意のオーナートークン、安全な解放の仕組みを持つ外部システムに保存されたロックである。Redis、PostgreSQL、etcd、ZooKeeperは、それぞれ異なるトレードオフで同じ考え方を実装している。パターン自体は単純だ。エッジケースはそうではない。

分散リースとは何か

リースとは、外部ストアが「一定期間、名前付きロックを保持できるのは1つのクライアントだけだ」という約束をすることだ。ヒープメモリに存在するミューテックスとは異なり、リースはRedisやPostgreSQLの中に存在する。ストアが永続化されるため、プロセスの再起動を跨いでも存続する。

基本的な操作は以下の通りだ。

  • Acquire: TTL付きのキーを、既存のものがない場合に限ってアトミックに書き込む。
  • Renew: ロックを保持している間、TTLを延長する。
  • Release: キーがまだ自分の一意のトークンを含んでいる場合に限って、キーを削除する。

一意のトークンが肝心な部分だ。これがないと、遅いクライアントがクラッシュ後に他者によって再取得されたロックを解放してしまう可能性がある。

なぜTTLは必須であり、なぜ危険なのか

クライアントがロックを取得してから死んだ場合、ロックは最終的に自分自身を解放しなければならない。人間の介入なしにそれを行う唯一の方法は有効期限だ。RedisはSET key value NX EX 30でこれをネイティブにサポートしている。PostgreSQLのアドバイザリーロックはセッションに紐付いており、TCP接続が閉じると解除される。これはエレガントだが、移植性は低い。

TTLは新しい障害モードをもたらす。作業がTTLより長くかかると、自分がまだ実行中にもかかわらずロックが期限切れになる。別のクライアントがロックを取得して同じ作業を開始する。こうして、2つの並行プロセスが同じデータを変更することになる。

素朴な解決策は非常に長いTTLを設定することだ。これは、クライアントがロックを取得した直後に死ぬまでは機能する。残りのTTLは、他の誰も引き継げない強制的なダウンタイムウィンドウになってしまう。

正しい解決策はハートビートだ。保持者はバックグラウンドのgoroutineを起動し、数秒ごとにリースを更新する。保持者がクラッシュすれば、ハートビートは停止し、TTLが期限切れになり、新しいオーナーが数秒以内にロックを取得できる。保持者が単に遅いだけなら、ハートビートはプロセスが生きている限りリースを存続させる。

Goでの実用的なRedisリース

ここに、取得、更新、安全な解放を扱う完全な実装を示す。解放にはLuaスクリプトを使い、トークンが一致した場合にのみ削除が成功するようにしている。

package lease

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "sync"
    "time"

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

type Lease struct {
    client     *redis.Client
    key        string
    token      string
    ttl        time.Duration
    renewEvery time.Duration
    stopRenew  chan struct{}
    once       sync.Once
}

func generateToken() string {
    b := make([]byte, 16)
    rand.Read(b)
    return hex.EncodeToString(b)
}

func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lease, error) {
    token := generateToken()
    ok, err := client.SetNX(ctx, key, token, ttl).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, fmt.Errorf("lock already held")
    }

    l := &Lease{
        client:     client,
        key:        key,
        token:      token,
        ttl:        ttl,
        renewEvery: ttl / 3,
        stopRenew:  make(chan struct{}),
    }
    go l.renew()
    return l, nil
}

func (l *Lease) renew() {
    ticker := time.NewTicker(l.renewEvery)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            script := `
                if redis.call("get", KEYS[1]) == ARGV[1] then
                    return redis.call("pexpire", KEYS[1], ARGV[2])
                else
                    return 0
                end
            `
            l.client.Eval(ctx, script, []string{l.key}, l.token, l.ttl.Milliseconds()).Result()
            cancel()
        case <-l.stopRenew:
            return
        }
    }
}

func (l *Lease) Release(ctx context.Context) error {
    l.once.Do(func() { close(l.stopRenew) })
    script := `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
    res, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Int64()
    if err != nil {
        return err
    }
    if res == 0 {
        return fmt.Errorf("lock was lost or stolen")
    }
    return nil
}

使用例:

lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
    // Another instance is running the job.
    return
}
defer lease.Release(ctx)

// Do the work. If this takes 30 seconds, the heartbeat keeps the lease alive.
generateInvoices()

Luaスクリプトが必要なのは、Redisがcompare-and-deleteを単一のネイティブコマンドとしてサポートしていないからだ。これがないと、解放が再取得と競合し、他者のロックを削除してしまう可能性がある。

ここで破綻する:フェンシングトークン問題

完璧なTTLと更新があっても、Martin KleppmannがRedlockの批判で指摘した微妙な競合状態が存在する。想像してみよう:

  1. クライアントAがリースを取得する。
  2. クライアントAが45秒間停止する(GC stop-the-world、VM suspension、CPU throttling)。
  3. リースが期限切れになる。
  4. クライアントBがリースを取得する。
  5. クライアントAが再開し、データベースに書き込む。
  6. クライアントBがデータベースに書き込む。

両方のクライアントが自分がロックを持っていると信じていた。両方が共有状態を変更した。

TTLは永続的なデッドロックを防ぐが、遅延したプロセスからは守れない。解決策はfencing tokenだ。ロック保持者がすべての書き込みに付与する、単調増加する数値またはUUIDである。ストレージ層は古いトークンを持つ書き込みを拒否する。

実際には、これはデータベースのテーブルにlock_versionカラムが必要になるか、BLOBストアに条件付き書き込みが必要になることを意味する。ほとんどのアプリケーションはこのステップを飛ばす。なぜなら、ロック層だけでなくデータ層も変更する必要があるからだ。これは合理的なトレードオフだが、それでもトレードオフである。自分がそれを行っていることを自覚すべきだ。

検討に値する代替案

PostgreSQL advisory locksはセッションスコープだ。TCP接続が閉じると、ロックは自動的に解放される。TTL管理もクロックスキューも存在しない。欠点は、単一のデータベース接続に紐付くため、コネクションプーリングやマルチリージョン構成ではうまく機能しないことだ。

etcd leasesはまさにこの問題のために設計されている。TTL、自動失効、リースが死んだ際のwatchベースの通知をサポートする。すでにKubernetesを動かしているなら、etcdは手元にある。RedisよりAPIは冗長だが、セマンティクスはよりクリーンだ。

ZooKeeper ephemeral sequential nodesは古典的な解決策だ。CAP定理の下でCP(consistent and partition-tolerant)であり、クロックスキュー問題を完全に排除する。ただし、Redisより遅く、運用上も重い。

試さなかったこと

リレーショナルデータベースの上にカスタムコンセンサスプロトコルを実装しなかった。どのチームも最終的にはこれを試す。INSERT ... ON CONFLICTを使ったlocksテーブルと、cronジョブで巡回するlast_heartbeatカラムだ。ハッピーパスでは動作する。しかし競合下では崩壊する。MVCCデータベースは衝突する書き込みを直列化するため、ロックの取得がシステム全体のボトルネックになる。適切な仕事には適切なツールを使え。

TTLの選び方

短すぎる:ハートビートが頻繁になり、1回の遅いGCポーズでリースを失う可能性がある。

長すぎる:クラッシュした保持者が、TTLの全期間にわたってフェイルオーバーをブロックする。

良い出発点は、TTLを10秒、ハートビートを3秒ごとに設定することだ。そこから観測されたGCポーズやネットワークレイテンシに基づいてチューニングする。p99のハートビートレイテンシを計測せよ。もし500msなら、TTLは少なくとも1オーダー・オブ・マグニチュード大きくしなければならない。

試してみよう

現在、バックグラウンドジョブを守るためにsync.Mutexを使っているなら、RedisやPostgreSQLをバックエンドとするリースロックに置き換えよ。上記の実装から始めよ。lease_acquiredlease_lostheartbeat_latencyのメトリクスを追加せよ。長時間実行中のジョブの最中にデプロイし、2番目のインスタンスが衝突するのではなく丁寧に待っているのを初めて目にした時、カテゴリーの誤りが修正されたことを実感できるだろう。