合约服务并发治理先守住哪条线
链上合约不会像 Web 服务那样“多开几个实例”就扩大吞吐。交易要竞争区块空间,RPC 只是提交与读取的入口,交易进入内存池后还会面对费用、替换和排序。设计并发治理时,先分清链下请求量、待发送交易数和链上可执行操作数,三者不是同一个容量指标。
合约层不要把排队问题硬塞进链上
最危险的写法通常是遍历可无限增长的用户数组,再在一次交易里批量结算。数据量上去后,调用可能因为 gas 不足而再也无法完成。应改成用户自行领取、按页处理,或用可证明的分批状态机。限额也要围绕业务风险设计,例如单笔上限、每个地址的冷却期、协议级暂停,而不是随意用“每区块 N 次”代替全部保护。
mapping(address => uint256) public nextClaimAt; function claim() external whenNotPaused { require(block.timestamp >= nextClaimAt[msg.sender], "claim too soon"); uint256 amount = claimable[msg.sender]; require(amount != 0, "nothing to claim"); claimable[msg.sender] = 0; nextClaimAt[msg.sender] = block.timestamp + claimInterval; token.safeTransfer(msg.sender, amount); }这段限制只适用于确实允许延后领取的业务。清算、偿债等对时间敏感的入口,不能简单套同一冷却期,否则保护机制会改变协议经济行为。评审时要把每个入口的最坏 gas、失败后的可恢复性和暂停策略列出来。
中继服务负责早拒绝和有序发送
链下 relayer 应在签名前验证请求来源、chainId、nonce、目标合约、方法选择器和金额上限。队列满、依赖服务异常或费用超过产品设置的上限时,应明确返回“暂不接受”,而不是继续堆积。已经广播的交易要按账户 nonce 维护状态:待发送、已广播、已确认、替换或失败。不能把一次 RPC 超时当成交易失败,也不能因重试而给同一个 nonce 发送多笔互相竞争的交易。
type Decision = "accept" | "review" | "reject"; function decide(request: { queueSize: number; valid: boolean; feeWithinLimit: boolean }): Decision { if (!request.valid) return "reject"; if (!request.feeWithinLimit || request.queueSize >= 200) return "review"; return "accept"; }这个例子只表示准入决策,不应直接持有私钥或广播交易。签名应隔离在权限受控的组件中,关键资产操作还应由用户钱包、多签或另一个审批层确认。
费用与 RPC 不是单一真相
EIP-1559 下baseFeePerGas、建议优先费和maxFeePerGas含义不同,不能把某个 RPC 返回的gasPrice直接当作最终成本。费用策略要记录估算来源、有效期和替换规则,并允许用户看到实际的金额与滑点限制。多 RPC 能提升可用性,但节点间读到的 pending 状态可能不同;链上确认和约定的确认数才是业务完成的依据。
最后用压测和故障演练验证:队列满时会不会泄漏请求,RPC 切换时 nonce 是否连续,合约暂停时中继是否停止接受相关操作。把这些边界讲清楚,比声称系统能承受某个并发数字更有意义。
还应把费用异常、确认过慢和请求被拒绝的原因反馈给调用方。用户看得见系统正在保护什么,才不会在拥堵时反复制造新的交易。