对局结算不是一次 HTTP:结果可重投,副作用只能发生一次
一局十人对战已经结束。客户端收到胜负画面,战斗节点也算出了双方战绩,接下来只差把结果 POST 给大厅。就在大厅写入 Elo 时,数据库连接断开;或者事务其实已经提交,只是 HTTP 响应在回程中丢了;又或者战斗进程在收到响应前被重启。此时最危险的问题不是“请求失败了怎么办”,而是系统根本不知道请求失败在提交之前还是之后。
如果战斗节点把一次 POST 当成结算,它可能在超时后丢掉唯一结果。如果它盲目重试,而大厅每次都重新加减 Elo,同一局又会变成两局。只增加三个重试、换一条消息队列,或者给接口套一个通用幂等中间件,都没有回答真正的问题:谁拥有尚未确认的结果,谁拥有已经提交的终态,哪一个持久事实允许上游删除数据?
本文依据一个真实多人对战项目的当前实现讨论这条边界。Rust 战斗节点生成 BattleResult,先写本地持久 outbox,再向 Go 大厅投递;大厅以 room_id 串行结算,在一个 PostgreSQL 事务内写战绩、玩家明细、Elo、回放、结算事件与 durable receipt,事务成功后才清理房间。写作当天重新运行了 Rust outbox 两项聚焦测试,以及 Go 的 store、lobby、api 三个包测试,均通过。测试覆盖失败保留、重启扫描、坏文件隔离、重复结算和结果类型冲突;它证明当前契约在这些边界上成立,不等于已经完成跨机房灾难恢复或长期容量验证。

两个所有者
对局结束并不产生一个全系统共享的“结算完成”布尔值。它至少产生两种不同事实。
第一种事实属于 Battle:这间房已经终止,胜负与战绩候选已经形成,结果需要被交付。当前房间状态机在 finish 入口先检查 Finished,重复进入立即返回;第一次进入才设置终态、向客户端广播 BattleEnd、写回放并发送 BattleResult。Battle 可以决定对局内的胜负报告是否形成多数,也可以在首份报告十秒后以相对多数兜底并标记 suspect,但它不拥有玩家 Elo 和数据库中的历史战绩。
第二种事实属于 Lobby 与数据库:某个 room_id 的结果已经以 settled 或 aborted 终态提交,相关副作用不会再发生。大厅掌握成局时持久化的玩家名单,因此能够计算 Elo、写 matches 与 match_players、解除玩家的 in_battle,并推送结算事件。Battle 发出结果不能替代这个事实,HTTP 客户端读到响应更不能替代数据库提交。
系统边界由此变得清楚:HTTP 只负责搬运结果,不负责证明全局唯一性。Battle 的持久 outbox 表示“我仍欠下游一个结果”;Lobby 的 durable receipt 表示“这个结果已经获得终态”。两边分别持有自己的真相,跨边界只交换可重复消息,不共享内存状态,也不要求分布式事务。
room_id 在这里不是方便查询的普通字段,而是贯穿房间创建、结果交付和终态提交的业务身份。只有它在一次对局生命周期内稳定且不复用,outbox 文件、事务锁、结算事件和 receipt 才能指向同一个事实。若节点重启后重新分配旧编号,或者把结束时间当作唯一幂等键,重投就可能命中新局或绕过去重。因此,房间 ID 的全局唯一性是这套协议的前置条件,不应隐藏在数据库自增实现里。
这形成三条不能破坏的不变量:没有 durable receipt,Battle 就不能把结果当作已完成;同一 room_id 无论投递多少次,最多产生一次战绩和一次 Elo 变化;数据库事务失败时,大厅不得先清理房间或 in_battle,否则重试到达时将失去结算上下文。
正常时序
正常路径看起来比“一次 POST”更长,但每一步都在缩小不确定性。
房间先根据在线且未被踢出的同步座位收集结果报告。相同 winner_team 的票数严格超过符合资格座位的一半,结算候选才成立;平局使用独立的胜方编号。随后房间进入 Finished,保留三十秒用于向仍在线的客户端发送终局消息。回放写入可以失败,失败时结果中的路径为空并记录警告,但结果仍继续生成;这说明回放制品与战绩交付是两个故障域,不能让附属制品阻断核心终态。
结果进入容量为 256 的内存通道后,outbox 任务先创建目录,再把 JSON 写入以点号开头的临时文件,执行文件 sync_all,关闭文件,原子改名为 {room_id}-{ended_at}.json,最后同步目录。只有完成这些步骤,目录中才出现可扫描的正式记录。若同名文件已经存在,持久化直接成功,避免同一结果覆盖或复制。单条记录硬上限为 1 MiB,超过上限不会偷偷截断。
投递器扫描全部 .json 文件并排序,逐条 POST 到大厅。单次 HTTP 最多等待五秒,只有 2xx 才删除文件;连接错误、超时、非 2xx 都保留原文件。失败退避从一秒倍增到三十秒。进程重启不需要恢复任何内存游标,只要再次扫描目录,就能从持久事实继续。
大厅收到结果后,不立刻清理玩家状态。它先用进程内 settling[room_id] 阻止同一实例并发处理,再进入数据库事务,以 pg_advisory_xact_lock(room_id) 把跨协程、跨实例的同房结算串行化。在同一事务中,它查询 receipt,写 matches、match_players、玩家 Elo、回放记录、唯一的 settlement_events,写入 settled receipt,并删除持久化的 battle_rooms。事务提交成功后,服务层才清除内存房间和玩家的 in_battle,再推送 battle.settled。

失败窗口
可靠性设计要逐个关闭提交边界,而不是笼统地说“失败就重试”。这条链路至少有五个不同窗口。
结果刚进入内存通道,尚未落盘时 Battle 崩溃,结果仍可能丢失。当前进程的关停路径会等待 outbox 任务,内存通道满时也会通过等待持久化形成背压,但进程被强杀、机器掉电发生在正式文件出现之前,依然没有可恢复记录。这是当前实现的真实上限:outbox 把网络和下游故障转成可恢复,不把结果生成到落盘之间变成零风险。
临时文件写到一半时崩溃,正式目录不会出现半条 JSON;重启扫描只认 .json,临时文件不会被投递。反过来,目录里若出现畸形 JSON 或超过 1 MiB 的正式文件,投递器会把它改名为 .bad 隔离,而不是让一条毒消息永久挡住其他结果。当天的 Rust 测试实际验证了畸形与超大文件都会被隔离。
POST 返回 503 或连接中断时,正式文件保留。测试先让假服务返回 503,确认 7-20.json 仍在;再模拟进程重启后的扫描,让服务返回 204,确认文件被删除。这验证的是最关键的恢复性质:可恢复性来自磁盘记录,而不是未持久化的重试计数。
更隐蔽的窗口发生在大厅事务已经提交、响应尚未抵达 Battle 时。此时上游必然重投。第二次请求拿到同一个 room_id,数据库事务锁使它不能与第一次交错;已存在的 match 或 receipt 使它返回同一个终态,而不会再次更新玩家。Go 集成测试把同一 NewMatch 调用两次,得到相同 matchID,获胜者仍只增加一场和一次 Elo;随后再把同一房间标为 aborted,得到明确的结果类型冲突。
最后是数据库失败。大厅的服务层只有在 RecordMatch 成功返回后才调用 clearRoom。如果事务回滚,房间名单与 in_battle 仍在,投递器得到非 2xx 后保留文件,下一次请求仍有完整上下文。这一点比“表上有唯一键”更重要:唯一键只能阻止重复行,无法修复过早释放玩家、漏发补偿或丢失房间名单造成的业务残缺。
可复算上限
当前实现没有宣称一个经过压测的每秒结算数,因此不能凭空给出吞吐结论。但可以从代码推出几个可复算的硬约束,用于评审容量和故障恢复窗口。
设 outbox 持久卷可用于结果的安全余量为 (D) 字节,结果平均大小为 (S) 字节,故障期间每秒结束 (R) 局,则填满时间为 T = D / (S × R)。代码只给出了 S ≤ 1,048,576 的硬上限,没有给出生产平均值。例如评审时若采用“10 GiB 安全余量、每条按最坏 1 MiB、每秒 2 局”的保守假设,T = 10,240 / 2 = 5,120 秒,约 85 分钟。这是示例估算,不是项目实测;上线门禁应替换为真实分位大小与峰值结束率。
恢复吞吐同样可算。当前投递是单任务按文件排序串行发送,若一次成功往返平均为 (L) 秒,理论上限近似 1 / L 条每秒;若 backlog 为 (N),最低清空时间约为 N × L,还没计入数据库事务耗时。HTTP 超时是五秒,所以在持续超时的最坏情况下,单文件尝试就可能占五秒;虽然代码会继续处理同批次的其他文件,但串行模型仍会在大积压时暴露恢复延迟。
还要把下游恢复后的新增流量算进去。若故障恢复期间正常产生速率仍为 (R),投递器有效消费速率为 (C),只有 C > R 时积压才会下降,清空时间近似 N / (C - R)。当两者相等,系统表面恢复、积压却永远不消失;当 C < R,增加磁盘只能延后再次填满。这比单看接口成功率更能决定是否需要并行投递。
内存通道容量 256 不是“可丢 256 条”的缓冲,它是磁盘故障时的背压界面。outbox 对某条结果持久化失败会每秒重试并停止消费;通道填满后,房间的 finish 在发送结果处等待。这会延长房间释放,但比静默丢结算更符合所有权约束。运维上必须同时观察 outbox 文件数、最老文件年龄、持久卷余量、结果通道占用和房间 Finished 停留,而不能只看 HTTP 错误率。
错误替代
一个常见替代方案是“数据库给 matches.room_id 加唯一索引,然后 HTTP 失败就重试”。它只能保证主表不重复,不能保证玩家 Elo、明细、回放、房间删除和 receipt 同时完成。如果这些动作分散在多个事务或事务外,第一次请求可能写完 match、尚未更新 Elo 就失败;第二次请求看到唯一冲突便返回成功,系统留下永久半结算。真正的原子单位必须覆盖全部业务副作用与终态回执。
另一个方案是“收到结果先清 in_battle,避免玩家卡住,再异步补写数据库”。这把短暂数据库故障变成状态所有权丢失。玩家可以重新匹配,旧局重投却找不到原名单;即使后来补写战绩,也可能与新局的状态和通知交错。释放玩家是结算成功的副作用,不是缓解故障的前置动作。若产品需要在长故障中允许玩家继续,应该引入明确的 settlement_pending 状态与补偿规则,而不是把未结算伪装成空闲。
“换成消息队列”也不是答案本身。队列可以提升跨节点持久性、消费并行度和积压可观测性,却仍通常提供至少一次投递。消费者若没有 room_id 级事务锁、唯一结算事件和 durable receipt,重复消息照样重复加 Elo。相反,在当前单区域、有限节点规模下,本地持久 outbox 加幂等事务已经形成完整语义,是否引入队列应由恢复吞吐、跨机容灾与运维成本触发,而不是由“可靠系统应该有 MQ”的审美触发。
演进门槛
当前方案适合结果量受控、Battle 节点拥有持久卷、Lobby 位于同一受控网络的阶段。它的优点是故障面小:一份可读 JSON、一套目录语义、一个数据库事务,没有额外集群需要维护。运行手册明确要求每个节点使用独立 outbox 目录并放在持久磁盘;这不是部署细节,而是交付契约的一部分。
出现四类信号时,应进入下一阶段。其一,最老 outbox 年龄持续超过业务允许的结算延迟,或按真实 (D/(S×R)) 算出的磁盘时间小于控制面恢复目标;其二,单目录扫描和串行 HTTP 使恢复速度长期低于结果产生速度;其三,需要承受节点与持久卷同时损坏,或者进行跨可用区、跨区域恢复;其四,运营要求能枚举“应该产生结果的全部房间”,并证明漏结算为零。
演进可以先保持业务语义不变:按哈希分片 outbox、限制并发投递、为每个 room_id 保序,并继续以数据库 receipt 作为删除依据。再往后可把本地文件替换为复制日志或消息系统,但消息键仍是房间终态标识,消费者事务仍要包含结算事件和 receipt。最后建立 expected-result 对账:从所有进入 ACTIVE 或 FINISHING 的 allocation 生成期望集合,与 receipt、结算事件和待投 outbox 做集合差,而不是用“队列现在为空”推断没有漏单。
架构评审的明确判断是:Battle 可以重算或重投结果,但不能拥有玩家资产终态;Lobby 可以提交终态,但不能要求 Battle 相信一次网络响应。只要系统把“可重复的交付”与“只能发生一次的副作用”分别落在两个持久事实中,超时、重启和重复请求就只是正常时序的分支。反之,无论接口写得多优雅,只要删除结果的依据仍是一瞬间的 HTTP 成功,对局结算就仍然是一场无法复盘的赌博。