基于 Redis Lua 的原子撮合:从单机极致到分布式演进

本文面向寻求在高性能场景下实现轻量级撮合引擎的中高级工程师。我们将深入探讨如何利用 Redis Lua 脚本的原子性,构建一个兼具低延迟与一致性的撮合系统。文章将从并发控制的基础问题出发,剖析 Redis 单线程模型与 Lua 脚本的内在联系,并通过真实的代码示例展示如何设计一个订单簿,最终讨论从单机到分布式集群的架构演进路径及其中的关键权衡,适用于秒杀、游戏匹配、轻量级交易等业务场景。

现象与问题背景

在构建一个交易或资源匹配系统时,核心是“撮合”操作的正确性与性能。无论是股票交易所的订单匹配,电商平台的秒杀库存扣减,还是游戏中的玩家匹配,本质上都是一个“状态读取-逻辑判断-状态写入”的过程。一个经典的错误场景是,当多个并发请求同时操作共享资源时,若缺乏原子性保障,极易导致数据不一致,即“竞态条件”(Race Condition)。

以一个简化的限价单交易为例:假设当前卖单(Ask)最低价为 100 元,数量为 10 个。此时,两个买家(A 和 B)同时提交了市价买单,都想以 100 元的价格购买 8 个。在典型的“读-改-写”模式下,可能会发生如下情况:

  • 请求 A 读取到库存为 10,判断足够,计算扣减后为 2。
  • 在请求 A 写入新库存之前,CPU 时间片切换,请求 B 开始执行。
  • 请求 B 也读取到库存为 10,判断足够,计算扣减后也为 2。
  • 请求 A 写入库存 2。
  • 请求 B 写入库存 2。

最终结果是库存剩余 2,但系统实际上卖出了 16 个,远超原始库存,造成了“超卖”。传统的解决方案通常是使用数据库的悲观锁(如 SELECT ... FOR UPDATE)或乐观锁(CAS,版本号机制)。但在高并发、低延迟的场景下,这些方案的性能开销是无法接受的。数据库事务锁会带来巨大的锁竞争开销和延迟,而乐观锁在冲突率高时会频繁失败重试,导致 CPU 空转和整体吞吐量下降。

我们需要一种能够在内存中完成“读-改-写”闭环,并且保证该过程不被其他操作打断的机制。这正是 Redis Lua 脚本的用武之地。

关键原理拆解

要理解为什么 Redis Lua 能解决这个问题,我们必须回归到底层的计算机科学原理,从 Redis 的并发模型和进程通信机制谈起。

第一性原理:Redis 的单线程事件循环模型

与多线程模型的 MySQL 等数据库不同,Redis 的核心网络事件处理和命令执行模块是基于单线程的 Reactor 模式构建的。这意味着在任何一个时间点,Redis 服务端只有一个线程在执行客户端发送的命令。所有命令都会被序列化到一个队列中,然后由这个单线程逐一执行。这种设计的哲学根基在于,对于纯内存操作,性能瓶颈通常不在 CPU,而在于内存带宽和网络 I/O。单线程模型避免了多线程环境下复杂的锁竞争、线程上下文切换带来的开销,使得其内存操作可以达到极高的性能。

这种单线程特性是实现原子性的天然土壤。当一个命令开始执行时,它会一直执行到完成,期间不会有其他命令插入。INCR, LPUSH 等命令的原子性就源于此。然而,撮合操作是一个复杂的逻辑流,包含多个读写命令,单个 Redis 命令无法完成。我们需要将这一组操作“打包”成一个原子单元。

从用户态到内核态:Lua 脚本的性能优势

传统的客户端与 Redis 交互模式,即使使用 MULTI/EXEC 事务,也存在多次网络往返(Round-Trip Time, RTT)。客户端发送 MULTI,然后发送一系列命令,最后发送 EXEC。这些命令在发送阶段依然有网络延迟。更重要的是,MULTI/EXEC 仅保证这些命令会被连续执行,但在执行前无法获取中间结果进行逻辑判断。例如,你无法在 MULTI 块内读取一个 key 的值,然后根据这个值决定下一个命令是 SET 还是 DEL

Lua 脚本则从根本上改变了交互模式。客户端通过 EVALEVALSHA 命令将一段 Lua 脚本发送给 Redis 服务端执行。这段脚本在 Redis 内部被视为一个单一的、不可分割的命令。当 Redis 的主线程开始执行这个脚本时,它会从头到尾完整地执行完毕,中途绝不会去处理其他任何客户端请求。这确保了脚本内所有操作的原子性。

从操作系统层面看,其性能优势在于:

  • 减少网络 RTT: 多个命令的逻辑被封装在一次请求中,将原本需要 N 次网络往返的操作缩减为 1 次。在广域网或高延迟环境下,这个收益是巨大的。
  • 数据局部性(Data Locality): 所有计算都发生在数据所在的 Redis 服务器上。数据无需离开服务器内存,避免了数据在网络中传输的开销,也使得计算可以直接利用服务器的 CPU Cache,效率极高。脚本执行本质上是 Redis 进程内部的函数调用,没有跨进程通信或内核态/用户态的频繁切换。

所以,Redis Lua 的原子性并非魔法,而是其单线程执行模型和脚本一体化执行策略的必然结果。它提供了一种机制,让我们可以在数据侧(Data-Side)执行复杂的业务逻辑,将“读-改-写”的竞态窗口完全消除。

系统架构总览

一个基于 Redis Lua 的轻量级撮合系统通常由以下几个核心部分组成,我们可以用文字来描述这幅逻辑架构图:

  • 客户端 (Client): 交易者或应用服务,通过 gRPC 或 WebSocket 等低延迟协议发起下单请求。
  • API 网关 (API Gateway): 负责协议转换、认证、限流等职责,并将合法的下单请求转发给后端的撮合服务。
  • 撮合服务 (Matching Service): 无状态的应用服务,主要职责是:
    1. 接收下单请求,进行初步的业务校验(如参数格式、用户资金等,资金校验可异步或预扣)。
    2. 将订单信息序列化,作为参数调用 Redis 中的核心撮合 Lua 脚本。
    3. 处理 Lua 脚本的返回结果(成交记录、未成交部分等)。
  • Redis 实例/集群: 系统的核心状态存储。它不仅仅是一个缓存,而是整个订单簿(Order Book)的实时状态机。
    • 存储所有未成交的买卖订单。
    • 执行核心的撮合 Lua 脚本。
    • (可选)通过 Stream 或 Pub/Sub 发布成交事件。
  • 下游消费者 (Downstream Consumer): 如图K线生成服务、清结算系统、风控系统等。它们订阅 Redis 中产生的成交记录,进行后续的异步处理。这部分通常由 Kafka 或其他消息队列承载,以实现系统解耦和削峰填谷。

在这个架构中,撮合服务的核心任务从复杂的并发控制转移到了如何正确地编写和调用 Lua 脚本。服务本身可以水平扩展,因为它不维护任何与订单簿相关的状态,真正的状态完全由 Redis 原子地管理。

核心模块设计与实现

成功的关键在于 Redis 内数据结构的设计以及与之匹配的 Lua 脚本逻辑。

1. 数据结构设计

一个交易对(例如:BTC/USDT)的订单簿,我们需要存储买单和卖单。使用 Redis 的 Sorted Set (ZSET) 是最理想的选择。

  • 买单簿 (Bids): 一个 ZSET,`key` 为例如 `bids:btc_usdt`。
    • `score`: 价格。买单希望价格越高越优先,所以我们将价格作为 score。
    • `member`: 订单 ID。为了处理同价位订单的时间优先原则,订单 ID 通常会包含时间戳信息,或者直接使用一个独立的自增 ID。
  • 卖单簿 (Asks): 另一个 ZSET,`key` 为例如 `asks:btc_usdt`。
    • `score`: 价格。卖单希望价格越低越优先,score 天然支持升序排列。
    • `member`: 订单 ID。
  • 订单详情 (Orders): 一个 HASH,`key` 为例如 `orders:btc_usdt`。
    • `field`: 订单 ID。
    • `value`: 一个序列化后的字符串(如 JSON 或 MessagePack),包含订单的全部详情,如用户ID、原始数量、剩余数量、状态等。

2. 核心撮合 Lua 脚本

这是整个系统的“心脏”。当一个新订单进入时,这个脚本负责将其与订单簿中的对手单进行匹配。


-- 
-- match.lua: 核心撮合脚本
-- KEYS[1]: 买单簿的 ZSET key (e.g., bids:btc_usdt)
-- KEYS[2]: 卖单簿的 ZSET key (e.g., asks:btc_usdt)
-- KEYS[3]: 订单详情的 HASH key (e.g., orders:btc_usdt)
-- KEYS[4]: 成交记录的 STREAM key (e.g., trades:btc_usdt)
--
-- ARGV[1]: 新订单的ID
-- ARGV[2]: 新订单的用户ID
-- ARGV[3]: 新订单类型 ('BUY' or 'SELL')
-- ARGV[4]: 新订单价格
-- ARGV[5]: 新订单数量

-- 订单详情
local order_id = ARGV[1]
local user_id = ARGV[2]
local order_type = ARGV[3]
local price = tonumber(ARGV[4])
local amount = tonumber(ARGV[5])

local order_details = {
    id = order_id,
    user = user_id,
    type = order_type,
    price = price,
    amount = amount,
    rem_amount = amount -- 剩余数量
}

local trades = {}
local remaining_amount = amount

if order_type == 'BUY' then
    -- 新订单是买单,与卖单簿匹配
    -- ZRANGEBYSCORE 从低到高获取价格,正合卖单逻辑
    local opponent_orders = redis.call('ZRANGEBYSCORE', KEYS[2], 0, price, 'LIMIT', 0, 100)

    for _, opponent_order_id in ipairs(opponent_orders) do
        if remaining_amount <= 0 then break end

        local opponent_details_json = redis.call('HGET', KEYS[3], opponent_order_id)
        if opponent_details_json then
            local opponent_details = cjson.decode(opponent_details_json)
            local trade_amount = math.min(remaining_amount, opponent_details.rem_amount)

            -- 更新双方剩余数量
            remaining_amount = remaining_amount - trade_amount
            opponent_details.rem_amount = opponent_details.rem_amount - trade_amount

            -- 记录成交
            table.insert(trades, {
                maker_order_id = opponent_order_id,
                taker_order_id = order_id,
                price = opponent_details.price,
                amount = trade_amount,
                timestamp = redis.call('TIME')[1]
            })
            
            -- 推送成交记录到Stream
            redis.call('XADD', KEYS[4], '*', 'trade', cjson.encode(trades[#trades]))

            if opponent_details.rem_amount <= 0 then
                -- 对手单完全成交,从 ZSET 和 HASH 中移除
                redis.call('ZREM', KEYS[2], opponent_order_id)
                redis.call('HDEL', KEYS[3], opponent_order_id)
            else
                -- 对手单部分成交,更新 HASH
                redis.call('HSET', KEYS[3], opponent_order_id, cjson.encode(opponent_details))
            end
        end
    end

    -- 如果新订单还有剩余,将其加入买单簿
    if remaining_amount > 0 then
        order_details.rem_amount = remaining_amount
        redis.call('ZADD', KEYS[1], price, order_id)
        redis.call('HSET', KEYS[3], order_id, cjson.encode(order_details))
    end

else -- order_type == 'SELL'
    -- 新订单是卖单,与买单簿匹配
    -- ZREVRANGEBYSCORE 从高到低获取价格,正合买单逻辑
    local opponent_orders = redis.call('ZREVRANGEBYSCORE', KEYS[1], '+inf', price, 'LIMIT', 0, 100)
    
    -- ... 逻辑与买单匹配类似,只是操作对象是 KEYS[1] ...
    -- (此处为保持简洁省略,实际代码与上面对称)

    -- 如果新订单还有剩余,将其加入卖单簿
    if remaining_amount > 0 then
        order_details.rem_amount = remaining_amount
        redis.call('ZADD', KEYS[2], price, order_id)
        redis.call('HSET', KEYS[3], order_id, cjson.encode(order_details))
    end
end

return cjson.encode(trades)

极客工程师的坑点提示:

  • 脚本必须无状态: Lua 脚本不应该依赖任何全局变量或上一次执行的状态。所有输入都应来自 `KEYS` 和 `ARGV`。
  • 小心慢脚本: Redis 是单线程的,一个慢脚本会阻塞所有其他客户端。上述脚本中的 `LIMIT 0, 100` 就是一个保护机制,防止一次匹配过多订单导致脚本执行超时。真实的生产环境需要对撮合深度做严格限制,并设置合理的 `lua-time-limit`。
  • 使用 `EVALSHA`: 首次执行用 `SCRIPT LOAD` 将脚本载入 Redis,获取一个 SHA1 校验和。后续调用都使用 `EVALSHA` 加上这个 SHA1 值。这能极大减少网络传输量,因为你只需要传输几十个字节的哈希值,而不是整个脚本。客户端代码需要有回退机制:如果 `EVALSHA` 失败(比如 Redis 重启导致脚本缓存丢失),则重新使用 `EVAL` 执行一次。
  • 序列化格式: 脚本中使用了 `cjson`。在生产环境中,可以考虑 MessagePack 等更紧凑、编解码更快的格式来序列化订单详情,以降低内存占用和 CPU 开销。

性能优化与高可用设计

对抗层:吞吐量 vs. 一致性 vs. 可用性的权衡

单机性能优化

我们选择 Redis Lua 的初衷就是为了极致的性能,但仍有优化的空间。核心的权衡在于单次撮合的深度与脚本执行时间。撮合深度越大(`LIMIT` 越大),taker 单的成交率越高,但脚本执行时间越长,会影响 Redis 的整体 QPS。这是一个需要根据业务场景反复压测和调优的参数。

高可用设计:从 Sentinel 到 Cluster

单点 Redis 是不可接受的。最初级的 HA 方案是使用 Redis Sentinel 做主备切换。这能解决节点宕机的问题,但主备切换期间会有秒级的服务中断,且在异步复制的场景下可能存在少量数据丢失。对于金融级别的应用,这可能是无法容忍的。

更进一步的方案是使用 Redis Cluster。然而,这里有一个巨大的陷阱:Redis Cluster 的原生限制。Lua 脚本中操作的所有 key 必须位于同一个哈希槽(slot)中。我们的脚本操作了 `bids`、`asks`、`orders` 等多个 key,默认情况下,Redis Cluster 会将它们哈希到不同的 slot,从而导致脚本执行失败,返回 `CROSSSLOT` 错误。

解决方案是使用 Hash Tags。我们可以通过在 key 中加入 `{…}` 来强制 Redis Cluster 将特定的 key 路由到同一个 slot。例如,对于交易对 `btc_usdt`,我们可以这样设计 key:

  • 买单簿: `bids:{btc_usdt}`
  • 卖单簿: `asks:{btc_usdt}`
  • 订单详情: `orders:{btc_usdt}`
  • 成交记录: `trades:{btc_usdt}`

通过 `{btc_usdt}` 这个公共部分,我们告诉 Redis Cluster,所有与 `btc_usdt` 交易对相关的 key 都必须放在一个 slot 里。这样,我们的 Lua 脚本就可以在集群模式下原子地操作这些 key 了。这是一种利用规则来驾驭分布式系统的典型工程技巧。

架构演进与落地路径

基于 Redis Lua 的撮合架构并非一成不变,它可以根据业务规模和复杂度分阶段演进。

第一阶段:单机强悍(MVP & 中小规模)

  • 架构: 单个 Redis 主实例 + 1-2 个从实例 + Redis Sentinel 监控和自动故障转移。
  • 适用场景: 项目初期、非核心交易业务、每日撮合量在千万级别以下。
  • 优势: 架构简单,运维成本低,性能对于绝大多数场景已经足够。单机 Redis 内存操作可达 10万+ QPS,足以支撑业务快速启动。
  • 瓶颈: 整个系统的吞吐量受限于单个 Redis 实例的 CPU 核心性能和内存容量。无法水平扩展。

第二阶段:集群化扩展(大规模)

  • 架构: 采用 Redis Cluster,并通过 Hash Tags 将不同交易对的数据分片到不同节点上。
  • 适用场景: 业务增长,需要支持海量交易对,或单个交易对的订单量巨大,单机内存无法承载。
  • 优势: 实现了水平扩展。每个分片(shard)可以看作一个独立的撮合引擎,整个集群的吞吐能力是所有分片之和。可以线性增加节点来提升系统容量。
  • 瓶颈: 虽然实现了扩展,但单个交易对的撮合性能瓶颈依然是单个节点的 CPU 核心。如果出现某个交易对成为超级热点(例如某个热门币种),该节点依然可能被打满。

第三阶段:专用化引擎(超大规模 & 极端低延迟)

  • 架构: 当 Redis 的通用性成为瓶颈时(例如 GC 停顿、事件循环中的其他慢命令干扰),需要将撮合逻辑从 Redis 中剥离出来,构建一个独立的、内存中的撮合引擎。
  • 实现: 通常使用 C++, Rust 或 Go 等高性能语言,结合 LMAX Disruptor 这样的无锁并发框架来构建。订单簿完全在进程内存中,通过内存队列接收指令,实现亚毫秒级的撮合延迟。
  • Redis 的角色退化: 在此阶段,Redis 可能退化为撮合引擎状态的快照存储、或作为成交结果的广播通道,而不再是撮合逻辑的执行者。
  • 适用场景: 专业的数字货币交易所、高频交易系统等对延迟和吞吐量有极致要求的场景。

对于绝大多数企业,从第一阶段平滑演进到第二阶段,已经能够满足未来几年的业务发展需求。Redis Lua 提供了一个极其优秀的高性能起点,它用一种轻量级、易于理解和实现的方式,解决了分布式系统中最棘手的原子性与一致性问题,是架构工具箱中一把锋利的瑞士军刀。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部