如何实现 TxPoolImpl,做出一个可靠的订单池

February 6, 2026

如何实现 TxPoolImpl,做出一个可靠的订单池

实现 TxPoolImpl 时,没必要先陷在 mempoolUTXORBF 这些词里,先把它当成一个订单池反而更容易展开。

这样很多方法就能直接对上业务动作:

  • mempool 是待处理订单池
  • spentOutpointToTx 是库存占用索引
  • packingTxs 是打包中的临时锁
  • onBlockConfirmed(...) 是最终落账
  • onBlockFailed(...) 是失败后的回收

真正要解决的也不是这些名字本身,而是这套实现怎么把一笔单从进来、冲突、替换、打包、确认,一路收干净。

先把术语翻成人话

如果你平时不写链上代码,先这么对照会省很多力气:

  • Transaction 是订单
  • UTXO 是还没被占掉的资源,最像可用库存
  • input 是这张订单要吃掉哪些库存
  • output 是处理完成后会生成哪些新资源
  • txId 是订单号
  • double spend 是两张单抢同一份库存
  • RBF 是未确认订单的加急改单

RBF 这里先别被名字吓到。放到业务里看,它就是旧单还没最终处理时,新单多付一点成本,把旧单挤掉。

实现时可以按这条流往下拆

TxPoolImpl 基本可以按这条业务流往下做:

  1. 先看一张单能不能进系统
  2. 进来以后会不会和别人抢同一份资源
  3. 如果抢了,能不能按规则替换
  4. 进池以后什么时候会被挑出去处理
  5. 处理成功时哪些状态会变成最终事实
  6. 处理失败时哪些状态只需要回退临时锁

顺着这条线实现,比一开始就扎进 ConcurrentHashMap 或者 UTXO 这些名词里要顺很多。

verifyTx(...) 先把准入卡住

verifyTx(...) 不是收尾校验,它更像入口风控。

它会先确认几件事:

  • 这张单是不是完整的
  • 输入输出是不是齐的
  • 它想占的那份资源是不是真的存在
  • 有没有已经被别的单抢走
  • 签名和内容是不是一致
  • 订单号和内容能不能对上

所以它回答的不是“能不能验过”,而是更直接的一句:

这张单有没有资格进池。

很多系统会把准入看轻,先写进去再说,后面慢慢修。TxPoolImpl 不是这么走的,它先把前提补齐,过了再入池。

addTx(...) 才是真正的入池入口

别把 addTx(...) 看成一个 put。它做的事比“把交易放进 Map”多得多。

它先看订单号是不是已经见过,再看有没有库存冲突。

如果这张单和池子里的老单抢同一份资源,它不会立刻硬拒绝,而是继续判断这是不是一笔可以替换的改单。

等这些都过了,它才会做完整校验,算手续费,看最低门槛,再决定要不要真的进池。

这一步最像业务里的“下单并进入待处理队列”。它做完以后,主表、索引、状态要一起更新,不能只改一半。

replaceTx(...) 处理的是合理改单

replaceTx(...) 这一段最好理解。

它做的不是“重发订单”,而是“旧单还没最终处理时,允许新单把它顶掉”。

系统会先确认几件事:

  • 旧单是不是还在池子里
  • 旧单是不是允许被替换
  • 新单抢的是不是同一批核心资源
  • 新单有没有偷偷引入新的未确认依赖
  • 新单出的优先级成本够不够高

放到订单系统里,就是用户原来下了一张单,还没处理完,又来了一张更愿意付加急费的新单。系统不会因为“都是改单”就无脑放行,它还是会看这是不是在合理升级。

这段可以直接记成一句话:

冲突不只有拒绝这一条路,还可以按规则替换。

这对很多 Java 系统都有启发。资源冲突不一定只能报错,很多时候,合理替换比生硬拒绝更贴近业务。

extractTxsForBlock() 做的是挑单和加锁

这一步最像仓库挑单或者出库批处理。

系统不是谁费用高就拿谁。它会把父子依赖一起算进去,因为有些单单看自己很值钱,但它依赖前面的父单,父单不一起走,子单单独拿出去也没意义。

这和订单系统是一个路子。你看一个待发货队列,不能只盯某一张单的优先级。有些单必须连着前置步骤一起走,不然局部最优会把整体流程搞乱。

挑完以后,它还会做两件事:

  • 标成 PACKING
  • 加上 MEMPOOL_LOCKED

翻成人话就是:这批单已经被线程拿走了,其他人先别动。

这里更值得抄的是这个处理方式:

进入处理中的批次以后,先上临时锁,防止重复消费。

onBlockConfirmed(...) 才是最终落账

这一点很重要。

很多人第一次做交易池,会把“入池”误当成“已经成功”。其实不是。入池只是候选,最终确认才是事实。

onBlockConfirmed(...) 发生时,系统才会:

  • 把状态改成 CONFIRMED
  • 再打上更安全的确认标记
  • 从候选池里移除
  • 把新生成的资源写进缓存和账本
  • 清掉那些和它抢过同一份资源的旧单

这和订单系统里的“最终完成”完全一样。真正落账的时刻,不在用户点击下单,也不在系统挑单,而在最后那次成功确认。

如果把这层边界想清楚,后面的很多设计都会顺。为什么候选态不能直接改最终库存,为什么失败时只回退临时状态,为什么索引要分层,答案都在这里。

onBlockFailed(...) 只回退临时状态

这一步没有做多余的事。

打包失败以后,系统不会去改最终账本,只会把临时锁放开,让订单回到候选状态。失败的是这次处理尝试,不是整张订单已经变成历史事实。

这就是补偿思路:

  • 临时态可以回退
  • 最终事实不能乱碰

很多系统失败处理做得乱,问题就出在这里。它们没把“候选态”和“事实态”拆开,结果一失败就把主状态也改脏了。TxPoolImpl 在这点上很克制。

定时重广播,本质上是补活性

initScheduledTasks()rebroadcastUnconfirmedTxs()broadcastTxList() 这类方法,看上去像链上逻辑,其实在业务上很熟。

它们更像:

  • 定时催单
  • 超时补发通知
  • 重试和补偿调度

目的都一样,别让一笔还有效的候选单因为网络抖动或者处理延迟直接沉底。

这类逻辑不改最终事实,但它对系统活性很重要。没有它,很多本来应该成功的单只会悄无声息地死在队列里。

查询方法更像后台看板

getTx(...)getAllTxs()getTxsByAddress(...)getStatusSnapshot() 这些方法,基本都可以当成后台查询接口来看。

它们回答的是:

  • 按订单号查单
  • 看当前待处理列表
  • 按用户地址查相关记录
  • 看系统里有多少单在排队、处理中、被锁住

getUTXOInPool()getUTXOsAvailableBalance(...) 更像库存视图。它们不是在改账,而是在告诉你哪部分资源已经被候选单占了,哪部分还真的能用。

真正该盯的是状态机

如果只看方法名,很容易把注意力放在术语上。真正该看的,是状态怎么流转。

大概可以这么理解:

  • 刚进来是候选态
  • 被挑去处理以后进入打包态和锁定态
  • 成功以后进入确认态
  • 失败就回到候选态

这就是一个完整的业务状态机。

复杂系统要稳,不能只靠成功和失败两个状态硬扛。中间态越清楚,补偿和观测就越容易做。

并发问题不在 Map,在状态迁移

很多人做这类池子时容易偏掉。

ConcurrentHashMap 很重要,但它解决的是单个容器操作安全,不是整段业务迁移的一致性。

addTx(...) 这种入口,一次会改很多地方:

  • 主池
  • 时间索引
  • 库存占用索引
  • 地址索引
  • 状态集合

如果这些更新被多个线程穿插执行,就会出现半成品状态。比如主表里已经有单了,库存索引还没占上;或者库存已经占了,用户视图还没更新。

所以这里真正保一致性的,是方法级别的串行化控制。容器负责不把自己弄坏,入口负责不让业务状态改一半。

这件事说白了就是:

ConcurrentHashMap 保证单点操作安全,串行入口保证多索引状态迁移一致。

实现时反复要看这三个问题

别只问“这里用了什么并发 API”,更该反复问这三个问题:

  1. 这一步改了哪些状态和索引
  2. 如果只改了一半,会脏到哪里
  3. 它靠什么避免只改一半

你顺着这三问去实现 TxPoolImpl,它就不再是一堆零散方法,而会慢慢长成一套完整的订单池实现。

收一下

如果实现 TxPoolImpl 时一直陷在区块链术语里,很容易把它做得又硬又绕。

但如果把它当成一个订单池,事情就会清楚很多:

  • 先准入
  • 再占资源
  • 冲突时按规则替换
  • 处理中先加锁
  • 成功才落最终事实
  • 失败只回退临时状态

这也是它最像 Java 业务系统的地方。