Una casa de apuestas deportivas cuyo Postgres se ralentiza durante los grandes partidos y liquida las apuestas mucho después de que termine el evento está haciendo pasar dos cargas de trabajo por un mismo conjunto de filas: la colocación, una escritura corta por solicitud, y la liquidación, una ráfaga disparada por un único resultado. Así se separan las dos rutas, de dónde viene la contención y cómo se mantienen correctos los saldos mientras la liquidación va con retraso.
Una casa de apuestas deportivas cuyo Postgres se convierte en el cuello de botella durante un gran partido suele tener un síntoma y dos causas. La colocación y la liquidación compiten por las mismas filas, bloqueos y conexiones justo cuando el tráfico alcanza su pico, y la liquidación se ejecuta como un trabajo que retiene esas filas en lugar de como una cola que puede esperar su turno.
Más hardware sube el nivel de tráfico al que esto ocurre sin eliminar la causa. Lo que sigue separa las dos rutas, localiza la contención y mantiene correctos los saldos mientras la liquidación va con retraso. El comportamiento de PostgreSQL que se cita abajo procede de la documentación de la versión 18.
La respuesta corta es estructural. La colocación y la liquidación dejan de compartir transacciones: la colocación escribe la apuesta, una reserva de saldo y una fila de outbox en una sola transacción corta bajo una clave de idempotencia, y la liquidación consume los eventos de resultado en lotes pequeños cuyos efectos también llevan clave, de modo que un mensaje reentregado no mueve dinero. Lo que amBrain puede sustentar públicamente es la ingeniería de plataformas de casino, y una cifra que publicamos como medida ahí es 12 operadores en producción. El diseño de abajo sale de la mecánica del problema, no de un caso nuestro, y ningún número que contiene está medido en un sistema nuestro.
La colocación es una solicitud con una persona esperando: leer el estado del mercado, comprobar un saldo, escribir una apuesta, responder. La liquidación parte de un único resultado y se reparte en fan-out, de golpe, a todas las apuestas abiertas de los mercados afectados. Un gran partido termina mientras otros eventos siguen abiertos, así que esa ráfaga cae sobre las filas de saldo de cuentas que vuelven a colocar apuestas.
Si las dos rutas escriben esas filas en sus propias transacciones, la latencia de colocación pasa a ser función de la transacción de liquidación más larga sobre la misma cuenta. Desacoplar es un conjunto de promesas sobre bloqueos y tiempo:
El capítulo sobre bloqueos de PostgreSQL dice que los bloqueos a nivel de fila solo bloquean a quienes escriben o bloquean esa misma fila, no a los lectores, y que una transacción que busca un bloqueo espera indefinidamente salvo que se detecte un deadlock. Una fila por cuenta, actualizada en cada colocación, es por tanto una cola, y con razón: el bloqueo impide que dos colocaciones gasten el mismo dinero. Lo que importa es cuánto tiempo lo mantiene cada poseedor.
Read Committed, el nivel de aislamiento por defecto, mantiene simple la reserva. Un UPDATE que encuentra una fila ya actualizada por una transacción concurrente espera a que esta haga commit o rollback y, si hizo commit, vuelve a evaluar su cláusula WHERE sobre la versión actualizada. Una actualización condicional que resta el importe solo donde el saldo disponible lo cubre no puede sobrevender y no necesita SELECT FOR UPDATE.
Una liquidación que marca todas las apuestas abiertas de un mercado en una sola sentencia retiene esos bloqueos de fila hasta el commit y deja una versión muerta de cada fila. El capítulo sobre vacuum dice que una versión antigua no debe eliminarse mientras otras transacciones todavía puedan verla, así que una liquidación larga, o un informe inactivo dentro de una transacción, mantiene toda la ráfaga en disco.
Autovacuum llega tarde por diseño. PostgreSQL 18 aplica vacuum a una tabla cuando las filas actualizadas o eliminadas desde el último vacuum superan el menor de dos valores: autovacuum_vacuum_max_threshold, o autovacuum_vacuum_threshold más autovacuum_vacuum_scale_factor por el número de filas. Con los valores por defecto, 100 000 000, 50 y 0.2, una tabla de apuestas de 50 millones de filas espera a unos diez millones de filas actualizadas o eliminadas.
Las tablas de cola hacen que el efecto sea fácil de ver. En un post de 2015 en brandur.org, Postgres Job Queues & Failure By MVCC, una transacción que se dejó inactiva junto a una cola de trabajos elevó el tiempo para bloquear un trabajo de menos de 0.01 segundos a picos de 15 veces ese nivel, porque las filas muertas de trabajos todavía no podían eliminarse.
Cada conexión es un proceso backend, y la documentación dice que subir max_connections, normalmente 100 por defecto, sube los recursos que se dimensionan a partir de él, incluida la memoria compartida. En lugar de eso, da a la colocación y a la liquidación pools separados, para que un backlog de liquidación haga cola por sus propias conexiones.
Las réplicas alivian las lecturas a cambio de dos costes. La replicación en streaming es asíncrona por defecto, así que un commit se vuelve visible en el standby tras un pequeño retraso. Y el capítulo sobre hot standby dice que las consultas en el standby que entran en conflicto con la limpieza de vacuum procedente del primario se cancelan tras un retraso configurado, mientras que hot_standby_feedback lo evita retrasando la limpieza en el primario, lo que puede causar bloat de tablas allí.
Diseña la colocación hacia atrás desde su fallo: un cliente agota el tiempo de espera y reintenta, y el reintento debe recibir el primer resultado, no crear una segunda apuesta.
Particiona el almacenamiento por tiempo y el trabajo por mercado. El capítulo sobre particionado exige que una restricción de unicidad en una tabla particionada incluya todas las columnas de la clave de partición, así que la clave de idempotencia o bien lleva la columna de partición o bien vive en su propia tabla. También dice que el planificador maneja razonablemente bien hasta unos pocos miles de particiones cuando las consultas podan todas salvo unas pocas, y los mercados son un conjunto abierto, así que las particiones por mercado ponen tiempo de planificación en la ruta de colocación.
La fila de outbox hace que el evento sea fiable. En el patrón transactional outbox tal como lo describe Chris Richardson, el mensaje se guarda en la base de datos dentro de la transacción que actualiza las entidades de negocio, y un proceso aparte se encarga de enviarlo. La misma descripción nombra el coste: el relay puede publicar un mensaje más de una vez, así que los consumidores deben ser idempotentes.
Desde el momento en que llega un resultado, la liquidación es un backlog con antigüedad, y nada en él retiene durante más de un lote una fila que la colocación está esperando:
A la liquidación se le permite llegar tarde. No se le permite ocurrir dos veces. A la colocación no se le permite ninguna de las dos cosas, y por eso las dos no pueden compartir una transacción.
Una sola columna de saldo no puede describir una apuesta aceptada y todavía no liquidada. Mantén dos números por cuenta, disponible y reservado, y mueve dinero entre ellos solo mediante asientos del libro mayor que lleven cada uno una clave:
La entrega puede repetirse: el relay del outbox puede volver a publicar, y cuando el outbox se lee mediante decodificación lógica, la documentación dice que un slot puede reenviar cambios recientes tras una caída. Así que el requisito es un efecto que ocurra una sola vez. Cada asiento del libro mayor tiene una clave única, la actualización del saldo se confirma junto con el insert, y un mensaje reentregado choca con la restricción y no mueve dinero.
Muchas lecturas en un pico están al lado de la colocación y no sobre ella: apuestas abiertas, historial, pantallas de saldo que se refrescan tras cada evento. La descripción de CQRS de Chris Richardson sirve esas consultas desde una base de datos de vistas que se mantiene al día suscribiéndose a los eventos del servicio al que pertenecen los datos, y nombra como coste el retraso de replicación y las vistas con consistencia eventual. El outbox de la colocación ya publica esos eventos.
Toma las lecturas durante el pico, en el mismo eje temporal que la latencia de colocación:
Leídas juntas, localizan el fallo. Una cola del pool que crece con las esperas de bloqueo planas apunta a las conexiones; las esperas de bloqueo que suben con los lotes de liquidación apuntan a las filas compartidas; que ninguna de las dos se mueva mientras suben las filas muertas apunta a la transacción más antigua.
La segunda mitad de la pregunta, qué empresas se especializan en esto, tiene una prueba que no necesita una lista de proveedores. Una empresa que ya ha separado estas rutas hace lo siguiente en una primera conversación:
Una respuesta que se queda en lo general en cualquiera de estos puntos significa que el trabajo empezaría sin diagnóstico.
Así que la primera decisión no es una base de datos más grande. Es qué mecanismo se lleva la latencia la noche en que la colocación se ralentiza, y si la colocación y la liquidación siguen compartiendo una transacción en algún punto de la ruta.
Lo que amBrain puede sustentar públicamente: amBrain es una empresa de desarrollo de software especializada en plataformas de trading, matching engines, sistemas de real-time bidding e ingeniería de plataformas de casino. amBrain lleva construyendo software desde 2019. Una cifra que publicamos como medida en iGaming es 12 operadores en producción. Trabajamos en tres formatos: entrega completa, un equipo dedicado o ingenieros integrados en tu equipo.
Traiga su arquitectura actual y el modo de fallo que le preocupa, y lo repasaremos juntos en media hora.