简介
经过数月的迭代,Lighter 的端到端 p99 从 280 ms 降至稳定的 55 ms。交易处理 p99 从 20–30 ms 的峰值降至 1 ms 以下。热路径应用时间稳定在 100–250 µs。所有数据均来自主网,且在大规模压力下运行。
规模有多大?2026 年 6 月 5 日,Lighter 单日交易量达到历史峰值 8.11 亿笔,平均每秒处理 9,388 笔交易(TPS),峰值达 20,740 TPS。
Lighter 的排序器使用 Go 语言编写。交易在 Lighter L2 上执行,并附带完整的密码学证明提交至 L1。架构的关键洞察在于:证明过程与执行过程解耦——证明生成异步运行,绝不阻塞交易。这意味着交易者体验到的延迟完全由 Lighter 的执行层和 API 层决定。
Lighter 的 API 层是交易者直接交互的服务。它在内存中维护交易所状态的完整最新视图,并基于该本地状态提供 REST 和 WebSocket 连接。一个关键的 goroutine 接收来自执行引擎的状态更新,并实时将其应用到内存缓存中。下游的所有操作(WebSocket 推送、API 读取、交易模拟)均基于这些缓存进行。
涵盖的关键概念
以下概念概述了本篇技术文档其余部分涵盖的核心主题:
在主网上线前发现问题:一个与生产环境完全相同的负载测试环境,使用合成账户和真实负载,并配备比生产环境更激进的监控工具,在交易者察觉之前捕获瓶颈。
双环境可观测性:负载测试环境承载了生产环境难以承受的高成本指标;主网则实时监控新鲜度、模拟延迟和端到端交易生命周期。两者结合,实现全面覆盖。
千次分配消耗:系统性地将堆密集型类型和深拷贝替换为栈分配替代方案和不可变快照。在带 GC 的语言中,更少的分配意味着更少的暂停,从而降低尾延迟。
操作系统级控制:将关键线程绑定到专用 CPU 核心,并赋予接近实时的优先级。
硬件感知部署:NUMA 感知的内存放置确保热路径享有快速本地内存访问。这是延迟优化栈的最后一环。
二进制优先,便捷退后:在热路径上使用手写二进制序列化,消除反射和分配开销。每节省一微秒,都会在每秒数千次的更新中产生叠加效应。
在主网上线前发现瓶颈
看不见的问题就无法修复。在优化任何内容之前,我们投入大量精力构建工具,以精确定位时间消耗在何处。
负载测试环境
当我们想测试某项功能时,会启动一个专用的负载测试环境:与主网基础设施完全相同的副本,运行相同的服务、相同的配置和相同的部署拓扑。它并非一直运行;我们按需启动用于特定测试,测试完成后即销毁。
该环境启动后,我们会创建合成账户,并生成真实的交易负载,以模拟市场在高压力下的真实状况。这允许我们进行:
- 超细粒度计时:热路径中每一步的耗时、每次缓存构建的持续时间,以及交易生命周期中从提交到确认的每个阶段的时间戳。
- 按需性能分析与飞行记录器:在负载期间,我们可按需捕获 CPU、内存和执行跟踪信息。Go 的飞行记录器提供了始终开启的跟踪收集功能。当检测到慢事件时,最后几秒的执行信息会被自动捕获,使我们能够在事后诊断瞬时的延迟峰值。
- 分布式追踪:我们为热路径中的每个主要函数都添加了追踪跨度,从而能够精细地看到在一个更新周期内,跨服务边界的时间具体消耗在何处。
双环境监控
我们密切监控两个环境(主网和负载测试),但粒度不同。
主网 进行实时严格监控。我们跟踪所有影响交易体验的指标:
- 新鲜度跟踪:我们测量最活跃市场通过
order_bookWebSocket 通道的延迟。本质上,这是订单簿更新产生时刻与客户端收到时刻之间的偏差。这是最直接反映交易者体验的指标:你看到的订单簿有多陈旧?我们在两个节点进行跟踪。最重要的是执行引擎 → 客户端:从执行引擎处理状态变更到最终order_book更新到达客户端的完整管道。我们还测量 API 层 → 客户端:API 层为每个外发更新打上时间戳,客户端将其与自身时钟比较,从而单独得出最后一公里的延迟。 - 模拟运行延迟:API 层在将交易传递给执行引擎之前,会模拟运行每一笔交易(验证签名、nonce 和余额)。每次模拟运行都会被计时并报告。
- 端到端交易生命周期:从提交到确认的完整周期延迟直方图。
- 缓存有效性:从内存提供服务与回退到较慢存储的频率。
负载测试 的监控粒度更细。由于它不服务真实交易者,我们可以不受限制地增加监控强度,而不用担心开销:热路径内每步时间、分配分析、每次缓存突变的额外直方图桶。这种更细的粒度能够捕获主网生产级安全指标无法揭示的微观回归。
主网监控告诉我们系统当前对真实交易者的表现。负载测试监控则在回归进入生产环境之前将其捕获。
深拷贝消除与堆分配之战
Go 的垃圾回收器是一种延迟税。每一次堆分配最终都会成为 GC 暂停,而 GC 暂停在交易引擎上就是交易者的延迟峰值。因此,我们梳理了热路径,尽可能地削减分配。结果是延迟峰值减少,性能更可预测。
深拷贝消除
深拷贝是分配工厂。我们通过以下几种方式解决:
- 不可变快照:使订单簿缓存不可变。读取返回当前快照的指针,无需复制。更新创建新版本,并通过
atomic.Pointer原子地替换。 - 移除不必要的复制:发现某些代码路径深拷贝了从未被后续修改的数据。直接移除这些复制。
分配感知设计
- 栈分配数值:将热路径中堆密集的
big.Int和big.Rat替换为栈分配替代方案(int128、float64、int64除法):价格转换、订单簿深度图、规模计算。关键格式化函数性能提升高达 8.3 倍,每次操作零堆分配。 - 条件更新:当无变化时跳过分配。
- 预分配集合:预先设置数据结构大小,消除增长-复制循环。
- 缓冲区复用:订阅者路径每秒反序列化数千次更新。我们通过基于池的中间缓冲区复用减少了分配。
这稳定了尾延迟。在此之前,Lighter 的端到端 p99(执行引擎到客户端,通过 order_book WebSocket 通道测量)在分配密集型期间会飙升至 200–280 ms。经过深拷贝消除、不可变缓存和堆分配优化后,p99 稳定在约 50–60 ms 的平坦区间,几乎没有峰值:

端到端延迟:执行引擎 -> 客户端(p99)
API 层的 GC 压力也显著下降,但这并非单一改动所致。我们从根本上重构了 API 服务器缓存的工作方式。之前,缓存采用基于 TTL 的过期策略,每次更新都分配新对象。这些短生命周期的分配都变成了 GC 工作。重构后,完整的交易所状态从快照热启动,并作为长寿命的不可变结构保留在内存中,通过原子替换进行更新。这消除了 TTL 抖动和每次更新的分配,GC 压力随之下降。我们密切监控内存使用,工作集是受限的。
我们将旧版 API 服务器和重构后的 API 服务器在主网流量下并行运行。重构后的服务器 GC 暂停持续时间(p75)约为 3 ms,而旧服务器约为 5–6 ms。GC 暂停时间大约减少一半:

GC 暂停持续时间:标准 vs. 快照 API 服务器
频率数据更能说明问题。旧 API 服务器触发 GC 循环的频率是重构后的 约 2.2 倍,这直接反映了更少的短生命周期分配和更低的整体 GC 压力:

GC 循环频率:标准 API 服务器 vs. 快照 API 服务器
执行引擎的 GOGC 调优
我们将类似的 GC 优化思路应用于执行引擎本身。Go 的 GOGC 参数控制垃圾回收器的激进程度。默认设置在 CPU 时间与内存效率之间取得平衡,但对于延迟关键路径,这种权衡是错误的。
调优 GOGC 后,执行引擎的 GC 持续时间从平均约 30 µs(峰值可达 100 µs)降至稳定的约 10 µs 区间。减少了约 3 倍,几乎没有峰值:

GOGC 调优后的执行引擎 GC 持续时间
三个月的线上数据证实了改进的持续性:调优后数据平坦且可预测。
交易处理也因此受益。在涵盖所有交易类型(创建订单、取消、清算、去杠杆、转账等)的 90 天线上数据中,p99 从 GC 调优前频繁出现的 20–30 ms 峰值,降至调优后大部分时间低于 1 ms,偶有 3–4 ms 的异常值。这一改进已维持超过两个月:

交易处理时间 - p99(最近 90 天)
快照服务——更少的数据,热部署
消除分配开销后,我们着手解决下一个瓶颈:内部消息总线上的数据量。我们构建了一个 快照服务,用于维护完整的在内存状态快照。这带来了两个主要效果:
- 更少的数据传输。快照服务持有完整状态后,执行引擎不再需要在每次更新时通过消息总线推送完整状态。它写入的数据更少,意味着网络带宽消耗减少,接收端的反序列化工作也相应减少。
- 零预热部署。部署时,API 层从快照启动(账户、订单簿、市场信息、API 公钥),并立即准备好提供服务。无需等待缓存填充的预热期。加载快照后,API 层订阅更新流,并实时应用增量。
在快照基础之上,内存缓存持续更新:
- 账户信息:通过
sync.Map实现无锁读取,原子指针交换进行更新。 - 订单簿:存储为不可变快照。读取获取指针,更新交换新版本。读取路径无锁。
- API 密钥缓存:所有密钥均驻留内存,完全消除了外部查询。
结果是:完整的交易所状态存在于本地内存中,实时更新,且每次部署都从热状态启动。
对执行引擎的连锁反应显著。以前,执行引擎需要通过网络写入缓存键,服务会不时读取这些更新。现在所有数据都驻留内存,且快照服务负责状态分发,这些网络写入变得不再必要。我们将其删除。结果:区块时间 p99 从约 2.6 ms 降至约 1.2–1.8 ms,仅仅是因为执行引擎现在写入的数据量大大减少:

区块时间 - p99
CPU 规划
在用户空间完成所有优化后,我们深入到内核层面。
我们拥有大量 goroutine,它们需要被调度到 CPU 上运行。最小化调度开销对于低延迟系统至关重要。默认情况下,Go 运行时将 goroutine 多路复用到 OS 线程上,OS 可以自由地将线程迁移到不同的 CPU 核心。这两种行为都会引入不可预测的延迟。
我们通过叠加四种机制来消除这些延迟:
- `runtime.LockOSThread()`:将 goroutine 锁定到单个 OS 线程,防止 Go 调度器迁移它。
- 通过 `sched_setaffinity` 设置 CPU 亲和性:将该 OS 线程绑定到特定 CPU 核心(Linux)。这防止内核在不同核心之间迁移线程,避免 L1/L2 缓存失效。
- 通过 SCHED_FIFO 设置高优先级调度:提升线程的调度优先级,确保内核优先处理它而非其他工作。
- 忙等待自旋循环:热路径运行一个带有空
default分支的select,因此 goroutine 永远不会进入休眠状态。如果没有自旋循环,当没有数据可用时,Go 会将 goroutine 移入“可运行”状态,重新调度会增加唤醒延迟。使用自旋循环后,goroutine 在其绑定的核心上持续运行,以零调度延迟获取新更新。
正确衡量影响至关重要。应用时间随流量条件变化,因此绝对数值会随负载变化。为了隔离 CPU 绑定的效果,我们并行运行两组 API 服务器,处理相同的流量。一组保持未绑定状态作为对照组,另一组启用 CPU 绑定。在相同时间、相同负载下测量两者的百分比差异,才能反映真实情况。
绑定之前,两组数据趋势一致。相同的基准延迟,相同的峰值行为:

热路径应用时间 - CPU 规划前
对一组启用绑定后,它始终低于未绑定的基准线。相同的流量,更低的延迟。绑定组的峰值也被限制在更低水平,因为线程迁移抖动和 L1/L2 缓存失效被消除了:

热路径应用时间 - CPU 规划后
NUMA 感知部署
如果绑定的核心的内存访问跨越 NUMA 边界,仅靠 CPU 绑定是不够的。NUMA 节点是一组拥有本地内存的 CPU。从远程 NUMA 节点访问内存相比本地访问有 10 倍的性能损失。
Lighter 的 API 服务器最初运行在具有 2 个 NUMA 节点的大型机器上:
1$ lscpu | grep NUMA2NUMA node(s): 23NUMA node0 CPU(s): 0-954NUMA node1 CPU(s): 96-19156$ cat /sys/devices/system/node/node0/distance710 100
距离矩阵揭示了问题:本地访问成本为 10,跨节点访问成本为 100。10 倍惩罚。当 N 台 API 服务器共享该机器时,部分服务器不可避免地出现绑定 CPU 在一个 NUMA 节点,而工作内存(内存缓存、更新缓冲区)在另一个 NUMA 节点的情况。每次热路径迭代都在支付跨节点开销。
解决方案看似反直觉:我们将 API 服务器迁移到 更小的单 NUMA 节点机器。规格减半,但所有内存访问现在都保证是本地访问:
1$ lscpu | grep NUMA2NUMA node(s): 13NUMA node0 CPU(s): 0-95
这同时降低了成本并改善了延迟。热路径应用时间进一步降至约 100–250 µs 区间,非高峰时段可降至约 100 µs。相比之下,在 2 NUMA 节点机器上仅使用 CPU 绑定时,该范围约为 200–520 µs:

热路径应用时间 - NUMA 优化
自定义二进制序列化
Lighter 的执行引擎通过内部消息总线将状态更新发布到 API 层。每个系统更新都流经此路径。原始的序列化使用通用编码库,但反射、类型切换和每个字段的分配在热路径上产生了不必要的开销。
我们将其替换为手写二进制序列化:系统中每个实体类型采用固定布局、零反射的编码/解码。每个类型都有专用的编码器/解码器,在已知字节偏移处读写字段。没有反射或类型切换,分配极少。每个编解码器都经过往返测试和模糊测试,以捕获回归。
结果是:在用于更新所有下游缓存的路径上,序列化开销显著降低。
社区成员 @UngusTrade 构建并维护了一个独立基准测试仪表盘,用于比较各永续合约交易场所的实时交易延迟:latency.perps.trading





