Introducción

El problema es simple: cuando un usuario intenta registrar un nuevo dispositivo en tu sistema, el backend debe validar que no supere el límite de dispositivos permitidos. La complejidad aparece cuando múltiples solicitudes de registro para el mismo usuario llegan casi al mismo tiempo. Sin una sincronización adecuada, dos peticiones pueden leer que hay cupo disponible (A=1 con L=2), ambas insertar un registro, y terminar con A=3 violando el límite (A > L).

Este artículo explica cómo resolverlo usando solo MySQL, sin Redis ni infraestructura adicional. La solución aprovecha las características nativas de InnoDB: transacciones, bloqueos a nivel de fila (SELECT ... FOR UPDATE) y control de aislamiento. También cubrimos por qué el nivel de aislamiento por defecto de MySQL (REPEATABLE READ) puede sabotear tu solución si no lo ajustás correctamente.

Qué es y para qué sirve

El patrón de time-of-check to time-of-use (TOCTOU) ocurre cuando el código verifica una condición (ej: «¿hay cupo?») y luego actúa sobre esa verificación (ej: «registrar dispositivo»), pero entre ambos pasos otra transacción modifica los datos. La solución clásica es serializar el acceso para cada usuario usando un bloqueo que sea visible por todas las réplicas del backend.

Escenarios comunes donde esto aplica

  • Límites de dispositivos por usuario en apps de escritorio/móvil.
  • Cuotas de API por clave de API.
  • Asignación de recursos limitados (ej: licencias de software).
  • Cualquier sistema donde múltiples procesos intenten modificar el mismo estado concurrente bajo una restricción global.

¿Por qué no usar Redis ni otros sistemas?

  • Costo de infraestructura: Cada nuevo servicio añade complejidad operativa y puntos de fallo.
  • Latencia: Una llamada a Redis desde el backend puede ser más lenta que una consulta local a MySQL.
  • Consistencia eventual: Redis no garantiza atomicidad transaccional entre réplicas sin configuraciones avanzadas (ej: Redlock).

MySQL ya es tu servicio de coordinación distribuida: tiene transacciones ACID, bloqueos a nivel de fila, y está accesible desde todas tus réplicas. Solo necesitás usarlo correctamente.

Prerequisitos

Software y versiones

ComponenteVersión mínimaNotas
MySQL5.7+InnoDB es obligatorio. MyISAM no soporta bloqueos de filas.
Conector de MySQL8.0+Usá el driver oficial (BLOCK26, BLOCK27, etc.). Versiones antiguas pueden tener bugs en transacciones distribuidas.
Backend (ej: Java, Go, Python)CualquieraEl ejemplo usa pseudocódigo, pero aplicable a cualquier lenguaje con soporte transaccional.
### Permisos y configuración
  1. Permisos en la base de datos:
   GRANT SELECT, INSERT, UPDATE, DELETE ON tu_base.* TO 'tu_usuario'@'%'';
   
Necesitás permisos para modificar la tabla de bloqueo y leer/actualizar la tabla de usuarios.
  1. Motor de tabla:
   SHOW TABLE STATUS LIKE 'users' \G
   

Verificá que el motor sea InnoDB. Si es MyISAM, migrá la tabla:

   ALTER TABLE users ENGINE=InnoDB;
   
  1. Aislamiento transaccional:
Configurá el nivel de aislamiento en tu conexión de aplicación. No uses el default de MySQL (REPEATABLE READ). En su lugar, usá READ COMMITTED o SERIALIZABLE (este último es más restrictivo pero evita condiciones de carrera por diseño).

Guía paso a paso

Paso 1: Creá una tabla de bloqueo dedicada

Esta tabla no contiene datos de negocio. Su único propósito es ser un punto de sincronización para bloqueos de fila. La columna created_at es decorativa (puede eliminarse si lo preferís).

CREATE TABLE IF NOT EXISTS device_registration_locks (
    user_id BIGINT UNSIGNED NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id)
) ENGINE=InnoDB;
Resultado esperado:
  • La tabla tiene un índice primario único en user_id.
  • No hay triggers ni relaciones con otras tablas.
  • La columna created_at puede ignorarse en consultas posteriores.

Paso 2: Diseñá la transacción con bloqueo correcto

La clave es combinar el bloqueo y la verificación en una sola transacción atómica. Usá SELECT ... FOR UPDATE para bloquear la fila del usuario y INSERT ... ON DUPLICATE KEY UPDATE para asegurar que la tabla de bloqueo siempre tenga una fila por usuario.

Flujo recomendado (pseudocódigo para cualquier lenguaje):

# Pseudocódigo para Python (usando SQLAlchemy)
from sqlalchemy import text

def register_device(user_id: int, max_devices: int) -> bool:
    with db.begin() as transaction:
        # 1. Bloquear la fila del usuario (o crearla si no existe)
        lock_stmt = text("""
            SELECT 1 FROM device_registration_locks
            WHERE user_id = :user_id
            FOR UPDATE
        """)
        db.execute(lock_stmt, {"user_id": user_id})

        # 2. Verificar el límite de dispositivos
        count_stmt = text("""
            SELECT COUNT(*) as device_count
            FROM devices
            WHERE user_id = :user_id
        """)
        result = db.execute(count_stmt, {"user_id": user_id}).fetchone()
        current_devices = result.device_count

        if current_devices >= max_devices:
            return False  # Límite alcanzado

        # 3. Registrar el dispositivo (si pasó la verificación)
        insert_stmt = text("""
            INSERT INTO devices (user_id, device_id, registered_at)
            VALUES (:user_id, :device_id, NOW())
        """)
        db.execute(insert_stmt, {
            "user_id": user_id,
            "device_id": generate_unique_id()  # Implementá tu lógica aquí
        })

        return True

Variantes para otros lenguajes:

Java (JDBC):
try (Connection conn = dataSource.getConnection()) {
    conn.setAutoCommit(false);
    conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); // ¡CRUCIAL!

    // Bloquear fila (o crearla)
    try (PreparedStatement stmt = conn.prepareStatement(
            "SELECT 1 FROM device_registration_locks WHERE user_id = ? FOR UPDATE")) {
        stmt.setLong(1, userId);
        stmt.executeQuery();
    }

    // Verificar límite
    int currentDevices = 0;
    try (PreparedStatement stmt = conn.prepareStatement(
            "SELECT COUNT(*) FROM devices WHERE user_id = ?")) {
        stmt.setLong(1, userId);
        try (ResultSet rs = stmt.executeQuery()) {
            if (rs.next()) currentDevices = rs.getInt(1);
        }
    }

    if (currentDevices >= maxDevices) {
        conn.rollback();
        return false;
    }

    // Insertar dispositivo
    try (PreparedStatement stmt = conn.prepareStatement(
            "INSERT INTO devices (user_id, device_id, registered_at) VALUES (?, ?, NOW())")) {
        stmt.setLong(1, userId);
        stmt.setString(2, UUID.randomUUID().toString());
        stmt.executeUpdate();
    }

    conn.commit();
    return true;
}
Go (database/sql):
func registerDevice(db *sql.DB, userID int64, maxDevices int) (bool, error) {
    tx, err := db.BeginTx(context.Background(), &sql.TxOptions{
        Isolation: sql.LevelReadCommitted, // Aislamiento crítico
    })
    if err != nil {
        return false, err
    }
    defer tx.Rollback() // Rollback por defecto; commit manual

    // Bloquear fila
    var dummy int
    err = tx.QueryRow("SELECT 1 FROM device_registration_locks WHERE user_id = ? FOR UPDATE", userID).Scan(&dummy)
    if err != nil && err != sql.ErrNoRows {
        return false, err
    }

    // Verificar límite
    var currentDevices int
    err = tx.QueryRow("SELECT COUNT(*) FROM devices WHERE user_id = ?", userID).Scan(&currentDevices)
    if err != nil {
        return false, err
    }
    if currentDevices >= maxDevices {
        return false, nil
    }

    // Insertar dispositivo
    _, err = tx.Exec("INSERT INTO devices (user_id, device_id, registered_at) VALUES (?, ?, NOW())",
        userID, uuid.New().String())
    if err != nil {
        return false, err
    }

    err = tx.Commit()
    return err == nil, err
}

Paso 3: Asegurate de usar el nivel de aislamiento correcto

MySQL usa MVCC (Multi-Version Concurrency Control) para manejar lecturas concurrentes. El nivel de aislamiento afecta qué datos ves dentro de una transacción:

Nivel de aislamientoComportamiento¿Apto para este caso?
BLOCK38Lee datos «sucios» (de transacciones no commiteadas). **No recomendado**.
BLOCK39Ve solo datos commiteados *al momento de leer*. Bloqueos (BLOCK40) ven cambios en progreso.✅ **Recomendado**
BLOCK41 (default)Ve un *snapshot* de la base al inicio de la transacción. Las lecturas posteriores no ven cambios de otras transacciones.❌ **Problema común**
BLOCK42Más restrictivo: bloquea filas incluso para lecturas. Evita TOCTOU por diseño.✅ Alternativa segura
¿Por qué REPEATABLE READ falla?

Con este nivel, cuando ejecutás:

SELECT COUNT(*) FROM devices WHERE user_id = 123;

MySQL toma un snapshot de los datos commiteados en ese momento. Si otra transacción inserta un nuevo dispositivo después de que tu transacción empezó pero antes de que termine, no lo verás. Así que incluso si otro proceso ya registró un dispositivo, tu COUNT(*) seguirá devolviendo A=1 y permitirá un segundo registro.

Solución:

Configurá el aislamiento en tu conexión de aplicación. Ejemplo en Java:

conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

En Go:

tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})

Paso 4: Probá el bloqueo con estrés

Ejecutá un test que lance múltiples solicitudes concurrentes para el mismo usuario. Usá una herramienta como hey o escribe un script en Python con concurrent.futures.

Ejemplo de test en Python:
import threading
import requests
import concurrent.futures

def register_concurrently(user_id: int, max_devices: int, threads: int = 10):
    futures = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
        for i in range(threads):
            future = executor.submit(
                requests.post,
                "https://tu-api.com/register-device",
                json={"user_id": user_id, "device_id": f"device-{i}"},
                timeout=5
            )
            futures.append(future)

        results = [f.result() for f in concurrent.futures.as_completed(futures)]
        successes = sum(1 for r in results if r.status_code == 200)
        print(f"De {threads} solicitudes, {successes} registraron dispositivos (límite={max_devices})")

register_concurrently(user_id=42, max_devices=2, threads=10)
Resultado esperado:
  • Solo max_devices solicitudes deberían tener éxito (200 OK).
  • Las demás deberían fallar con 409 Conflict o 429 Too Many Requests.
  • No debería haber registros duplicados (verificá en la base de datos).

Consideraciones y buenas prácticas

1. MyISAM vs InnoDB: el error silencioso

Si tu tabla de bloqueo o la tabla de usuarios usa el motor MyISAM, SELECT ... FOR UPDATE no bloquea nada. MyISAM no soporta transacciones ni bloqueos a nivel de fila. Verificá con:

SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'tu_base' AND ENGINE = 'MyISAM';

Si aparece alguna tabla, migrála:

ALTER TABLE device_registration_locks ENGINE=InnoDB;

2. Transacciones largas = bloqueos largos = problemas de rendimiento

  • No mantengas la transacción abierta más de lo necesario. Ejemplo de mal práctica:
  # ❌ MAL: la transacción incluye lógica de negocio externa
  with db.begin():
      lock_row()
      external_call_to_payment_service()  # ¡Esto puede tardar segundos!
      insert_device()
  
Solución: Mové las operaciones no críticas fuera de la transacción:
  # ✅ BIEN: transacción mínima
  with db.begin():
      lock_row()
      current_devices = verify_limit()

  if current_devices >= max_devices:
      reject_registration()

  # Operaciones no críticas (ej: logs, analytics) fuera de la transacción
  log_registration(user_id, device_id)
  

3. Deadlocks: detectalos y manejalos

Si dos transacciones intentan bloquear filas en órdenes diferentes, pueden generarse deadlocks. MySQL los detecta y aborta una transacción con error 1213 (Deadlock found when trying to get lock).

Ejemplo de deadlock:
-- Transacción 1
BEGIN;
SELECT ... FOR UPDATE WHERE user_id = 1; -- Bloquea fila 1
SELECT ... FOR UPDATE WHERE user_id = 2; -- Bloquea fila 2

-- Transacción 2
BEGIN;
SELECT ... FOR UPDATE WHERE user_id = 2; -- Bloquea fila 2
SELECT ... FOR UPDATE WHERE user_id = 1; -- Espera por fila 1 (¡DEADLOCK!)
Solución:
  • Orden consistente: Siempre bloqueá filas en el mismo orden (ej: por user_id ASC).
  • Timeouts: Configurá un timeout en tu aplicación para reintentar en caso de deadlock:
  try:
      register_device(user_id, max_devices)
  except mysql.connector.Error as err:
      if err.errno == 1213:  # Deadlock
          retry_register_device(user_id, max_devices)
  

4. Alternativa: bloqueo con SERIALIZABLE

Si no querés lidiar con READ COMMITTED ni deadlocks, podés usar SERIALIZABLE, el nivel de aislamiento más restrictivo. Garantiza serialización pero reduce el throughput:

conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);
Desventaja: Puede generar más deadlocks porque bloquea filas incluso para lecturas.

5. ¿Y si no puedo modificar la base de datos?

Si no podés crear la tabla device_registration_locks, usá la tabla de usuarios solo si:

  • Tiene motor InnoDB.
  • Tiene un índice primario en user_id.
  • No tenés miedo de añadir una columna auxiliar (ej: registration_lock_version).
Ejemplo:
ALTER TABLE users ADD COLUMN registration_lock_version INT DEFAULT 0;

Luego bloqueá esa fila:

SELECT * FROM users WHERE user_id = 123 FOR UPDATE;

Conclusión

La solución presentada usa solo MySQL para evitar condiciones de carrera en registros concurrentes, cumpliendo con:

Sin nueva infraestructura (Redis, etcd, etc.).

Escalable (bloqueos a nivel de fila no afectan a usuarios diferentes).

Atómico (transacción que une verificación e inserción).

Durable (bloqueos liberados solo al commitear/rollback).

Pasos clave para recordar:
  1. Creá una tabla dedicada solo para bloqueos (sin datos de negocio).
  2. Usá SELECT ... FOR UPDATE dentro de una transacción con aislamiento READ COMMITTED.
  3. Verificá el límite dentro de la misma transacción que el bloqueo.
  4. Probá con carga concurrente para validar que solo max_devices registros pasen.
Errores comunes a evitar:
  • Usar REPEATABLE READ (default de MySQL) sin entender MVCC.
  • Confiar en MyISAM para bloqueos.
  • Dejar transacciones abiertas por operaciones externas (ej: llamadas a APIs).

Cuando el límite de dispositivos sea crítico (ej: licencias de software), no delegues la sincronización al código de la aplicación. Usá las herramientas transaccionales que tu base de datos ya provee: son más simples, más rápidas y más confiables que cualquier solución externa.

FIN

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *