你的 sync.Mutex 撐不過 kill -9。它也撐不過 OOM、部署推出或節點重啟。程序結束的瞬間,lock 就消失了。如果那把 lock保護的是一個排程任務、資料遷移或領導者選舉,你現在會有兩個程序都認為自己是唯一在執行的那個。

這不是你的mutex有 bug。這是類別錯誤。程序本地的lock無法保護叢集範圍的資源。

解決方案是分散式租約:一個儲存在外部系統中的lock,具有存活時間(Time-To-Live)、唯一的持有者 token,以及安全釋放的機制。Redis、PostgreSQL、etcd 和 ZooKeeper 都以不同的取捨實現了相同的概念。這個模式本身很直觀。邊界情況則不然。

什麼是分散式租約?

租約是外部儲存系統的一項承諾:在一段有限的時間內,只有一個客戶端持有某個具名 lock。與存在於堆積記憶體中的mutex不同,租約存在於 Redis 或 PostgreSQL 中。它會在程序重啟後仍然存在,因為儲存系統本身會持續存在。

基本操作如下:

  • Acquire:以原子方式寫入一個帶有 TTL 的鍵,但僅限於該鍵尚未存在時。
  • Renew:在你仍然持有 lock的期間延長 TTL。
  • Release:刪除該鍵,但僅限於它仍然包含你的唯一 token 時。

唯一的 token 是關鍵所在。沒有它,一個緩慢的客戶端可能會釋放一把在當機後已被其他客戶端重新取得的lock。

為什麼 TTL 是強制性的,以及為什麼它很危險

如果客戶端取得 lock後當機,lock最終必須自行釋放。要做到這點而無需人工介入,唯一的方式就是設定過期時間。Redis 原生支援這個功能,例如 SET key value NX EX 30。PostgreSQL 的諮詢 lock(advisory locks)繫結於連線階段(session),當 TCP 連線關閉時 lock 就會自動釋放,這很優雅但可攜性較差。

TTL 帶來了一種新的失效模式:如果你的工作耗時超過 TTL,lock會在你仍在執行時過期。另一個客戶端取得 lock並開始執行相同的工作。你現在有兩個並行程序在修改同一份資料。

天真的解法是設定非常長的 TTL。這確實可行,直到某個客戶端在取得 lock後立刻當機。剩餘的 TTL 就變成了強制性的停機窗口,期間沒有其他人可以接手。

正確的解法是心跳機制。持有者會啟動一個背景 goroutine,每隔幾秒續約一次。如果持有者當機,心跳停止,TTL 過期,新的持有者就能在數秒內取得 lock。如果持有者只是比較慢,只要程序還活著,心跳就會持續讓租約保持有效。

一個可用的 Go Redis 租約實作

這裡提供一個完整的實作,處理取得、續約與安全釋放。它使用 Lua 指令碼來執行釋放,確保只有在 token 相符時刪除才會成功。

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 作為單一原生指令。沒有它們,釋放操作可能會與重新取得發生競爭,進而刪除別人的 lock。

這裡會出問題:fencing token 難題

即使有完美的 TTL 與續約機制,Martin Kleppmann 在對 Redlock 的批判中指出了一個細微的race condition。試想:

  1. 客戶端 A 取得租約。
  2. 客戶端 A 暫停 45 秒(GC stop-the-world、VM 暫停、CPU 節流)。
  3. 租約過期。
  4. 客戶端 B 取得租約。
  5. 客戶端 A 恢復並寫入資料庫。
  6. 客戶端 B 寫入資料庫。

兩個客戶端都認為自己持有 lock。兩個都修改了共享狀態。

TTL 可以防止永久性deadlock,但無法防止延遲的程序。解決方案是 fencing token:一個單調遞增的數字或 UUID,由lock持有者附加到每一次寫入。儲存層會拒絕使用過期 token 的寫入。

實務上,這意味著你的資料庫表格需要一個 lock_version 欄位,或者你的 blob 儲存需要支援條件式寫入。大多數應用程式會跳過這一步,因為這需要改動資料層,而不只是lock定層。這是合理的取捨,但它終究是取捨。你應該知道自己正在做這個選擇。

值得考慮的替代方案

PostgreSQL advisory locks 是連線階段範圍的(session-scoped)。當 TCP 連線關閉時,lock會自動釋放。沒有 TTL 管理,也沒有時鐘偏移問題。缺點是它們繫結於單一資料庫連線,因此與連線池或多區域架構的相容性不佳。

etcd leases 正是為這個問題而設計的。它們支援 TTL、自動撤銷,以及租約失效時的 watch 通知。如果你已經在執行 Kubernetes,你就已經有 etcd 了。它的 API 比 Redis 冗長,但語義更清晰。

ZooKeeper ephemeral sequential nodes 是經典的解決方案。它們在 CAP 定理下是 CP(一致且分區容忍)的,這完全消除了時鐘偏移問題。它們也比 Redis 更慢,且在維運上更沉重。

我們沒有嘗試的事

我們沒有在關聯式資料庫上實作自訂的共識協議。每個團隊最終都會嘗試這麼做:一個 locks 表格,搭配 INSERT ... ON CONFLICT 和一個由 cron job 清理的 last_heartbeat 欄位。它在快樂路徑下運作良好。但在高競爭下會崩解,因為 MVCC 資料庫會將衝突的寫入序列化,而你的lock取得操作會變成整個系統的瓶頸。為工作選擇正確的工具。

選擇 TTL

太短:心跳會變得過於頻繁,而且一次緩慢的 GC 暫停就可能遺失租約。

太長:當機的持有者會在整個 TTL 期間阻擋容錯移轉。

一個好的起點是 TTL 設為 10 秒,每 3 秒發送一次心跳。再根據你觀察到的 GC 暫停與網路延遲進行調校。測量你的 p99 心跳延遲。如果它是 500 毫秒,你的 TTL 至少要比它大上一個數量級。

動手試試

如果你目前正在使用 sync.Mutex 來保護背景任務,請將它替換為由 Redis 或 PostgreSQL 支援的租約lock。從上面的實作開始。為 lease_acquiredlease_lostheartbeat_latency 加入指標。第一次你在長時間執行的任務期間部署,並看到第二個實例禮貌地等待而不是發生衝突時,你就會知道這個類別錯誤已經被修正了。