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 实战中的几个坑

最后分享几个我在实战中踩过的坑,希望能帮你少走弯路:

  1. 时间戳精度不够:同一毫秒内大量订单涌入,如果没有序列号辅助,订单顺序就是随机的。解决方案是时间戳 + 自增序列号组合成唯一键。
  2. 价格比较用浮点数:这个前面说过了,一定要用整数。我习惯把价格乘以 10000 存储,比较时直接比整数。
  3. 订单簿的并发访问:撮合引擎通常是单线程处理的,但如果你用多线程,一定要处理好锁的粒度。跳表的无锁读特性在这里很有优势。
  4. 内存溢出:订单簿里的订单如果不及时清理,内存会越占越多。我见过一个系统跑了一周后,订单簿里积压了上千万笔未成交订单,直接 OOM 了。记得加一个超时清理机制。

总结一下:价格优先时间优先原则,说起来就一句话,但实现起来涉及价格比较、时间戳生成、数据结构选型等多个环节。每个环节都有坑,但只要理解了背后的数学定义和工程实现,就能写出稳定高效的撮合引擎。

专注资料整理