3. 价格优先时间优先原则:价格优先的数学定义、时间戳处理、同价格订单排序
好,咱们今天聊一个撮合引擎里最核心、也最基础的原则——价格优先、时间优先。说白了,就是谁出价高谁先成交,出价一样就看谁先来。
听起来很简单对吧?但我在实际项目中踩过的坑,恰恰就出在这个“简单”上。你想想看,一个每秒处理几万笔订单的系统,如果价格比较逻辑写错一个符号,或者时间戳精度差了一毫秒,那可能就是几百万的损失。嗯,咱们今天就把这个原则掰开揉碎了讲清楚。
3.1 价格优先的数学定义
先给个严谨的定义。在订单簿里,所有订单按照价格排序:
- 买单(Bid):价格从高到低排序。最高价优先成交。
- 卖单(Ask):价格从低到高排序。最低价优先成交。
这个规则用数学语言表达就是:
// 买单比较函数
function compareBids(orderA, orderB):
// 价格高的优先
if orderA.price > orderB.price: return -1
if orderA.price < orderB.price: return 1
// 价格相同,按时间戳
return compareTime(orderA.timestamp, orderB.timestamp)
// 卖单比较函数
function compareAsks(orderA, orderB):
// 价格低的优先
if orderA.price < orderB.price: return -1
if orderA.price > orderB.price: return 1
// 价格相同,按时间戳
return compareTime(orderA.timestamp, orderB.timestamp)
这里有个细节我提醒一下:价格比较必须使用定点数或整数,千万别用浮点数。为什么?浮点数有精度问题。0.1 + 0.2 不等于 0.3 这种事儿,在金融系统里就是灾难。我个人习惯把所有价格乘以 10000 或者 1000000,转成整数再比较。
核心要点:价格优先的本质是“最优价格匹配”。买单的最高价和卖单的最低价,这两个极端价格决定了谁能先成交。
3.2 时间戳处理——毫秒定生死
价格相同的时候,就看谁先到。这里的时间戳处理,我见过太多新手翻车了。
先说说时间戳的精度选择:
| 精度 | 适用场景 | 风险 |
|---|---|---|
| 秒级 | 低频交易、日结系统 | 同秒订单无法区分顺序 |
| 毫秒级 | 主流交易所 | 高并发下可能冲突 |
| 微秒/纳秒级 | 高频交易、FPGA撮合 | 时钟同步要求极高 |
我在做某二线交易所的时候,一开始用的秒级时间戳。结果呢?行情一波动,同一秒内涌进来几千笔订单,价格都一样,系统直接懵了——谁先谁后根本分不清。后来改成毫秒级,配合自增序列号,才算解决。
时间戳的生成方式也有讲究:
// 推荐方案:系统时间 + 自增序列号
public class TimestampGenerator {
private long lastTimestamp = -1L;
private long sequence = 0L;
public synchronized long nextTimestamp() {
long current = System.currentTimeMillis();
if (current == lastTimestamp) {
// 同一毫秒内,序列号递增
sequence++;
} else {
// 新的一毫秒,重置序列号
sequence = 0;
lastTimestamp = current;
}
// 组合:毫秒时间戳左移10位 + 序列号
return (current << 10) | (sequence & 0x3FF);
}
}
避坑指南:我曾经遇到过服务器时钟回拨的问题——NTP同步时,系统时间突然往回跳了50毫秒。结果新订单的时间戳比旧订单还小,排序全乱了。解决方案有两个:一是用单调递增的硬件时钟(如 TSC),二是在应用层维护一个永不回退的序列号。
3.3 同价格订单排序——红黑树还是跳表?
价格相同的时候,订单按时间戳从小到大排序。这个逻辑本身不复杂,但实现方式决定了系统的性能上限。
常见的实现方案有三种:
- 数组 + 插入排序:适合订单量小的场景。O(n) 的插入复杂度,订单一多就扛不住。
- 红黑树(TreeMap):O(log n) 的插入和删除。Java 的 TreeMap 底层就是红黑树,用起来方便。但要注意,红黑树的节点开销比较大。
- 跳表(SkipList):同样是 O(log n),但实现更简单,并发性能更好。ConcurrentSkipListMap 在高并发场景下表现优异。
我个人更倾向于跳表。为什么?因为撮合引擎是典型的多读少写场景——订单簿的查询频率远高于修改频率。跳表的读操作是无锁的,这一点在并发环境下优势明显。
// 使用跳表实现价格优先队列
public class PriceLevel {
// 价格相同的一档订单,按时间戳排序
private ConcurrentSkipListMap<Long, Order> orders =
new ConcurrentSkipListMap<>();
public void addOrder(Order order) {
// 时间戳作为key,保证有序
orders.put(order.getTimestampKey(), order);
}
public Order getBestOrder() {
// 获取时间戳最小的订单(最早到达的)
Map.Entry<Long, Order> entry = orders.firstEntry();
return entry != null ? entry.getValue() : null;
}
public void removeOrder(Order order) {
orders.remove(order.getTimestampKey());
}
}
注意:时间戳作为 key 时,必须保证唯一性。如果两个订单在同一微秒到达,key 冲突了怎么办?我的做法是在时间戳后面拼接一个自增的序列号,确保全局唯一。
3.4 整体流程可视化
说了这么多,咱们用一张图把整个流程串起来。这张图展示了从订单进入系统到最终成交的完整路径:
这张图把整个流程分成了四个关键步骤:订单到达 → 价格比较 → 时间戳处理 → 订单簿更新。每一步都有对应的数据结构和算法支撑。
3.5 实战中的几个坑
最后分享几个我在实战中踩过的坑,希望能帮你少走弯路:
- 时间戳精度不够:同一毫秒内大量订单涌入,如果没有序列号辅助,订单顺序就是随机的。解决方案是时间戳 + 自增序列号组合成唯一键。
- 价格比较用浮点数:这个前面说过了,一定要用整数。我习惯把价格乘以 10000 存储,比较时直接比整数。
- 订单簿的并发访问:撮合引擎通常是单线程处理的,但如果你用多线程,一定要处理好锁的粒度。跳表的无锁读特性在这里很有优势。
- 内存溢出:订单簿里的订单如果不及时清理,内存会越占越多。我见过一个系统跑了一周后,订单簿里积压了上千万笔未成交订单,直接 OOM 了。记得加一个超时清理机制。
总结一下:价格优先时间优先原则,说起来就一句话,但实现起来涉及价格比较、时间戳生成、数据结构选型等多个环节。每个环节都有坑,但只要理解了背后的数学定义和工程实现,就能写出稳定高效的撮合引擎。