一个 Postgres 在重大比赛期间变慢、赛事结束很久之后才结算注单的体育博彩平台,是在让两种负载跑过同一组行:下注,每个请求一次短写入;结算,由一个赛果触发的一波突发。本文讲这两条路径怎么分开、争用从哪里来,以及在结算滞后时余额如何保持正确。
一个在重大比赛期间 Postgres 成为瓶颈的体育博彩平台,通常是一个症状、两个原因。下注和结算恰好在流量达到峰值时争抢同样的行、锁和连接,并且结算是以持有这些行的工作形式运行的,而不是一条能排队等候的队列。
加硬件会抬高出现这种情况的流量水平,但不会消除原因。下文把两条路径分开,定位争用所在,并在结算滞后时保持余额正确。下文引用的 PostgreSQL 行为均出自 18 版文档。
简短的答案是结构性的。下注和结算不再共用事务:下注在一个带幂等键的短事务里写入注单、余额预留和一条 outbox 行;结算以小批次消费赛果事件,这些批次产生的效果同样带键,所以被重投的消息不会挪动任何资金。amBrain 可以公开证实的内容是娱乐场平台工程,我们在这方面作为实测公布的一个数字是:12 家运营商已上线运行。下面的设计来自问题本身的机理,不是来自我们的某个案例,其中没有任何一个数字是在我们自己的系统上测得的。
下注是一个有人在等的请求:读取盘口状态,检查余额,写入一笔注单,回复。结算从一个赛果开始,一次性扇出到受影响盘口上的每一笔未结算注单。一场重大比赛结束时,其他赛事还在进行,所以这波突发会落在正在再次下注的账户的余额行上。
如果两条路径都在各自的事务里写这些行,下注延迟就会变成同一账户上最长那个结算事务的函数。解耦是一组关于锁和时间的承诺:
PostgreSQL 文档讲锁的那一章说,行级锁只阻塞同一行的写入者和加锁者,不阻塞读取者;并且一个请求锁的事务会无限期等待,除非检测到死锁。因此,每个账户一行、每次下注都要更新,这就是一条队列,而且理应如此:这把锁阻止了两笔下注花掉同一笔钱。要紧的是每个持有者持锁多久。
默认隔离级别 Read Committed 让预留保持简单。UPDATE 如果发现某行已被一个并发事务更新,就会等待该事务提交或回滚;如果它已提交,就针对更新后的版本重新求值自己的 WHERE 子句。只在可用余额足够时才扣减金额的条件更新,不会超卖,也不需要 SELECT FOR UPDATE。
用一条语句把某个盘口上所有未结算注单都标记掉的结算,会一直持有这些行锁直到提交,并给每一行留下一个死版本。文档中讲 vacuum 的那一章说,只要其他事务还可能看到旧版本,旧版本就不能被移除,所以一次长时间的结算,或者一个在事务里空闲着的报表,会把整波突发都留在磁盘上。
autovacuum 按设计就是迟到的。PostgreSQL 18 会在自上次 vacuum 以来被更新或删除的行数,超过 autovacuum_vacuum_max_threshold 与(autovacuum_vacuum_threshold 加上 autovacuum_vacuum_scale_factor 乘以行数)两者中较小的那个时,对表执行 vacuum。按默认值 100,000,000、50 和 0.2 计算,一张 5000 万行的注单表要等到大约一千万行被更新或删除。
队列表让这种效应一目了然。在 brandur.org 上 2015 年的一篇文章 Postgres Job Queues & Failure By MVCC 中,一个在任务队列旁边闲置的事务,把锁定一个任务所需的时间从不到 0.01 秒推高到峰值达该水平的 15 倍,因为死掉的任务行暂时还无法被移除。
每个连接都是一个后端进程,文档说调高 max_connections(默认通常是 100)会同时调高按它来确定大小的资源,包括共享内存。与其如此,不如给下注和结算分别配独立的连接池,这样结算积压排队等的是它自己的连接。
副本分担读取要付两项代价。流复制默认是异步的,所以一次提交要经过一小段延迟才在备库上可见。另外,讲热备的那一章说,与来自主库的 vacuum 清理相冲突的备库查询,会在配置的延迟之后被取消;而 hot_standby_feedback 通过推迟主库上的清理来避免这种取消,这可能导致主库上的表膨胀。
从失败倒推着设计下注:客户端超时后重试,而重试必须拿到第一次的结果,而不是创建第二笔注单。
存储按时间分区,工作按盘口划分。讲分区的那一章要求,分区表上的唯一约束必须包含全部分区键列,所以幂等键要么带上分区列,要么放在自己单独的表里。这一章还说,当查询能把分区裁剪到只剩少数几个时,规划器能相当好地处理多达几千个分区;而盘口的数量没有上限,所以按盘口分区会把规划时间压到下注路径上。
outbox 行让事件变得可信。在 Chris Richardson 描述的事务性 outbox 模式中,消息在更新业务实体的那个事务内存入数据库,再由一个单独的进程把它发送出去。同一份描述也点明了代价:中继可能把同一条消息发布不止一次,所以消费者必须是幂等的。
从赛果到达的那一刻起,结算就是一份以等待时长衡量的积压,其中任何东西持有下注要等待的行,都不会超过一个批次的时间:
结算允许迟到。它不允许发生两次。下注两样都不允许,这就是两者不能共用一个事务的原因。
单独一个余额列,描述不了一笔已被接受但尚未结算的注单。每个账户保留两个数字——可用和预留——资金只通过每条都带键的账本分录在两者之间移动:
投递可能重复:outbox 中继可能重新发布;而当通过逻辑解码读取 outbox 时,文档说崩溃之后复制槽可能重发最近的变更。所以要求的是一个只发生一次的效果。每条账本分录都有唯一键,余额更新与插入一起提交,被重投的消息会撞上约束,不挪动任何资金。
高峰时的许多读取位于下注旁边,而不在下注路径之上:未结算注单、历史记录、每个事件之后都会刷新的余额界面。Chris Richardson 对 CQRS 的描述,是由一个视图数据库来响应这类查询,该视图数据库通过订阅拥有数据的那个服务发出的事件保持最新,并把复制延迟和最终一致的视图列为代价。下注的 outbox 已经在发布这些事件了。
在高峰期间采集这些读数,并与下注延迟放在同一时间轴上:
放在一起读,它们就能定位故障。连接池队列在增长而锁等待平稳,指向连接;锁等待随结算批次上升,指向共享的行;两者都不动而死行在攀升,指向最老的那个事务。
问题的后半部分——哪些公司专门做这件事——有一个不需要供应商名单的判别方法。以前拆分过这两条路径的公司,在第一次谈话中会这样做:
只要其中任何一项的回答停留在泛泛而谈,就说明这项工作会在没有诊断的情况下开工。
所以第一个决定不是要一个更大的数据库。而是在下注变慢的那个夜晚,延迟出在哪个机制上,以及下注和结算是否仍在路径上的某处共用一个事务。
amBrain 可以公开证实的内容:amBrain 是一家软件开发公司,专注于交易平台、撮合引擎、实时竞价系统和娱乐场平台工程。amBrain 自 2019 年起做软件开发。我们在 iGaming 领域作为实测公布的一个数字是:12 家运营商已上线运行。我们有三种合作方式:整体交付、专属团队,或工程师嵌入你的团队。
带上您当前的架构和让您担心的故障场景,我们用半小时一起梳理。