Saltar al contenido

Capítulo 6 de 13

Hacer una decisión sin dejar una carrera abierta

Separar ahorro de viajes, ejecución agrupada y comparación condicional, y construir un límite de peticiones verificable.

#Una lectura seguida de una escritura no es una decisión atómica

Dos clientes pueden leer un contador con valor nueve, sumar uno y escribir diez. Se pierden incrementos porque ambos calcularon a partir del mismo estado. INCR evita esa carrera al hacer el cambio dentro de Redis. Empieza buscando un comando que exprese la operación completa antes de implementar coordinación adicional.

Pero incluso INCR no resuelve un resultado de red incierto: si se ejecutó y se perdió la respuesta, repetirlo incrementa otra vez. Atomicidad impide interferencias durante el comando; idempotencia permite repetir la intención sin duplicar su efecto. Ninguna de ellas equivale a guardar una copia de seguridad.

#Preparar la sesión de ejemplos

Los bloques de esta página se ejecutan en una misma sesión Python con el entorno virtual activo y Redis en el puerto 6380. Este cliente pone límites de red y no reintenta escrituras automáticamente:

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry

r = redis.Redis(host="127.0.0.1", port=6380, decode_responses=True,
                socket_connect_timeout=0.5, socket_timeout=1,
                retry=Retry(NoBackoff(), 0))

Al terminar todos los ejemplos, llama a r.close(). Las claves usan el prefijo curso: de la instancia desechable: no son operaciones para ejecutar sobre datos de producción.

#Pipeline y transacción contestan preguntas distintas

Un pipeline agrupa comandos para reducir viajes de red. No permite usar en Python el resultado de la primera lectura antes de enviar las demás si todavía no has ejecutado el lote. Limita su tamaño: miles de respuestas grandes consumen memoria tanto en el servidor como en el cliente.

with r.pipeline(transaction=False) as p:
    p.get("curso:producto:1")
    p.get("curso:producto:2")
    resultados = p.execute()
print(resultados)

El bloque with libera recursos aunque falle. Los métodos sobre p acumulan instrucciones; execute() devuelve una lista de resultados en el mismo orden. En redis-py, pipeline() usa transacción por defecto. Escribimos transaction=False para enseñar explícitamente el caso que solo agrupa viajes.

Una transacción Redis con MULTI y EXEC ejecuta la secuencia sin intercalarla con comandos de otros clientes. No tiene rollback general de errores de ejecución como una transacción SQL. Prueba:

r.set("curso:tipo", "texto")
with r.pipeline(transaction=True) as p:
    p.set("curso:antes", "guardado")
    p.lpush("curso:tipo", "elemento")  # Tipo incorrecto: no es una lista.
    p.set("curso:despues", "también guardado")
    resultados = p.execute(raise_on_error=False)
print([type(x).__name__ for x in resultados])
print(r.get("curso:antes"), r.get("curso:despues"))

La operación central devuelve ResponseError, pero ambas strings se guardan. Los errores de sintaxis al encolar tienen otro tratamiento y pueden impedir el EXEC; no generalices un solo caso. Valida tipos y contratos antes de ejecutar y diseña claves que no se reutilicen para estructuras incompatibles. La referencia de transacciones detalla esta distinción.

#WATCH: escribir solo si nada cambió

WATCH vigila claves. Si cambian entre vigilarlas y ejecutar la transacción, Redis rechaza el conjunto y redis-py lanza WatchError. Es control optimista: preparas un cambio suponiendo poca contención y compruebas la suposición al confirmar.

El siguiente ejemplo fuerza el conflicto desde otro comando del mismo cliente, que usa otra conexión mientras el pipeline mantiene la suya:

r.set("curso:stock", 1)
with r.pipeline() as p:
    p.watch("curso:stock")
    disponible = int(p.get("curso:stock"))
    r.set("curso:stock", 0)  # Simula otra petición que se adelanta.
    p.multi()
    p.set("curso:stock", disponible - 1)
    try:
        p.execute()
    except redis.WatchError:
        print("Conflicto: hay que releer, no reutilizar disponible")
print(r.get("curso:stock"))  # 0

Entre watch y multi, los comandos del pipeline se envían inmediatamente para obtener las lecturas. Después de multi, se encolan. Si reintentas, vuelve a leer todo el estado relevante y limita intentos y plazo. Un bucle infinito bajo mucha contención puede consumir recursos sin progresar.

#Evitar la repoblación posterior a una invalidación

Retomamos la carrera de caché. Un lector guarda un token de generación antes de consultar el origen. El escritor cambia ese token después del commit del origen. El lector solo publica su copia si sigue viendo el mismo token:

import secrets

GEN = "curso:producto:42:generacion"
CACHE = "curso:producto:42:copia"

def invalidar():
    with r.pipeline(transaction=True) as p:
        p.set(GEN, secrets.token_hex(16))
        p.delete(CACHE)
        p.execute()

def publicar_si_vigente(generacion_leida, contenido):
    with r.pipeline() as p:
        try:
            p.watch(GEN)
            if p.get(GEN) != generacion_leida:
                return False
            p.multi()
            p.set(CACHE, contenido, ex=30)
            p.execute()
            return True
        except redis.WatchError:
            return False

invalidar()
generacion = r.get(GEN)          # A recuerda la generación.
lectura_antigua = "precio=3900"  # A termina una lectura lenta.
invalidar()                     # B ya confirmó precio=4100.
print(publicar_si_vigente(generacion, lectura_antigua))  # False
print(r.get(CACHE))  # None

secrets evita reutilizar generaciones al reiniciar un contador. Para este patrón la clave de generación debe conservarse y no ser expulsada mientras existan lectores; si permites que desaparezca y ambos lectores observen ausencia, la prueba pierde fuerza. Separa esta coordinación de una caché con expulsión arbitraria o diseña el contrato con versiones duraderas del origen.

Esta condición cierra la carrera desde que se aplica la invalidación en Redis. No hace atómico el commit de la base y la invalidación. La outbox conserva esa intención, pero las decisiones que requieren consistencia inmediata siguen leyendo el origen.

#Lua para una decisión corta dentro del servidor

Lua es un lenguaje pequeño que Redis puede ejecutar junto a los datos. Un script evita que otro comando se intercale entre su comprobación y su escritura. Mientras ejecuta trabajo, ocupa capacidad del servidor: no hagas bucles sobre colecciones sin límite ni llamadas a servicios externos. Los scripts tampoco revierten automáticamente escrituras previas a un error.

KEYS contiene nombres de claves; ARGV, valores. Se pasan separados para que Redis conozca qué datos toca el script. register_script devuelve una función invocable y gestiona la carga del script cuando hace falta.

liberar = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0
""")
r.set("curso:permiso", "nuevo-propietario", px=1500)
print(liberar(keys=["curso:permiso"], args=["antiguo-propietario"]))  # 0
print(r.get("curso:permiso"))  # nuevo-propietario

Este script hace segura la liberación del permiso temporal del capítulo anterior. redis.call ejecuta el comando; == compara y return devuelve el resultado al cliente. No convierte el permiso en exclusión permanente cuando expira mientras un trabajador sigue vivo.

#Un límite sencillo con su ventana explícita

Queremos permitir como máximo tres intentos durante los dos segundos que empiezan con el primer intento. El script incrementa y fija el TTL solo al crear el contador. Si fueran dos comandos separados, una caída entre ambos podría dejar un límite que nunca caduca.

limitar = r.register_script("""
local n = redis.call('INCR', KEYS[1])
if n == 1 then
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local restante = redis.call('PTTL', KEYS[1])
return {n, restante}
""")
r.delete("curso:limite:usuario-42")
for _ in range(5):
    numero, restante_ms = limitar(keys=["curso:limite:usuario-42"], args=[2000])
    print(numero <= 3, restante_ms)

Esperas tres True y dos False, con tiempos restantes positivos. local declara una variable de Lua. La identidad del usuario debe venir de autenticación, no de una cabecera arbitraria. Limita también el tamaño/cardinalidad de identidades para que un atacante no cree infinitos contadores.

Esto es una ventana fija anclada en el primer intento. Cerca del límite pueden ocurrir tres al final de una ventana y tres al principio de la siguiente en un intervalo breve. Si necesitas suavizar ráfagas, un token bucket repone capacidad con el tiempo; una ventana deslizante puede usar un sorted set y eliminar timestamps antiguos. Elige ese coste solo si cambia la garantía que necesitas.

Si Redis falla, decide explícitamente si rechazas temporalmente o permites con un límite local reducido. Los permisos de seguridad y el control de abuso no deberían desaparecer accidentalmente al capturar una excepción. La guía de pipelines de redis-py sirve como referencia de la API; las decisiones del ejemplo proceden de los contratos que acabamos de fijar.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar