Ваш sync.Mutex не переживает kill -9. Он не переживает OOM, deployment rollout и перезагрузку узла. В тот момент, когда процесс завершается, lock пропадает. Если этот lock защищал scheduled job, data migration или leadership election, теперь у вас два процесса, каждый из которых уверен, что работает один.

Это не баг в вашем mutex. Это ошибка категории. Process-local lock не может защищать cluster-wide resource.

Решение — distributed lease: lock, хранящийся во внешней системе с Time-To-Live, уникальным owner token и механизмом безопасного освобождения. Redis, PostgreSQL, etcd и ZooKeeper реализуют одну и ту же идею с разными trade-off. Паттерн простой. Edge cases — нет.

Что такое distributed lease?

Lease — это обещание от внешнего хранилища, что только один клиент удерживает named lock в течение ограниченного периода времени. В отличие от mutex, который живёт в heap memory, lease живёт в Redis или PostgreSQL. Он сохраняется при перезапуске процесса, потому что хранилище сохраняется.

Базовые операции:

  • Acquire: атомарно записать key с TTL, но только если он ещё не существует.
  • Renew: продлить TTL, пока вы всё ещё удерживаете lock.
  • Release: удалить key, но только если он всё ещё содержит ваш уникальный token.

Уникальный token — это критически важная часть. Без него медленный клиент может освободить lock, который был повторно захвачен кем-то другим после сбоя.

Почему TTL обязателен и почему это опасно

Если клиент захватывает lock и затем падает, lock должен в конечном итоге освободиться. Единственный способ сделать это без вмешательства человека — expiration time. Redis поддерживает это нативно через SET key value NX EX 30. PostgreSQL advisory locks привязаны к сессии и умирают при закрытии TCP-соединения, что элегантно, но менее portable.

TTL вносят новый режим отказа: если ваша работа занимает больше времени, чем TTL, lock истекает, пока вы всё ещё работаете. Другой клиент захватывает lock и начинает ту же работу. Теперь у вас два конкурирующих процесса, изменяющих одни и те же данные.

Наивное решение — очень длинный TTL. Это работает, пока клиент не падает сразу после захвата lock. Оставшийся TTL превращается в обязательное окно простоя, в течение которого никто другой не может взять управление на себя.

Правильное решение — heartbeat. Удерживающий lock запускает background goroutine, которая продлевает lease каждые несколько секунд. Если удерживающий падает, heartbeat останавливается, TTL истекает, и новый владелец может захватить lock в течение секунд. Если удерживающий просто медленный, heartbeat поддерживает lease в живых, пока процесс жив.

Рабочий Redis lease на Go

Вот полная реализация, которая обрабатывает acquisition, renewal и безопасный release. Она использует Lua script для release, так что удаление проходит только если 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 scripts необходимы, потому что Redis не поддерживает compare-and-delete как единую нативную команду. Без них release может состязаться с re-acquisition и удалить чужой lock.

Где это ломается: проблема fencing token

Даже с идеальным TTL и renewal существует тонкое race condition, выявленное Martin Kleppmann в его критике Redlock. Представьте:

  1. Client A захватывает lease.
  2. Client A приостанавливается на 45 секунд (GC stop-the-world, VM suspension, CPU throttling).
  3. Lease истекает.
  4. Client B захватывает lease.
  5. Client A возобновляет работу и пишет в базу данных.
  6. Client B пишет в базу данных.

Оба клиента считали, что удерживают lock. Оба изменили shared state.

TTL защищает от перманентных deadlock, но не может защитить от задержанных процессов. Решение — fencing token: монотонный номер или UUID, который удерживающий lock прикрепляет к каждой записи. Storage layer отклоняет записи с устаревшим token.

На практике это означает, что вашей таблице базы данных нужна колонка lock_version, или вашему blob store нужны conditional writes. Большинство приложений пропускают этот шаг, потому что это требует изменения data layer, а не только locking layer. Это разумный trade-off, но это trade-off. Вы должны знать, что делаете его.

Альтернативы, которые стоит рассмотреть

PostgreSQL advisory locks привязаны к сессии. Когда TCP-соединение закрывается, lock освобождается автоматически. Нет управления TTL и нет clock skew. Недостаток в том, что они привязаны к одному database connection, поэтому они плохо работают с connection pooling или multi-region setups.

etcd leases созданы именно для этой проблемы. Они поддерживают TTL, automatic revocation и watch-based notifications, когда lease умирает. Если вы уже запускаете Kubernetes, у вас есть etcd. API более многословен, чем у Redis, но семантика чище.

ZooKeeper ephemeral sequential nodes — классическое решение. Они CP (consistent and partition-tolerant) согласно теореме CAP, что полностью устраняет проблему clock skew. Они также медленнее и операционно тяжелее, чем Redis.

Что мы не стали пробовать

Мы не реализовывали custom consensus protocol поверх реляционной базы данных. Каждая команда рано или поздно пробует это: таблица locks с INSERT ... ON CONFLICT и колонкой last_heartbeat, которую чистит cron job. Это работает на happy path. Это разваливается под contention, потому что MVCC базы данных сериализуют конфликтующие записи, и ваш lock acquisition становится bottleneck для всей системы. Используйте подходящий инструмент для задачи.

Выбор TTL

Слишком короткий: сообщения heartbeat становятся слишком частыми, и одна медленная GC pause может привести к потере lease.

Слишком длинный: упавший удерживающий блокирует failover на весь TTL.

Хорошая отправная точка — 10 секунд с heartbeat каждые 3 секунды. Настраивайте дальше на основе наблюдаемых GC pauses и network latency. Измеряйте вашу p99 heartbeat latency. Если она составляет 500 мс, ваш TTL должен быть как минимум на порядок больше.

Попробуйте

Если вы сейчас используете sync.Mutex для защиты background job, замените его на leased lock на базе Redis или PostgreSQL. Начните с реализации выше. Добавьте метрики для lease_acquired, lease_lost и heartbeat_latency. В первый раз, когда вы задеплоите во время долгой работы и увидите, как второй экземпляр вежливо ждёт вместо столкновения, вы поймёте, что ошибка категории исправлена.