按用户分片 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。
路由:两个分层
Section titled “路由:两个分层”每个 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 是带版本校验的 CAS(WHERE 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。
dehydration 握手
Section titled “dehydration 握手”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 写负载与订单转发路径隔离开。
hydration 延迟
Section titled “hydration 延迟”一个冷用户的首单到不了常驻 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 内容。
跨到撮合引擎的信用预留 saga
Section titled “跨到撮合引擎的信用预留 saga”以上一切都把 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 计价,如 BinancequoteOrderQty/ OKXtgtCcy=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 必须:
- 接受调用方提供的确定性
orderId(例如omsClusterId:userId:userSeqNo——可在重放时推导, 绝非随机 UUID),并在该订单生命周期内对它去重。 - 让接受/成交状态可按该 id 幂等重查——重新提交一个已被接受的
orderId返回既有订单,而不是 第二笔。 - 对每一个状态转换发出恰好一次、有序的生命周期事件——包括 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 后面。
对账:资金泄漏的兜底
Section titled “对账:资金泄漏的兜底”事件流在数月尺度上一定会丢一条消息,而一个卡住的预留永不自愈。一个周期性、有上限、确定性输入的 对账 sweep(驱逐 sweep 的孪生)拿 ME 的按用户 open-orders 快照(契约 #3)比对 OMS 的 index 与 预留,并修复漂移:关闭幽灵挂单、释放泄漏的预留、标记不可能状态。
分层变更与扩缩容
Section titled “分层变更与扩缩容”因为状态住在 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 与滚动升级。
- 每用户单写者 —— 任一时刻至多常驻在一个 cluster。其他一切都是为守护它而存在。
- 状态机内无外部 I/O —— DB、时钟、RNG、网络都活在 consensus 之外;结果只作为 logged 消息重新 进入。
- DB 是 system of record;actor 是写回缓存 —— 资金在 U 被丢弃前先刷出(带版本校验的 CAS),
且 U 只在
FlushComplete时才被丢弃。 - cluster 是路由权威 —— gateway 是可纠正的缓存;cluster NAK 掉它不拥有的用户的订单。
- 一份原子带版本的路由配置 —— 成员列表与鲸鱼覆盖项在一个 epoch 下一起移动。
- 每分片确定性 —— 驱逐用 consensus 时钟;hydration 与驱逐是 logged、有序的事件,在确定性 log position 上应用。
- 跨两个 cluster 的 exactly-once 订单提交 —— 确定性
orderId+ ME 侧去重;bridge 可自由重新 提交任一在途订单,ME 吸收重复。 - OMS 是 ME 的 client,绝不在 ME 内部 —— handler 把订单作为 egress 发出;一个 leader-only 的
ME-bridge agent 是唯一的 ME client。handler 里没有
offer()、没有isLeader分支。 - 先扣款、扣得精确、退还余量 —— 资金在订单发往 ME 之前于本地扣除(精确,因为 market buy 以 quote 计价);成交按真实价结算,未用的冻结资金被释放。一个丢失的释放/结算是资金 bug——由对账 兜底。
- 「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 Cluster、 cluster standby 与 HA 设计。
本站与 Adaptive Financial Consulting Limited 或 Aeron 项目无任何关联,未获其背书或赞助。 Aeron 是 Adaptive Financial Consulting Limited 的注册商标。
Aeron 是 Adaptive Financial Consulting Limited 在英国及其他国家/地区的商标。