Tu sync.Mutex no sobrevive a un kill -9. No sobrevive a un OOM, un deployment rollout ni un reinicio de node. En el instante en que el proceso termina, el lock desaparece. Si ese lock protegía un job programado, una migration de datos o una elección de leader, ahora tienes dos procesos convencidos de que son los únicos ejecutándose.

Eso no es un bug en tu mutex. Es un error de categoría. Un lock local al proceso no puede proteger un recurso a nivel de cluster.

La solución es un lease distribuido: un lock almacenado en un sistema externo con un Time-To-Live, un token de propietario único y un mecanismo de liberación segura. Redis, PostgreSQL, etcd y ZooKeeper implementan la misma idea con distintos trade-offs. El patrón es sencillo. Los edge cases no lo son.

¿Qué es un lease distribuido?

Un lease es una promesa de un store externo de que solo un cliente posee un lock nombrado durante un período limitado de tiempo. A diferencia de un mutex, que vive en la memoria heap, un lease vive en Redis o PostgreSQL. Persiste tras reinicios de proceso porque el store persiste.

Las operaciones básicas son:

  • Acquire: escribir atómicamente una key con un TTL, pero solo si no existe ya.
  • Renew: extender el TTL mientras aún posees el lock.
  • Release: eliminar la key, pero solo si aún contiene tu token único.

El token único es la parte crítica. Sin él, un cliente lento puede liberar un lock que fue re-adquirido por otro después de un crash.

Por qué el TTL es obligatorio, y por qué es peligroso

Si un cliente adquiere un lock y luego muere, el lock debe liberarse eventualmente. La única forma de hacerlo sin intervención humana es un tiempo de expiración. Redis soporta esto de forma nativa con SET key value NX EX 30. Los advisory locks de PostgreSQL están ligados a la sesión y mueren cuando el TCP connection se cierra, lo cual es elegante pero menos portable.

Los TTLs introducen un nuevo failure mode: si tu trabajo tarda más que el TTL, el lock expira mientras aún estás ejecutando. Otro cliente adquiere el lock y comienza el mismo trabajo. Ahora tienes dos procesos concurrentes mutando los mismos datos.

La solución ingenua es un TTL muy largo. Eso funciona hasta que un cliente muere inmediatamente después de adquirir el lock. El TTL restante se convierte en una ventana de downtime obligatoria donde nadie más puede hacerse cargo.

La solución correcta es un heartbeat. El holder lanza una goroutine en segundo plano que renueva el lease cada pocos segundos. Si el holder hace crash, el heartbeat se detiene, el TTL expira y un nuevo propietario puede adquirir el lock en cuestión de segundos. Si el holder es simplemente lento, el heartbeat mantiene el lease vivo mientras el proceso viva.

Un lease de Redis funcional en Go

Aquí hay una implementación completa que maneja la adquisición, la renovación y la liberación segura. Usa un script Lua para la liberación, de modo que el delete solo tenga éxito si el token coincide.

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
}

Uso:

lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
    // Otra instancia está ejecutando el job.
    return
}
defer lease.Release(ctx)

// Haz el trabajo. Si esto tarda 30 segundos, el heartbeat mantiene el lease vivo.
generateInvoices()

Los scripts Lua son necesarios porque Redis no soporta compare-and-delete como un comando nativo único. Sin ellos, una liberación podría competir con una re-adquisición y eliminar el lock de otra persona.

Dónde esto falla: el problema del fencing token

Incluso con TTL y renovación perfectos, existe una race condition sutil identificada por Martin Kleppmann en su crítica a Redlock. Imagina:

  1. El cliente A adquiere el lease.
  2. El cliente A se pausa durante 45 segundos (GC stop-the-world, VM suspension, CPU throttling).
  3. El lease expira.
  4. El cliente B adquiere el lease.
  5. El cliente A se reanuda y escribe en la base de datos.
  6. El cliente B escribe en la base de datos.

Ambos clientes creían poseer el lock. Ambos mutaron el estado compartido.

El TTL protege contra deadlocks permanentes, pero no puede proteger contra procesos retrasados. La solución es un fencing token: un número monotónico o UUID que el lock holder adjunta a cada escritura. La capa de storage rechaza escrituras con un token desactualizado.

En la práctica, esto significa que tu tabla de base de datos necesita una columna lock_version, o tu blob store necesita conditional writes. La mayoría de las aplicaciones omiten este paso porque requiere cambiar la capa de datos, no solo la capa de locking. Eso es un trade-off razonable, pero es un trade-off. Deberías saber que lo estás haciendo.

Alternativas que vale la pena considerar

Los advisory locks de PostgreSQL están ligados a la sesión. Cuando el TCP connection se cierra, el lock se libera automáticamente. No hay gestión de TTL ni skew de reloj. La desventaja es que están atados a una única database connection, así que no funcionan bien con connection pooling o setups multi-región.

Los leases de etcd están diseñados exactamente para este problema. Soportan TTL, revocación automática y notificaciones basadas en watch cuando un lease muere. Si ya estás ejecutando Kubernetes, tienes etcd. La API es más verbosa que Redis, pero la semántica es más limpia.

Los nodes efímeros secuenciales de ZooKeeper son la solución clásica. Son CP (consistent and partition-tolerant) bajo el teorema CAP, lo que elimina el problema de clock skew por completo. También son más lentos y operacionalmente más pesados que Redis.

Lo que no intentamos

No implementamos un protocol de consensus custom sobre una base de datos relacional. Cada equipo eventualmente lo intenta: una tabla locks con INSERT ... ON CONFLICT y una columna last_heartbeat barrida por un cron job. Funciona en el happy path. Se desmorona bajo contención porque las bases de datos MVCC serializan escrituras conflictivas, y tu adquisición de lock se convierte en el cuello de botella de todo tu sistema. Usa la herramienta adecuada para el trabajo.

Elegir un TTL

Demasiado corto: los heartbeats se vuelven chatty, y una sola pausa lenta de GC puede perder el lease.

Demasiado largo: un holder que hace crash bloquea el failover durante todo el TTL.

Un buen punto de partida es 10 segundos con un heartbeat cada 3 segundos. Ajusta a partir de ahí según tus pausas de GC observadas y tu latencia de red. Mide tu latencia de heartbeat p99. Si es de 500 ms, tu TTL debe ser al menos un orden de magnitud mayor.

Pruébalo

Si actualmente estás usando un sync.Mutex para proteger un background job, reemplázalo con un leased lock respaldado por Redis o PostgreSQL. Comienza con la implementación de arriba. Añade metrics para lease_acquired, lease_lost y heartbeat_latency. La primera vez que hagas deploy durante un job de larga duración y observes que la segunda instancia espera educadamente en lugar de colisionar, sabrás que el error de categoría está corregido.