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
| Componente | Versión mínima | Notas |
|---|---|---|
| MySQL | 5.7+ | InnoDB es obligatorio. MyISAM no soporta bloqueos de filas. |
| Conector de MySQL | 8.0+ | Usá el driver oficial ( BLOCK26 , BLOCK27 , etc.). Versiones antiguas pueden tener bugs en transacciones distribuidas. |
| Backend (ej: Java, Go, Python) | Cualquiera | El ejemplo usa pseudocódigo, pero aplicable a cualquier lenguaje con soporte transaccional. |
- 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.- 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;
- Aislamiento transaccional:
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_atpuede 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 TrueVariantes 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(¤tDevices)
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 aislamiento | Comportamiento | ¿Apto para este caso? |
|---|---|---|
