从做早餐悟出的低延迟系统设计之道
做早餐 与 低延迟 设计之道
本文是低延迟交易系统设计的硬核技术分享,从生活案例切入,深入浅出地讲解了决策点前移、编译期计算、增量计算等核心优化思想,并附有代码示例。适合量化交易开发者、系统架构师研究学习,可据此优化自己的交易系统。
我从做早餐中悟出的低延迟系统设计之道。
每天清晨给孩子做一顿热气腾腾的早餐,是一件幸福却伴随着早起痛苦的事。
很长一段时间里,我就是这么"痛并快乐着"地过来的。直到有一天,我在反思这个流程时,突然意识到一个被"线性思维"遮蔽的事实:
为什么早餐必须在早上从头做?
一旦打破这个思维定式,优化路径就清晰了——把所有准备工作的"时间点"前移。前一晚把食材洗净切好,靠电饭煲或空气炸锅的预约功能定时启动。第二天孩子起床洗漱完毕,热乎乎的早餐刚好出锅。
这个不起眼的改动,让我每天多了 30 分钟睡眠。
它的核心,是把一个原本线性的过程一刀切成两段:
- • 固定部分提前做:食材的清洗、切配——这些事的答案,前一晚就已经确定了。
- • 灵活部分按需做:加热的火候、每个人不同的口味——这些事必须等客户提需求时才能定。
- 这种"两段式"思维,在现代工业里其实早就运用到极致:汽车业先造标准化底盘和发动机,最后按订单组装内饰;连锁餐饮中央厨房完成九成预制,门店只做最后一成加热装盘。它们的底层逻辑是同一句:
剥离确定性工作,压降最终交付的"响应延迟"。
同样的思想,也可以落地到低延迟系统设计的全流程中去。
加速订单流水线
把一笔订单的处理也看成一条极速流水线,"做早餐"的智慧立刻适用。低延迟交易的口诀,一句话讲透:
尽可能把计算与访存前置,以空间换时间。
那些在盘中被反复使用的"数据状态"和"确定性逻辑",绝不能等行情到来的瞬间再去现找、现算。它们必须长期驻留,时刻处于子弹上膛的待发状态。
其实,它们背后共享一个更锋利的思维工具 —— 决策点 。
每一件要做的事,都有一个"最早可以被完成的时刻"。优化,就是不断追问:这件事,最早能在什么时候ready?
然后,把它尽量往前挪。
你会发现,它们之间的差别仅仅在于——决策点被前移到了哪一刻。能挪到编译期的,绝不留到盘前;能挪到盘前的,绝不留到盘中;只有那些"客户开口才能定"的事,才留在最后。
带着这把尺子,我们看代码。
决策点前移
为了让你一眼看懂每段代码的价值,我都会先给出笨办法(决策点靠后,慢),再给出聪明办法(决策点前移,快),并标注它们各自把决策点挪到了哪里。
贯穿全文的是一个统一的领域模型:一笔订单 Order,要找到它对应的持仓 Position,校验客户权限、算保证金、做风控。
图模型加速订单处理
笨办法(收到订单后的处理,如决策点在盘中,每次都找):
// 每笔订单都要现查三张表auto* client = clientTable_.find(order.clientId); // 查一次auto* contract = contractTable_.find(order.contractId); // 再查一次auto* member = memberTable_.find(order.memberId); // 再查一次// 三次 hash、三次内存跳转,全压在热路径上聪明办法(决策点前移到首次访问):
以"会员 + 客户 + 合约"三元组定位唯一的持仓节点,把上面三张表的数据挂载到这个持仓记录的节点上。首次访问时建好关联,之后所有订单复用。
// 持仓节点:struct alignas(64) Position { PositionKey key; // member + client + contract const ClientAuth* auth; // 客户权限(直读,不再查表) const ContractSpec* spec; // 合约规格:乘数 / 保证金率 / tick const RiskParam* risk; // 会员级风控阈值 int64_t longVol, shortVol; // 净头寸(基准态,见技巧 4) int64_t margin; // 已占用保证金(基准态)};class PositionBook { ankerl::unordered_dense::map<uint64_t, Position*> idx_;public: Position* locate(const Order& o) noexcept { auto it = idx_.find(o.key.pack()); // 整条热路径唯一的查找 if (it != idx_.end()) return it->second; // 99.9%:直接命中 return lazyLoad(o); // 首次:建图,之后无限复用 }};盘中热路径塌缩成这样:
Position* p = book.locate(order); // 一次 hashint64_t need = order.qty * p->spec->multiplier * // 乘数 / 费率都直读 p->spec->marginRateBps / 10000;if (!p->auth->canOpen(order)) reject(order); // 权限也直读启示:三次查找塌缩成一次。决策点从"每笔订单"前移到了"这个持仓第一次出现"。省下的是热路径上的内存跳转和 cache miss。
编译期计算
笨办法(决策点在盘中):
int64_t margin = price * contract->multiplier * contract->marginRate / 10000;// 乘数、费率明明是常量,却每次都从内存里读、每次都重新算聪明办法(决策点前移到编译期):
struct IF2106 { // 合约规格 = 编译期常量 static constexpr int64_t multiplier = 300; static constexpr int64_t marginRateBps = 1200; // 12%};template <typename Contract>constexpr int64_t marginPerLot(int64_t price) noexcept { return price * Contract::multiplier * Contract::marginRateBps / 10000;}// 实例化 marginPerLot<IF2106> 时,乘数和费率退化为立即数// 编译后只剩一条 imul + 一次移位,连内存都不用读启示:能 constexpr 的,绝不留给运行期。决策点前移到编译期的好处是——运行时这件事根本"不存在",它已经被编译器折叠掉了。
增量计算
笨办法(决策点在"被问到时",全量遍历):每次做风控检查,都把该会员名下所有持仓的保证金重新加总一遍——持仓越多越慢。
聪明办法(决策点固定在每次成交,只吃差值):长期维护一个"会员总保证金"的累计数,每笔成交只把这次的增量加上去。读取时直接拿,永不汇总。
每笔成交:会员.总保证金 += 本笔成交的保证金增量读取风控:直接读 会员.总保证金 ← O(1),无需遍历启示:增量计算和图模型是一对孪生子——前者让"找数据"是 O(1),后者让"算汇总"也是 O(1)。热路径上,从此再无 O(N)。
上面讲的还都是"计算"的前移。但同一句口诀,能继续往更底层外推。网络组包、内存管理。。。都是一样的道理。
报文头预编码
笨办法:每发一单,都从头拼一遍"版本号 + 会员号 + 通道号 + 校验码"——可这些一整天都不会变。
聪明办法:盘前编好一个报文模板,盘中只覆盖合约 / 价格 / 数量 / 方向这些动态变化字段。
盘前:填好报文模板 = {版本, 会员, 通道, 校验头} ← 固定字段盘中:复制模板 → 只改 {合约, 价格, 数量, 方向} → 发送和交易所的连接、登录、订阅、心跳通道,这些"重而确定"的活儿,全部在盘前做完,并做 warmup:
- • mlockall 锁定内存页,杜绝盘中缺页;
- • pthread_setaffinity_np 绑核独占,避免上下文切换;
- • 配置 kernel-bypass(OpenOnload / efvi),绕过内核协议栈;
- • 发若干"空跑"包,把网卡、PCIe、内核栈、L1 / L2 缓存全部预热。行情一到就直奔处理函数,不经过任何冷启动。连接建立这件事的决策点,被彻底前移了。
结语
回到做早餐。
那个顿悟之所以值 30 分钟睡眠,是我终于学会问一个问题:
这件事,真的非得在早上做吗?
低延迟交易也一样,其设计的本质,就是在关键路径上坚决不为信息熵为0的数据消耗哪怕1个CPU时钟周期。
要反复训练自己形成如下肌肉记忆的时间观——
遇到任何一段代码、任何一次访存、任何一个分支,先问一句:"它最早能在什么时候完成准备?" 然后把它尽量往前挪。
把能挪到编译期的,挪到编译期;把能挪到盘前的,挪到盘前;把能挪到首次访问的,挪到首次访问。只有那些"客户开口才能定"的事——价格、数量、方向——才留在最后一刻。
点击关注,共同进步
我从做早餐中悟出的低延迟系统设计之道。每天清晨给孩子做一顿热气腾腾的早餐,是一件幸福却伴随着早起痛苦的事。
查看原文 →
更进一步:量化金融体系
看懂新闻只是起点——沿量化金融路径,把它变成能交付的工程能力