Tema representativo de entrevista

Entrevista de Backend: ¿Cómo Evitar Actualizaciones Perdidas con Control de Concurrencia Optimista?

BackendIntermedio
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Dos usuarios leen la versión 7 de una línea de factura, y luego la editan y guardan. ¿Cómo diseñarías la API HTTP y la escritura en la base de datos para que la solicitud obsoleta posterior no pueda sobrescribir silenciosamente el resultado anterior?

Problema y escenarios aplicables

El recurso invoice_items/{id} contiene una descripción y un monto. Alice y Bob leen la versión 7. Alice guarda primero y crea la versión 8. Bob luego envía una edición que todavía se basa en la versión 7. Un UPDATE incondicional permite que Bob sobrescriba a Alice mientras ambos emisores reciben una respuesta exitosa. Esa es la actualización perdida que esta pregunta debe prevenir.

Asume una API HTTP JSON, una base de datos relacional que admite actualizaciones condicionales e identificadores de recursos que nunca se reutilizan. Cada respuesta tiene una representación JSON canónica. Cada cambio en un campo de negocio avanza la versión del recurso y produce un nuevo ETag fuerte. Se espera que los conflictos sean poco frecuentes, por lo que el diseño utiliza concurrencia optimista en lugar de mantener un bloqueo en la base de datos mientras una persona edita. PUT, PATCH y DELETE están dentro del alcance; los algoritmos de edición colaborativa en tiempo real no lo están.

La competencia fundamental es el diseño de backend: conectar la versión que leyó el cliente, una precondición HTTP y una escritura atómica en la base de datos en un único contrato verificable. Los ejemplos utilizan SQL de estilo PostgreSQL; otra base de datos necesita una actualización condicional equivalente y la verificación de filas afectadas. Comparar una versión en un controlador y luego emitir una actualización incondicional aún deja una condición de carrera entre la verificación y la escritura.

Qué evalúa el entrevistador

La primera señal es si el candidato puede mostrar el entrelazado de la actualización perdida y explicar por qué la última escritura ganadora no es necesariamente correcta. Una respuesta sólida devuelve un testigo de versión en cada lectura editable y requiere que el escritor demuestre que está basado en la versión actual.

La segunda señal es la semántica precisa de HTTP. Un GET devuelve un ETag fuerte y una solicitud de modificación lo envía en If-Match. If-Match utiliza comparación fuerte, por lo que una etiqueta obsoleta o débil no puede pasar. Una precondición requerida faltante puede producir 428 Precondition Required; una etiqueta proporcionada que no coincide produce 412 Precondition Failed. Reserva 409 Conflict para un conflicto de negocio no expresado por la precondición HTTP.

La tercera señal es hacer que la verificación y la escritura en la base de datos sean atómicas. Haz coincidir tanto id como version, actualiza los campos de negocio e incrementa la versión en una sola sentencia. Cero filas afectadas es el punto en el que el recurso ya no existe o está obsoleto. Leer la versión y luego realizar una escritura incondicional es una carrera TOCTOU.

Finalmente, busca la recuperación de conflictos y los límites. La concurrencia optimista no reemplaza una clave de idempotencia, y la versión de una fila no protege un predicado entre múltiples filas. Si un recurso de alta contención devuelve continuamente 412, el diseño debe cambiar su estrategia de contención en lugar de hacer que los clientes reintenten inmediatamente para siempre.

Preguntas para aclarar antes de responder

  • ¿El dominio del conflicto es todo el recurso o un solo campo? Una versión para todo el recurso es más fácil de validar, pero

las ediciones en diferentes campos aún entrarán en conflicto. Frecuentes falsos conflictos pueden justificar agregados más pequeños, APIs de operaciones explícitas o reglas de fusión a nivel de campo.

  • ¿Qué métodos de mutación requieren una versión? Esta pregunta requiere If-Match en PUT, PATCH y

DELETE. Las importaciones, los trabajos en segundo plano y los scripts de administración deben seguir el mismo protocolo en lugar de conservar una ruta de escritura desprotegida.

  • ¿Quién resuelve un conflicto? Para ediciones humanas, muestra los valores original, propuesto y actual, y permite que el usuario

elija. Una máquina puede recalcular y reintentar solo cuando su función de fusión preserve el invariante de negocio.

  • ¿Cuáles son la tasa de conflicto y el presupuesto de latencia? Las colisiones raras se adaptan a un diseño optimista. Los registros de inventario caliente o

de subastas pueden favorecer una actualización de negocio atómica, una transacción pesimista corta o una cola de escritor único.

  • ¿Puede un cliente reenviar la misma solicitud después de un tiempo de espera? Las verificaciones de versión gestionan la competencia de intenciones diferentes.

Las claves de idempotencia gestionan el transporte repetido de una misma intención. El diseño puede necesitar ambas.

  • ¿La versión cubre una regla multifila? La versión de una fila solo protege ese recurso. Una regla como "un

médico debe permanecer de guardia" necesita Serializable, una fila de bloqueo común o una restricción de base de datos.

Estructura de respuesta de 30 segundos

"Devolvería el recurso actual con un ETag fuerte, como \"invoice-item-42-v7\" para la versión 7. El cliente envía ese valor en If-Match al guardar. El servidor devuelve 428 si falta el encabezado requerido y 412 si la etiqueta es obsoleta. La protección real es una sentencia atómica en la base de datos: UPDATE ... WHERE id = ? AND version = 7 modifica los campos de negocio e incrementa la versión al mismo tiempo. Cero filas significa obsoleto o eliminado; nunca verifiques primero y luego actualices incondicionalmente. Una respuesta exitosa lleva el ETag de la versión 8. En caso de 412, el cliente vuelve a consultar y permite que el usuario fusione los cambios. Una clave de idempotencia cubre por separado la retransmisión tras un tiempo de espera. Probaría dos clientes que lean la versión 7 y demostraría que exactamente una escritura tiene éxito."

Análisis detallado paso a paso

Comencemos con el historial incorrecto:

text
Alice: GET item 42 -> amount=10000, version=7
Bob:   GET item 42 -> amount=10000, version=7
Alice: PUT amount=10500 -> unconditional UPDATE -> success
Bob:   PUT amount=9800  -> unconditional UPDATE -> success
Final: amount=9800; Alice's accepted update is lost

El contrato de lectura recomendado devuelve un ETag fuerte. Es un valor opaco que el cliente debe devolver exactamente igual. No debe contener información confidencial y nunca reemplaza la autenticación ni la autorización:

http
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "invoice-item-42-v7"

{"id":42,"description":"Consulting","amountCents":10000,"version":7}

Alice coloca esa etiqueta en If-Match:

http
PUT /invoice-items/42 HTTP/1.1
Content-Type: application/json
If-Match: "invoice-item-42-v7"

{"description":"Consulting","amountCents":10500}

El servidor realiza su autenticación, autorización y validación de solicitud habituales antes de evaluar la precondición. Si la API requiere que toda mutación sea condicional, la ausencia de If-Match devuelve 428 e indica al cliente que vuelva a consultar y reenvíe con una etiqueta. Si el valor proporcionado no es el ETag fuerte actual, el servidor no modifica el recurso y devuelve 412. La comparación fuerte exige que ambas etiquetas de entidad no sean débiles y que sus valores opacos coincidan carácter por carácter. Por lo tanto, W/"invoice-item-42-v7" no puede pasar.

Tras la comprobación HTTP, la base de datos debe aplicar atómicamente la misma versión esperada:

sql
UPDATE invoice_items
SET description = $1,
    amount_cents = $2,
    version = version + 1
WHERE id = $3
  AND version = $4
RETURNING id, description, amount_cents, version;

$4 es la versión 7 esperada, decodificada del ETag validado. Una fila devuelta significa éxito, por lo que la respuesta contiene la versión 8 y su nuevo ETag. Con cero filas, lee el registro actual dentro de los límites de autorización: devuelve 404 si está ausente, o 412 si aún existe. La premisa de no reutilización de ID evita que una eliminación y recreación se hagan pasar por el recurso original. Independientemente de la clasificación, la ruta de cero filas nunca escribe.

No ejecutes SELECT version, lo compares y continúes con un UPDATE que carezca del predicado de versión. Otra transacción puede confirmarse entre esas sentencias, permitiendo que la solicitud que acaba de pasar su verificación sobrescriba el nuevo estado. Incluso si el controlador comparó el ETag, WHERE version = $4 es el límite final de corrección. Un token de concurrencia de ORM debe generar la actualización condicional equivalente y transformar cero filas afectadas en un conflicto de concurrencia.

Alinea cada estado con su causa:

  • 428 Precondition Required: el cliente omitió una condición requerida por esta API.
  • 412 Precondition Failed: el cliente envió If-Match, pero la representación actual no la satisface.
  • 404 Not Found: sujeto a la política de autorización, el recurso ya no existe.
  • 409 Conflict: la precondición de versión pasó, pero se aplica otro conflicto de estado de negocio, como una

factura liquidada que es inmutable.

Tras un 412, un cliente interactivo realiza un nuevo GET y mantiene tres valores: lo que el usuario leyó originalmente, lo que el usuario propuso y lo que el servidor almacena ahora. Un cambio propuesto puede aplicarse automáticamente solo a un campo cuyo valor actual aún sea igual al original; otros campos necesitan una decisión de conflicto explícita. Una versión de recurso única rechaza conservadoramente incluso ediciones disjuntas. Si eso se convierte en un cuello de botella medido, divide el recurso o expón una operación basada en intención como POST /invoice-items/42/adjust-amount en lugar de revertir silenciosamente a la última escritura ganadora.

Una clave de idempotencia resuelve un fallo diferente. Supón que la actualización de la versión 7 de Alice se confirmó como versión 8 pero la respuesta exitosa se perdió. Un reintento literal tiene ahora un If-Match obsoleto. Con una clave de idempotencia estable, el servidor puede devolver el primer resultado almacenado. El testigo de versión detecta dos ediciones diferentes basadas en la versión 7; el registro de idempotencia reconoce que una misma edición se transmitió dos veces. No son sustitutos. Cuando el servidor no puede demostrar de manera confiable la ejecución previa, devolver 412 es más seguro que adivinar el éxito a partir de datos actuales similares.

La concurrencia optimista asume baja contención. Si un recurso tiene una tasa de conflicto persistentemente alta, las lecturas repetidas y las fusiones manuales desperdician capacidad y degradan la experiencia del usuario. La disminución de inventario puede usar directamente UPDATE ... SET stock = stock - $1 WHERE stock >= $1 para hacer cumplir un invariante de una sola fila. Una operación corta de lectura-decisión-escritura puede usar un bloqueo de fila. Un agregado que debe procesar comandos en orden puede particionarse a un único escritor. Elige según la tasa de conflicto medida, si las operaciones conmutan, el presupuesto de espera y el alcance del invariante.

La verificación debe forzar un entrelazado en lugar de llamar a la API dos veces en secuencia. Haz que ambos clientes lean la versión 7, liberen diferentes montos a través de una barrera y valida que haya exactamente un éxito, un 412, la versión final 8 y el contenido del ganador. También prueba la ausencia de If-Match devolviendo 428, el fallo con una etiqueta débil, una respuesta exitosa con un nuevo ETag, la etiqueta antigua manteniéndose obsoleta, la carrera entre actualización y eliminación, y la reproducción con la misma clave de idempotencia tras una respuesta perdida. En producción, monitorea la tasa de precondiciones faltantes, la tasa de 412, los reintentos automáticos y el abandono final por endpoint. Un aumento repentino suele identificar un punto crítico o un cliente que no se está actualizando.

Respuesta de muestra de alta calidad

"Establecería el dominio del conflicto en una única línea de factura. Ambos usuarios tienen la versión 7, por lo que el guardado posterior no debe sobrescribir incondicionalmente al primero. Cada lectura editable devuelve un ETag fuerte, y cada PUT, PATCH o DELETE debe repetirlo en If-Match. A un encabezado faltante se le responde 428; a una etiqueta obsoleta, 412 sin realizar una escritura.

Comparar en la capa HTTP no es suficiente. Almacenaría una versión por recurso y haría que la persistencia ejecute UPDATE invoice_items SET ..., version = version + 1 WHERE id = ? AND version = ? RETURNING ... atómicamente. Después de que Alice tenga éxito con la versión 7, la fila pasa a la versión 8. El mismo predicado de Bob afecta a cero filas, por lo que no puede sobrescribir a Alice. Un éxito devuelve el nuevo ETag. Tras cero filas, una lectura con control de autorización distingue 404 de 412, pero ninguna rama recurre a una actualización incondicional.

Ante un 412, el cliente vuelve a consultar y compara los valores original, propuesto y actual para que el usuario resuelva un conflicto real. Si los campos disjuntos colisionan frecuentemente, dividiría el recurso o diseñaría operaciones atómicas basadas en intención. Un reintento por tiempo de espera utiliza por separado una clave de idempotencia: reconoce el transporte duplicado de una misma intención, mientras que la versión reconoce diferentes intenciones basadas en el mismo estado antiguo.

Usaría dos conexiones y una barrera para que ambos lean la versión 7 antes de enviar. Exactamente uno debe tener éxito, el otro debe recibir 412 y la versión final debe ser 8. También probaría la ausencia de If-Match, etiquetas débiles, carreras con eliminaciones y retransmisión tras respuesta perdida. Si la tasa de 412 permanece alta en producción, evaluaría una actualización de negocio condicional, un bloqueo corto o un escritor único en lugar de incrementar los reintentos indefinidamente."

Errores comunes

  • Verificar la versión en el código de la aplicación antes de actualizar → otra confirmación puede intercalarse entre la verificación y

la escritura → coloca la versión en el mismo predicado UPDATE e inspecciona las filas afectadas.

  • Continuar cuando If-Match está ausente → clientes protegidos coexisten con clientes legados que aún pueden sobrescribir

datos → haz que la mutación condicional sea parte del contrato de la API y devuelve 428 cuando esté ausente.

  • Aceptar un ETag débil para If-Match el estándar exige comparación fuerte, por lo que una etiqueta débil no puede coincidir

genera un ETag fuerte semánticamente válido para representaciones editables.

  • Devolver 409 para cada conflicto → los clientes no pueden distinguir un fallo de precondición HTTP de un conflicto

de estado de negocio → devuelve 412 para una precondición de versión fallida y reserva 409 para el conflicto de negocio independiente.

  • Reenviar automáticamente la misma solicitud después de 412 → la etiqueta antigua sigue siendo obsoleta, mientras que reemplazarla silenciosamente

puede sobrescribir otra edición → vuelve a consultar y recalcular o solicita al usuario que fusione.

  • Asumir que las claves de idempotencia evitan sobrescrituras concurrentes → dos ediciones diferentes usan claves distintas pero aún

pueden compartir una base obsoleta → usa idempotencia y condiciones de versión para el transporte duplicado y la intención concurrente respectivamente.

  • Afirmar que una versión en cada fila protege cualquier invariante → el write skew multifila nunca entra en conflicto en la versión de una sola fila

elige Serializable, un punto de contención común o una restricción para el alcance del invariante.

  • Reintentar en un punto caliente inmediatamente sin límite → las solicitudes fallidas vuelven a colisionar y amplifican la carga → **mide

los conflictos y considera una operación atómica, un bloqueo corto o un único escritor.**

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Deberían entrar en conflicto dos solicitudes PATCH a campos diferentes?

La versión de todo el recurso avanza con cualquier cambio de campo, por lo que parches disjuntos entran en conflicto. Ese es el valor predeterminado seguro y fácil de explicar. Si la evidencia muestra muchos falsos conflictos, divide los ciclos de vida que cambian de forma independiente en subrecursos o envía líneas base de campo y define una fusión tripartita. Los tokens a nivel de campo reducen los falsos conflictos pero multiplican el estado de los tokens y complican los invariantes compuestos y la auditoría; no los agregues solo para suprimir respuestas 412.

Pregunta de seguimiento 2: ¿Qué sucede cuando un cliente reintenta tras un tiempo de espera y el ETag antiguo es obsoleto?

Envía una clave de idempotencia estable y persiste la clave, el resumen de la solicitud, la versión esperada y el primer resultado juntos. Una retransmisión con la misma clave y solicitud devuelve el primer resultado; la misma clave con una solicitud diferente es rechazada. Sin un registro de idempotencia confiable, los campos actuales que simplemente parecen similares no prueban que la primera solicitud haya tenido éxito. Devuelve 412 y permite que el cliente inspeccione el estado actual.

Pregunta de seguimiento 3: ¿Puede updated_at ser el token de versión?

Es seguro solo si la base de datos lo genera, cada mutación relevante lo modifica, su precisión distingue escrituras sucesivas y cada nodo le otorga la misma semántica. Una precisión de marca de tiempo truncada o una ruta de escritura que la eluda pueden dar el mismo valor a dos versiones. Un entero por recurso o una versión de fila nativa de la base de datos generalmente ofrece una comprobación de igualdad menos ambigua y aún puede codificarse como un ETag opaco.

Pregunta de seguimiento 4: ¿Cómo debería cambiar el diseño bajo alta contención?

Primero agrupa las respuestas 412 por recurso y operación para separar una clave caliente, un dominio de conflicto excesivamente amplio y clientes desconectados por mucho tiempo. Convierte las intenciones de incremento o decremento conmutativas en expresiones atómicas de base de datos. Usa un bloqueo de fila para decisiones cortas no conmutativas, o enruta comandos estrictamente ordenados por clave de recurso a un único escritor. Cada alternativa intercambia espera, rendimiento o complejidad arquitectónica por menos escrituras rechazadas, por lo que los datos medidos de conflicto y latencia deben motivar el cambio.

Pregunta de seguimiento 5: ¿Por qué la versión de una fila no evita el write skew?

En el write skew, dos transacciones leen el mismo predicado entre filas y actualizan registros diferentes, por lo que ambas condiciones de versión de fila pueden cumplirse. Cada token solo demuestra que la fila que se está actualizando no ha cambiado; no dice nada sobre el predicado compartido. Proyecta el invariante en una fila de contador común, bloquea un guardián común, agrega una restricción de base de datos apropiada o utiliza Serializable para detectar la dependencia no serializable.

Fuentes públicas

Preguntas relacionadas