跳转到内容

按用户分片 OMS:带驱逐的热用户 actor

actor 模式分片撮合引擎订单簿(symbol) 分片——那是撮合唯一能被切分的一致性单元。本页讲的是另一层:置于按-symbol 撮合引擎之前按用户的 账户 / 风险 / 会话状态。这一层可以按用户分片,而且因为用户停止交易后其状态会转冷, 它天然适合做可驱逐的 actor——用户首单时 hydrate,转静默时 dehydrate。

整个设计是一个想法被贯彻到底:user-actor 是一个建立在持久 DB 之上的写回缓存(write-back cache), 运行在一个必须始终是确定性状态机的 Aeron Cluster 内。下面每个决策都从同时握住这两个事实中推导 而来。

user-actor 拥有什么——以及它为何是缓存

Section titled “user-actor 拥有什么——以及它为何是缓存”

一个 user-actor 拥有接纳一笔订单所需的可重建的、按用户的状态:会话与序列号、限频计数器、 open-orders index(该用户哪些订单在挂单、挂在哪个 symbol/分片上),以及从 DB 缓存来的风险/买力。

数据库是 system of record;actor 是热缓存。 用户首单时 actor 被 hydrate(从 DB 读状态); 用户转冷时它被 dehydrate(状态刷回 DB,然后从内存丢弃)。这就是 Akka cluster-sharding 的 「passivation + persistence」形态,只是多了一条重塑一切的硬约束:它跑在一台确定性 replicated 状态机 上。

不可妥协的一条:I/O 活在 consensus 之外

Section titled “不可妥协的一条:I/O 活在 consensus 之外”

Aeron Cluster 的价值在于 service 是一台确定性状态机——每个节点重放同一条 log,且必须到达相同 状态,handler 内没有 DB 调用、没有墙钟、没有 RNG、没有网络。在 clustered service SELECT … FROM customer 会:

  • 让每个 follower 都去踩踏 DB(它们都重放同一条消息),并且
  • 崩溃重放时,对一行此后已改变的数据重新发起读取 → 节点分叉,并且
  • 阻塞那唯一的 duty-cycle 线程,卡在一个用户的 I/O 后面,拖住该分片上的每一个其他用户。

所以所有 DB I/O 都发生在状态机之外,结果作为 logged 消息重新进入:

  • Hydration: gateway / persistence 层读 DB,并把一条 HydrateUser{userId, state, epoch} 消息 注入到 log 上、排在订单之前。cluster 只会看到已物化的、确定性的状态。
  • Flush: cluster 把 BeginDehydrate{userId, state, epoch} 作为 egress 发出;一个外部 writer 做(带版本校验的)持久化,并把 FlushComplete{userId, epoch} 作为 ingress 送回。

分片请求 I/O;这一层执行 I/O。cluster 从不碰 DB。基础见 确定性状态机与 Raft

每个 gateway 必须把某用户的所有订单发往那一个持有(或将持有)该用户的 cluster——单写者不变量。 两个分层:

  • 鲸鱼——钉住(pinned)。 每个 gateway 都携带一张精心维护的 whaleId → cluster 映射,有意打散, 使两个大户不会撞在一起。重分片 = 移动一个条目(见分层变更与扩缩容)。
  • 普通用户——rendezvous(HRW)哈希。带 epoch 版本的 cluster 成员列表上做 HRW(userId, membershipList)。没有按用户的目录:每个 gateway 算出相同的答案。

为什么用 HRW,而不是 hash % N 朴素取模会在你增删一个 cluster 的瞬间重映射 ~(N-1)/N 的所有 用户——一次大规模脑裂,因为每个被重映射的用户仍常驻在它的旧 cluster,而新订单已哈希到新 cluster。 HRW 把 churn 界定在 ~1/N,并且让移动集合可确定性枚举,所以一次扩缩容事件可以只迁移那些用户。

cluster 才是路由权威。 gateway 的路由是一个可纠正的提示:cluster NAK 掉它不拥有的用户的 订单,而 NAK 告诉 gateway 去重新路由。hydration 与 flush 携带每用户的单调 fencing epoch, 且 DB flush 是带版本校验的 CASWHERE updated_at = expected)——所以一个基于陈旧路由行事的 被分区 gateway,或一次与更新写入竞争的慢 flush,都无法破坏状态。

判定一个用户「转冷」——确定性地

Section titled “判定一个用户「转冷」——确定性地”

你要的是一个 keep-alive TTL:静默超过 T 且无新订单 → dehydrate。但状态机里禁止读墙钟、禁止 自由运行的本地定时器——follower 会读到与 leader 不同的时钟,而崩溃重放会拿今天的时钟去比上周的 log。唯一合法的时钟是 cluster 的 replicated(consensus)时间,作为一条 logged 定时器消息在 一个确定性的 log position 送达。

低延迟的结构是一个 LRU + TTL 混合体

  • 每单: O(1)——把 actor 拼接到一条 intrusive recency list 的 MRU 端,并盖上 lastActiveClusterTime 戳。(没有每用户定时器要重排——那会在每一单上给热路径上税,对你最忙的 用户最糟。)
  • 周期性 sweep(跑在 consensus 时间上): 只走冷的尾部,在第一个未过期的 actor 处停下—— 所以 sweep 是 O(过期数),不是 O(常驻数),绝不会在 duty-cycle 线程上造成 O(N) 抖动尖峰。 每次 sweep 给驱逐数设上限,使一次大规模过期不会卡住撮合。
  • 容量上限的安全阀: 若常驻数触到内存上限,就立即驱逐 LRU 尾部、先于 TTL——防止冷用户洪峰把 分片 OOM。

BeginDehydrate 只是标记 actor——它的状态仍在 RAM 里。flush 由一个同机部署的 persistence agent 在线程外完成;U 只在 FlushComplete 从 log 被消费时(每个节点上的一个确定性 position) 才从常驻表里丢弃。

  • flush 窗口内到来订单 → cancel-on-activity。 因为 U 的状态仍在内存里,中止驱逐并从活着的 actor 零代价服务——无需读 DB、无需 park。actor 提升它的 epoch;一个陈旧的在途 FlushComplete 随后被当作过期丢弃。这是窗口内那笔订单的最低延迟结局。
  • flush 中途崩溃是安全的。 U 只在 FlushComplete 时才被移除,所以崩溃只会让 U 在恢复后仍然 常驻,稍后再被驱逐。没有丢写。
  • persistence agent 同机部署,而非放在 gateway 侧。 本地 Aeron IPC 胜过一次 gateway 往返,且它 把 DB 写负载与订单转发路径隔离开

一个冷用户的首单到不了常驻 actor——但你不想让它阻塞分片:

  • gateway hydrate-ahead: 它已经路由了订单、知道 U,于是它先发起 DB 读,并把 HydrateUser 注入到订单之前——没有 shard→层→shard 的往返。
  • single-flight / 负缓存 把某用户首单的一阵突发合并为一次 DB 读。
  • 订单被 park,并在 HydrateUser 落地后于一个确定性 log position 应用——冷单只付一次异步 DB 读,而分片上其他每一单继续流动。

重启时分片通过重放它的 log + 最近 snapshot 恢复,而 snapshot 包含常驻 actor。常驻集合是 log 的确定性函数(HydrateUser/FlushComplete 都在 log 上),所以恢复以零 DB 读重建出完全一致的 已提交常驻集——没有重启后踩踏——而已提交但未刷(「脏」)的缓存状态存活在 snapshot 里,而非丢失。 DB 只在冷用户真正 cache miss 时才被查询。

snapshot 还必须包含在途 saga(已发往 ME、尚无结果的订单),这与驱逐-flush 不同。驱逐-flush (见上)只序列化状态的静止子集,因为驱逐只在用户无在途 saga 时才发生(三条件「flat」)。 崩溃-snapshot 序列化完整的 actor(含 saga),因为崩溃可能发生在 saga 中途——而 failover 时 ME-bridge 会从这些常驻的在途 saga 重建它的工作队列snapshot 内容 ≠ flush 内容。

以上一切都把 user-shard 当作独居来处理。它并非如此:一笔订单诞生在 user-shard(买力所在),却 撮合在一个不同的 cluster——按-symbol 撮合引擎。因此「把订单转发给 ME」是一个跨两个独立 Raft cluster 的分布式事务,也是真正的热路径。全系统的延迟与大部分正确性都住在这里,而不在 LRU 拼接上。

OMS 是 ME 的 client——但 clustered service 不能是

Section titled “OMS 是 ME 的 client——但 clustered service 不能是”

那条禁止 handler 内做 DB I/O 的规则(见上)同样禁止 ME client 在那里。OMS handler 跑在全部 3–5 个 节点上;如果 handler 直接调用 meClient.offer(order),ME 会收到该订单 3–5 次,崩溃重放会重新提交 历史订单,而 offer() 的背压会阻塞 duty-cycle 线程。if (isLeader) offer() 分支同样是错的——它把 一个按节点身份的分支塞进了确定性状态机,且在重放时仍会重新提交。

所以订单通过一个 leader-only 的 ME-bridge agent——persistence agent 的 egress 孪生离开。handler 发出一条确定性的 SubmitToME egress 消息(每个节点上完全相同);Aeron 只从 leader 投递 egress, 所以跑在 leader 侧的 bridge 是唯一的 ME client——leader-only 提交,且业务逻辑里没有 isLeader 分支。ME 的回复作为一条 MEResult ingress 消息重新进入。(路由很简单:订单指明它的交易对,而 symbol → ME 是静态配置。)

先扣款再发送,配以即时的临时回执

Section titled “先扣款再发送,配以即时的临时回执”

为了把 ME 往返移出客户端的关键路径,user-shard 在本地扣款并即刻回执,然后异步结算:

  • 扣款金额永远是精确的。 limit buy = price × qty + fee;limit sell = base 数量;market buy = 花费金额(以 quote 计价,如 Binance quoteOrderQty / OKX tgtCcy=quote_ccy)——你锁定的是 花费,而非未知的到手量;market sell = base 数量。买单侧没有最坏情况的带宽预留,也没有退还余额的 路径。
  • 「已接受」是临时的。 ME 仍可能拒绝(post-only 会穿价、价格带/最小变动单位、自成交防护、 订单簿停牌),产生一次异步拒绝 + 退款。UI 必须建模 pending → open | rejected。这是把 ME 链条 移出客户端关键路径所要付出的、有意的代价——一个产品/UX 决策,而不只是架构决策。
  • 预留 / 结算 / 释放是四个 logged、幂等的转换。 available = total − Σ reservations;按真实 成交价结算已成交腿;在取消/拒绝/过期时、以及部分成交的未成交余量上做释放。一个丢失的结算/释放 事件是一个资金 bug——由对账(见下)兜底。

ME 是协议参与方,而非不可触碰的黑盒

Section titled “ME 是协议参与方,而非不可触碰的黑盒”

这是承重的依赖:跨两个各自独立会故障的 cluster 做到恰好一次(exactly-once)的订单处理,没有一份 加在 ME 上的契约就不可能。「OMS 只是个 client,ME 原封不动」是错的。ME 必须

  1. 接受调用方提供的确定性 orderId(例如 omsClusterId:userId:userSeqNo——可在重放时推导, 绝非随机 UUID),并在该订单生命周期内对它去重
  2. 让接受/成交状态可按该 id 幂等重查——重新提交一个已被接受的 orderId 返回既有订单,而不是 第二笔。
  3. 每一个状态转换发出恰好一次、有序的生命周期事件——包括 ME 自身的自主过期/取消(GTD/GTT 过期、kill-switch、停牌)——并暴露一个按用户的 open-orders 快照用于对账。

各自为何重要:(1)+(2) 让 bridge 在 OMS-leader failover 时重新提交整个在途集合而不冒重复成交的 风险(去重键 + 安全重试——教科书式的 exactly-once 构造)。(3) 让 OMS 的 open-orders index——ME 状态 的一份副本——保持为真:一个丢失的 ME 侧过期否则会留下一个幽灵挂单,于是用户永远不「flat」、 永远不被驱逐(击穿整个冷驱逐前提),而它背后的预留会永久泄漏(冻结的资金)。

背压、failover,以及 bridge 的持久工作队列

Section titled “背压、failover,以及 bridge 的持久工作队列”

bridge 的 offer() 非阻塞且可能失败(被背压、ME 未连接)。bridge 不是 source of truth——在途 (PENDING_RESERVE)订单的常驻集合才是,且它可从 log + snapshot 重建。所以:

  • PENDING_RESERVE 就是 bridge 的持久工作队列。 它内存里的重试队列只是缓存;它真正的职责是 「把每一笔在途订单驱动到 ME,直到它的 MEResult 返回」。在 bridge 崩溃 OMS-leader failover 时,从状态重建并幂等地重新驱动每一笔待处理订单——ME 的去重(契约 #1)让无限重试是安全的。 failover 后的一个 PENDING_RESERVE 无法区分「从未到达 ME」与「已经在那里」;重新提交、让 ME 去重 即可解决。
  • 上游背压。 当在途计数越过阈值(ME 慢或宕)时,OMS 停止接受新订单(在 ingress 处 NAK / 告知 gateway),而不是为它无法投递的订单给用户扣款——那会把资金锁在一个不可达的 ME 后面。

事件流在数月尺度上一定会丢一条消息,而一个卡住的预留永不自愈。一个周期性、有上限、确定性输入的 对账 sweep(驱逐 sweep 的孪生)拿 ME 的按用户 open-orders 快照(契约 #3)比对 OMS 的 index 与 预留,并修复漂移:关闭幽灵挂单、释放泄漏的预留、标记不可能状态。

因为状态住在 DB,任何迁移都不在 cluster 之间搬运状态。 每一次迁移——分层变更或扩缩容事件——都是 同一个带 fence 的原语:

静默 U → 刷到 DB → 从源驱逐 → 原子地翻转路由(一个 epoch)→ U 在目标上于下一单冷 hydrate。

  • 分层变更(普通 ↔ 鲸鱼) 是一次管理员操作,从不按量自动触发。管理员提交时,为那一个用户 实时跑带 fence 的迁移;把接连的管理员改动**合并(coalesce)**成一次 epoch 提升。这是一次罕见的 单用户操作——分批只会把冲击集中成一个尖峰。
  • 扩缩容 会一次性改变 ~1/N 普通用户的 HRW 归属——本质上是一个批量,所以杠杆是把它去集中化。 新 cluster 以 receiving 状态加入(在它转 active 前 HRW 不会把新的冷用户路由给它,以控制 灌入速率);一个 controller 以受限并发的波次迁移受影响用户,每波带 fence;空闲的受影响用户 惰性迁移(它们的下一单在新 epoch 下重新路由并在新拥有者上冷 hydrate),所以只有常驻的受影响 用户被主动排空。这以更长的墙钟时间(无人感知)换取平坦的热路径画像(无全群 p99 尖峰)。
  • 一份原子带版本的路由配置 + epoch。 成员列表与鲸鱼覆盖项一起分发,所以一个 gateway 永远完整 处于 epoch N 或 N+1——绝不半途,否则会让一个用户双重常驻。

关于每波所依赖的单 cluster 机制,见扩展 Aeron Cluster滚动升级

  1. 每用户单写者 —— 任一时刻至多常驻在一个 cluster。其他一切都是为守护它而存在。
  2. 状态机内无外部 I/O —— DB、时钟、RNG、网络都活在 consensus 之外;结果只作为 logged 消息重新 进入。
  3. DB 是 system of record;actor 是写回缓存 —— 资金在 U 被丢弃前先刷出(带版本校验的 CAS), 且 U 只在 FlushComplete 时才被丢弃。
  4. cluster 是路由权威 —— gateway 是可纠正的缓存;cluster NAK 掉它不拥有的用户的订单。
  5. 一份原子带版本的路由配置 —— 成员列表与鲸鱼覆盖项在一个 epoch 下一起移动。
  6. 每分片确定性 —— 驱逐用 consensus 时钟;hydration 与驱逐是 logged、有序的事件,在确定性 log position 上应用。
  7. 跨两个 cluster 的 exactly-once 订单提交 —— 确定性 orderId + ME 侧去重;bridge 可自由重新 提交任一在途订单,ME 吸收重复。
  8. OMS 是 ME 的 client,绝不在 ME 内部 —— handler 把订单作为 egress 发出;一个 leader-only 的 ME-bridge agent 是唯一的 ME client。handler 里没有 offer()、没有 isLeader 分支。
  9. 先扣款、扣得精确、退还余量 —— 资金在订单发往 ME 之前于本地扣除(精确,因为 market buy 以 quote 计价);成交按真实价结算,未用的冻结资金被释放。一个丢失的释放/结算是资金 bug——由对账 兜底。
  10. 「flat」= 无挂单 且 无在途 saga 且 无未了结预留 —— 驱逐要求三者皆满足。
  • 在 clustered handler 内做 DB I/O —— follower 踩踏、重放分叉、duty cycle 被阻塞。
  • 在物理 cluster 上做 hash % N —— 每次拓扑变更都是一次大规模迁移。
  • handler 内用墙钟 TTL 或本地定时器 —— 非确定性;follower 与重放会分叉。
  • 驱逐一个有活跃挂单的用户 —— 一笔成交随后落到一个不存在的 actor 上(或者你「修」它时撤单, 反而抽走流动性)。
  • 两份各自带版本的路由映射 —— gateway 看到半途应用的状态,把一个用户双重常驻。
  • 按单选最闲的路由 —— 会话中途重新放置一个常驻用户 → 双重 hydrate → 双花。负载可以挑初始 放置;此后必须黏住。
  • 从 handler 调用 ME client(或 if (isLeader) offer())—— 跨节点重复提交、重放时重新提交、 阻塞 duty cycle。发出 egress;让 leader-only bridge 去提交。
  • 把 ME 当作不可触碰 —— 没有调用方键控的去重 + 生命周期事件 + 对账快照,你无法跨两个 cluster 做到 exactly-once,且预留会在事件丢失时泄漏。
  • 驱逐一个资金已押在市场上的用户 —— 一个在途 saga 或未了结预留意味着用户不是 flat;驱逐会 丢掉一个即将被 MEResult 命中的 actor。
  • 在 ME 确认前给订单发终态回执 —— ME 仍可能拒绝;「已接受」必须是临时的,配以异步拒绝 + 退款 路径。

相关:actor 模式(symbol)分片cluster 与 Raft 概览扩展 Aeron Clustercluster standby 与 HA 设计