Concurrencia e Integridad de Datos: Los Patrones que Salvaron Nuestra Producción
Patrones de concurrencia en producción para sistemas empresariales. Field ownership, bloqueo optimista, leases cooperativos, stores de idempotencia, gestión de versiones y capas de gobernanza transaccional.
La Condición de Carrera que No Ves Hasta Producción
Las condiciones de carrera son invisibles en desarrollo. Tu máquina local ejecuta un solo proceso. Tu suite de tests corre secuencialmente. Todo funciona. Entonces despliegas a producción con 4 pods web y 3 pods de workers, y dos procesos modifican el mismo registro al mismo tiempo. Uno sobreescribe los cambios del otro. Los datos se corrompen silenciosamente. Nadie se da cuenta hasta que un cliente se queja.
Hemos corregido condiciones de carrera en múltiples sistemas empresariales: plataformas CMS con 20 editores y workers en background, plataformas de comercio con procesamiento concurrente de pedidos, y sistemas de IA con workflows de agentes paralelos. Los patrones en este artículo son los que sobrevivieron.
Para más contexto, consulta nuestra guía de arquitectura de sistemas y la guía de arquitectura event-driven. Para concurrencia específica en CMS, nuestra guía de workflows en Pimcore cubre esos patrones en profundidad.
Field Ownership: Quién Puede Escribir Qué
La causa raíz de la mayoría de los bugs de concurrencia empresarial: múltiples escritores modificando el mismo registro a través del mismo path de guardado sin coordinación.
Un CMS tiene editores escribiendo descripciones de producto y workers generando thumbnails. Ambos llaman a save(). Ambos persisten el objeto completo. Si el worker guarda después de cargar pero antes de que el editor guarde, el guardado del editor sobreescribe el thumbnail del worker. Si el editor guarda primero, el guardado del worker sobreescribe la descripción del editor.
La solución: asignar cada campo a un propietario.
field_ownership:
Product:
editor_owned:
- name
- description
- images
system_owned:
- thumbnail
- searchIndex
- checksum
- lastSyncTimestamp
shared:
- categories
- price
- availability
| Dominio | Propietario | Path de Mutación | Estrategia de Conflicto |
|---|---|---|---|
| Editor-owned | Usuarios admin | Guardado estándar | Sin conflicto (solo editores escriben) |
| System-owned | Workers/integraciones | Capa transaccional con locks | Reintentar en conflicto |
| Shared | Ambos | Capa transaccional con resolución de conflictos | Configurable: reintentar, omitir, merge |
Los campos de editor pasan por el path de guardado estándar. Los campos de sistema pasan por una capa transaccional con locks y chequeos de versión. Los campos compartidos usan estrategias explícitas de resolución de conflictos.
Sin field ownership, dependes de la suerte. Con él, el sistema impone quién puede escribir qué y resuelve conflictos de forma determinística.
Bloqueo Optimista con Chequeos de Versión
El bloqueo optimista asume que los conflictos son raros. En lugar de bloquear antes de la modificación, verifica si el registro cambió entre la carga y el guardado.
async function updateWithOptimisticLock(
productId: string,
updateFn: (product: Product) => void,
maxRetries: number = 3,
): Promise<Product> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const product = await productRepo.findById(productId, { force: true });
const versionBefore = product.versionCount;
updateFn(product);
// Verificar que la versión no cambió antes de guardar
const currentVersion = await productRepo.getVersionCount(productId);
if (currentVersion !== versionBefore) {
if (attempt === maxRetries - 1) {
throw new ConcurrencyError(
`Product ${productId} was modified concurrently (version ${versionBefore} -> ${currentVersion})`
);
}
continue; // Reintentar con datos frescos
}
await product.save();
return product;
}
}
El chequeo de versión no es atómico en este ejemplo. Para atomicidad real, usa soporte a nivel de base de datos:
-- PostgreSQL: chequeo atómico de versión + actualización
UPDATE products
SET name = $1, version_count = version_count + 1
WHERE id = $2 AND version_count = $3;
-- Si 0 filas afectadas: se detectó modificación concurrente
// TypeORM: @VersionColumn para bloqueo optimista automático
@Entity()
class Product {
@VersionColumn()
version!: number;
// TypeORM verifica la versión automáticamente al guardar
// Lanza OptimisticLockVersionMismatchError en conflicto
}
El bloqueo optimista funciona bien cuando los conflictos son raros (< 5% de las escrituras). Para escenarios de alta contención (múltiples workers procesando el mismo registro), usa locks cooperativos en su lugar.
Locks Cooperativos Basados en Leases
Cuando múltiples workers compiten por el mismo recurso, un lock cooperativo serializa el acceso. A diferencia de los mutexes distribuidos, los locks cooperativos usan semántica de leases: el lock expira después de un TTL, previniendo deadlocks de workers caídos.
// Redis: SET NX EX atómico con ownership basado en token
class RedisLockProvider {
async acquire(key: string, ttlSeconds: number = 30): Promise<Lock | null> {
const token = crypto.randomBytes(16).toString('hex');
const acquired = await this.redis.set(key, token, 'NX', 'EX', ttlSeconds);
return acquired ? new Lock(key, token, ttlSeconds) : null;
}
async release(lock: Lock): Promise<void> {
// Check-and-delete atómico vía script Lua
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
await this.redis.eval(script, 1, lock.key, lock.token);
}
async extend(lock: Lock, ttlSeconds: number): Promise<boolean> {
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
`;
return !!(await this.redis.eval(script, 1, lock.key, lock.token, ttlSeconds));
}
}
Heartbeat del Lock
El TTL del lock por sí solo no es suficiente. Si una operación tarda más de lo esperado, el lock expira y otro worker lo adquiere. Ahora dos workers corren concurrentemente.
async function executeWithLock(key: string, operation: () => Promise<void>) {
const lock = await lockProvider.acquire(key, 30);
if (!lock) throw new LockError(`Could not acquire lock: ${key}`);
// Heartbeat: extender el lock si la operación tarda demasiado
const heartbeat = setInterval(async () => {
const extended = await lockProvider.extend(lock, 30);
if (!extended) {
clearInterval(heartbeat);
// El lock fue robado, abortar operación
throw new LockError(`Lock stolen during operation: ${key}`);
}
}, 15000); // Extender cada 15s (50% del TTL)
try {
await operation();
} finally {
clearInterval(heartbeat);
await lockProvider.release(lock);
}
}
Jerarquía de Scope del Lock
Diferentes operaciones necesitan diferente granularidad de lock:
| Scope | Patrón de Key | Caso de Uso |
|---|---|---|
| Element | lock:product:123 | Guardado completo del objeto |
| Field group | lock:product:123:generatedAssets | Actualización parcial (solo thumbnails) |
| Operation | lock:product:123:thumbnail:en | Operación específica individual |
Mismo producto + mismo field group -> esperar/reintentar (serial)
Mismo producto + grupos diferentes -> paralelo (seguro)
Productos diferentes -> siempre paralelo
Los scopes más estrechos permiten más paralelismo. Un worker de thumbnails y un indexador de búsqueda pueden procesar el mismo producto simultáneamente si bloquean field groups diferentes.
Stores de Idempotencia
Los reintentos de red, la reentrega de mensajes y los replays de workflows causan que la misma operación se ejecute múltiples veces. Sin idempotencia, obtienes registros duplicados, cobros dobles o emails repetidos.
interface IdempotencyEntry {
key: string; // Key con significado de negocio
scope: string; // Categoría de operación
status: string; // PENDING | COMPLETED | FAILED
requestHash: string; // SHA-256 del input normalizado
resultId?: string; // ID del recurso creado
expiresAt: Date; // TTL para limpieza
createdAt: Date;
}
class IdempotencyStore {
async checkAndAcquire(key: string, scope: string, requestHash: string): Promise<IdempotencyResult> {
try {
await this.db.insert('idempotency_keys', {
key, scope, requestHash,
status: 'PENDING',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
return { acquired: true };
} catch (error) {
if (isDuplicateKeyError(error)) {
const existing = await this.db.findOne({ key, scope });
if (existing.status === 'COMPLETED') {
return { acquired: false, cached: true, resultId: existing.resultId };
}
if (existing.status === 'PENDING' && isStale(existing)) {
// PENDING obsoleto: el intento anterior crasheó, permitir reintento
await this.db.update({ key, scope }, { status: 'PENDING', createdAt: new Date() });
return { acquired: true };
}
return { acquired: false, inProgress: true };
}
throw error;
}
}
async complete(key: string, scope: string, resultId: string): Promise<void> {
await this.db.update({ key, scope }, { status: 'COMPLETED', resultId });
}
}
Diseño de Keys
La key de idempotencia debe tener significado de negocio:
| Operación | Componentes de la Key | Ejemplo |
|---|---|---|
| Agregar a wishlist | customerId + productVariantId + wishlistId | wish:cust_123:var_456:wl_789 |
| Enviar reseña | customerId + productId | review:cust_123:prod_456 |
| Enviar notificación | recipient + category + entityRef + dayBucket | notify:sara@beispiel.de:stock:var_456:2026-04-20 |
| Canjear puntos de fidelización | customerId + orderId + points | redeem:cust_123:ord_789:500 |
| Importar registro ERP | sourceRecordId + importBatchId | import:erp_456:batch_20260420 |
Dos modelos de idempotencia distintos:
- Idempotencia de API: Para mutaciones iniciadas por el usuario. El cliente provee la key o se genera a partir del hash del input. La respuesta cacheada se reproduce en duplicados.
- Idempotencia de jobs: Para procesamiento en background. Key de deduplicación en el payload del job. Usa constraints de BD, marcadores de completado o chequeos de business-key.
Nunca los mezcles. Tienen ciclos de vida y estrategias de limpieza diferentes.
El Problema de la Explosión de Versiones
En sistemas donde los workers de background llaman a save(), cada guardado crea una versión. Con 6 workers procesando cada cambio de producto, un solo guardado del editor genera 6+ versiones innecesarias. Con el tiempo, los productos acumulan miles de versiones que consumen almacenamiento, ralentizan la UI de historial de versiones y hacen imposible encontrar los cambios editoriales reales.
La solución: version guards con scope que suprimen la creación de versiones durante las operaciones de sistema mientras preservan las versiones de los guardados del editor.
// Version guard con conteo de referencias
class ScopedVersionGuard {
private static refCount = 0;
suppress(): void {
ScopedVersionGuard.refCount++;
if (ScopedVersionGuard.refCount === 1) {
VersionManager.disable();
}
}
restore(): void {
ScopedVersionGuard.refCount--;
if (ScopedVersionGuard.refCount === 0) {
VersionManager.enable();
}
}
}
// Uso: las operaciones anidadas funcionan correctamente
const outerGuard = new ScopedVersionGuard();
outerGuard.suppress();
try {
product.setThumbnail(asset);
product.save(); // No se crea versión
const innerGuard = new ScopedVersionGuard();
innerGuard.suppress();
try {
product.setChecksum(hash);
product.save(); // Sigue sin crear versión
} finally {
innerGuard.restore(); // refCount pasa de 2 a 1, sigue suprimido
}
} finally {
outerGuard.restore(); // refCount pasa de 1 a 0, versiones rehabilitadas
}
El resultado: los guardados del editor crean versiones (auditoría preservada). Los guardados de workers crean entradas de log de operación (observabilidad sin explosión de versiones).
Para cómo implementamos esto específicamente en Pimcore, consulta nuestra guía de workflows en Pimcore que cubre el version guard de PimTx en detalle.
Errores Comunes
-
Sin field ownership. Sin él, cada escritor compite por cada campo a través del mismo path de guardado. Define quién posee qué antes de escribir la primera línea de código concurrente.
-
Bloqueo optimista sin reintento. Detectar el conflicto no es suficiente. La operación debe reintentar con datos frescos. Establece un conteo máximo de reintentos y maneja el agotamiento de forma elegante.
-
Disable global del lock. Un flag global
disable()falla cuando múltiples operaciones corren concurrentemente. Usa guards con scope y conteo de referencias. -
Keys de idempotencia sin significado de negocio. Un UUID aleatorio como key de idempotencia no previene nada. La key debe codificar la operación de negocio: quién, qué, cuándo.
-
Sin heartbeat en locks de larga duración. Si la operación supera el TTL, el lock expira y otro worker entra. Extiende el lock al 50% del TTL.
-
Ignorar entradas PENDING obsoletas. Si un worker crashea mientras mantiene una key de idempotencia PENDING, la operación queda bloqueada permanentemente. Detecta y recupera entradas obsoletas.
-
Bloquear con la granularidad incorrecta. Los locks a nivel de elemento serializan todo. Los locks a nivel de field group permiten paralelismo donde es seguro. Elige el scope más estrecho que sea seguro.
-
Sin estrategia de gestión de versiones. Que cada
save()cree una versión es el comportamiento por defecto. En sistemas con workers, esto crea miles de versiones inútiles. Suprime las versiones para operaciones de sistema.
Conclusiones Clave
-
Field ownership previene la condición de carrera más común. Define qué campos pertenecen a los editores, cuáles a los workers y cuáles son compartidos. El registro de ownership determina la estrategia de bloqueo y la resolución de conflictos.
-
Bloqueo optimista para escrituras de baja contención. Verifica el conteo de versión antes de guardar. Reintenta en conflicto. Usa soporte a nivel de base de datos (TypeORM
@VersionColumn, actualización condicional de PostgreSQL) para atomicidad. -
Leases cooperativos para recursos de alta contención. Redis SET NX EX con ownership basado en token. Heartbeat al 50% del TTL. Scripts Lua para operaciones atómicas. Nunca uses mutexes distribuidos sin TTL.
-
Las keys de idempotencia deben tener significado de negocio. Codifica la semántica de la operación (quién + qué + cuándo) en la key. Separa la idempotencia de API de la idempotencia de jobs.
-
Los version guards preservan la auditoría sin explosión. Supresión con conteo de referencias para operaciones de sistema. El anidamiento funciona correctamente. Los guardados del editor siguen creando versiones.
Aplicamos estos patrones en nuestros proyectos de software a medida y pipelines de ingeniería de datos. Si estás lidiando con problemas de concurrencia en producción, habla con nuestro equipo o solicita un presupuesto.
Temas cubiertos
Guías relacionadas
Arquitectura Event-Driven en la Práctica: Qué Sale Realmente Mal
Patrones reales de arquitectura event-driven en producción. Event storms, loops de sincronización bidireccional, dead letters, idempotencia y elección entre Kafka, RabbitMQ, BullMQ y Symfony Messenger.
Leer guíaGuía Empresarial de Sistemas de IA Agéntica
Guia tecnica de sistemas de IA agentica en entornos empresariales. Descubre la arquitectura, capacidades y aplicaciones de agentes IA autonomos.
Leer guíaComercio Agéntico: Cómo Dejar que los Agentes IA Compren de Forma Segura
Cómo diseñar comercio iniciado por agentes IA con gobernanza. Motores de políticas, puertas de aprobación HITL, recibos HMAC, idempotencia, aislamiento de tenants y el Agentic Checkout Protocol completo.
Leer guía¿Listo para construir sistemas de IA listos para producción?
Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.
Iniciar una conversación