7 min read
Es Black Friday. Queda una sola unidad de un producto en promoción y dos clientes hacen clic en “Comprar” con una diferencia de milisegundos. Tu servicio de inventario hace lo que parece lo más lógico: lee el stock (1), verifica que sea mayor a cero, y escribe el stock nuevo (0).
Los dos clientes leyeron “1” antes de que cualquiera de los dos escribiera. Resultado: dos ventas confirmadas, una sola unidad en bodega, y un cliente molesto al que le vas a tener que cancelar el pedido.
Este problema se llama actualización perdida (lost update) y es una de las condiciones de carrera más comunes en sistemas con base de datos.
Hay dos estrategias clásicas para resolverlo: Optimistic Locking (bloqueo optimista) y Pessimistic Locking (bloqueo pesimista).
En este post vemos cómo funciona cada una, con código que podés correr y ver el problema (y la solución) con tus propios ojos.
Dos filosofías opuestas
- Bloqueo pesimista: asume que el conflicto va a pasar. Antes de leer, bloquea la fila para que nadie más la toque hasta que termines. En SQL (Structured Query Language — Lenguaje de Consulta Estructurado) es
SELECT ... FOR UPDATE. - Bloqueo optimista: asume que el conflicto es raro. No bloquea nada. Lee el registro junto con un número de versión, y al escribir verifica que la versión no haya cambiado:
UPDATE ... WHERE version = ?. Si alguien se adelantó, la actualización afecta cero filas y se reintenta.
El diagrama

Guía paso a paso
Paso 1 — Agregar una columna de versión
La tabla inventory necesita una columna version (un entero que empieza en 1).
Paso 2 — Leer el registro junto con su versión
Cuando el cliente lee el stock, también guarda la versión que vio.
Paso 3 — Escribir condicionado a la versión
El UPDATE incluye WHERE version = ? con la versión leída, e incrementa la versión en el mismo statement. Si la versión cambió, la base de datos afecta cero filas.
Paso 4 — Detectar el conflicto y reintentar
Si el UPDATE afectó cero filas, alguien se adelantó. Se relee el registro (con el estado nuevo) y se reevalúa la regla de negocio: tal vez ahora el producto está agotado.
Paso 5 — Poner un límite de reintentos
Sin límite, un registro muy disputado puede generar reintentos indefinidos. Definí un máximo y una respuesta clara cuando se agote.
El código
Simulamos la tabla de inventario en memoria y lanzamos 8 compradores simultáneos contra 5 unidades de stock, con tres estrategias: sin control, optimista y pesimista.
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
// --- Fila de la tabla inventory ---
@Getter
@AllArgsConstructor
class StockRecord {
private final String productId;
private final int quantity;
private final int version;
}
// --- Simulación de la tabla (cada método es una sentencia atómica de la base de datos) ---
class InventoryTable {
private StockRecord record;
public InventoryTable(String productId, int quantity) {
this.record = new StockRecord(productId, quantity, 1);
}
public synchronized StockRecord read() {
return record;
}
// UPDATE inventory SET quantity = ? WHERE product_id = ? (sin verificar nada)
public synchronized void blindUpdate(int newQuantity) {
record = new StockRecord(record.getProductId(), newQuantity, record.getVersion() + 1);
}
// UPDATE inventory SET quantity = ?, version = version + 1
// WHERE product_id = ? AND version = ?
public synchronized boolean updateIfVersionMatches(int newQuantity, int expectedVersion) {
if (record.getVersion() != expectedVersion) {
return false; // 0 filas afectadas: alguien se nos adelantó
}
record = new StockRecord(record.getProductId(), newQuantity, expectedVersion + 1);
return true;
}
}
interface PurchaseStrategy {
boolean buy(String customer) throws Exception;
}
// --- Estrategia 1: sin control de concurrencia (el bug) ---
@AllArgsConstructor
class NaivePurchase implements PurchaseStrategy {
private final InventoryTable table;
@Override
public boolean buy(String customer) throws Exception {
StockRecord current = table.read();
Thread.sleep(50); // simula latencia de red o lógica de negocio
if (current.getQuantity() <= 0) {
return false;
}
table.blindUpdate(current.getQuantity() - 1);
return true;
}
}
// --- Estrategia 2: bloqueo optimista con control de versión ---
@AllArgsConstructor
class OptimisticPurchase implements PurchaseStrategy {
private static final int MAX_ATTEMPTS = 10;
private final InventoryTable table;
@Override
public boolean buy(String customer) throws Exception {
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
StockRecord current = table.read();
if (current.getQuantity() <= 0) {
return false; // agotado
}
Thread.sleep(50);
if (table.updateIfVersionMatches(current.getQuantity() - 1, current.getVersion())) {
return true;
}
System.out.println(" " + customer + ": conflicto de versión, reintentando (intento " + attempt + ")");
}
return false;
}
}
// --- Estrategia 3: bloqueo pesimista (equivalente a SELECT ... FOR UPDATE) ---
@AllArgsConstructor
class PessimisticPurchase implements PurchaseStrategy {
private final InventoryTable table;
private final ReentrantLock rowLock = new ReentrantLock();
@Override
public boolean buy(String customer) throws Exception {
rowLock.lock(); // nadie más toca la fila hasta que terminemos
try {
StockRecord current = table.read();
if (current.getQuantity() <= 0) {
return false;
}
Thread.sleep(50);
table.blindUpdate(current.getQuantity() - 1);
return true;
} finally {
rowLock.unlock();
}
}
}
// --- Main ---
public class LockingDemo {
public static void main(String[] args) throws Exception {
int buyers = 8;
int initialStock = 5;
scenario("SIN CONTROL DE CONCURRENCIA", NaivePurchase::new, buyers, initialStock);
scenario("BLOQUEO OPTIMISTA (version)", OptimisticPurchase::new, buyers, initialStock);
scenario("BLOQUEO PESIMISTA (FOR UPDATE)", PessimisticPurchase::new, buyers, initialStock);
}
static void scenario(String title, Function<InventoryTable, PurchaseStrategy> factory,
int buyers, int stock) throws Exception {
InventoryTable table = new InventoryTable("PROD-1", stock);
PurchaseStrategy strategy = factory.apply(table);
System.out.println("\n=== " + title + " ===");
int sold = runConcurrently(buyers, strategy);
System.out.println("Stock inicial: " + stock + " | Compras confirmadas: " + sold
+ " | Stock final registrado: " + table.read().getQuantity());
}
static int runConcurrently(int buyers, PurchaseStrategy strategy) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(buyers);
AtomicInteger successes = new AtomicInteger();
CountDownLatch startSignal = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
for (int i = 1; i <= buyers; i++) {
String customer = "CUST-" + i;
futures.add(pool.submit(() -> {
startSignal.await(); // todos arrancan al mismo tiempo
if (strategy.buy(customer)) {
successes.incrementAndGet();
}
return null;
}));
}
startSignal.countDown();
for (Future<Object> future : futures) {
future.get();
}
pool.shutdown();
return successes.get();
}
}
Output esperado (los mensajes de conflicto y el orden varían en cada corrida):
=== SIN CONTROL DE CONCURRENCIA ===
Stock inicial: 5 | Compras confirmadas: 8 | Stock final registrado: 4
=== BLOQUEO OPTIMISTA (version) ===
CUST-3: conflicto de versión, reintentando (intento 1)
CUST-7: conflicto de versión, reintentando (intento 1)
...
Stock inicial: 5 | Compras confirmadas: 5 | Stock final registrado: 0
=== BLOQUEO PESIMISTA (FOR UPDATE) ===
Stock inicial: 5 | Compras confirmadas: 5 | Stock final registrado: 0
El primer escenario es el bug en acción: vendimos 8 unidades con solo 5 en bodega, y el stock final dice 4 porque todas las escrituras pisaron a las anteriores. Los otros dos escenarios confirman exactamente 5 compras y dejan el stock en 0.
¿Cuál elegir?
| Criterio | Optimista | Pesimista |
|---|---|---|
| Conflictos esperados | Pocos (la mayoría de las operaciones no chocan) | Frecuentes (mucha disputa por la misma fila) |
| Costo de un reintento | Bajo | Alto (operaciones largas o con efectos externos) |
| Bloqueos en la base de datos | Ninguno | Mantiene bloqueos mientras dura la transacción |
| Riesgo principal | Tormenta de reintentos si hay mucha disputa | Esperas largas y deadlocks (bloqueos mutuos) |
Para tener en cuenta en producción
- JPA (Java Persistence API — API de Persistencia de Java) lo trae resuelto: agregá un campo
@Versiona tu entidad y Hibernate agrega la condición de versión a cadaUPDATE, lanzandoOptimisticLockExceptioncuando hay conflicto. Para el modo pesimista usá@Lock(LockModeType.PESSIMISTIC_WRITE)en el repositorio. - Para un contador como el stock, a veces alcanza un UPDATE atómico:
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = ? AND quantity > 0resuelve este caso puntual sin versiones ni bloqueos, y es la opción más simple cuando la regla cabe en una sola sentencia. - Mantené las transacciones cortas con bloqueo pesimista: nunca llames a un servicio externo (pagos, correos) mientras tenés una fila bloqueada. Un servicio lento convierte ese bloqueo en un cuello de botella para todos los demás.
- Un reintento no reemplaza la idempotencia: si reintentás una operación con efectos externos, combiná este patrón con las Idempotency Keys que vimos en un post anterior.
Conclusión
Si los conflictos son raros, el bloqueo optimista te da concurrencia sin esperar; si son frecuentes y los reintentos son caros, el pesimista protege mejor.
Lo que no podés hacer es leer, decidir y escribir sin ninguna de las dos protecciones, porque en el Black Friday va a pasar justo cuando más duele.
Recordá suscribirte aquí para recibir los próximos posts directamente en tu correo.
Referencias:
Patterns of Enterprise Application Architecture — Martin Fowler (Optimistic Offline Lock / Pessimistic Offline Lock)
Designing Data-Intensive Applications — Martin Kleppmann