Cómo eliminé duplicados en la generación de números de factura sin usar locks
Tenía un sistema de facturación en producción (Java/JAX-RS + MongoDB, corriendo en Tomcat) donde cada documento —factura, egreso, comprobante— necesita un número consecutivo único. Nada exótico. El problema apareció solo bajo carga: dos requests casi simultáneos generaban el mismo número.
El síntoma
Un usuario reportó dos facturas con el mismo folio. En logs, ambas requests llegaron con 40ms de diferencia. El código hacía esto:
// Antes: lee el último número, le suma 1, guarda
Documento ultimo = dao.findLastByTipo("FACTURA");
int siguiente = ultimo.getNumero() + 1;
nuevoDocumento.setNumero(siguiente);
dao.save(nuevoDocumento);
Clásico read-then-write sin atomicidad. Entre el find y el save, otro thread (u otro proceso, u otro pod si escalas horizontal) puede leer el mismo "último número" y generar el mismo consecutivo. Es el mismo problema que un debounce mal hecho en un botón físico: dos pulsos que deberían ser uno solo porque no hay una ventana atómica que los serialice.
Por qué no usé locks
Lock a nivel de aplicación (synchronized, semáforos) no sirve si vas a escalar a más de una instancia. Lock a nivel de base de datos (transacciones con bloqueo explícito) funciona pero añade latencia y complejidad de manejo de deadlocks que no necesitaba para este caso.
La solución: operación atómica nativa de Mongo
MongoDB tiene findOneAndUpdate con $inc, que es atómico a nivel de documento. En vez de leer-calcular-guardar, le pides a Mongo que incremente y te devuelva el valor ya incrementado, en una sola operación indivisible.
Colección dedicada:
// colección: contadores
{ _id: "FACTURA", secuencia: 1042 }
Código:
Bson filtro = Filters.eq("_id", "FACTURA");
Bson update = Updates.inc("secuencia", 1);
FindOneAndUpdateOptions opts = new FindOneAndUpdateOptions()
.returnDocument(ReturnDocument.AFTER)
.upsert(true);
Document contador = collection.findOneAndUpdate(filtro, update, opts);
int siguienteNumero = contador.getInteger("secuencia");
Esto es atómico incluso con N instancias de la aplicación pegándole a la misma base al mismo tiempo. Mongo serializa internamente las operaciones sobre el mismo _id, así que no hay ventana donde dos requests lean el mismo valor.
La red de seguridad: índice único
findOneAndUpdate resuelve el 99% de los casos, pero no cuesta nada blindar el dato final con un índice único sobre el campo numero dentro de la colección de documentos:
db.facturas.createIndex(
{ tipo: 1, numero: 1 },
{ unique: true }
)
Si por alguna razón (bug futuro, migración, dato legacy) se intenta insertar un duplicado, Mongo lo rechaza con un E11000 en vez de guardarlo silenciosamente. Prefiero un error explícito en logs a un duplicado silencioso que alguien descubre tres meses después en una auditoría.
Resultado
Cero duplicados desde que se desplegó, incluyendo picos de concurrencia reales (varios usuarios facturando al cierre de mes). El patrón se repite igual para cualquier secuencia que necesite unicidad garantizada bajo concurrencia: consecutivos de comprobantes, tickets, números de orden, lo que sea.
Resumen para copiar y pegar
- Nunca generes consecutivos con
find+ cálculo en memoria +save. - Usa
findOneAndUpdate+$incsobre una colección de contadores dedicada. - Blinda con índice único como última línea de defensa, no como solución principal.
Top comments (0)