一、Order结构体:订单的灵魂
做链上订单簿,第一个要解决的问题就是:订单长什么样?
我个人习惯,先把结构体定义清楚。结构体就是订单的骨架,后面所有逻辑都围着它转。
// 限价单结构体
struct Order {
uint256 id; // 订单ID,全局唯一
address trader; // 交易者地址
bool isBuy; // true=买单,false=卖单
uint256 price; // 限价价格,单位:wei
uint256 amount; // 原始数量
uint256 filled; // 已成交数量
uint256 timestamp; // 创建时间戳
uint256 expireTime; // 过期时间(0=永不过期)
bool cancelled; // 是否已撤销
}
嗯,这里要注意几个细节:
- filled字段:记录已成交数量,而不是用剩余数量。为什么?因为做清算时,用filled累加更安全,不容易出现精度误差
- expireTime:我建议加上。我在做永续合约时,遇到过僵尸订单一直挂在链上,gas费白烧
- cancelled:软删除标记。链上数据不能真删,标记一下就行
核心原则:结构体字段要够用,但别冗余。每个字段都要有明确的业务含义,别为了"以后可能用到"就乱加字段。
二、OrderBook合约:订单簿的大脑
结构体定义好了,接下来就是怎么存、怎么查、怎么改。
我设计合约时,喜欢把存储层和逻辑层分开。但初学者可以先放一起,好理解。
contract OrderBook {
// 订单计数器
uint256 public nextOrderId = 1;
// 核心存储:订单ID → Order结构体
mapping(uint256 => Order) public orders;
// 用户维度的订单列表
mapping(address => uint256[]) public userOrders;
// 价格维度的订单列表(用于撮合)
mapping(uint256 => uint256[]) public priceLevelOrders;
// 所有订单ID列表(用于遍历)
uint256[] public allOrderIds;
}
你看,这里用了三个mapping加一个array。为什么这么设计?
设计思路:
- orders mapping:主存储,O(1)查询。这是最核心的
- userOrders mapping:方便用户查自己的订单。我在做DEX时,用户经常问"我的单子呢?"——有这个就快多了
- priceLevelOrders mapping:按价格聚合,撮合时直接扫这个列表,不用遍历所有订单
- allOrderIds array:全局遍历用。虽然gas贵,但管理员做数据统计时有用
三、mapping vs array:怎么选?
说实话,这是Solidity里最让人纠结的选择之一。
| 特性 | mapping | array |
|---|---|---|
| 查询速度 | O(1),极快 | O(n),慢 |
| 遍历能力 | 不能遍历 | 可以遍历 |
| 存储成本 | 低(只存有值的key) | 高(连续存储) |
| 删除操作 | 直接delete,简单 | 需要移位或标记删除 |
| 适用场景 | 按ID/地址查询 | 需要顺序访问 |
我的经验是:能用mapping就别用array。为什么?
有一次我写一个做市商机器人,用array存订单列表。结果订单量到5000笔时,遍历一次要烧掉0.1 ETH的gas。老板脸都绿了。
但array也不是一无是处。比如你要展示"最近10笔成交",用array就很自然。
四、实战:订单CRUD操作
光说不练假把式。我们来看看实际怎么操作这些数据结构。
4.1 创建订单
function placeOrder(
bool _isBuy,
uint256 _price,
uint256 _amount,
uint256 _expireTime
) external returns (uint256) {
require(_amount > 0, "Amount must be > 0");
require(_price > 0, "Price must be > 0");
uint256 orderId = nextOrderId++;
orders[orderId] = Order({
id: orderId,
trader: msg.sender,
isBuy: _isBuy,
price: _price,
amount: _amount,
filled: 0,
timestamp: block.timestamp,
expireTime: _expireTime,
cancelled: false
});
// 同步更新索引
userOrders[msg.sender].push(orderId);
priceLevelOrders[_price].push(orderId);
allOrderIds.push(orderId);
emit OrderPlaced(orderId, msg.sender, _isBuy, _price, _amount);
return orderId;
}
避坑指南:我曾经犯过一个错——创建订单时忘了更新priceLevelOrders。结果撮合引擎扫不到新订单,用户挂单半小时都没成交。后来加了事件监听才补上。
4.2 撤销订单
function cancelOrder(uint256 _orderId) external {
Order storage order = orders[_orderId];
require(order.trader == msg.sender, "Not your order");
require(!order.cancelled, "Already cancelled");
require(order.filled < order.amount, "Already filled");
order.cancelled = true;
emit OrderCancelled(_orderId);
}
注意这里用的是storage引用,不是memory。直接修改storage,gas更省。我刚开始写Solidity时老用memory,后来发现多花了好多gas。
4.3 查询活跃订单
function getActiveOrders(address _trader)
external
view
returns (Order[] memory)
{
uint256[] storage orderIds = userOrders[_trader];
// 先统计活跃订单数量
uint256 activeCount = 0;
for(uint256 i = 0; i < orderIds.length; i++) {
Order storage order = orders[orderIds[i]];
if(!order.cancelled && order.filled < order.amount) {
activeCount++;
}
}
// 再填充结果数组
Order[] memory activeOrders = new Order[](activeCount);
uint256 index = 0;
for(uint256 i = 0; i < orderIds.length; i++) {
Order storage order = orders[orderIds[i]];
if(!order.cancelled && order.filled < order.amount) {
activeOrders[index] = order;
index++;
}
}
return activeOrders;
}
你想想看,为什么这里要遍历两次?
因为Solidity的数组长度是固定的。你不知道活跃订单有多少个,就得先数一遍。虽然gas多花点,但这是最安全的做法。
五、知识体系总览
下面这张图,是我做这个章节时画的。它把整个订单簿的数据结构串起来了:
六、一些心里话
数据结构设计这事,说白了就是trade-off的艺术。
mapping快但不能遍历,array能遍历但慢。你想想看,如果订单量上到10万笔,用array遍历一次,gas费够你吃一顿火锅了。
我个人的建议是:
- 核心数据用mapping:订单详情、用户余额这些
- 索引数据用array:但控制长度,别超过1万
- 聚合数据用mapping of array:比如按价格分组,既快又好查
嗯,差不多就这些。数据结构是地基,地基稳了,上面的撮合逻辑、清算逻辑才能跑得顺。