iGamingSep 11, 2026阅读需 10 分钟

比赛高峰下的体育博彩 Postgres:热点行、结算滞后与不被阻塞的下注

体育博彩工程PostgreSQL注单结算幂等性
图片加载失败

一个 Postgres 在重大比赛期间变慢、赛事结束很久之后才结算注单的体育博彩平台,是在让两种负载跑过同一组行:下注,每个请求一次短写入;结算,由一个赛果触发的一波突发。本文讲这两条路径怎么分开、争用从哪里来,以及在结算滞后时余额如何保持正确。

一个在重大比赛期间 Postgres 成为瓶颈的体育博彩平台,通常是一个症状、两个原因。下注和结算恰好在流量达到峰值时争抢同样的行、锁和连接,并且结算是以持有这些行的工作形式运行的,而不是一条能排队等候的队列。

加硬件会抬高出现这种情况的流量水平,但不会消除原因。下文把两条路径分开,定位争用所在,并在结算滞后时保持余额正确。下文引用的 PostgreSQL 行为均出自 18 版文档。

简短的答案是结构性的。下注和结算不再共用事务:下注在一个带幂等键的短事务里写入注单、余额预留和一条 outbox 行;结算以小批次消费赛果事件,这些批次产生的效果同样带键,所以被重投的消息不会挪动任何资金。amBrain 可以公开证实的内容是娱乐场平台工程,我们在这方面作为实测公布的一个数字是:12 家运营商已上线运行。下面的设计来自问题本身的机理,不是来自我们的某个案例,其中没有任何一个数字是在我们自己的系统上测得的。

下注和结算是共享同一批行的两种负载

下注是一个有人在等的请求:读取盘口状态,检查余额,写入一笔注单,回复。结算从一个赛果开始,一次性扇出到受影响盘口上的每一笔未结算注单。一场重大比赛结束时,其他赛事还在进行,所以这波突发会落在正在再次下注的账户的余额行上。

如果两条路径都在各自的事务里写这些行,下注延迟就会变成同一账户上最长那个结算事务的函数。解耦是一组关于锁和时间的承诺:

  • 结算持有余额行的时间,不超过一个下注事务持有它的时间
  • 结算可以落后,而它的积压是一条以等待时长衡量的队列,而不是一堆未结束的事务
  • 对资金的每一次影响只发生一次,无论背后的消息被投递了多少次
  • 不需要主库的读取,就不去碰主库

不管你是否有意设计,余额行都是一把锁

PostgreSQL 文档讲锁的那一章说,行级锁只阻塞同一行的写入者和加锁者,不阻塞读取者;并且一个请求锁的事务会无限期等待,除非检测到死锁。因此,每个账户一行、每次下注都要更新,这就是一条队列,而且理应如此:这把锁阻止了两笔下注花掉同一笔钱。要紧的是每个持有者持锁多久。

默认隔离级别 Read Committed 让预留保持简单。UPDATE 如果发现某行已被一个并发事务更新,就会等待该事务提交或回滚;如果它已提交,就针对更新后的版本重新求值自己的 WHERE 子句。只在可用余额足够时才扣减金额的条件更新,不会超卖,也不需要 SELECT FOR UPDATE。

  • 余额锁最后才拿,拿到后立即提交;不需要锁的校验先跑
  • 对多个账户加锁时始终按同一顺序,讲锁的那一章把这作为避免死锁的方法给出
  • 不要把盘口敞口放在每次下注都要更新的单独一行上,否则一个热门盘口的所有下注都会在同一把锁后面串行;把这个计数器分散到固定的一组行上
  • 在下注路径上设置 lock_timeout,让无限期的等待变成一个被计数的错误,并以同一个幂等键重试
  • 不要把余额列放进索引:讲存储的那一章规定,只有在没有索引列发生变化、且存放旧行的页面还有空间时,才允许 HOT 更新,而更低的 fillfactor 会让这更有可能

长事务和 autovacuum 把突发留在磁盘上

用一条语句把某个盘口上所有未结算注单都标记掉的结算,会一直持有这些行锁直到提交,并给每一行留下一个死版本。文档中讲 vacuum 的那一章说,只要其他事务还可能看到旧版本,旧版本就不能被移除,所以一次长时间的结算,或者一个在事务里空闲着的报表,会把整波突发都留在磁盘上。

autovacuum 按设计就是迟到的。PostgreSQL 18 会在自上次 vacuum 以来被更新或删除的行数,超过 autovacuum_vacuum_max_threshold 与(autovacuum_vacuum_threshold 加上 autovacuum_vacuum_scale_factor 乘以行数)两者中较小的那个时,对表执行 vacuum。按默认值 100,000,000、50 和 0.2 计算,一张 5000 万行的注单表要等到大约一千万行被更新或删除。

  • 在余额表和未结算注单表上按表覆盖这些阈值,讲 vacuum 的那一章允许通过存储参数这样做
  • 设置 idle_in_transaction_session_timeout,它的文档警告:未结束的事务会让最近产生的死元组无法被 vacuum 清理,并可能加剧表膨胀
  • 追加结算行,而不是翻转一个带索引的状态列,因为修改了索引列的更新不能走 HOT
  • 通过分离或删除分区来淘汰历史数据,讲分区的那一章说这比批量操作快得多,而且没有批量 DELETE 带来的 VACUUM 开销

队列表让这种效应一目了然。在 brandur.org 上 2015 年的一篇文章 Postgres Job Queues & Failure By MVCC 中,一个在任务队列旁边闲置的事务,把锁定一个任务所需的时间从不到 0.01 秒推高到峰值达该水平的 15 倍,因为死掉的任务行暂时还无法被移除。

连接和副本属于同一个高峰

每个连接都是一个后端进程,文档说调高 max_connections(默认通常是 100)会同时调高按它来确定大小的资源,包括共享内存。与其如此,不如给下注和结算分别配独立的连接池,这样结算积压排队等的是它自己的连接。

  • PgBouncer 的事务级池化只在一个事务的持续期间分配服务端连接,所以许多客户端共享数量更少的后端
  • 会话级特性在该模式下会失效:PgBouncer 把 SET 和 RESET、LISTEN、WITH HOLD 游标以及会话级咨询锁列为不支持
  • 自 2023 年 10 月发布的 PgBouncer 1.21.0 起,只要 max_prepared_statements 不为零,协议层的命名预备语句在该模式下就能工作

副本分担读取要付两项代价。流复制默认是异步的,所以一次提交要经过一小段延迟才在备库上可见。另外,讲热备的那一章说,与来自主库的 vacuum 清理相冲突的备库查询,会在配置的延迟之后被取消;而 hot_standby_feedback 通过推迟主库上的清理来避免这种取消,这可能导致主库上的表膨胀。

下注是一个短事务,在第一次重试之前就带上键

从失败倒推着设计下注:客户端超时后重试,而重试必须拿到第一次的结果,而不是创建第二笔注单。

  • 客户端,或者第一个接收到请求的边缘节点,为每次提交生成一个幂等键,每次重试都原样携带它
  • 一个事务写入注单记录、以条件式余额更新形式完成的预留,以及一条对应已接受注单的 outbox 行
  • 注单记录只追加:结算、作废和更正都是引用该注单的新行,绝不是修改
  • 唯一约束把重试变成一次冲突:带 ON CONFLICT DO NOTHING 的 INSERT 什么都不插入,RETURNING 只返回被插入的行,而这条路径则回读已保存的结果
  • Stripe 在文档中为它的 API 写明了同样的约定:一个键的第一次结果会被保存,并返回给之后的请求,无论它成功还是失败;而以不同参数复用的键会被拒绝

存储按时间分区,工作按盘口划分。讲分区的那一章要求,分区表上的唯一约束必须包含全部分区键列,所以幂等键要么带上分区列,要么放在自己单独的表里。这一章还说,当查询能把分区裁剪到只剩少数几个时,规划器能相当好地处理多达几千个分区;而盘口的数量没有上限,所以按盘口分区会把规划时间压到下注路径上。

outbox 行让事件变得可信。在 Chris Richardson 描述的事务性 outbox 模式中,消息在更新业务实体的那个事务内存入数据库,再由一个单独的进程把它发送出去。同一份描述也点明了代价:中继可能把同一条消息发布不止一次,所以消费者必须是幂等的。

结算是一条允许迟到的队列

从赛果到达的那一刻起,结算就是一份以等待时长衡量的积压,其中任何东西持有下注要等待的行,都不会超过一个批次的时间:

  • 按盘口保序,而不是全局保序:Kafka 会把键相同的事件写入同一个分区,并在文档中说明消费者按写入顺序读取分区,所以以盘口为键的赛果事件能保持先后顺序
  • 队列表在一定限度内可行:文档称 SKIP LOCKED 不适合通用场景,但可以用来避免类队列表的多个消费者之间的锁争用
  • 每个批次结算数量有上限的注单,写入它们的账本分录,按账户顺序更新余额行,然后提交
  • 进度与这些效果一起提交,所以在批次中途挂掉的 worker 会从它最后一个已提交的批次继续
  • 更正后的赛果是一个新事件:先写冲销分录,再写新的结算分录,绝不修改旧分录

结算允许迟到。它不允许发生两次。下注两样都不允许,这就是两者不能共用一个事务的原因。

余额需要两个数字,以及只落账一次的分录

单独一个余额列,描述不了一笔已被接受但尚未结算的注单。每个账户保留两个数字——可用和预留——资金只通过每条都带键的账本分录在两者之间移动:

  • 下注在它的条件更新里,把金额从可用挪到预留
  • 结算在一个事务里释放预留,并记入最终的借记和可能存在的贷记,以注单、分录类型和结算版本为键
  • 注单作废会释放预留,而一笔始终等不到结算的预留,有指名的负责人和期限
  • 余额行是账本的投影,按账户汇总分录的定时对账会把偏差作为事故上报,而不是悄悄修正它

投递可能重复:outbox 中继可能重新发布;而当通过逻辑解码读取 outbox 时,文档说崩溃之后复制槽可能重发最近的变更。所以要求的是一个只发生一次的效果。每条账本分录都有唯一键,余额更新与插入一起提交,被重投的消息会撞上约束,不挪动任何资金。

注单历史属于读模型,而不属于写路径

高峰时的许多读取位于下注旁边,而不在下注路径之上:未结算注单、历史记录、每个事件之后都会刷新的余额界面。Chris Richardson 对 CQRS 的描述,是由一个视图数据库来响应这类查询,该视图数据库通过订阅拥有数据的那个服务发出的事件保持最新,并把复制延迟和最终一致的视图列为代价。下注的 outbox 已经在发布这些事件了。

  • 下注响应返回已接受的注单,这样客户端无需从可能滞后的视图回读就能显示它
  • 需要最新状态的界面显式地从主库读取,而且这份清单保持简短
  • 把 synchronous_commit 设为 remote_apply,会让每次提交都等到同步备库回放完成:换来副本上的读己之写,代价付在下注延迟上

比赛还在进行时要测什么

在高峰期间采集这些读数,并与下注延迟放在同一时间轴上:

  • 锁等待:对 pg_stat_activity 采样,找出等待事件类型为 Lock 的会话,并用 pg_blocking_pids 找出阻塞者;文档警告,频繁调用 pg_blocking_pids 可能影响性能
  • log_lock_waits 默认关闭,并且只报告长于 deadlock_timeout(默认一秒)的等待,所以日志里看不到任何更短的等待
  • 最老的事务(取自 pg_stat_activity 中的 xact_start),以及每一个处于 idle in transaction 状态的会话
  • 热表上的清理情况:n_dead_tup、last_autovacuum,以及 n_tup_hot_upd 与 n_tup_upd 的对比
  • 连接池压力:SHOW POOLS 中的 cl_waiting 和 maxwait,PgBouncer 把上升的 maxwait 解读为连接池跟不上
  • 以最老一条的等待时长来衡量结算积压,因为条数分辨不出队列是很大还是卡住了
  • 每个备库的 replay_lag,以及逻辑复制槽的 wal_status 和 safe_wal_size

放在一起读,它们就能定位故障。连接池队列在增长而锁等待平稳,指向连接;锁等待随结算批次上升,指向共享的行;两者都不动而死行在攀升,指向最老的那个事务。

如何分辨哪些工程公司真正在做这类工作

问题的后半部分——哪些公司专门做这件事——有一个不需要供应商名单的判别方法。以前拆分过这两条路径的公司,在第一次谈话中会这样做:

  • 先要一次真实高峰期间、画在同一时间轴上的下注延迟和结算积压,再要表结构
  • 指明它预期延迟出在哪个机制上,以及什么读数能证明它判断错了
  • 把资金当作一套测试套件来对待:重复投递、批次中途被杀掉的 worker、更正后的赛果
  • 在下注流量持续的同时,对赛果引发的突发做压测,而不是只单独压其中一条路径
  • 提前写明退出标准:突发期间的下注延迟分位数,以及突发过后可接受的积压等待时长
  • 能在决赛之夜安排人为结算 worker、复制槽和连接池值班

只要其中任何一项的回答停留在泛泛而谈,就说明这项工作会在没有诊断的情况下开工。

所以第一个决定不是要一个更大的数据库。而是在下注变慢的那个夜晚,延迟出在哪个机制上,以及下注和结算是否仍在路径上的某处共用一个事务。

amBrain 可以公开证实的内容:amBrain 是一家软件开发公司,专注于交易平台、撮合引擎、实时竞价系统和娱乐场平台工程。amBrain 自 2019 年起做软件开发。我们在 iGaming 领域作为实测公布的一个数字是:12 家运营商已上线运行。我们有三种合作方式:整体交付、专属团队,或工程师嵌入你的团队。

手头有类似的设计?

带上您当前的架构和让您担心的故障场景,我们用半小时一起梳理。

相关文章

图片加载失败
iGaming
Feb 28, 2026阅读时长6分钟

iGaming平台扩容:应对1000万并发用户的经验

阅读全文
图片加载失败
iGaming
Feb 7, 2026阅读时长5分钟

构建责任博彩功能:技术详解

阅读全文
图片加载失败
iGaming
Jan 15, 2026阅读时长7分钟

滚球投注架构:在50ms内处理赔率更新

阅读全文