撮合引擎的核心数据结构:Leetcode 1801. Number of Orders in the Backlog

1,182 阅读5分钟

Leetcode 有些题目还是挺有趣的,这周的Leetcode contest,第二道题为 Number of Orders in the Backlog,需要实现一个撮合订单的数据结构

先来看一下题目,Leetcode 1801. Number of Orders in the Backlog

截屏2021-03-22 下午4.20.54.png

简单来说,订单分为买单和卖单两种类型,用户下了一个买单后,需要在卖单集合里找到价格等于或小于这个买单的订单,然后成交。

相反,用户下了一个卖单后,需要在买单集合里找到价格等于或大于这个卖单的订单,然后成交。

撮合引擎复杂在于,订单是存在数据库里的,从数据库里找到匹配的订单也是可以的,但一个订单就对应一个数据库查询,效率恐怕不高。

一个解决思路是,把这些数据放到内存里。国内的很多数字货币交易所的撮合引擎,就是这么一个发展历程,从数据库撮合迭代为内存撮合

把数据放在内存,听起来有点奇怪,但要是考虑一下亿级别的订单,放到一个Map里所需占用的内存,按现在机器的配置,完全是小case。更何况,内存里还不会有亿级别的订单,正在挂着的订单远远达不到这个数。

写《重构》的作者 Martin Fowler 十年前写过一篇很出名的文章介绍 LMAX 公司(LMAX是伦敦的一家外汇交易所)开源的 Disruptor,以及LMAX 架构,它的一个核心概念就是把数据放内存里,由此实现了每秒处理600万订单的超高效率。

国内的头部交易所,据我所知也是用的内存撮合。币安的技术介绍文章,《币安交易平台技术栈首揭秘:什么样的架构体系能挡住全世界的黑客攻击》,里面就提到从数据库撮合到内存撮合的一个演化。

截屏2021-03-22 上午11.18.50.png

LMAX架构巧妙在于,它并不认为多核CPU和多线程能解决问题,因为锁是一种很复杂的东西,而且并不见得效率高,它把锁去掉,降低整个系统的复杂度,并且用无锁队列 RingBuffer 来处理生产者消费者问题。

当然也并非所有的交易所都用LMAX架构。据悉,火币的撮合引擎用的 Clojure,这就是另一种并发模型了。

回到正题,撮合引擎很复杂,但它的核心数据结构是很简单的。

首先,买单需要找到卖单集合里价格最低的订单,卖单需要找到买单集合里价格最高的订单,按这个规则,我们需要一个有序的集合。

另外,撮合的规则一般是“价格优先,时间优先”,所以我们需要它可以根据价格和时间来排序。

这么一看,比较符合的数据结构是TreeMap,插入、查找的时间复杂度都是log(n)。

首先我们需要定义两个TreeMap,一个是买单集合,一个卖单集合。这两个TreeMap的key为价格,买单按价格从高到低排序,卖单从低到高排序。

因为撮合规则是“价格优先,时间优先”,我们还需要按时间优先排序,TreeMap里再套一个TreeMap(由于题目不涉及时间这个概念,这里简化用LinkedList表示)

可以定义一个DepthLine类,用于表示相同价格的一组订单。

val buy = TreeMap<Int, DepthLine> { a, b ->
    b - a
}

val sell = TreeMap<Int, DepthLine> { a, b ->
    a - b
}

定义订单数据结构和DepthLine,里面的数据结构应为TreeMap,这样就能把价格相同的订单按时间排序,但由于题目没有时间一说,我就偷懒不写了。

其实这道题可以设计得更好,加上时间的概念,订单按顺序传入,这样写出来的代码和工业环境能用的就差不了多少了。批量加减反而让这道题成了一道算法技巧题,毫无优雅可言。

class DepthLine(val price: Int) {
    val orderList = LinkedList<Order>()

    fun size(): Int {
        return orderList.size
    }

    fun offer(order: Order) {
        orderList.offer(order)
    }

    fun poll() {
        orderList.poll()
    }
}

class Order(val price: Int, val type: Int)

剩下的match逻辑就很简单了,用户下了一个买单后,需要在卖单集合里找到价格等于或小于这个买单的订单,然后成交。相反,用户下了一个卖单后,需要在买单集合里找到价格等于或大于这个卖单的订单,然后成交。如果找不到对手单,把订单放到对应的订单集合里。

private fun match(order: Order, book: TreeMap<Int, DepthLine>, oppositeBook: TreeMap<Int, DepthLine>) {
    var matched = false
    for ((price, depthLine) in oppositeBook) {
        if (order.type == SELL) {
            if (price >= order.price && depthLine.size() > 0) {
                depthLine.poll()
                if (depthLine.size() == 0) {
                    oppositeBook.remove(price)
                }
                matched = true
            }
        } else {
            if (price <= order.price && depthLine.size() > 0) {
                depthLine.poll()
                if (depthLine.size() == 0) {
                    oppositeBook.remove(price)
                }
                matched = true
            }
        }
        break
    }
    if (!matched) {
        var depthLine = book[order.price]
        if (depthLine == null) {
            depthLine = DepthLine(order.price)
            book[order.price] = depthLine
        }
        depthLine.offer(Order(order.price, order.type))
    }
}

最后贴一下完整代码吧。不过这个代码运行是超时,题目希望做个批量的加减,而我是逐个订单处理了,数据一大自然就超时了。真实的撮合引擎中,订单都是按顺序处理的,还有个叫“定序”的过程。这里改为不超时也很简单,但改了恐怕代码就不能优美地展示这个撮合模型了,暂时就不处理了。

class `Number of Orders in the Backlog` {

    val buy = TreeMap<Int, DepthLine> { a, b ->
        b - a
    }

    val sell = TreeMap<Int, DepthLine> { a, b ->
        a - b
    }

    val BUY = 0

    val SELL = 1

    val mod = 1000000007

    fun getNumberOfBacklogOrders(orders: Array<IntArray>): Int {
        for (order in orders) {
            for (i in 0 until order[1]) {
                match(Order(order[0], order[2]))
            }
        }
        var res = 0
        buy.forEach {
            res += it.value.size()
        }
        sell.forEach {
            res += it.value.size()
        }
        return res % mod
    }

    private fun match(order: Order) {
        if (order.type == SELL) {
            match(order, sell, buy)
        } else {
            match(order, buy, sell)
        }
    }

    private fun match(order: Order, book: TreeMap<Int, DepthLine>, oppositeBook: TreeMap<Int, DepthLine>) {
        var matched = false
        for ((price, depthLine) in oppositeBook) {
            if (order.type == SELL) {
                if (price >= order.price && depthLine.size() > 0) {
                    depthLine.poll()
                    if (depthLine.size() == 0) {
                        oppositeBook.remove(price)
                    }
                    matched = true
                }
            } else {
                if (price <= order.price && depthLine.size() > 0) {
                    depthLine.poll()
                    if (depthLine.size() == 0) {
                        oppositeBook.remove(price)
                    }
                    matched = true
                }
            }
            break
        }
        if (!matched) {
            var depthLine = book[order.price]
            if (depthLine == null) {
                depthLine = DepthLine(order.price)
                book[order.price] = depthLine
            }
            depthLine.offer(Order(order.price, order.type))
        }
    }

    class DepthLine(val price: Int) {
        val orderList = LinkedList<Order>()

        fun size(): Int {
            return orderList.size
        }

        fun offer(order: Order) {
            orderList.offer(order)
        }

        fun poll() {
            orderList.poll()
        }
    }

    class Order(val price: Int, val type: Int)
}