玄学放置 · 让买卖两端见面:市场撮合
起因 / 背景
市场系列做到第四篇,基本功能都齐了:挂单、委托、整单与部分成交、交易弹窗。但还差最后一公里——买卖两端依然是"自助式"的:卖家挂了 100 金/个的剑,买家挂了 120 金/个的求购,两个人都在原地等,谁也不动。要等其中一个玩家手动点开物品详情、看到对方的单子、按下成交按钮。
这种"对方挂在那里我却不知道"的状态很反直觉。买家发求购的本意是"我愿意出 120 收",而不是"我打算挂单等卖家来"。如果市场上已经有 100 金的卖单,买家的求购应该当场就吃掉它——这才是限价单(limit order)在真实交易所里的语义:报价是上限,价格更好就立即成交。
于是这次的核心任务是把"挂单"路径改造成"挂单即撮合"。买方挂单时,自动吃掉所有满足条件的卖方挂牌;反之同理。撮合不完的剩余数量再挂入订单簿。
关键决策
撮合必须在后端做
这一点没有讨论余地。如果让前端先查市场、再决定是发起买入还是挂单,会同时踩到三个雷区:
- 竞态——查询和下单之间数据可能已经变了,前端看到的挂牌可能已被别人吃掉
- 安全——前端逻辑可以绕过,恶意客户端直接调
/buy-order就能跳过撮合,让市场里同时存在能匹配的买卖双方 - 公平——多个玩家同时挂单时,撮合顺序应该由服务端统一调度,按"价格优先 + 时间优先"决定谁先成交
撮合是金融系统的标准范式,服务端事务化处理是唯一答案。
以 maker 价成交,差价退回买方"待收取"
撮合最关键的设计点是成交价用买卖哪方的报价。有三种选项:taker 价(后挂方报价)、maker 价(先挂方报价)、中间价。最后选了 maker 价,因为它对"流动性提供者"友好——先把单挂出去的人定价,后来者按这个价吃单。这是主流交易所的做法。
具体例子:A 先挂卖 100 金/个,B 后挂买 120 金/个,N 件成交。
- 成交价 = 100 金(A 的 maker 价)
- B 实际支付 = 100 × N
- B 当初挂单时已托管 120 × N
- 差额 (120-100) × N 必须退回 B——不是直接加金币,而是写入 B 的"待收取"队列
这里有一个小但重要的设计一致性:所有资金变动都走待收取。这和此前"取消委托退金币"逻辑略有不同(那条路径是直接退到 players.gold),但和"成交分润"流程完全一致。买家在待收取里能看到"差价退款"这一项,知道自己省了多少——这种透明感很重要。
把撮合逻辑抽到独立服务
routes/market.ts 已经有 600 行了,继续往里塞撮合循环会让路由层变成业务大仓库。新建 services/market-match.ts,导出两个对称函数 matchBuyOrder 和 matchListing,路由层只做参数校验、事务包装和错误响应。这样如果以后要给撮合加成交日志、行情推送、价格上下限保护,都不需要动路由文件。
实现要点
撮合的查询与排序
撮合的第一步是锁定所有可成交的对手单,而不是只挑一个最优的——因为新单的数量可能比单个对手单大,需要连续吃多张。Drizzle 的 for('update') 用来加行级锁,防止两个买家同时吃同一个卖单:
const candidates = await tx.select().from(marketListings)
.where(and(
eq(marketListings.itemId, itemId),
eq(marketListings.status, 'active'),
lte(marketListings.price, buyPrice),
ne(marketListings.sellerId, buyerId) // 自挂自吃过滤
))
.orderBy(asc(marketListings.price), asc(marketListings.createdAt))
.for('update')price ASC 是"买方先吃最便宜的"——这对买家最有利。createdAt ASC 是同价位下"先挂的先成交"——对挂单者公平。两个排序条件叠加就是标准的"价格优先 + 时间优先"。
自挂自吃过滤特别要放在 SQL 的 WHERE 里,而不是循环里跳过。原因不是性能,而是锁的语义:FOR UPDATE 会把命中行全部加锁,如果自己的挂牌也被锁住,在同一事务里再去更新它会引发死锁或不必要的等待。直接从查询结果排除是最干净的做法。
撮合循环里的账目平衡
撮合循环每次吃一张对手单,需要同时写四条流水:卖方收金币、买方收物品、买方收差价退款、对手单状态更新。这四步必须在同一事务里全部成功:
for (const listing of candidates) {
if (result.remaining === 0) break
const fillQty = Math.min(result.remaining, listing.quantity)
const makerPrice = listing.price
const dealTotal = makerPrice * fillQty
const refundDiff = (buyPrice - makerPrice) * fillQty
// 1. 卖方收金币(maker 价)
await tx.insert(marketPending).values({ playerId: listing.sellerId, type: 'gold', quantity: dealTotal, ... })
// 2. 买方收物品
await tx.insert(marketPending).values({ playerId: buyerId, type: 'item', itemId, quantity: fillQty, ... })
// 3. 买方收差价退款(>0 才写)
if (refundDiff > 0) { await tx.insert(marketPending).values({ playerId: buyerId, type: 'gold', quantity: refundDiff, ... }) }
// 4. 更新对手挂牌状态
if (fillQty === listing.quantity) { /* status='sold' */ } else { /* quantity-=fillQty */ }
}写完后做了一遍账目平衡校验(脑算,不是测试):买方挂买单时托管了 buyPrice × N 金币,撮合后 dealTotal + refundDiff = makerPrice×N + (buyPrice-makerPrice)×N = buyPrice×N,刚好等于托管金额,没有凭空生币也没有黑钱。卖方路径同理,卖家拿到的高价收入与买家早就托管的金币一一对应。这种"对账心算"是撮合系统永远绕不开的环节,出一分钱的差错都会被玩家发现。
卖方路径不需要差价退款
镜像写卖方撮合时,我下意识又写了一遍 refund 逻辑,然后停下来想:卖方报价 100,被对方 120 的求购吃掉,成交价是对方的 120(对方先挂,maker 价)。卖家拿到 120 × N,比自己挂的还多 20 × N——这部分差额是卖方的"意外收入",不需要退款给任何人。买方早在挂买单时就托管了 120 × N,资金平账。
所以卖方撮合代码里,refunded 字段恒为 0。两条路径的对称性是 maker 价规则的自然结果,maker 价对后到者有利或刚好——具体到买/卖,体现为退款 / 高价。
注意事项 / 踩到的坑
出师不利:Fastify 的空 body 校验
正经开干前先栽了个小跟头。前端调 /api/market/collect-all 时报 FST_ERR_CTP_EMPTY_JSON_BODY——明明这接口不需要任何参数,为什么 Fastify 拒绝?
定位下来是前端通用 request 封装无条件设置了 Content-Type: application/json,而 collectAllPending 是当前唯一一个没有 body 的 POST。Fastify 看到 Content-Type 是 JSON 但 body 为空,直接抛错。这不是 Fastify 的 bug,是 HTTP 规范层面的合理校验:没有 body 的请求本就不该带 Content-Type 头。
修复就是一行条件判断:
if (options.body != null && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}只在带 body 时才注入。这种通用工具函数的小毛病往往潜伏很久——一直没人写无 body POST,问题就一直没暴露。
部分成交后的剩余托管金币
买方挂单撮合不完时,剩余数量会挂入 marketBuyOrders,但对应的托管金币早就在挂单时全额扣完了。这次没问题,因为剩余金额 = 原报价 × 剩余数量,刚好够后续撮合用。但前提是"剩余挂单的报价 = 原报价",如果以后想做"剩余挂单按某种规则调价",就需要重新设计托管金的释放/补扣逻辑。这是一个伏笔,暂时按下不表。
数据库索引刚好够用
撮合查询的 WHERE 条件是 itemId + status,已有的 idx_market_item_status 复合索引可以走。price 和 createdAt 的排序没有专门索引——但单个 itemId 下的活动挂牌一般不会超过几十条,排序在内存里跑完全没问题。等到某个热门物品的挂牌量上千再考虑加 (itemId, status, price, createdAt) 的复合索引,提前优化是浪费。
收获 / 总结
这次改动让市场的体验从"挂着等运气"跳到了"挂上就成交"。玩家发求购时,如果市场上已经有更便宜的卖单,瞬间就能拿到物品和差价退款。这种"我的意图被立即满足"的感觉是市场系统真正活起来的标志——之前的市场更像公告板,现在终于像一个交易所了。
最值得记的设计选择是maker 价 + 差价退回待收取。它不仅是个价格规则,还和此前"所有资金变动走待收取"的一致性原则严丝合缝地咬合。新撮合逻辑加进去后,不需要新增任何前端 UI——玩家会在熟悉的待收取列表里看到差价退款,这种"新功能自然融入旧界面"的设计美感很难得。
另一个收获是事务和行锁不是高深的东西。db.transaction + for('update') 用起来比想象中简单,关键是想清楚临界区:扣资金、扣物品、写待收取、更新对手单,这四件事必须捆在一个事务里,中间任何一步失败都要全部回滚。一旦想清楚边界,代码反而很短——撮合服务核心逻辑加起来不到 200 行。
下一步如果要继续加深市场体验,可以做成交历史与价格走势:把每次撮合记录下来,在物品详情页画一条价格曲线。玩家看着曲线决定挂单价,这才是真正的市场感。