1. 現場の阿鼻叫喚:なぜ「SETNXロック」で二重書き込みが起きるのか?

マイクロサービスや複数台構成のWebアプリケーションにおいて、「同一リソースに対する重複処理を防ぎたい」という要求は日常茶飯事です。注文確定処理、ポイント二重付与の防止、定期バッチの二重起動防止など、枚挙にいとまがありません。そして、多くの開発者が真っ先に思いつき、現場のコードベースに無造作に放り込まれるのが次のような「素朴なRedisロック」です。

// 現場で親の顔より見かける「素朴なRedis分散ロック」のアンチパターン
func ProcessOrderWithLock(ctx context.Context, orderID string) error {
    lockKey := "lock:order:" + orderID

    // 10秒の有効期限付きでロックを取得 (SET key val NX EX 10)
    acquired, err := redisClient.SetNX(ctx, lockKey, "locked", 10*time.Second).Result()
    if err != nil {
        return fmt.Errorf("redis error: %w", err)
    }
    if !acquired {
        return errors.New("現在他のプロセスが処理中です")
    }
    defer redisClient.Del(ctx, lockKey) // 処理終了後にロックを解除

    // クリティカルセクション(DB更新、決済、在庫引き当てなど)
    return executeOrderWorkflow(ctx, orderID)
}

コードレビューでも「SETNXでアトミックに取得しているし、サーバーがクラッシュしても10秒のTTL(Time To Live)があるからデッドロックしない。素晴らしい!」と即座にApproveされてしまう典型例です。単体テストやステージング環境でも、期待通りに排他が効いて並列リクエストが弾かれ、完璧に機能しているように見えます。

しかし、本番環境で高負荷や予期せぬレイテンシが発生した瞬間、この無邪気な実装は牙を剥きます。

「『毎月1〜2件だけ、同一注文に対して二重課金や二重配送が発生している。調査してもRedisのエラーログは一切なく、両方のワーカープロセスが正常終了している。コード上は間違いなくSETNXで排他しているはずなのに、なぜ同時にDBを叩いて処理が進んでしまったのか?』」

この不可解な現象を引き起こす真犯人こそが、分散システムにおける古典的かつ致命的な罠である「GCポーズ(Stop-the-World)やI/OストールによるTTLの蒸発」です。

時系列で見るデータ破壊のメカニズム

非同期分散環境(Asynchronous Distributed System)では、プロセスの実行速度を誰も保証できません。次のような時系列が現実の本番環境で発生します。

結果として、「相互排除(Mutual Exclusion)」は完全に崩壊し、ワーカーAとワーカーBが同時にクリティカルセクションを実行してデータを破壊します。これが、多くのエンジニアが「分散ロックを使っているから安全」と思い込んでいる現場で起きている真実です。

2. 「Luaスクリプトで安全に解除」が救ってくれない理由

この問題を指摘されたとき、中級以上のエンジニアは次のように反論するかもしれません。

「いやいや、単純な DEL は他人のロックを消してしまうからNGなのは常識だ。ロック取得時にユニークなUUIDを値に埋め込み、解除時はLuaスクリプトで値が一致するか照合してから DEL しているから、うちのシステムは大丈夫だ」

-- よく知られた「安全なロック解放」Luaスクリプト
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

確かにこのスクリプトは、「他人が現在保持しているロックを誤って削除してしまう事故(Accidental Release)」を防ぐことには貢献します。先ほどの例で言えば、ワーカーAが処理終了後にワーカーBのロックを勝手に消してしまう事態は防げます。

しかし、冷静に考えてみてください。「ワーカーAとワーカーBが同時にクリティカルセクションを実行し、データを破壊してしまった事実」は1ミリも防げていません。

ロック解除時に「お前のロックはもう無効だったよ」と判明したところで、時すでに遅し。ワーカーAのSQLはすでに実行され、外部APIにはすでにリクエストが飛んでしまっているのです。分散システムにおいて、「時間(TTL)」に依存したロック判定をクライアント側で行う限り、この競合状態(Race Condition)を原理的に回避することは不可能です。

3. Martin Kleppmannの警告:Fencing Token(フェンシングトークン)の数理

この問題の本質は、2016年に分散システムの世界的権威である Martin Kleppmann(『データ指向アプリケーションデザイン』著者)と、Redisの作者である Salvatore Sanfilippo(Antirez)の間で巻き起こった歴史的論争で鮮烈に浮き彫りになりました。

Kleppmannは自身の論文・論考『How to do distributed locking』の中で、Redisの分散ロックアルゴリズム(Redlockを含む)に対して明確な警鐘を鳴らしました。

「非同期ネットワークと非同期プロセスの世界において、物理的な経過時間(TTL)に依存したロック機構は安全性を保証できない。クライアントが『自分はまだロックを持っているか?』を確認した瞬間から、実際にストレージへ書き込みパケットが届くまでの間に、プロセスはいつでも一時停止し得るからだ」

では、どのようにしてこの「ゾンビ書き込み」を防ぐのか? Kleppmannが提唱した数理的解決策が「Fencing Token(フェンシングトークン)」です。

門番(Fence)をストレージ側に設ける

原理は極めて明快です。ロックサーバー(Redis等)からロックを取得する際、単に「ロックが取れた」という真偽値だけでなく、「単調増加する整数トークン(Monotonically Increasing Token)」を一緒に発行します。

ここで重要なのは、排他制御の最終防壁(Fence)を「クライアント」ではなく「書き込み先ストレージ(RDBMSなど)」に持たせる点です。

ストレージ側は「最後に書き込まれたトークンより大きいトークンのみを受け入れ、過去の古いトークンは拒絶する」という制約を実行します。ストレージはすでに Token 34 を知っているため、Token 33 を持ってきたワーカーAの書き込みを即座に門前払い(Reject)します。これにより、クライアントのGCポーズやTTL切れが起ころうとも、データの不整合は物理的に阻止されます。

4. 実践:Go言語によるFencing Tokenと条件付き書き込みのクリーンルーム実装

それでは、この Fencing Token の仕組みをGo言語とRDBMS(PostgreSQL/MySQL)を用いて、外部の肥大化したライブラリに頼らずクリーンルーム実装してみましょう。

Step 1: Redisで単調増加トークンをアトミックに発行する

ロックの取得と同時に、グローバルなカウンターを INCR して単調増加トークンを払い出します。これをアトミックに行うためのLuaスクリプトを定義します。

// package lock: Fencing Token付き分散ロックのクリーンルーム実装
package lock

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

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

// 単調増加トークンをアトミックに発行・取得するLuaスクリプト
const acquireLockScript = `
local lockKey = KEYS[1]
local counterKey = KEYS[2]
local clientID = ARGV[1]
local ttlSeconds = tonumber(ARGV[2])

-- すでにロックが存在する場合は取得失敗 (-1 を返す)
if redis.call("exists", lockKey) == 1 then
    return -1
end

-- グローバルカウンターをインクリメントして単調増加トークンを生成
local token = redis.call("incr", counterKey)

-- ロックキーに clientID と token を結合して格納し、TTLを設定
redis.call("set", lockKey, clientID .. ":" .. tostring(token), "EX", ttlSeconds)

return token
`

type DistributedLock struct {
    client *redis.Client
}

func NewDistributedLock(client *redis.Client) *DistributedLock {
    return &DistributedLock{client: client}
}

// Acquire: ロック取得に成功すると、正の Fencing Token を返す
func (l *DistributedLock) Acquire(ctx context.Context, resource string, ttl time.Duration) (int64, string, error) {
    lockKey := fmt.Sprintf("lock:%s", resource)
    counterKey := fmt.Sprintf("counter:%s", resource)

    // クライアントを一意に識別するランダムID
    randomBytes := make([]byte, 8)
    if _, err := rand.Read(randomBytes); err != nil {
        return 0, "", err
    }
    clientID := hex.EncodeToString(randomBytes)

    ttlSeconds := int(ttl.Seconds())
    if ttlSeconds <= 0 {
        ttlSeconds = 5
    }

    res, err := l.client.Eval(ctx, acquireLockScript, []string{lockKey, counterKey}, clientID, ttlSeconds).Result()
    if err != nil {
        return 0, "", fmt.Errorf("lock eval error: %w", err)
    }

    token, ok := res.(int64)
    if !ok || token < 0 {
        return 0, "", errors.New("ロックは既に別のプロセスに保持されています")
    }

    return token, clientID, nil
}

Step 2: ストレージ(RDBMS)側でFencing Tokenを検証する

次に、データベースのテーブルに fencing_token カラムを用意し、更新クエリの WHERE 句で「過去のトークンによる書き込み」を遮断します。

-- orders テーブルに fencing_token カラムを追加
ALTER TABLE orders ADD COLUMN fencing_token BIGINT NOT NULL DEFAULT 0;

そしてGo側のビジネスロジックで、トークンを用いた条件付き更新(Conditional Update)を実行します。

// ビジネスロジック側での条件付き書き込み
func (s *OrderService) ProcessOrder(ctx context.Context, orderID string) error {
    // 1. Fencing Token 付きでロックを取得
    token, clientID, err := s.lock.Acquire(ctx, orderID, 10*time.Second)
    if err != nil {
        return fmt.Errorf("処理を開始できません: %w", err)
    }
    // 解放は省略可能(Fencing Tokenがあれば安全)、またはUUID照合で解放
    defer s.lock.Release(ctx, orderID, clientID)

    // 2. クリティカルセクション(計算処理など)
    // ... ここで万が一GCポーズや遅延が発生し、TTLが切れても ...

    // 3. ストレージ防壁による書き込み:
    // 自身のトークンが、DB上の現在のトークンより厳格に大きい場合のみ更新
    query := `
        UPDATE orders
        SET 
            status = 'COMPLETED',
            updated_at = NOW(),
            fencing_token = $1
        WHERE 
            id = $2
            AND fencing_token < $1;
    `

    result, err := s.db.ExecContext(ctx, query, token, orderID)
    if err != nil {
        return fmt.Errorf("db update error: %w", err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return err
    }

    if rowsAffected == 0 {
        // 別のワーカーがより新しいトークンで更新済みであるため、ゾンビ書き込みを検知!
        return fmt.Errorf("競合検知: Fencing Token (%d) は既に失効しています。更新を中止しました", token)
    }

    return nil
}

このパターンを採用することで、ワーカーが何秒停止しようが、クロックが狂おうが、TTLが切れて別のワーカーが処理を進めていれば、古いワーカーの RowsAffected() は確実に 0 となり、データの二重更新が完全に阻止されます。

5. 【逆張り】そもそも「分散ロック」を今すぐ捨てよ

ここまで Fencing Token の堅牢な設計と実装を解説してきました。しかし、鋭い洞察力を持つエンジニアなら、ある一つの根源的な疑問に行き着いたはずです。

「DB側で WHERE fencing_token < $token なんて防壁を書くくらいなら、最初からRedisの分散ロックなんて捨てて、RDBMSの機能だけで排他制御すればいいのでは?」

——まったくもってその通りです。これこそが本稿で最も主張したい強いオピニオンです。

現場の開発者が「分散ロックが必要だ!」と叫ぶ場面の90%以上は、単にRDBMSが半世紀かけて洗練させてきたACID特性と排他制御の基本を忘れているだけです。

RDBMSだけで美しく完結する3つの現実解

① ステートマシンによる条件付きUPDATE(CASの原則)

注文処理が二重に実行されるのを防ぎたいなら、状態遷移をクエリの条件にするだけで済みます。

UPDATE orders 
SET status = 'PROCESSING', updated_at = NOW() 
WHERE id = 'ord_123' AND status = 'PENDING';

更新された行数(Affected Rows)が1なら自分が処理権を獲得、0なら他のプロセスが既に進行中。外部ミドルウェア(Redis)の導入も、ネットワーク越しのロック取得も、TTL切れの恐怖も、すべて1ミリも必要ありません。

② 行レベル排他ロック(SELECT ... FOR UPDATE)

トランザクション内で行をロックすれば、データベース自身がコネクションの生存期間に合わせてロックを管理してくれます。プロセスが突発的に死ねばDB接続が切断され、トランザクションは安全にロールバックされ、ロックは即座に解放されます。TTLのように「早すぎて二重起動」「遅すぎてデッドロック」というパラメータチューニング地獄に悩まされることもありません。

③ 唯一性制約(UNIQUE制約)テーブル

バッチの二重起動防止やタスクの重複防止なら、task_locks (task_name VARCHAR PRIMARY KEY, locked_at TIMESTAMP) のようなテーブルを作り、INSERT が成功したプロセスだけが実行すれば良いのです。

では、分散ロックが本当に必要な「唯一の領域」とは?

外部の分散ロックが正当化されるのは、「元に戻せない外部の副作用(Side Effect)」が存在し、かつ「外部サービス側が冪等性をサポートしていない」絶望的な境界だけです。

これら以外の「社内のDBデータを更新する」目的のためにRedis分散ロックを導入するのは、障害点(SPOF)を無駄に増やし、運用の認知負荷を高め、静かなデータ破壊の時限爆弾を抱え込む過剰設計(Over-engineering)に他なりません。

6. まとめ:分散システムにおける排他制御の鉄則

分散環境での排他制御において、私たちが肝に銘じるべき原則を整理します。

「とりあえずRedisでロックを取っておこう」——その一行を書く前に、立ち止まって問いかけてみてください。あなたの背後には、何十年もの障害と戦い抜いてきた堅牢なリレーショナルデータベースが控えているのです。