Impón la unicidad en la base de datos, no solo en el modelo
Las validaciones de Rails mienten bajo carga concurrente. La base de datos no. Un validates :email, uniqueness: true parece garantizar una sola fila por dirección — hasta que dos peticiones llegan en el mismo milisegundo y ambas pasan.
La carrera, paso a paso
El validador de unicidad ejecuta un SELECT para comprobar si ya existe un duplicado, y solo entonces hace el INSERT. Son dos sentencias separadas con un hueco entre ellas. Bajo concurrencia:
- La petición A hace la comprobación — no encuentra duplicado.
- La petición B hace la comprobación — tampoco encuentra duplicado.
- La petición A inserta.
- La petición B inserta.
Ambas pasaron la comprobación en el mismo instante, así que ambas se guardan y obtienes el duplicado que creías haber evitado. La guía de Rails lo dice con claridad: esta validación "no crea una restricción de unicidad en la base de datos, por lo que puede ocurrir un escenario en el que dos conexiones distintas creen dos registros con el mismo valor en una columna que pretendías que fuera única".
La solución: un índice único
Baja la invariante a la única capa que la evalúa de forma atómica. Agrega un índice único en una migración:
class AddUniqueIndexToUsersEmail < ActiveRecord::Migration[7.1]
def change
add_index :users, :email, unique: true
end
end
Ahora el segundo INSERT de la carrera no puede ganar — la base de datos lo rechaza y lanza ActiveRecord::RecordNotUnique. La correctitud queda garantizada sin importar cuántas conexiones compitan. Para la unicidad con alcance (uniqueness: { scope: :account_id }), el índice debe cubrir ambas columnas: add_index :memberships, [:account_id, :user_id], unique: true.
Mantén ambas capas
La validación del modelo no está mal — simplemente no basta. Consérvala para lo que hace bien.
| Capa | Función | Destaca en |
|---|---|---|
validates ..., uniqueness: true | Mensaje de error amigable por campo antes de guardar | Experiencia de usuario |
| Índice único en la base de datos | Garantía atómica bajo concurrencia | Correctitud |
La validación atrapa ~99% de los duplicados de forma barata y le entrega al usuario un mensaje limpio de "el email ya está en uso". El índice atrapa la carrera poco frecuente que la validación no puede ver. Dos capas, una invariante.
Movimientos avanzados
- Rescata la excepción donde importa. Cuando el índice se dispara,
ActiveRecord::RecordNotUniquesube como un 500 crudo a menos que la manejes. Rails trae ayudantes atómicos hechos justo para esto:create_or_find_byintenta el insert y, si la restricción única lo rechaza, atrapa la excepción y recurre afind_by!— sin hueco de comprobar-y-actuar, porque la comprobación la hace la base de datos. upsert_allrespeta tus índices. Por defecto deduplica por cada índice único de la tabla; pasaunique_by:para apuntar a uno. Si una fila choca con un índice único distinto, igual lanzaRecordNotUnique— así que las rutas masivas necesitan los mismos índices en su lugar.- Hazlo un hábito, no una hazaña. Cualquier campo que una validación marque con
uniqueness: truey cualquier invariante de negocio que deba sostenerse bajo carga merece una restricción de base de datos que la respalde. Si la regla del reel es "validar para UX, restringir para la verdad", la auditoría es: por cada validación de unicidad, ¿hay un índice detrás?
Recursos
- Validación de unicidad y su advertencia sobre condiciones de carrera — Rails Guides
add_index ... unique: true— referencia de migraciones de Active Recordcreate_or_find_by— Rails APIupsert_allyunique_by— Rails API- One row, many threads: cómo evitar duplicados en la base de datos en aplicaciones Rails — Evil Martians
¿Llevas Rails a carga real? Yeda AI diseña, audita y endurece sistemas en producción.