Problema y alcance
Diseñe el contrato de idempotencia para POST /orders. Un cliente móvil puede experimentar un tiempo de espera en una red inestable y reintentar automáticamente. Una pasarela también puede reproducir una solicitud después de que se interrumpa su conexión. Ya sea que una operación lógica llegue una o varias veces, el servidor solo debe crear un pedido. Después de que una solicitud tenga éxito, un reintento idéntico debe reproducir el primer resultado; el servidor debe rechazar la misma clave de idempotencia cuando represente parámetros de pedido diferentes.
Asuma que el emisor envía un identificador aleatorio para una operación lógica en Idempotency-Key y que el servidor utiliza PostgreSQL. Las escrituras del pedido y un evento pendiente pueden compartir una misma transacción local en la base de datos. Los sistemas externos de pago e inventario no pueden participar en esa transacción. La API es multiinquilino (multi-tenant) y su respuesta puede contener datos del pedido que requieren autorización.
El alcance abarca la deduplicación de solicitudes, condiciones de carrera concurrentes, límites de transacción, reproducción de resultados, retención y efectos secundarios posteriores. La replicación de bases de datos multirregión, la máquina de estados propia del dominio de pagos y la selección del intermediario de mensajes (message broker) quedan fuera del alcance. Esta es una pregunta de backend porque la tarea principal consiste en convertir la semántica de la API en restricciones de base de datos, recuperación de fallos y un flujo de solicitudes observable, en lugar de diseñar un sistema amplio de múltiples componentes.
Qué evalúan los entrevistadores
La primera señal es si el candidato separa la identidad del reintento de la identidad del negocio. Una clave de idempotencia identifica una operación desde la perspectiva del emisor. No reemplaza la autenticación, el aislamiento de inquilinos ni una clave de pedido a nivel de dominio. El servidor debe delimitar su alcance como mínimo mediante (tenant_id, endpoint, idempotency_key). Una búsqueda únicamente por la clave simple podría permitir que dos inquilinos que casualmente elijan la misma cadena lean el resultado del otro.
La segunda señal es si la corrección de la concurrencia se basa en una restricción de unicidad en lugar de depender de los tiempos de la aplicación. Un SELECT seguido de un INSERT permite que dos solicitudes observen que no existe ninguna fila y ambas actúen como la primera solicitud. Un índice único con INSERT ... ON CONFLICT permite que la base de datos elija a un único propietario. La comparación también debe incluir una huella digital de la solicitud; de lo contrario, la reutilización accidental de una clave antigua podría devolver un pedido anterior como resultado de una solicitud de pedido diferente.
La tercera señal es un límite de confirmación (commit) preciso. El pedido, el resultado de la idempotencia y el evento del outbox deben confirmarse juntos. Un fallo antes de la confirmación revierte los tres. Si la confirmación tiene éxito pero la respuesta HTTP se pierde, un reintento lee el registro completado y lo reproduce. Las llamadas a pagos e inventario ocurren fuera de la transacción a través de un consumidor del outbox, el cual pasa un identificador de idempotencia derivado a los servicios posteriores.
Una respuesta sólida también define la retención, la respuesta ante un duplicado concurrente, qué fallos se almacenan, cómo se recupera una operación de larga duración y cómo se monitorean los conflictos de claves y el trabajo atascado. Decir simplemente «usar un bloqueo de Redis» deja sin resolver la expiración de bloqueos, las caídas de procesos, la reproducción de resultados y la condición de carrera posterior a la confirmación en la base de datos.
Preguntas para aclarar antes de responder
- ¿Qué se considera un duplicado? En este caso, el mismo inquilino, punto de conexión y clave de idempotencia identifican una sola operación. Si el dominio ya cuenta con un
checkout_idno reutilizable, la tabla de pedidos también debe tener una restricción de unicidad independiente sobre él. - ¿Puede el emisor elegir el ID del recurso? Si el emisor posee un ID de pedido estable,
PUT /orders/{clientOrderId}es una alternativa porque HTTP define PUT como idempotente. POST con una clave de idempotencia se adapta a un ID de pedido generado por el servidor. - ¿La creación del pedido es un trabajo exclusivo de la base de datos? Si cabe en una sola transacción corta, utilice el diseño de transacción única. Un flujo de trabajo que toma decenas de segundos necesita un registro
IN_PROGRESSvisible, un arrendamiento (lease) y un token de apropiación en lugar de una transacción de base de datos prolongada. - ¿Qué tan exacta debe ser la reproducción? Este diseño almacena el primer estado HTTP y un cuerpo de respuesta que sea seguro de reproducir. Si una respuesta contiene una firma de corta duración o campos dinámicos, almacene el ID del recurso y reconstruya la respuesta bajo un contrato explícito.
- ¿Durante cuánto tiempo se retienen las claves? La retención debe cubrir la ventana de reintento o reproducción sin conexión más larga del cliente. Si la duplicación debe seguir siendo imposible después de esa ventana, agregue una clave de dominio permanente; un TTL de caché más largo no sustituye a una restricción de negocio.
- ¿Qué fallos deben recordarse? La validación que falla antes de adquirir la propiedad de la clave no se almacena. Un rechazo de negocio determinista dentro de la transacción se puede almacenar y reproducir. Un fallo de infraestructura que revierte la transacción no deja ningún resultado completado, por lo que el cliente puede reintentar.
Respuesta de 30 segundos
«Delimitaría el alcance de cada clave de idempotencia al inquilino y a POST /orders, y luego obtendría la huella digital de los parámetros normalizados del pedido. Una restricción de unicidad en (tenant_id, endpoint, idempotency_key) arbitra las solicitudes concurrentes. La primera solicitud escribe el registro de idempotencia, el pedido y el evento del outbox en una sola transacción, y luego almacena el estado y el cuerpo de la respuesta antes de la confirmación. Un reintento con la misma huella digital reproduce ese resultado; una huella digital diferente devuelve un conflicto. Por lo tanto, una respuesta perdida después de la confirmación no puede crear otro pedido. El pago y el inventario se ejecutan de forma asíncrona desde el outbox con claves de idempotencia derivadas. Retendría el registro de la solicitud durante la ventana máxima de reintentos y usaría una restricción de unicidad independiente en el dominio del pedido para la deduplicación permanente».
Análisis detallado paso a paso
Comience con el protocolo. Un emisor autenticado genera una clave de alta entropía para una intención de «crear un pedido» y la reutiliza en cada reintento. Un nuevo pedido necesita una nueva clave. La clave no debe contener direcciones de correo electrónico, números de teléfono ni otros datos sensibles. El servidor restringe su longitud y conjunto de caracteres, pero una clave de apariencia aleatoria nunca es una credencial de autorización. POST no tiene la semántica idempotente de PUT por defecto; los reintentos seguros provienen de este contrato de aplicación.
Construya la huella digital de la solicitud a partir de los campos de negocio validados por el servidor, no de los bytes JSON sin procesar. El orden de los campos, los espacios en blanco insignificantes y los valores predeterminados no deben generar huellas digitales distintas para una misma solicitud. Excluya identificadores de rastreo (trace IDs), marcas de tiempo y otros campos ajenos al negocio. Una entrada práctica es una codificación estable del DTO normalizado, la versión del punto de conexión y cada campo que afecte el resultado del pedido. Aplique un hash a esa codificación. El digest detecta el uso indebido de la clave; no es una firma ni un mecanismo de autenticación.
Este esquema mínimo expresa las restricciones requeridas; el SQL es ilustrativo:
CREATE TABLE idempotency_requests (
tenant_id text NOT NULL,
endpoint text NOT NULL,
idempotency_key text NOT NULL,
request_fingerprint text NOT NULL,
response_status integer,
response_body jsonb,
resource_id uuid,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, endpoint, idempotency_key)
);La ruta normal para una transacción corta es:
- Fuera de la transacción, autenticar, validar el formato de la clave y la solicitud, y calcular la huella digital.
- Iniciar una transacción e intentar reclamar la clave con
INSERT ... ON CONFLICT DO NOTHING RETURNING .... - La solicitud que inserta la fila es la propietaria. Esta crea el pedido, añade el evento al outbox, actualiza la fila de idempotencia con el estado, el cuerpo de la respuesta y el ID del pedido, y confirma una sola vez.
- Una solicitud que no insertó lee la fila existente. Una huella digital diferente devuelve
409 idempotency_key_reused. La misma huella digital devuelve el primer estado y cuerpo almacenados, opcionalmente con un marcador de reproducción definido por la API. - Enviar la respuesta HTTP solo después de la confirmación. Si la conexión se interrumpe después de la confirmación, la siguiente solicitud sigue el paso 4 en lugar de crear un pedido.
El índice único de PostgreSQL hace que las inserciones concurrentes de la misma clave esperen y luego las resuelve en una inserción o en un conflicto. Limite esa espera. Si la primera transacción se confirma rápidamente, la segunda solicitud puede leer y reproducir el resultado. Si la espera alcanza su límite, devuelva un resultado explícito de idempotency_in_progress con instrucciones para el reintento. No utilice un SELECT a nivel de aplicación antes de decidir insertar, y nunca sobrescriba la huella digital ni el resultado almacenados ante un conflicto.
El invariante central es que siempre que la fila del pedido sea visible, su resultado de idempotencia y su evento de outbox también lo serán; cuando la transacción se revierte, ninguno de ellos es visible. Los casos de fallo se derivan directamente de esto. La caída de un proceso antes de la creación del pedido revierte la transacción, por lo que un reintento puede competir nuevamente. Una respuesta perdida después de que el pedido se confirma se reproduce. La misma clave con diferentes parámetros nunca se ejecuta. De dos solicitudes idénticas que llegan al mismo tiempo, solo una puede superar la restricción de unicidad y finalizar las escrituras. Un rechazo determinista del dominio, como un cupón expirado, puede almacenarse como una respuesta de error estable dentro de la transacción. Un fallo no confirmado, como la pérdida de conexión a la base de datos, no se almacena.
Si la creación del pedido no puede mantenerse dentro de una transacción corta, utilice una máquina de estados de dos fases. Una primera transacción corta confirma IN_PROGRESS, lease_expires_at y un attempt_token incremental monotónico. La API devuelve 202, u otra solicitud con la clave lee un punto de conexión de estado. Tras la expiración del arrendamiento (lease), un nuevo worker utiliza compare-and-swap para reclamar un token mayor. Cada actualización de finalización verifica ese token, lo que evita que un worker antiguo sobrescriba un resultado más reciente tras reanudarse. El pedido aún necesita una restricción de unicidad sobre el ID de la operación de negocio, y los efectos secundarios siguen usando el outbox o claves de idempotencia posteriores. Una fila de estado con un TTL pero sin token de apropiación permite que workers antiguos y nuevos se ejecuten juntos; esto simplemente pospone la condición de carrera por duplicación.
Las llamadas externas de pago, inventario y notificación no pertenecen a la transacción local de la base de datos. El propietario escribe una fila de outbox en OrderCreated junto con el pedido, y un consumidor la entrega tras la confirmación. El consumidor deduplica por ID de evento. Las llamadas a una API de pagos o de inventario utilizan una clave estable derivada del ID del pedido y del tipo de operación. De este modo, la entrega de mensajes de tipo «al menos una vez» (at-least-once) no se convierte en un cobro duplicado. Si un servicio posterior carece de una interfaz idempotente, utilice una máquina de estados local, consultas de conciliación o compensación manual; el outbox por sí solo no elimina los duplicados externos.
La retención sigue el contrato de reintentos. La implementación pública de Stripe permite la eliminación de claves tras un período de retención. Esto demuestra que las claves pueden tener un ciclo de vida, pero su duración no es universal. Configure expires_at para cubrir la cola fuera de línea móvil más larga, los reintentos de pasarelas y la ventana de reproducción manual. Reutilizar una clave antigua después de la limpieza se considera una nueva operación. Si un checkout_id debe producir como máximo un pedido para siempre, coloque una restricción de unicidad permanente sobre orders para que continúe bloqueando duplicados incluso después de que el registro de idempotencia haya desaparecido.
Redis puede almacenar en caché los resultados completados, pero no debe ser el único límite de corrección. Después de que SET NX tenga éxito, un proceso puede fallar antes o después de la confirmación del pedido, y el TTL del bloqueo puede expirar antes de que finalice una solicitud lenta. La caché tampoco puede confirmar atómicamente el pedido y el outbox. Deje que la restricción de unicidad de la base de datos decida la condición de «creado a lo sumo una vez». Use la caché únicamente para reducir lecturas en reproducciones frecuentes, manteniendo el registro de la base de datos como la fuente de la verdad.
Las pruebas requieren más que dos solicitudes secuenciales. Cubra al menos estos casos: 50 solicitudes concurrentes con una sola clave producen un único pedido y un único evento de outbox; la misma clave con diferentes parámetros devuelve un conflicto; una desconexión forzada después de la confirmación pero antes de la respuesta aún reproduce el pedido original; la misma clave puede reintentar tras una reversión de transacción; el comportamiento tras la expiración de la clave cumple con el contrato; dos inquilinos que usan la misma clave simple permanecen aislados; y la entrega duplicada al consumidor no genera un cobro duplicado. Monitoree nuevas reclamaciones, reproducciones, conflictos de parámetros, tiempos de espera de concurrencia agotados, la operación sin finalizar más antigua, reversiones de transacciones, retraso (lag) del outbox y limpieza de claves expiradas. Los registros (logs) no deben incluir cuerpos de solicitud sensibles asociados a una clave.
Respuesta de ejemplo sólida
«Definiría una clave de idempotencia como una intención de creación para un inquilino en POST /orders. El cliente la genera en la primera llamada y la conserva para los reintentos por tiempo de espera agotado. El servidor genera la huella digital de los campos validados del pedido junto con la versión de la API. La misma clave y huella digital pueden reproducir el resultado; la misma clave con una huella digital diferente genera un conflicto.
En PostgreSQL, establecería (tenant_id, endpoint, idempotency_key) como la clave primaria. Tras la validación de la solicitud, cada llamada compite por la propiedad con INSERT ... ON CONFLICT DO NOTHING. El ganador crea el pedido, escribe la fila del outbox y almacena el primer estado y el cuerpo de la respuesta en una sola transacción corta antes de confirmar. El perdedor no puede crear otro pedido. Espera a la primera transacción, lee el registro y devuelve el resultado original si la huella digital coincide. Si se alcanza el límite de espera interno, le informa al emisor que la operación aún está en progreso. Por lo tanto, una caída tras la confirmación pero antes de la respuesta HTTP se recupera como el mismo pedido en el reintento.
No llamaría al pago ni al inventario de forma síncrona dentro de esa transacción. Un consumidor del outbox entrega esas acciones y pasa una clave derivada del ID del pedido a cada operación posterior. El registro de idempotencia solo existe durante la ventana máxima de reintento, por lo que si una sesión de pago nunca debe crear un segundo pedido, checkout_id también recibe una restricción de unicidad permanente en el pedido.
Finalizaría con inyección de fallos de concurrencia, desconexión y mensajes duplicados. Verificaría los recuentos de pedidos, resultados de idempotencia y eventos de outbox en lugar de limitarme a comprobar un HTTP 200. En producción, monitorearía la tasa de reproducción, los conflictos de parámetros con la misma clave, los tiempos de espera de concurrencia agotados, las reversiones de transacciones y el retraso del outbox».
Errores comunes
- Verificar si existe una fila y luego insertar el pedido → dos solicitudes pueden observar que no hay fila y ambas escribir → deje que una restricción de unicidad en la base de datos y el manejo atómico de conflictos elijan al propietario.
- Buscar únicamente la clave de idempotencia → los inquilinos pueden colisionar o incluso recibir la respuesta de otro inquilino → delimite el alcance por el inquilino autenticado y el punto de conexión, y autorice nuevamente en la reproducción.
- Devolver el resultado anterior sin comparar parámetros → la reutilización accidental de claves hace que un pedido antiguo parezca uno nuevo → almacene una huella digital de la solicitud normalizada y rechace cualquier discrepancia.
- Confirmar el pedido y luego escribir el resultado de idempotencia → una caída entre las escrituras deja un pedido sin registro de deduplicación → confirme el pedido, el resultado y el outbox en una sola transacción de base de datos.
- Llamar al servicio de pagos dentro de la transacción de la base de datos → los tiempos de espera de la red prolongan la duración del bloqueo, y un pago exitoso no se puede revertir localmente de manera atómica → escriba una entrada de outbox en la transacción y use idempotencia downstream fuera de ella.
- Usar solo un bloqueo de Redis con un TTL → tras una caída o expiración, no puede revelar si el pedido se confirmó ni reproducir el resultado → use la restricción de la base de datos para la corrección y la caché solo para aceleración.
- Almacenar en caché todos los errores → una solicitud corregida aún puede recibir un error de validación antiguo, mientras que un fallo transitorio se vuelve imposible de reintentar → no almacene fallos de validación previos a la adquisición de la propiedad; distinga los resultados estables de dominio de los fallos no confirmados.
- Prometer deduplicación permanente tras eliminar registros de solicitud → una clave expirada se trata como una nueva operación → publique la ventana de retención y use una clave única de dominio para el invariante permanente.
Preguntas de seguimiento y respuestas
Pregunta de seguimiento 1: ¿Qué debe recibir la segunda solicitud mientras la primera aún no se ha confirmado?
Con el diseño de transacción corta, el conflicto con el índice único hace que la segunda inserción espere. Limite tanto la espera en la base de datos como la ejecución del punto de conexión por debajo del tiempo de espera del cliente. Si la primera transacción se confirma a tiempo, la segunda solicitud lee y reproduce el resultado. Al límite, devuelva un resultado idempotency_in_progress reconocible y tiempos para reintentar en lugar de crear un pedido. Una tarea prolongada utiliza un estado IN_PROGRESS confirmado y un punto de conexión de estado para que la conexión HTTP no permanezca ocupada.
Pregunta de seguimiento 2: ¿Qué sucede si el servicio se cae después de la confirmación pero antes de escribir la respuesta HTTP?
Por eso se almacena el resultado. El pedido, el evento del outbox y la captura de la respuesta se han confirmado juntos. El reintento entra en conflicto en la clave única, verifica la huella digital y devuelve el estado y cuerpo almacenados. Un simple booleano «procesado» no le diría al servidor qué pedido devolver, y el emisor no podría distinguir el éxito de un resultado desconocido.
Pregunta de seguimiento 3: ¿Qué sucede si la misma clave de idempotencia incluye un importe diferente?
Devuelva 409 idempotency_key_reused, registre una métrica de conflicto y no ejecute la creación del pedido. Nunca sobrescriba el registro antiguo con los nuevos parámetros ni devuelva silenciosamente el pedido anterior. La huella digital debe incluir importe, moneda, artículos, campos de referencia de envío y cualquier otro campo que modifique el resultado de negocio, además de la versión de la API o de canonización.
Pregunta de seguimiento 4: Un worker de larga duración se pausa, otro toma el control de su arrendamiento expirado y el worker antiguo se reanuda. ¿Qué previene las escrituras obsoletas?
Incremente attempt_token en cada apropiación. Los cambios de estado del pedido, las escrituras de finalización y las tareas downstream controlables llevan ese token y se actualizan solo cuando coincide con el valor actual. La escritura del worker antiguo falla tras reanudarse, por lo que lee el nuevo estado y finaliza. Si un sistema posterior no puede verificar el token, aún necesita una API idempotente vinculada a un ID de operación de negocio estable o un proceso de conciliación y compensación. El arrendamiento por sí solo no puede retirar un efecto secundario externo ya enviado.
Pregunta de seguimiento 5: ¿Por qué no usar únicamente una restricción de unicidad en orders.checkout_id?
Esa restricción evita un segundo pedido y es un excelente respaldo permanente. No define si diferentes parámetros bajo una misma clave de idempotencia entran en conflicto, qué estado devolvió la primera solicitud, cómo responder mientras el trabajo es concurrente o qué hacer para los emisores sin un checkout_id. El registro de idempotencia proporciona un protocolo a nivel de solicitud y reproducción de resultados; la clave única de negocio protege el invariante del dominio. Operan en capas diferentes.