Diseño de sistema de vouchers: límite de usos bajo carga alta
1. Descripción general
Un voucher “descuento 100k, solo 1.000 usos” suena simple. Cuando llega la hora del flash sale, decenas de miles de requests llegan al mismo tiempo. En ese momento, el sistema puede emitir fácilmente 1.050 usos en lugar de 1.000, y el dinero extra se pierde de verdad. Este artículo te llevaré a través de un estudio de caso completo: requisitos, modelo de datos, API, y luego resuelvo progresivamente los puntos difíciles: contención, consistencia y picos de carga. Después de leerlo, sabrás cuándo basta con un UPDATE atómico, y cuándo necesitas introducir Redis y Kafka.
2. Requisitos
2.1. Funcionalidades
- Crear voucher con código, cantidad de descuento, monto mínimo de pedido, período de validez.
- Limitar total de usos y usos por cliente.
- Durante checkout, el voucher se reserva (reserve). Si el pago es exitoso se confirma (confirm). Si se cancela la orden o expira, se libera (release).
2.2. Escala y restricciones
- Aproximadamente 10 millones de clientes. Voucher de flash sale con 1.000 usos pero puede recibir 50.000 requests/segundo en los primeros segundos.
- P99 de la API reserve menor a 100 ms.
- Nunca oversell. Undersell temporal es aceptable, por ejemplo, un uso reservado que se libera 15 minutos después.
- Un request reenviado (retry) no debe consumir otro uso. En otras palabras, la API debe ser idempotente según
orderId.
3. Modelo de datos
La base de datos (PostgreSQL) es la fuente de verdad final:
CREATE TABLE voucher (
code VARCHAR(32) PRIMARY KEY,
discount_amount BIGINT NOT NULL,
min_order_amount BIGINT NOT NULL DEFAULT 0,
total_limit INT NOT NULL,
used_count INT NOT NULL DEFAULT 0,
per_user_limit INT NOT NULL DEFAULT 1,
starts_at TIMESTAMP NOT NULL,
ends_at TIMESTAMP NOT NULL,
CONSTRAINT ck_used CHECK (used_count <= total_limit)
);
CREATE TABLE voucher_redemption (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
customer_id BIGINT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(16) NOT NULL, -- RESERVED, CONFIRMED, RELEASED
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);
Algunos puntos a notar:
CHECK (used_count <= total_limit)es la red de seguridad final. Incluso si hay bugs en el código, la base de datos no permitirá oversell.order_id UNIQUEasegura idempotencia: un pedido solo puede usar un voucher una vez.UNIQUE (voucher_code, customer_id)es correcto solo cuandoper_user_limit = 1, que es el caso más común. Si permites múltiples usos por cliente, elimina este constraint y cuenta en una tabla separada.
4. API
POST /vouchers/{code}/reservations body: {customerId, orderId, orderAmount}
201 {orderId, discountAmount}
409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
422 {error: "NOT_ELIGIBLE"} (expirado, monto de pedido insuficiente)
POST /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}
orderId es generado por el cliente (order service). Gracias a esto, cuando se reintenta, el cliente envía el mismo orderId antiguo y el servidor reconoce que es un request duplicado.
5. El enfoque ingenuo y por qué falla
@Transactional
public boolean redeemNaive(String code) {
Voucher v = voucherRepository.findById(code).orElseThrow();
if (v.getUsedCount() >= v.getTotalLimit()) {
return false;
}
v.setUsedCount(v.getUsedCount() + 1);
return true;
}
Este es el error clásico de “check-then-act”. Dos threads leen usedCount = 999, ambos ven que hay espacio, ambos escriben 1000. El resultado es que dos clientes obtienen el descuento pero el contador solo aumenta uno. Con isolation level READ COMMITTED por defecto, la transaction no te salva en este caso.
6. Solución 1: UPDATE condicional en la base de datos
La forma más simple de arreglarlo es combinar la verificación y la escritura en una sola sentencia atómica:
@Service
public class VoucherService {
private final JdbcTemplate jdbc;
public VoucherService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public boolean redeem(String code, long customerId, String orderId) {
int updated = jdbc.update("""
UPDATE voucher SET used_count = used_count + 1
WHERE code = ?
AND used_count < total_limit
AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
""", code);
if (updated == 0) {
return false; // sin usos disponibles o expirado
}
jdbc.update("""
INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
VALUES (?, ?, ?, 'CONFIRMED')
""", code, customerId, orderId);
return true;
}
}
El código anterior funciona así:
- La sentencia
UPDATEadquiere un lock sobre la fila del voucher. La base de datos reevalúaused_count < total_limitdespués de obtener el lock, por lo que no puede haber dos requests que pasen en el último uso. updated == 0significa que la condición es falsa: sin usos o voucher expirado.- Si el cliente ya usó el voucher, o si
orderIdse reenvía, la sentenciaINSERTlanzaDuplicateKeyException. La transaction hace rollback,used_countvuelve al valor anterior. En la capa controller, mapeamos esta excepción a409 ALREADY_USED.
6.1. Prueba de comportamiento bajo concurrencia
@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired VoucherService voucherService;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM voucher_redemption");
jdbc.update("DELETE FROM voucher");
jdbc.update("""
INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
DATEADD('DAY', 1, CURRENT_TIMESTAMP))
""");
}
@Test
void shouldNeverExceedTotalLimit() throws Exception {
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 200; i++) {
long customerId = i;
executor.submit(() -> {
if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
success.incrementAndGet();
}
});
}
}
Integer used = jdbc.queryForObject(
"SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
assertThat(success.get()).isEqualTo(10);
assertThat(used).isEqualTo(10);
}
}
@Transactional(propagation = NOT_SUPPORTED) desactiva la transaction por defecto de @JdbcTest. De lo contrario, los datos creados en setUp no estarían committed y otros threads no los verían. Hay 200 virtual threads compitiendo por 10 usos, y el resultado siempre es exactamente 10:
success = 10, used_count = 10
6.2. Limitaciones de la solución 1
Este enfoque es correcto, y para la mayoría de vouchers normales (cientos de requests/segundo) es suficiente. El problema solo surge cuando la carga se concentra en una sola fila (hot row). 50.000 requests/segundo se encolan esperando el lock de la fila FLASH100. Cada transaction mantiene el lock por unos milisegundos, así que el throughput real se limita a algunos miles por segundo. El resto sufre timeout, el connection pool se agota, y toda la base de datos se ralentiza, incluso las tablas no relacionadas con vouchers.
7. Solución 2: Redis como “puerta de entrada” con Lua script
La idea: colocar el contador de usos restantes en Redis. Redis funciona single-threaded, cada Lua script se ejecuta atómicamente y toma solo algunos micro segundos. Solo los requests que han reservado un uso avanzan a la base de datos. Con un voucher de 1.000 usos, la base de datos recibe máximo 1.000 escrituras en lugar de 50.000.
Dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Script de reserva, guardado en resources/scripts/reserve.lua:
-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users (hash customerId -> número de usos)
-- KEYS[3] = voucher:{code}:orders (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end -- reintento, ya reservado
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end -- voucher no cargado
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end -- cliente ya usó su cuota
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end -- sin usos disponibles
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1
Las claves usan hash tag {code}. Gracias a esto, en Redis Cluster, las tres claves quedan en el mismo slot y el script se ejecuta.
Del lado Java:
@Component
public class VoucherGate {
public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }
private static final RedisScript<Long> RESERVE =
RedisScript.of(new ClassPathResource("scripts/reserve.lua"), Long.class);
private final StringRedisTemplate redis;
public VoucherGate(StringRedisTemplate redis) {
this.redis = redis;
}
public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
String p = "voucher:{" + code + "}:";
Long r = redis.execute(RESERVE,
List.of(p + "remaining", p + "users", p + "orders"),
String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
return switch (r.intValue()) {
case 1 -> Result.RESERVED;
case 0 -> Result.DUPLICATE;
case -1 -> Result.OUT_OF_STOCK;
case -2 -> Result.ALREADY_USED;
default -> Result.NOT_LOADED;
};
}
}
DUPLICATE se considera éxito: si el cliente reintenta, obtiene el mismo resultado. NOT_LOADED significa que el voucher aún no ha sido “calentado”. Puedes cargar lazily desde DB usando SET ... NX, o mejor, precarga antes de la venta usando un job:
redis-cli SET "voucher:{FLASH100}:remaining" 1000
Una optimización adicional: cuando remaining llega a 0, un API gateway o un cache local (local cache, TTL de unos segundos) puede devolver OUT_OF_STOCK inmediatamente sin llamar a Redis. Decenas de miles de requests “llegados tarde” se detienen en la capa más externa.
8. Consistencia entre Redis y base de datos
Redis ahora es quien decide, pero la base de datos es la verdad sobre el dinero. Hay dos formas de sincronizar.
Forma A: escribir DB síncronamente después de Redis. Aún ejecutas la sentencia UPDATE de la sección 6. Si DB falla, llamas al script release para devolver el uso a Redis. Este enfoque es más simple y tiene consistencia más fuerte, adecuado cuando el número de usos es pequeño.
Forma B: enviar eventos por Kafka cuando quieres separar completamente DB del camino crítico:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}
@Component
public class RedemptionConsumer {
private final JdbcTemplate jdbc;
public RedemptionConsumer(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
@Transactional
public void on(VoucherReserved e) {
int inserted = jdbc.update("""
INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
VALUES (?, ?, ?, 'RESERVED')
ON CONFLICT (order_id) DO NOTHING
""", e.code(), e.customerId(), e.orderId());
if (inserted == 1) {
jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
}
}
}
Del lado del service, después de que Lua devuelve RESERVED, llamas a kafkaTemplate.send("voucher-reserved", code, event) con clave code. El consumer tiene estas características:
- Kafka entrega at-least-once, así que el mismo evento puede llegar dos veces.
ON CONFLICT (order_id) DO NOTHINGconvierte la segunda vez en una no-op, yused_countsolo aumenta cuando realmente se inserta una nueva fila. - El constraint
CHECK (used_count <= total_limit)sigue ahí. Si Redis se precarga con número incorrecto, DB lo rechaza y el evento cae en dead-letter topic para que el operador lo maneje.
Nota: Si Redis reserva exitosamente pero falla el envío a Kafka, ese uso queda “suspendido” en Redis. Eso es undersell, seguro para el dinero pero requiere limpieza. Por eso necesitas un job de reconciliación (sección 8.1).
8.1. Expiración de reservas y reconciliación
Si un cliente reserva un voucher y luego abandona el carrito, ese uso debe devolverse. Un job periódico maneja esto:
@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
List<Map<String, Object>> expired = jdbc.queryForList("""
UPDATE voucher_redemption SET status = 'RELEASED'
WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
RETURNING voucher_code, customer_id, order_id
""");
expired.forEach(row -> voucherGate.release(
(String) row.get("voucher_code"),
((Number) row.get("customer_id")).longValue(),
(String) row.get("order_id")));
}
La sentencia UPDATE ... RETURNING (PostgreSQL) cambia el estado y devuelve las filas afectadas. Así, incluso si dos instancias ejecutan el job simultáneamente, no liberan duplicados: una fila que ya cambió a RELEASED no coincide con la condición. También necesitas decrementar used_count en DB correspondientemente. La función release llama a un script Lua inverso: solo cuando HDEL orders orderId devuelve 1 hace INCR remaining e HINCRBY users customerId -1. De esta forma, llamar release dos veces es seguro.
Además, agrega un job de reconciliación que corre después de cada venta: compara total_limit - used_count en DB con remaining en Redis, y alerta si hay discrepancia.
9. Errores comunes y cuándo no usar esto
- Usar
GETluegoDECRcomo dos comandos separados desde Java. Esto vuelve a ser check-then-act, solo que en Redis. La verificación y el decremento deben estar en el mismo Lua script. - Solo
DECRy luego verificar si es negativo eINCRde nuevo. Este enfoque funciona, pero durante el momento en que el valor es negativo, otro request puede leerlo mal. También hace que release sea más complejo. - Optimistic locking (
@Version) para vouchers calientes. Con 50.000 requests compitiendo por una fila, casi todos fallan y reintentan. La carga es aún peor que con pessimistic lock. - Redis sin persistence/replication. Si Redis se reinicia y pierdes datos, y un job de warmup recarga desde
total_limit, overselleras. Siempre recarga desdetotal_limit - used_counten DB, menos los usos en estadoRESERVED. - Aplicar Redis + Kafka a todos los vouchers. Un voucher normal con carga baja solo necesita la solución 6. Agregar dos sistemas es agregar dos lugares donde los datos pueden divergir. Solo activa “modo flash sale” para vouchers marcados.
Nota: Todas las capas superiores son solo optimizaciones. Los constraints en la base de datos (
CHECK,UNIQUE) son la garantía final de que ningún uso se emite en exceso.
10. Conclusión
El problema de limitar usos de voucher es esencialmente un problema de conteo atómico bajo contención. Con carga moderada, una sentencia UPDATE ... WHERE used_count < total_limit junto con unique constraints es suficientemente correcto y rápido. Cuando hay flash sale y hot row, usas Lua script de Redis como puerta de entrada atómica. Kafka ayuda a mover la escritura DB fuera del camino crítico, mientras que el mecanismo de reserva con expiración y el job de reconciliación manejan divergencias. El principio transversal es: acepta undersell temporal, nunca oversell, y deja que la base de datos mantenga la capa de protección final.