本文面向寻求在高性能场景下实现轻量级撮合引擎的中高级工程师与架构师。我们将深入探讨如何利用 Redis 及其内嵌的 Lua 脚本引擎,构建一个既能保证数据一致性,又能满足低延迟要求的原子化撮合系统。我们将从计算机科学的基本原理出发,剖析 Redis 单线程模型的本质,最终落地到可扩展、高可用的生产级架构设计,覆盖从广告竞价到数字资产交易等多个典型场景。
现象与问题背景
在许多业务场景中,我们需要一个“撮合”机制。这不仅仅局限于股票或期货交易所的高频交易,更广泛存在于广告系统的实时竞价(RTB)、电商平台的优惠券匹配、网约车的订单派发等。其核心诉셔求是在一个共享的资源池(订单簿、广告位、优惠券库存)中,根据预设规则,将供需双方进行匹配,并原子性地更新状态。
一个典型的场景是限价订单簿(Limit Order Book)。假设我们有一个买单(Bid)和卖单(Ask)的集合,一个新订单进入系统时,必须执行一系列操作:
- 查询对手方订单簿,看是否存在价格匹配的订单。
- 如果存在,按照价格优先、时间优先的原则,逐一进行匹配。
- 更新成交双方的订单数量(部分成交或完全成交)。
- 从订单簿中移除已完全成交的订单。
- 生成成交记录(Trade)。
- 如果新订单未完全成交,则将其剩余部分放入己方订单簿。
传统的关系型数据库通过事务(Transaction)来保证这一系列操作的原子性。开发者会使用 BEGIN TRANSACTION;,执行多个 SELECT ... FOR UPDATE、UPDATE、INSERT,最后 COMMIT;。这种方式在低并发下工作良好,但在高并发场景下,问题会迅速暴露:
- 性能瓶颈: 频繁的行锁、表锁导致严重的锁竞争,吞吐量急剧下降。
- 网络开销: 客户端与数据库之间需要多次网络往返(Round-Trip Time, RTT),每次RTT都意味着延迟的累加。对于延迟敏感的系统,这是不可接受的。
- 复杂性: 为了优化性能,可能需要复杂的锁策略和隔离级别调整,这增加了代码的复杂性和出错的风险。
此时,工程师们自然会把目光投向内存数据库,如 Redis。但简单地将多个 Redis 命令(如 ZRANGEBYSCORE, HGET, HSET, ZREM)组合在客户端执行,会引入严重的竞态条件(Race Condition)。在命令A和命令B之间,另一个客户端可能已经修改了数据,导致状态不一致。使用 MULTI/EXEC 事务块虽然能保证一组命令的原子执行,但它缺乏条件判断能力,无法在事务中间根据读取到的数据决定下一步操作,这对于撮合逻辑是致命的。因此,我们需要一种方法,将整个撮合逻辑作为一个不可分割的单元在 Redis 服务端执行,这正是 Redis Lua 脚本的用武之地。
关键原理拆解
要理解为什么 Redis Lua 脚本能解决上述问题,我们需要回到计算机科学的一些基础原理,以一位严谨教授的视角来审视。
原子操作与临界区
在操作系统理论中,原子操作(Atomic Operation) 指的是一个不可被中断的操作序列。在单核处理器上,一条机器指令通常是原子的。但在多核环境或并发编程中,我们关注的是一个逻辑单元的原子性。为了保护共享数据,我们需要划定一个临界区(Critical Section),确保同一时间只有一个线程或进程能进入该区域执行代码。实现临界区保护的经典机制包括互斥锁(Mutex)、信号量(Semaphore)等。
将撮合逻辑封装在 Lua 脚本中,并由 Redis 执行,本质上是将 Redis 服务器本身变成了一个巨大的、高效的、针对数据操作的互斥锁。当 Redis 执行一个 Lua 脚本时,它会阻塞所有其他客户端的命令(除了少数管理命令),直到脚本执行完毕。这等同于为我们的撮合逻辑创建了一个完美的临界区,从而天然地保证了原子性。
Redis 单线程模型与事件循环
Redis 的核心命令处理模块是单线程的。这常常被误解为“Redis 只有一个线程”。实际上,Redis 在后台会运行多个线程处理如持久化、异步删除等任务。但关键在于,所有客户端命令的接收、解析、执行和响应,都由一个主线程在一个事件循环(Event Loop)中串行处理。
这个模型基于 I/O 多路复用技术(如 Linux 上的 `epoll`)。主线程大部分时间阻塞在 `epoll_wait` 上,等待网络I/O事件(如新的连接、客户端数据到达)。当事件发生时,线程被唤醒,处理相应的请求,然后再次回到循环中等待下一个事件。这种模型的优势在于:
- 避免了线程切换的开销: 传统的每个连接一个线程的模型,在大量连接时,操作系统在线程上下文切换上会消耗大量CPU时间。
- 无锁数据结构: 因为所有数据操作都在一个线程内,所以访问内存中的数据结构(如哈希表、跳表)时,不需要加锁,极大地提升了性能。
当一个 Lua 脚本被执行时,它就成为事件循环中的一个“重任务”。整个脚本的执行过程是连续的、不可中断的。这就解释了为什么 Lua 脚本内的多条 Redis 命令可以作为一个原子单元执行:在脚本执行期间,事件循环不会去处理任何其他客户端的命令请求。
Lua 虚拟机(VM)的集成
Redis 内嵌了一个完整的 Lua 5.1 解释器。当执行 `EVAL` 或 `EVALSHA` 命令时,Redis 会:
- 创建一个沙箱化的 Lua 环境。
- 将脚本加载到这个环境中。
- 通过 `redis.call()` 或 `redis.pcall()` 函数,将 Redis 的命令 API 暴露给 Lua 脚本。
- 执行脚本。脚本中的 `redis.call()` 会直接在 Redis 核心中调用相应的 C 函数实现,绕过了网络协议栈,几乎是零开销的函数调用。
- 销毁 Lua 环境,返回结果。
这种设计的精妙之处在于,它将业务逻辑(撮合算法)下推到了数据存储层,避免了数据在网络中的来回传输,将“计算”和“数据”紧密结合,这是实现低延迟的关键。
系统架构总览
一个基于 Redis Lua 的撮合系统,其典型的部署架构如下(以文字描述):
- 接入层 (Gateway): 一组无状态的服务,负责处理客户端(Web/App/API)的连接,进行协议转换、身份认证和参数校验。接收到下单请求后,将其封装成标准格式的消息。
- 消息队列 (Message Queue – 如 Kafka): 作为系统的缓冲和解耦层。Gateway 将订单消息推送到 Kafka 的特定 topic 中。这提供了削峰填谷、异步处理和数据可回溯的能力。即使撮合服务短暂宕机,订单数据也不会丢失。
- 撮合服务 (Matching Service): 一组无状态的服务,是撮合逻辑的“触发者”。它们消费 Kafka 中的订单消息,然后调用 Redis 的 `EVALSHA` 命令执行撮合脚本。它们本身不维护任何状态,可以水平扩展。
- Redis 集群 (Redis Cluster): 系统的核心状态存储。它保存了所有未成交的订单(即订单簿)。撮合的原子性完全由 Redis Lua 保证。通常采用主从复制 + Sentinel 或 Redis Cluster 模式来保证高可用。
- 持久化数据库 (Persistence DB – 如 MySQL/PostgreSQL): 用于存储最终的成交记录和订单历史。撮合服务在收到 Redis 的成功返回后,异步地将成交结果写入数据库。这使得对 Redis 的关键路径操作极为轻量,而将重度的磁盘 I/O 操作移出主流程。
- 推送服务 (Push Service): 负责将成交结果、订单状态变更等信息实时推送给客户端。
整个数据流:客户端下单 -> Gateway -> Kafka -> Matching Service -> Redis (Lua Script Execution) -> Matching Service -> 异步写入 Persistence DB & 推送结果给 Push Service。
核心模块设计与实现
现在,让我们切换到极客工程师的视角,深入探讨代码层面的设计和实现。
数据结构设计
在 Redis 中,我们需要精心设计 Key 和数据结构。假设我们撮合的是 `BTC/USDT` 交易对。
- 订单簿 (Order Books):
- 买单簿: `zset:orderbook:{btcusdt}:bids`。这是一个 Sorted Set。
- Score: 价格。为了方便按最优价格撮合,买单价格应存为负数或进行处理,使得价格越高,排序越靠前。
- Member: `order_id`。如果要求时间优先,可以使用 `price:timestamp:order_id` 这样的组合字符串,利用 Sorted Set 成员的字典序排序。但更常见的做法是依赖插入顺序或独立的队列。为简化,此处我们只用 `order_id`。
- 卖单簿: `zset:orderbook:{btcusdt}:asks`。
- Score: 价格(正数)。价格越低,排序越靠前。
- Member: `order_id`。
- 买单簿: `zset:orderbook:{btcusdt}:bids`。这是一个 Sorted Set。
- 订单详情 (Order Details):
- `hash:order:{order_id}`。这是一个 Hash。
- `userId`: 用户ID
- `price`: 价格
- `quantity`: 原始数量
- `filled`: 已成交数量
- `status`: 订单状态 (new, partial, filled, cancelled)
- `hash:order:{order_id}`。这是一个 Hash。
注意: 如果使用 Redis Cluster,为了确保一个交易对的所有相关 Key(买单簿、卖单簿)都落在同一个哈希槽(slot)中,我们必须使用哈希标签(Hash Tags)。例如,将 Key 设计为 `zset:orderbook:{{btcusdt}}:bids` 和 `zset:orderbook:{{btcusdt}}:asks`,花括号内的部分 `btcusdt` 将被用于计算哈希槽。这样,我们的 Lua 脚本才能原子地访问这两个 Key。
Lua 撮合脚本核心逻辑
下面的 Lua 脚本展示了一个简化的限价买单撮合逻辑。它接收一个新买单,并尝试与卖单簿进行撮合。
--
-- KEYS[1]: 卖单簿 ZSET (e.g., zset:orderbook:{btcusdt}:asks)
-- KEYS[2]: 买单簿 ZSET (e.g., zset:orderbook:{btcusdt}:bids)
-- ARGV[1]: 新订单ID (e.g., order123)
-- ARGV[2]: 新订单用户ID (e.g., user456)
-- ARGV[3]: 新订单价格
-- ARGV[4]: 新订单数量
-- ARGV[5]: 当前时间戳
local asks_book_key = KEYS[1]
local bids_book_key = KEYS[2]
local new_order_id = ARGV[1]
local new_order_user_id = ARGV[2]
local new_order_price = tonumber(ARGV[3])
local new_order_quantity_left = tonumber(ARGV[4])
local timestamp = ARGV[5]
-- 存储新订单详情
local new_order_key = "hash:order:" .. new_order_id
redis.call("HMSET", new_order_key,
"userId", new_order_user_id,
"price", new_order_price,
"quantity", new_order_quantity_left,
"filled", 0,
"status", "new")
local trades = {}
-- 查找价格小于等于新买单价格的卖单 (价格最低的优先)
-- ZRANGEBYSCORE asks_book_key 0 new_order_price WITHSCORES LIMIT 0 100
-- 为避免脚本过长,一次只处理一部分,真实场景可能需要循环处理
local potential_matches = redis.call("ZRANGE", asks_book_key, 0, -1, "WITHSCORES")
for i = 1, #potential_matches, 2 do
local counter_order_id = potential_matches[i]
local counter_order_price = tonumber(potential_matches[i+1])
-- 价格不匹配则停止
if counter_order_price > new_order_price then
break
end
if new_order_quantity_left == 0 then
break
end
local counter_order_key = "hash:order:" .. counter_order_id
local counter_order_details = redis.call("HGETALL", counter_order_key)
-- 将 HGETALL 返回的 array 转换为 map
local counter_order = {}
for j = 1, #counter_order_details, 2 do
counter_order[counter_order_details[j]] = counter_order_details[j+1]
end
local counter_order_quantity_left = tonumber(counter_order["quantity"]) - tonumber(counter_order["filled"])
local trade_quantity = 0
if new_order_quantity_left >= counter_order_quantity_left then
trade_quantity = counter_order_quantity_left
else
trade_quantity = new_order_quantity_left
end
-- 更新双方订单的已成交数量
redis.call("HINCRBYFLOAT", new_order_key, "filled", trade_quantity)
redis.call("HINCRBYFLOAT", counter_order_key, "filled", trade_quantity)
new_order_quantity_left = new_order_quantity_left - trade_quantity
-- 更新对手单状态
if trade_quantity == counter_order_quantity_left then
-- 对手单完全成交,从订单簿移除
redis.call("ZREM", asks_book_key, counter_order_id)
redis.call("HSET", counter_order_key, "status", "filled")
else
redis.call("HSET", counter_order_key, "status", "partial")
end
-- 记录成交
local trade = {
tradeId = new_order_id .. "-" .. counter_order_id .. "-" .. i, -- 简化的tradeId
price = counter_order_price, -- 成交价以挂单为准
quantity = trade_quantity,
buyOrderId = new_order_id,
sellOrderId = counter_order_id,
timestamp = timestamp
}
table.insert(trades, cjson.encode(trade))
end
-- 更新新订单状态
if new_order_quantity_left == 0 then
redis.call("HSET", new_order_key, "status", "filled")
else
if new_order_quantity_left < tonumber(ARGV[4]) then
redis.call("HSET", new_order_key, "status", "partial")
end
-- 将剩余数量挂入买单簿
redis.call("ZADD", bids_book_key, new_order_price, new_order_id)
end
return trades
极客解读:
- 无条件逻辑: 这段脚本没有任何条件分支依赖于外部状态,所有决策都基于传入的参数和从 Redis 内部读取的数据。这是 `MULTI/EXEC` 无法做到的。
- 零网络延迟: 循环内的 `HGETALL`, `HINCRBYFLOAT`, `ZREM` 等操作都是内存中的函数调用,开销极低。如果这些操作在客户端执行,每一个都会是一次网络RTT。
- 返回值: 脚本返回一个JSON字符串数组,包含了本次撮合产生的所有成交记录。调用方可以直接解析这个结果进行后续处理,而不需要再次查询 Redis。
- 坑点: 上述脚本为了演示简化了逻辑。生产环境的脚本需要更严谨:
- 防重入/幂等性: 调用方需要生成唯一的订单ID,脚本执行前可以检查订单ID是否存在,防止重复提交。
- 错误处理: 使用 `redis.pcall()` 代替 `redis.call()` 可以捕获 Redis 命令执行中的错误(如Key类型错误),并进行处理,而不是让整个脚本失败。
- 脚本超时: 如果订单簿非常深,循环可能会执行很长时间,导致 Redis 阻塞。必须设置 `lua-time-limit`,并在脚本中设计保护机制,例如限制单次撮合的循环次数,将未完成的撮合任务重新入队。
性能优化与高可用设计
对抗延迟与吞吐量
脚本缓存(EVALSHA): 首次执行脚本时使用 `EVAL`,Redis 会计算脚本的 SHA1 哈希并缓存。后续所有调用都应使用 `EVALSHA` 加上脚本哈希值。这大大减少了网络传输的数据量,特别是对于大脚本。客户端应该实现一个逻辑:先尝试 `EVALSHA`,如果 Redis 返回 `NOSCRIPT` 错误,再用 `EVAL` 执行一次,之后就可以一直使用 `EVALSHA` 了。或者在服务启动时通过 `SCRIPT LOAD` 预加载脚本。
数据结构优化: 避免在 Lua 脚本中进行复杂的数据转换。例如,`HGETALL` 返回一个数组,在 Lua 中转为 table 有一定开销。如果只关心少数几个字段,使用 `HMGET` 会更高效。对于订单这种结构化数据,可以考虑使用 JSON 字符串或者 MessagePack 序列化后存储在单个 String 类型的 Key 中,以减少Key的数量和内存碎片,但这会牺牲字段级别原子更新的能力,是一种典型的 **Trade-off**。
避免“慢脚本”: 这是 Redis Lua 的头号天敌。一个执行几百毫秒的脚本会冻结整个 Redis 实例,导致所有其他请求超时。必须遵守:
- 避免在脚本中出现复杂度为 O(N) 且 N 非常大的循环。如果必须遍历,考虑分批处理。
- 避免不确定的、可能耗时很长的计算。
- 通过 `redis-cli --latency` 和 `SLOWLOG` 命令持续监控 Redis 性能,及时发现慢脚本。
对抗单点故障与数据丢失
高可用方案:
- Redis Sentinel: 适用于主从架构,提供自动故障转移。当 Master 节点宕机,Sentinel 会选举一个新的 Master 并通知客户端。
- Redis Cluster: 提供数据分片和内置的故障转移。它是构建大规模撮合系统的首选,因为它解决了单个 Redis 实例的内存和CPU瓶颈。
数据一致性权衡: Redis 的主从复制默认是异步的。这意味着在 Master 节点执行完 Lua 脚本并返回成功后,数据可能还未同步到 Slave。如果此时 Master 宕机且发生故障转移,这部分撮合结果就会丢失。这是一个经典的分布式系统 CAP 理论的权衡。解决方案包括:
- 半同步复制: 使用 `WAIT` 命令。在脚本执行后,客户端可以调用 `WAIT
`,阻塞直到至少 `num_replicas` 个从节点确认收到了写命令。这会增加延迟,但换取了更高的数据一致性保证(但不是绝对的100%)。 - 外部一致性保证: 依赖上游的 Kafka。由于订单消息持久化在 Kafka 中,即使 Redis 数据丢失,我们也可以通过重新消费特定 offset 的消息来恢复状态或进行对账。Kafka 成为事实上的操作日志(Write-Ahead Log, WAL)。
架构演进与落地路径
一个健壮的系统不是一蹴而就的,它需要根据业务发展分阶段演进。
第一阶段:单实例 Redis + Sentinel (MVP)
对于初创业务或内部系统,流量可控。可以直接部署一个主从架构的 Redis,并由 Sentinel 进行监控和故障转移。所有交易对的订单簿都放在这一个实例中。此阶段的重点是快速验证业务逻辑,Lua 脚本的健壮性是核心关注点。
第二阶段:迁移到 Redis Cluster (水平扩展)
当业务增长,单个交易对的订单量激增,或交易对种类繁多导致单实例内存不足时,就需要水平扩展。迁移到 Redis Cluster,并按照交易对(或用户ID,取决于业务模型)进行分片。此时,必须重构 Key 的设计,引入哈希标签(`{}`),确保关联数据落在同一槽点。应用层的连接逻辑也需要适配 Cluster 模式。
第三阶段:LMAX Disruptor 模式或专用撮合引擎 (极致性能)
对于需要微秒级延迟的超高频交易场景(如数字货币交易所的核心交易对),Redis Lua 可能达到瓶颈。此时的演进方向是:
- 内存撮合引擎: 使用 C++, Rust 或 Java(借助 JNI 或 GraalVM)开发一个纯内存的撮合引擎。该引擎在单个线程内按序处理所有订单,完全避免锁竞争,采用 LMAX Disruptor 这样的环形缓冲区(Ring Buffer)作为核心数据结构,实现极致的低延迟和高吞吐。
- Redis 的角色转变: 在这种架构下,Redis 不再是撮合的核心,而是作为一个状态快照的“备份”或用于查询非核心数据(如用户资产)。撮合引擎会定期或实时地将状态(如当前订单簿快照、成交记录)同步到 Redis 和持久化数据库中。
这个演进路径清晰地展示了技术选型如何服务于业务需求。Redis Lua 方案的价值在于,它为绝大多数非极端性能要求的场景提供了一个成本、复杂度和性能之间绝佳的平衡点,是一个强大而优雅的工程解决方案。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。