如何实现 TxPoolImpl,做出一个可靠的订单池
实现 TxPoolImpl 时,没必要先陷在 mempool、UTXO、RBF 这些词里,先把它当成一个订单池反而更容易展开。
这样很多方法就能直接对上业务动作:
mempool是待处理订单池spentOutpointToTx是库存占用索引packingTxs是打包中的临时锁onBlockConfirmed(...)是最终落账onBlockFailed(...)是失败后的回收
真正要解决的也不是这些名字本身,而是这套实现怎么把一笔单从进来、冲突、替换、打包、确认,一路收干净。
先把术语翻成人话
如果你平时不写链上代码,先这么对照会省很多力气:
Transaction是订单UTXO是还没被占掉的资源,最像可用库存input是这张订单要吃掉哪些库存output是处理完成后会生成哪些新资源txId是订单号double spend是两张单抢同一份库存RBF是未确认订单的加急改单
RBF 这里先别被名字吓到。放到业务里看,它就是旧单还没最终处理时,新单多付一点成本,把旧单挤掉。
实现时可以按这条流往下拆
TxPoolImpl 基本可以按这条业务流往下做:
- 先看一张单能不能进系统
- 进来以后会不会和别人抢同一份资源
- 如果抢了,能不能按规则替换
- 进池以后什么时候会被挑出去处理
- 处理成功时哪些状态会变成最终事实
- 处理失败时哪些状态只需要回退临时锁
顺着这条线实现,比一开始就扎进 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”,更该反复问这三个问题:
- 这一步改了哪些状态和索引
- 如果只改了一半,会脏到哪里
- 它靠什么避免只改一半
你顺着这三问去实现 TxPoolImpl,它就不再是一堆零散方法,而会慢慢长成一套完整的订单池实现。
收一下
如果实现 TxPoolImpl 时一直陷在区块链术语里,很容易把它做得又硬又绕。
但如果把它当成一个订单池,事情就会清楚很多:
- 先准入
- 再占资源
- 冲突时按规则替换
- 处理中先加锁
- 成功才落最终事实
- 失败只回退临时状态
这也是它最像 Java 业务系统的地方。