凌晨两点半,手机连着震了七下。第一反应是“又来”,第二反应是“最近三周没白过,终于轮到我了”。打开告警群一看,果然是跨链桥那边出了事:一条链上的充值交易已经确认超过四十分钟,目标链上迟迟没有任何响应,用户已经在群里刷屏了。再往下一翻,更刺眼的是一条自动提醒——relayer钱包的Gas余额出现了明显下滑,但目标链上并没有新增的待处理交易。
这正是“消失的Gas”的典型症状:钱扣了,交易却没影了。
这篇不是什么教科书级的跨链桥原理科普,就是把我在Web3运维这行第12天真实遇到的一次跨链桥拥堵事故,从头到尾复盘一遍。从最初的告警定位,到查RPC、翻mempool、抠Nonce和Gas的计算,再到最后写了一个“守护者”脚本来自动处理这类卡交易问题,整个过程踩了不少坑,也攒了一些能在实战里直接用的排查套路。如果你也是做Web3运维的,或者正在接触跨链桥、中继器、链上自动化监控这一类工作,这篇应该能帮你省几个小时。
1. 事件还原:凌晨两点的告警与“消失”的Gas
1.1 告警来了:不是网络问题,是资金问题
先说结论:这次事故最麻烦的地方不在“链堵了”,而在“链没堵,但交易就是上不了链”。
告警内容有三条,分开看好像都还好,放在一起就说明问题了。第一条是某跨链桥源链的充值交易状态为success,但目标链没有在预设时间窗口内完成对应扣款/铸造操作;第二条是relayer程序日志里连续出现replacement transaction underpriced;第三条是relayer钱包的非ce值在目标链浏览器上出现了很久没动的迹象。
前两条合在一起,基本可以断定是中继交易没有成功被打包。第三条就更特殊了,正常情况下relayer钱包有频繁的转账交易,nonce会持续递增。如果nonce停住不动,多半是有一笔pending交易堵住了队列。
这里我强烈建议所有做链上运维的同行,告警规则里不光要看“余额变化”,更要看“nonce是否异常停滞”。前者只能告诉你“钱少了”,后者才能真正指引你往哪个方向排查。
1.2 跨链桥的“正常流程”长什么样
跨链桥的核心,说穿了就是“一条链上锁定资产,另一条链上释放等值资产”。我负责的这个桥属于最典型的“锁定-铸造”型,用户从A链充值进去,合约锁定USDC,然后relayer监听A链的Deposit事件,再到B链上调用mint或release。
整个过程涉及两笔链上交易:一笔在A链,用户发起;一笔在B链,relayer发起。B链这笔交易的Gas自然由relayer钱包出。也正是因为这一步,运维的注意力必须从“用户够不够钱”切换到“relayer够不够钱、配置对不对、nonce卡不卡”。
在正常路径里,relayer的逻辑是这样的:
- 订阅或轮询源链
Deposit事件。 - 对事件做去重、验签、查重放。
- 在目标链构造交易,设置Gas参数,签名并广播。
- 等待目标链交易确认后,更新本地任务状态。
听起来不复杂,但一旦网络拥堵、Gas市场剧烈波动、或者多线程同时处理多个事件,就有可能出现我这次遇到的连锁问题:Gas参数估算错了,交易一直pending,后续事件全部排队,然后钱不断从relayer账户里被动消耗,但目标链上什么结果都没有。
1.3 为什么Gas会默默消失
“消失的Gas”这个词其实有点误导。Gas不会真的凭空消失,只是它从relayer钱包余额里被扣除或锁定,但没有换来一笔正常上链的交易。
在我这次的事件里,Gas“消失”有三层表现:
第一层是relayer余额下降。每次广播交易都会先冻结或扣除预估Gas费用,即使是pending状态,这部分费用也是不可用的。
第二层是交易没有被打包。如果Gas价格低于目标链当前网络的平均价格,交易就一直留在mempool里,既不成功也不失败。用户的情绪很容易崩,因为链上明明看到“钱打了”,但另一条链上“钱没到”。
第三层是替换交易时额外交了学费。如果用更高的GasPrice去替换一笔pending交易,需要确保手续费比原交易高至少10%(不同链略有区别)。如果设置不当,替换交易会被网络拒绝,或者原交易继续躺着,新的替换交易也没进mempool,白白消耗了签名和广播的资源。
明白了这三点,后面所有排查动作都是在回答同一个问题:那笔已经被扣走Gas的交易,到底卡在了哪个环节。
2. 排查过程:从链上数据到RPC日志
2.1 第一步:先确认源链交易到底有没有成功
我习惯遇到问题先从最上游确认,不要一上来就翻relayer日志。第一步直接打开源链浏览器,查用户那笔充值交易的收据。
如果源链交易本身是success,并且事件日志里有完整的Deposit数据,那么问题就锁定在“relayer没有正确处理A链事件”或“B链交易没有成功”。如果源链交易是fail,那一切都不用查了,去跟用户解释为什么充值失败。
我当时查到的结果是:源链交易确认高度已经过去几百个区块了,Deposit事件确实存在,参数也正常。所以判断必须去目标链找relayer的痕迹。
这里有个很容易踩的坑:不要只查目标链浏览器的“已确认交易”。因为pending交易在浏览器上不一定显示,尤其在部分自定义RPC节点上。所以更靠谱的方法是直接通过RPC接口去查eth_getBlockByNumber('pending', false)或者eth_getTransactionByHash,看目标链mempool里到底有没有这比交易。
2.2 第二步:目标链的mempool里到底有没有交易
我当时用Python的web3.py连上目标链RPC节点,直接查relayer地址的pending交易:
from web3 import Web3 w3 = Web3(Web3.HTTPProvider("https://your-rpc-endpoint")) relayer = "0xYourRelayerAddress" # 查nonce pending_nonce = w3.eth.get_transaction_count(relayer, "pending") latest_nonce = w3.eth.get_transaction_count(relayer, "latest") print("latest nonce:", latest_nonce) print("pending nonce:", pending_nonce)如果pending nonce比latest nonce大,说明mempool里确实有pending交易;如果两者相等,说明relayer本地可能记录了交易,但目标链节点根本不知道这比交易存在。
我这次碰到的情况就是:pending nonce比latest nonce大了1,但用浏览器查那笔hash,一直提示“not found”。这就非常诡异了,因为按道理pending交易即使没打包,也应该能查到。
后来确认是RPC节点返回的pending数据不完整,某些公共节点或自建节点的eth_getTransactionByHash只返回已打包交易,不返回mempool里的pending交易。所以排查的时候一定要多换几个节点交叉验证,不能单看一个RPC的返回就下结论。
2.3 第三步:relayer钱包余额和nonce也要查
查到pending nonce异常后,下一步就是查钱包余额。为什么?因为pending交易虽然还没上链,但已经占用了nonce,如果Gas参数一直高于当前网络水平,钱包余额会被持续消耗;如果低于网络水平,交易就一直躺在mempool里,后面所有交易都发不出去。
我当时查了relayer地址的余额,发现分为两部分看:
一是余额绝对值。如果余额低到连一笔普通转账的Gas都付不起,那问题就不是“参数设置错”,而是“资金耗尽”。
二是余额变化趋势。我调了历史转账记录,发现最近几小时有几笔“幽灵交易”,即relayer自己给自己转账或者向目标链协议的调用,产生的Gas消耗异常偏高,但这些交易在目标链浏览器上看不到。
这种情况往往是多签名或者多个relayer实例并发广播了同一笔交易,最后只有一笔能成功,另外几笔变成“gas黑洞”:签名发出去了,没被网络接收,但本地线程认为已经广播成功,于是继续生成下一笔相同nonce的交易,导致同一nonce被反复踩踏。
3. 核心问题:Gas为什么“消失”了
3.1 头号原因:EIP-1559参数估算错位
现在很多链都上了EIP-1559,Gas费用拆成了基础费(baseFee)和小费(priorityFee)。广播交易时,客户端要设置maxFeePerGas和maxPriorityFeePerGas。很多运维脚本写得太粗糙,直接把一个固定值写死在配置里,遇到网络拥堵,baseFee一飙,你的maxFeePerGas就低于网络要求,交易就永远处于“等待”状态。
用生活里的例子解释,就是你去坐地铁,但充值卡里只留了刚好够平时票价的钱,结果那天赶上高峰票价上浮,闸机就是不开门,你也不走,就在那堵着。后面排队的人全被堵住了。
我这次事件的第一层原因,就是relayer脚本里写死的maxFeePerGas远低于当时目标链的baseFee。交易广播出去之后,节点只是把它收进了mempool,但验算发现支付不起费用,就一直不打包。等到baseFee回落,理论上它可以被打包,但因为又出现了新的手续费替代规则,它又被放到了队尾。
3.2 二号原因:Nonce被低Gas交易卡死
第二个原因和第一个是伴生关系。因为那笔低Gas交易占用了relayer的nonce,后续所有交易都必须排在它后面。EVM账户的nonce规则是严格的顺序执行,前面没有消耗掉的nonce,后面再高的GasPrice也没办法插队。
这就好比你到了办事大厅取号,取了一个号,但窗口一直不叫号。你后面又取了好几个号,但系统规定必须按顺序叫号,于是后面的号全部作废,大厅里就乱作一团。
这种情况下,唯一正确的做法是“用同一nonce发送一笔替代交易”,把卡住的那笔替换掉,给队列腾位置。但替代交易的GasPrice必须比原交易高一定比例,否则节点会返回replacement transaction underpriced,这也是我当时日志里反复出现的那行报错。
很多人一看到这个报错就慌,其实它反而是个明确的信号:你的替代交易已经被节点接收了,但价格不够,被拒了。需要做的是用更高的GasPrice重新广播,而不是放弃。
3.3 三号隐藏原因:签名与链ID不匹配
第三个原因藏得更深,通常在更换RPC节点、切换主网/测试网时才会触发。relayer在签名交易时如果没显式指定chainId,默认可能沿用上一次的链ID。链ID不匹配的交易广播到目标链,会被节点直接丢弃,但本地不会报错,只是静默失败。
你可能觉得这不是运维该犯的低级错误,但在多个链、多个环境之间切换的跨链桥系统里,这种“配置漂移”特别常见。尤其是当你的配置中心被某个同事改过、又没同步到所有节点时,链ID错乱导致的“Gas消失”比想象中频繁得多。
我在排查过程中也专门查询了一下这个点:用目标链的eth_chainId和relayer本地配置文件对比,确认没有漂移。如果这一步不查,后面所有Gas分析都是白费。
3.4 这次事故的真实定位
把这些原因理清之后,我这次的最终结论是“多因素叠加”。触发点是并发场景下nonce冲突,两个relayer实例同时监听到一笔事件,各自构造了相同nonce的交易。由于脚本没有做分布式锁,两笔交易相互竞争,最后在RPC节点侧产生了“先到先得”的状态,其中一个实例的交易成功上链,另一个实例的交易变成了pending状态中永远无法被打包的那笔。
更麻烦的是,那个失败实例不知道自己的交易已经被“半路拦截”,日志里仍然显示广播成功。于是后续所有任务都停在这个错误nonce上,整个relayer队列完全卡死,而钱包里的Gas却在不断被锁定。
4. 守护者脚本:让跨链桥自己救自己
4.1 为什么我决定写这个脚本
出了事故,第一反应肯定是手工处理。但手工处理很累,尤其当跨链桥交易量大的时候,每一个卡住的nonce都可能牵出一堆待处理事件。如果每次都要人肉去调Gas、查nonce、广播替代交易,那运维就成了7x24小时的全职盯盘工。
所以我的目标很明确:写一个小脚本,定时监控relayer的交易状态,发现卡住的pending交易就自动用新的Gas参数替换,发现nonce异常就报警,发现余额低了就提前预警。名字就叫“守护者”(Guardian),因为它本质上是替我把守资金和交易的最后一道防线。
守护者不是要替代relayer主程序,它更像一个“陪伴型探针”,从旁边看着主程序干活,出问题兜底。这样即使主程序本身存在并发缺陷,守护者也能把损失控制在最小范围。
4.2 脚本的核心设计
设计上分三个模块:
第一个模块是“链路健康检查”。每隔一段时间,用RPC检查源链最新区块和目标链最新区块,如果目标链长时间没有新块,说明节点本身可能出问题了,不急着处理交易,先告警。
第二个模块是“Relayer状态扫描”。读取relayer地址的latest和pending状态,如果pending大于latest,意味着存在pending交易。再以relayer本地的任务表为参考,找到pending交易对应的任务ID和nonce,判断它的存在是否合理。
第三个模块是“自动修复动作”。对符合条件的pending交易,执行非阻塞替换:用当前网络推荐Gas的上浮比例重新构造交易,设置相同nonce,广播替代交易。如果替代交易被拒,就再等几秒重新尝试,最多尝试5轮。
另外还要处理一个边界情况:如果pending交易对应的任务ID在数据库里已经标记为完成,说明主程序已经处理过这件事了,那这笔pending交易就是“孤儿”,不需要花更多Gas去替换,直接告警让运维人工清理。
4.3 关键代码与运行方式
守护者脚本我用的Python + web3.py,代码结构大概长这样:
import time import logging from web3 import Web3 from web3.middleware import geth_poa_middleware logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger("guardian") RPC_URL = "https://your-target-chain-rpc" RELAYER_ADDRESS = "0xYourRelayerAddress" RELAYER_PRIVATE_KEY = "0xYourPrivateKey" # 建议用环境变量或密钥服务 w3 = Web3(Web3.HTTPProvider(RPC_URL)) w3.middleware_onion.inject(geth_poa_middleware, layer=0) # 兼容POA链 def get_gas_price(): # 使用节点建议价格,再上浮20%作为替换价格 base = w3.eth.gas_price return int(base * 1.2) def replace_pending_tx(nonce, original_tx_hash): tx = { "from": RELAYER_ADDRESS, "to": RELAYER_ADDRESS, # 在实际场景中替换为目标合约地址 "value": 0, "nonce": nonce, "gas": 21000, "maxFeePerGas": get_gas_price(), "maxPriorityFeePerGas": w3.to_wei(1, "gwei"), "chainId": w3.eth.chain_id } signed = w3.eth.account.sign_transaction(tx, RELAYER_PRIVATE_KEY) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) logger.info("replacement sent: %s", tx_hash.hex()) return tx_hash.hex() def check_and_repair(): latest_nonce = w3.eth.get_transaction_count(RELAYER_ADDRESS, "latest") pending_nonce = w3.eth.get_transaction_count(RELAYER_ADDRESS, "pending") if pending_nonce <= latest_nonce: return logger.info("pending nonce %s > latest nonce %s", pending_nonce, latest_nonce) for nonce in range(latest_nonce, pending_nonce): try: # 这里可以查询本地数据库,判断该nonce对应的task状态 task_status = get_task_status_by_nonce(nonce) if task_status == "completed": continue except Exception as e: logger.error("query task status failed: %s", e) replace_pending_tx(nonce, None) while True: try: check_and_repair() except Exception as e: logger.exception("guardian loop error: %s", e) time.sleep(30)这段代码只是一个最小可行原型,实际生产环境里至少还要做三件事:
一是把私钥从代码里拿掉,放到环境变量或专门的密钥管理服务里,比如Vault。我见过不少项目把私钥硬编码在脚本里,出事之后被全网扒,是真的难受。
二是不要把to地址设置成relayer自己。示例代码里我为了方便写成了自转账,真实场景应该先把任务详情从数据库里加载出来,重新构造一笔与原始交易相同to和data的交易。
三是Gas的maxFeePerGas和maxPriorityFeePerGas要区分链的类型。部分链虽然兼容EIP-1559,但eth_gas_price接口返回的可能不是最终需要设置的值,建议再加一层“推荐价格来自第三方API”的逻辑,比如聚合多个RPC的结果做中位数。
4.4 部署、日志与告警配置
部署我用的systemd,这样即使服务器重启也能自动拉起。Unit文件里加一行Restart=always,再配合日志切割,基本上就能跑很久。
告警我接的是Webhook通知,脚本里发现四种情况就告警:检测到非预期pending交易、替换交易连续失败超过3次、relayer余额低于某个阈值、RPC节点性能异常。
告警文案要尽量带上区块高度、nonce、交易hash和错误码,否则运维半夜爬起来还要自己查半天。信息越全,恢复越快。
5. 常见问题与排查技巧实录
5.1 跨链桥运维高频问题对照表
这次事故之后,我整理了下面这张速查表。严格来说它不局限于跨链桥,任何涉及EVM链上交易自动广播的服务都有参考价值。
| 症状 | 可能原因 | 排查命令/工具 | 解决方案 |
|---|---|---|---|
| 交易长时间pending | GasPrice设置过低 | eth_getTransactionByHash,检查maxFeePerGas | 用当前网络推荐价格上浮10%-20%重新广播 |
| nonce卡住,后续交易无法发送 | 某笔低Gas交易占用nonce | eth_getTransactionCount(address, "pending") | 构造同nonce替代交易替换 |
替换交易报underpriced | 新Gas费未比原交易高足额比例 | 查看报错时使用的Gas价格 | 提升GasPrice到原交易的1.1倍以上 |
| 交易已广播但浏览器查不到 | RPC节点未同步mempool | 换多个RPC节点交叉验证 | 使用带mempool的节点或自建节点 |
| relayer余额异常下降但交易未成功 | 多个实例重复广播同一nonce | 检查所有relayer实例日志,对比nonce | 加分布式锁,统一nonce分配 |
| 目标链上无任何记录 | 链ID配置错误或签名错误 | eth_chainId对比配置 | 修复链ID配置,重新签名 |
| 交易成功但跨链桥合约状态未更新 | 事件监听遗漏或数据表status错误 | 对比合约事件与数据库记录 | 写守护者脚本对账补发 |
5.2 我在这类事故里总结的检查清单
现在每次处理跨链桥卡顿,我都会按下面这个顺序来,不跳步:
- 确认用户源链交易状态和事件日志是否完整。
- 确认relayer本地任务表里这笔任务处于什么状态。
- 确认relayer地址在目标链的
latest和pendingnonce。 - 对比目标链浏览器和RPC返回的交易hash,确认mempool实际情况。
- 检查relayer钱包余额,计算按当前Gas价格还能支撑多少笔中继交易。
- 检查目标链当前baseFee走势,判断是短期波动还是持续拥堵。
- 确认多个relayer实例之间是否存在并发锁。
- 如果以上都查不出问题,最后再怀疑合约本身的bug。
这套顺序帮我在后来几次类似事故里把定位时间压缩到了10分钟以内。关键点在于:先看数据,再看日志;先看链上,再看程序。很多运维一上来就翻日志,容易把自己绕进去。
5.3 一个容易被忽略的“最小权限”原则
这里额外说一个很多人忽略的运维习惯:relayer钱包一定要和运维操作钱包分开。守护者脚本里如果用同一个钱包做自动替换交易,风险非常高,一旦脚本被误触发,它有能力广播任何交易。
我当时给relayer单独开了一个只用于中继的冷钱包,执行替换交易时用的是一把权限更受限的操作密钥,同时设置了额度上限,单笔替换交易的Gas费不允许超过阈值。这样就算脚本出了bug,损失也可控。
另外一个容易被漏掉的点是,链上交易广播不等于任务完成。我之前吃过亏,脚本判断“交易返回hash”就算成功,结果交易卡在mempool里一整天。后来我调整了完成标准:必须等到目标链上交易收据状态为success,且合约事件存在对应日志,才允许把任务标记为完成。这才是真正的“闭环”。
写在最后
这次事故处理完,已经是早上六点半了。目标链恢复了正常的交易流,relayer钱包里的Gas也重新回到了可预期的消耗曲线。我坐在工位上想,跨链桥运维最大的难点,其实不是链本身多复杂,而是它必须时刻面对“分布式系统的最终一致性”这个老问题:源链已经确认的事,目标链可能还没发生,中间隔着一条充满不确定性的消息通道。
守护者脚本并不能彻底消除这种不确定性,它只是让不确定性变得可观测、可干预、可恢复。至少下次再出类似问题,我不用再半夜爬起来一个个nonce去对,脚本会先替我处理好大部分机械操作,我只需要看它留下的排查记录就行。
最后再分享一个实用的小技巧:如果你也在维护类似的链上中继服务,强烈建议在告警规则里加一条“relayer地址的pending交易数量”监控。这个指标几乎能提前预判80%的跨链桥卡顿事故,因为大多数问题在变成用户投诉之前,已经在mempool里暴露出来了。至于怎么修RPC节点返回pending数据不全的问题,那就等下次踩坑再聊了。