¿Cómo funciona el bloqueo distribuido — Redis Redlock vs. ZooKeeper — y qué puede salir mal?
Un mutex solo protege la memoria de un proceso -- coordinar varias máquinas alrededor de una única sección crítica exige un lock que viva fuera de todas ellas, y toda implementación intercambia seguridad por latencia en algún punto.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo funciona el bloqueo distribuido — Redis Redlock vs. ZooKeeper — y qué puede salir mal?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué necesitás un lock fuera de cualquier proceso
Un mutex o un lock a nivel de lenguaje solo coordina hilos dentro de la memoria de un proceso. En el momento en que corrés más de una instancia de un servicio -- que es el caso normal, no la excepción, para cualquier cosa que necesite escalar o sobrevivir a un reinicio -- coordinar 'solo uno de ustedes debería hacer esto ahora' requiere un lock que viva en algún lugar que todas las instancias puedan ver: típicamente Redis o ZooKeeper (o etcd, que se comporta de forma similar a ZooKeeper para este propósito). El ejemplo canónico es un job programado: cinco instancias de un servicio se despiertan a las 2am para correr una reconciliación nocturna, y exactamente una de ellas debería hacer el trabajo.
#Un lock de Redis de un solo nodo
El bloque básico es SET key value NX PX ttl: fijar la clave solo si todavía no existe (NX), con un vencimiento (PX) para que un titular caído no bloquee a todos los demás para siempre. El valor debería ser un token aleatorio único para esta adquisición, no una constante -- ese token es lo que te permite liberar tu propio lock de forma segura, con un compare-and-delete en vez de un delete a ciegas.
import redisimport uuidr = redis.Redis(host='localhost', port=6379)RELEASE_SCRIPT = '''if redis.call('get', KEYS[1]) == ARGV[1] thenreturn redis.call('del', KEYS[1])elsereturn 0end'''def acquire_lock(lock_name, ttl_ms=10000):token = str(uuid.uuid4())acquired = r.set(lock_name, token, nx=True, px=ttl_ms)return token if acquired else Nonedef release_lock(lock_name, token):release = r.register_script(RELEASE_SCRIPT)# Solo borra la clave si todavía tiene NUESTRO token -- un DEL a ciegas# podría liberar un lock que otro cliente adquirió después de que el nuestro expiró.return release(keys=[lock_name], args=[token])
Esto es correcto frente al bug más común en locks de Redis hechos a mano, que es liberar con un DEL simple y borrar por accidente un lock que otro cliente adquirió después de que el nuestro expiró.
#Redlock: repartir el lock entre un quórum
Un solo nodo de Redis es un punto único de falla -- si se cae antes de que el lock expire, nadie puede adquirirlo ni liberarlo. Redlock aborda esto pidiendo el mismo lock en N nodos independientes de Redis (típicamente 5), contándolo como adquirido solo si una mayoría responde exitosamente dentro de un presupuesto de tiempo bien por debajo del TTL, y teniendo en cuenta el desvío de reloj entre nodos. La liberación se intenta contra los N nodos sin importar cuáles lo hayan otorgado.
#La advertencia clásica: las pausas rompen el supuesto de tiempo
La seguridad de Redlock depende de un supuesto fácil de enunciar y sorprendentemente fácil de violar: ningún cliente puede pausarse más tiempo que el TTL del lock entre adquirirlo y terminar su trabajo. Una pausa larga de garbage collection, un hipervisor migrando una VM, o el proceso simplemente perdiendo el CPU bajo carga pueden violar ese supuesto.
El arreglo no es un mejor algoritmo para el lock en sí -- es hacer que el recurso protegido rechace escritores obsoletos. Un fencing token es un número que aumenta cada vez que se otorga el lock; el recurso registra el último token que aceptó y rechaza cualquier escritura que traiga uno menor. La escritura del Cliente A en el diagrama de arriba llevaría un token viejo y sería rechazada, aunque A todavía crea (equivocadamente) que tiene el lock.
#ZooKeeper: consenso en vez de tiempo
Los locks de ZooKeeper funcionan distinto: un cliente crea un znode efímero secuencial bajo una ruta de lock (/locks/job-0000001), y tiene el lock cuando su znode tiene el número de secuencia más bajo entre sus hermanos -- si no, observa el siguiente más bajo y espera. 'Efímero' significa que el znode está atado a la sesión del cliente: si el proceso del cliente muere o su conexión se cae, ZooKeeper borra el znode automáticamente y el siguiente cliente en la fila adquiere el lock, sin necesidad de adivinar con un TTL. El número de secuencia funciona además como un fencing token natural.
CuratorFramework client = CuratorFrameworkFactory.newClient("zk1:2181,zk2:2181,zk3:2181",new ExponentialBackoffRetry(1000, 3));client.start();InterProcessMutex lock = new InterProcessMutex(client, "/locks/nightly-reconciliation");if (lock.acquire(10, TimeUnit.SECONDS)) {try {runReconciliationJob();} finally {lock.release();}} else {log.warn("could not acquire lock, another instance is running the job");}
| Dimensión | Redis / Redlock | ZooKeeper |
|---|---|---|
| Modelo de consistencia | Quórum de mejor esfuerzo entre nodos independientes, basado en tiempo | Consistencia fuerte vía el protocolo de consenso ZAB |
| Latencia | Muy baja -- en memoria, un round trip por nodo | Más alta -- pasa por un camino de escritura con consenso |
| Fencing tokens | No vienen incluidos -- hay que agregarlos a mano | Naturales -- el número de secuencia del znode sirve como uno |
| Recuperación ante fallos | El lock expira por TTL aunque el cliente haya desaparecido | El lock se libera automáticamente cuando termina la sesión del cliente |
| Costo operativo | Bajo -- Redis ya suele ser infraestructura común | Más alto -- requiere un ensemble dedicado para correr y monitorear |
| Mejor uso | Locks de vida corta y alto throughput donde una carrera ocasional es tolerable | Coordinación crítica para la corrección -- elección de líder, locks de configuración |