iGamingSep 11, 202610 min de lectura

Postgres de una casa de apuestas deportivas en los picos de partido: filas calientes, retraso en la liquidación y colocación de apuestas que no espera

Ingeniería de apuestas deportivasPostgreSQLLiquidación de apuestasIdempotencia
Error al cargar la imagen

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 y la liquidación son dos cargas de trabajo que comparten filas

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:

  • La liquidación no retiene una fila de saldo más tiempo del que la retiene una transacción de colocación
  • La liquidación puede quedarse atrás, y su backlog es una cola con antigüedad y no un montón de transacciones abiertas
  • Cada efecto sobre el dinero ocurre una sola vez, por muchas veces que se entregue el mensaje que hay detrás
  • Las lecturas que no necesitan el primario no lo tocan

Una fila de saldo es un bloqueo, lo hayas diseñado o no

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.

  • Toma el bloqueo del saldo en último lugar y haz commit justo después; la validación que no necesita bloqueo se ejecuta antes
  • Bloquea varias cuentas en un único orden consistente, que el capítulo sobre bloqueos da como la forma de evitar deadlocks
  • Mantén la exposición del mercado fuera de una única fila que actualice cada colocación, o un mercado popular serializa sus colocaciones detrás de un único bloqueo; reparte el contador entre un conjunto fijo de filas
  • Configura lock_timeout en la ruta de colocación, para que una espera indefinida se convierta en un error contabilizado que se reintenta con la misma clave de idempotencia
  • Deja las columnas de saldo fuera de los índices: el capítulo sobre almacenamiento solo permite una actualización HOT cuando no cambia ninguna columna indexada y la página que contiene la fila antigua tiene espacio, algo que un fillfactor más bajo hace más probable

Las transacciones largas y autovacuum mantienen la ráfaga en disco

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.

  • Sobrescribe esos umbrales tabla a tabla en saldos y apuestas abiertas, algo que el capítulo sobre vacuum permite mediante parámetros de almacenamiento
  • Configura idle_in_transaction_session_timeout, cuya documentación advierte de que una transacción abierta impide que vacuum elimine las tuplas muertas recientemente y puede contribuir al bloat de las tablas
  • Añade filas de liquidación en lugar de cambiar una columna de estado indexada, porque una actualización que modifica una columna indexada no puede ser HOT
  • Retira el histórico separando o eliminando particiones, algo que el capítulo sobre particionado describe como mucho más rápido que una operación masiva y libre de la sobrecarga de VACUUM de un DELETE masivo

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.

Las conexiones y las réplicas forman parte del mismo pico

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.

  • El pooling por transacción de PgBouncer asigna una conexión de servidor solo mientras dura una transacción, así que muchos clientes comparten menos backends
  • Las funcionalidades de sesión se rompen en ese modo: PgBouncer lista como no soportados SET y RESET, LISTEN, los cursores WITH HOLD y los advisory locks a nivel de sesión
  • Las prepared statements con nombre a nivel de protocolo funcionan ahí desde PgBouncer 1.21.0, publicado en octubre de 2023, cuando max_prepared_statements es distinto de cero

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í.

La colocación es una única transacción corta, con clave antes del primer reintento

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.

  • El cliente, o el edge que recibe primero la solicitud, crea una clave de idempotencia por envío, y cada reintento la lleva sin cambios
  • Una sola transacción escribe el registro de la apuesta, la reserva como actualización condicional del saldo y una fila de outbox para la apuesta aceptada
  • Los registros de apuestas son append-only: la liquidación, las anulaciones y las correcciones son filas nuevas que hacen referencia a la apuesta, nunca ediciones
  • Una restricción de unicidad convierte un reintento en un conflicto: INSERT con ON CONFLICT DO NOTHING no inserta nada, RETURNING devuelve solo las filas insertadas y la ruta vuelve a leer el resultado almacenado
  • Stripe documenta el mismo contrato para su API: el primer resultado de una clave se guarda y se devuelve a las solicitudes posteriores, tanto si tuvo éxito como si falló, y una clave reutilizada con parámetros distintos se rechaza

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.

La liquidación es una cola a la que se le permite llegar tarde

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:

  • Orden por mercado, no global: Kafka escribe los eventos con la misma clave en la misma partición y documenta que los consumidores leen una partición en orden de escritura, así que los eventos de resultado con el mercado como clave se mantienen en secuencia
  • Una tabla de cola funciona dentro de unos límites: la documentación califica SKIP LOCKED de inadecuado para trabajo de propósito general, pero utilizable para evitar la contención de bloqueos entre consumidores de una tabla tipo cola
  • Cada lote liquida un número acotado de apuestas, escribe sus asientos en el libro mayor, actualiza las filas de saldo en orden de cuenta y hace commit
  • El progreso se confirma junto con los efectos, así que un worker que muere a mitad de lote se reanuda desde su último lote confirmado
  • Un resultado corregido es un evento nuevo: contraasientos y después nuevos asientos de liquidación, nunca ediciones de los antiguos

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.

Los saldos necesitan dos números y asientos que se registran una sola vez

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 colocación mueve el importe de disponible a reservado en su actualización condicional
  • La liquidación libera la reserva y registra el cargo final y el abono, si lo hay, en una sola transacción, con clave por apuesta, tipo de asiento y versión de liquidación
  • Una anulación libera la reserva, y una reserva cuya liquidación nunca llega tiene un responsable con nombre y un plazo
  • La fila de saldo es una proyección del libro mayor, y una conciliación programada que suma los asientos por cuenta reporta la desviación como un incidente en lugar de corregirla en silencio

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.

El historial de apuestas pertenece a un modelo de lectura, no a la ruta de escritura

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.

  • La respuesta de la colocación devuelve la apuesta aceptada, así que el cliente la muestra sin volver a leerla de una vista que puede ir con retraso
  • Las pantallas que necesitan el estado más reciente leen del primario de forma explícita, y esa lista se mantiene corta
  • synchronous_commit con valor remote_apply hace que cada commit espere hasta que los standbys síncronos lo hayan reproducido: read-your-writes en la réplica, pagado en latencia de colocación

Qué medir mientras el partido sigue en juego

Toma las lecturas durante el pico, en el mismo eje temporal que la latencia de colocación:

  • Esperas de bloqueo: muestrea pg_stat_activity en busca del tipo de evento de espera Lock y encuentra a los bloqueadores con pg_blocking_pids, que, según advierte la documentación, puede afectar al rendimiento si se llama a menudo
  • log_lock_waits está desactivado por defecto y solo informa de las esperas más largas que deadlock_timeout, un segundo por defecto, así que el log no muestra ninguna de las esperas más cortas
  • La transacción más antigua, a partir de xact_start en pg_stat_activity, y cada sesión en estado idle in transaction
  • La limpieza en las tablas calientes: n_dead_tup, last_autovacuum y n_tup_hot_upd frente a n_tup_upd
  • Presión sobre el pool: cl_waiting y maxwait de SHOW POOLS, donde PgBouncer interpreta un maxwait creciente como un pool que no da abasto
  • El backlog de liquidación como antigüedad, porque un recuento no distingue una cola grande de una atascada
  • replay_lag por standby, y wal_status y safe_wal_size para los slots de replicación lógica

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.

Cómo saber qué empresas de ingeniería hacen realmente este trabajo

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:

  • Pide la latencia de colocación y el backlog de liquidación de un pico real en un mismo eje temporal antes de pedir el esquema
  • Nombra el mecanismo que espera que se lleve la latencia y la lectura que demostraría que se equivoca
  • Trata el dinero como una suite de pruebas: entrega duplicada, un worker terminado a mitad de lote, un resultado corregido
  • Hace pruebas de carga de una ráfaga de resultados mientras continúa el tráfico de colocación, en lugar de probar cada ruta por separado
  • Enuncia criterios de salida de antemano: un percentil de colocación durante la ráfaga y una antigüedad aceptable del backlog después de ella
  • Puede poner a alguien de guardia para los workers de liquidación, los slots de replicación y los pools de conexiones la noche de la final

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.

¿Tiene un diseño así sobre la mesa?

Traiga su arquitectura actual y el modo de fallo que le preocupa, y lo repasaremos juntos en media hora.

Artículos relacionados

Error al cargar la imagen
iGaming
Feb 28, 20266 min de lectura

Escalar plataformas iGaming: lecciones de gestionar 10M de usuarios simultáneos

Leer artículo
Error al cargar la imagen
iGaming
Feb 7, 20265 min de lectura

Cómo se construyen las funciones de juego responsable: análisis técnico en profundidad

Leer artículo
Error al cargar la imagen
iGaming
Jan 15, 20267 min de lectura

Arquitectura de apuestas en vivo: procesar actualizaciones de cuotas en menos de 50ms

Leer artículo