上周还好好的,自己测也没问题。然后网络一忙,交易就开始凭空消失 —— 没有报错,没有 revert,只有一个永远返回 null 的签名。
排查这类问题最麻烦的地方在于:四种根因在应用日志里长得一模一样。每一种的表现都是"我提交了,拿到签名了,然后什么都没发生"。
下面是应该按顺序排查的四步。每一步都很便宜,而且能排掉一整类问题 —— 不用猜。
动手之前:先确认它真的没上链
有一半的情况,这套排查压根用不上,因为交易其实上链了、只是在链上失败了。这是两类完全不同的问题,修其中一个对另一个毫无帮助。
const { value } = await connection.getSignatureStatuses([signature], {
searchTransactionHistory: true,
});
if (!value[0]) {
// 从未上链。继续往下看。
} else if (value[0].err) {
// 上链了但失败了。问题在链上:滑点、余额、
// 账户状态、程序报错。到此为止,去读错误码。
} else {
// 上链且成功了。那出问题的是你的监控。
}
如果返回结果里有 err,后面就不用看了 —— 你的交易到了链上,该修的是指令,不是提交。
下面所有内容都假设 value[0] 是空的。
如果这份清单你已经走过一遍了,免费领个 BoltTx key 改一行就能测测路由那一侧。
第一步:blockhash 当时还有效吗?
放在第一位,因为它最常见,也最容易确认。
Solana 的 blockhash 有效期大约 150 个区块,正常情况下约 60 秒。如果你在窗口之后才提交,交易在到达的那一刻就已经是无效的 —— 再高的费用、再好的路由都救不回来。
把这个时间差打进日志:
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const fetchedAt = Date.now();
// ... 构造、签名、提交 ...
const submittedAt = Date.now();
console.log("提交时 blockhash 已存在(毫秒):", submittedAt - fetchedAt);
console.log("过期的区块高度:", lastValidBlockHeight);
你要看什么。 如果这个差值超过几秒,基本就是它了。常见原因:
- 取一次 blockhash 然后整批复用。前面几笔能上,尾部必然过期。
- 签名环节要等人、等硬件钱包、或者等一个外部 API。
- 重试循环反复重发同一份签名字节。那份字节已经永久作废,再发多少次都不会成功。
怎么修。 取 blockhash 的时机尽量贴近签名。如果重试已经越过 lastValidBlockHeight,要做的是用新 blockhash 重新构造一笔,而不是把旧的再发一遍。
第二步:你给 priority fee 了吗?
第一步排干净之后,这是下一个最可能的答案。
区块空间被抢时,Solana 按 priority fee 排程。一笔没给费用的交易不是"排在最后",在真正拥堵的时候,它往往根本没进这个队列。
import { ComputeBudgetProgram } from "@solana/web3.js";
// 问网络"最近实际是什么行情",
// 而不是写死一个很快就过时的数字。
const recent = await connection.getRecentPrioritizationFees({
lockedWritableAccounts: writableAccountsInYourTx,
});
const fees = recent.map((r) => r.prioritizationFee).sort((a, b) => a - b);
const median = fees[Math.floor(fees.length / 2)] ?? 0;
instructions.unshift(
ComputeBudgetProgram.setComputeUnitPrice({
// 中位数是下限不是目标。写热门账户要往上乘。
microLamports: Math.max(median * 2, 1_000),
}),
);
你要看什么。 如果你的费用是 0,或者是几个月前写死的一个常数,那就是问题所在。
有一点值得单独说: getRecentPrioritizationFees 接受你这笔交易会写入的账户列表。费用是按账户竞争的,不是全局的。 对着热门池子做 swap,和一笔普通转账,需要的费用完全不是一个量级 —— 不传账户列表查出来的数字,跟你这笔交易没什么关系。
怎么修。 按你实际要写的账户、根据近期行情来算。另外记得设置 compute unit limit —— 不设的话会按一个通常远高于你真实用量的默认值计费,白白浪费费用预算:
instructions.unshift(
ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }),
);
开发阶段模拟一次拿到真实用量,然后设一个略高于它的值。
第三步:你真的在重试吗?
拥堵时单次提交就是掷硬币。很多团队会发现,自己的"重试逻辑"重试的是错的东西。
// 没用:同一份字节,可能好几次之前就过期了。
for (let i = 0; i < 5; i++) {
await connection.sendRawTransaction(raw);
await sleep(2000);
}
上面这个循环的问题不在于"重发" —— 重发相同字节是安全且正确的。问题在于它在 blockhash 过期之后还在发,而且 2 秒一次的间隔浪费掉了窗口里的大部分时间。
// 可用:持续重发,到期就停,之后重新签名。
while (await connection.getBlockHeight("confirmed") <= lastValidBlockHeight) {
const { value } = await connection.getSignatureStatuses([signature]);
if (value[0]) break; // 已上链,无论成功失败
await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0, // 重试由我们驱动,别让 RPC 也在重试
});
await sleep(400); // 大约一个 slot
}
你要看什么。 统计每笔交易的实际重发次数。如果是 1,那就是问题。如果是"10 秒里发了 5 次",那多半有几次发生在过期之后,等于没发。
关于 maxRetries: 0: RPC 自带的重试按一套你看不见的节奏在跑。如果你也在重试,就有两个组件按不同的时钟在重发,行为会变得没法推理。挑一个。
第四步:是不是提交路径的问题?
放在最后查,因为它是唯一没法在应用层代码里解决的 —— 也因为前三步能解释掉大部分情况。
指向这一步的特征很明确:只在拥堵时失败,而且只在拥堵时失败。 凌晨三点跑同样的代码一切正常;一到高负载,上链率就掉下去。
这个特征恰好排除了前三种。blockhash 的 bug 会稳定地失败;费用不够会在全网费率上涨时失败,这个你能对上;没有重试会以一个稳定的比例失败。而路径问题在区块空间充裕时是完全沉默的,一旦紧张就成为主导因素。
拥堵不是把所有交易均匀拖慢,它是把差距拉开 —— 大部分交易不受影响,少数会明显变慢。而提交路径薄弱的问题,恰好就暴露在这少数里;对时间敏感的机会,也恰好都在这里。
路径为什么在这一步起作用。 验证节点按转发方的质押权重来接收转发过来的交易,这就是 SWQoS 在实际中的含义。区块空间充裕时你完全看不见它;紧张时,从低质押路径过来的交易会被降级 —— 恰好在你最不希望被降级的时候。
怎么修。 走一条为此而建的路径。到了这一步,换端点能做到的事情,是应用层怎么调都做不到的。
速查表
| 现象 | 最可能的原因 | 怎么查 |
|---|---|---|
| 任何时段都稳定失败 | blockhash 过期 | 打印"取到"到"提交"的时间差 |
| 全网费率一涨就失败 | priority fee 太低 | 和 getRecentPrioritizationFees 对比 |
| 一批交易头部成功尾部失败 | 整批复用了一个 blockhash | 改成每笔单独取 |
| 以一个稳定的低比例失败 | 没有重试 | 统计每笔的重发次数 |
| 只在拥堵时失败 | 提交路径 | 把上链率和网络负载做关联 |
返回的是 err 不是 null |
根本不是上链问题 | 去读链上错误码 |
如果问题不在你的代码里
存在这样一种情况:你什么都做对了,交易还是在丢。费用按实时行情算、blockhash 贴着签名取、持续重试到过期、compute limit 也设对了 —— 可只要网络一忙,上链率还是往下掉。
到这一步,剩下的变量就只有"你的进程到出块方之间那条路"了,而应用层任何改动都动不了它。
这正是我们做的事。BoltTx 只做一件事:把签好名的交易送进区块。不做索引,不做解析历史。提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前没有人能观察到。
私钥始终在你手里。我们不托管资金、不代签、不修改交易内容。你在交易里带一笔 tip,从自己的钱包链上支付;交易 revert 的话 tip 跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费。切换只要改一行:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
上面这些话你先别信,自己跑一天对比。真正要看的数字是你在拥堵时段的上链率,不是任何人的宣传口径。
常见问题
Solana 交易为什么不上链? 四个原因,按出现频率大致排序:提交前 blockhash 已经过期、priority fee 不足以应对当前拥堵、只提交了一次而没有重试到过期、以及提交路径在高负载下被降级。按这个顺序查 —— 前三个都能在你自己的代码里解决。
怎么判断是过期了还是被丢弃了?
取 blockhash 时记下当时的区块高度,和最后一次重试时的 lastValidBlockHeight 对比。越过去了就是过期,再怎么重发都没用;还在窗口内却没上链,那就去看费用和路由。
返回了签名但链上找不到,发生了什么?
签名是在任何数据发往网络之前、用交易字节在本地算出来的,拿到签名只说明交易格式合法、被接收准备转发。它从未上链。用 getSignatureStatuses 去确认,别把签名当成凭证。
为什么只有网络繁忙时才失败? 因为那时候区块空间是抢的,四种失败模式会同时叠加:priority fee 飙升、上链窗口收窄、提交路径上任何薄弱环节都不再隐形。如果凌晨跑得好好的、一到上新就挂,原因在费用和路由,不在交易构造。
priority fee 应该设多少?
用 getRecentPrioritizationFees 按你这笔交易要写的账户去查,不要写死。费用是按账户竞争的,对着热门池子做 swap 和普通转账完全不是一个量级。把近期中位数当下限,写热门账户时往上乘。
反复重发同一笔交易会不会重复扣款? 不会。相同的签名字节产生相同的签名,Solana 对同一个签名最多收录一次。持续重发直到 blockhash 过期,是生产环境的标准做法。
"被丢弃"和"失败"有什么区别?
被丢弃的交易从未进入区块 —— 查询返回 null,没有 slot 号。失败的交易进了区块、但指令执行报错 —— 有 slot 也有错误码。前者是提交侧问题,后者是程序或行情问题。
需要设置 compute unit limit 吗? 需要。不设的话会按一个通常远高于实际用量的默认值计费,白白浪费费用预算。开发阶段模拟一次拿到真实消耗,然后设一个略高的值。
为什么一批交易里前面的成功、后面的失败? 几乎都是整批复用了同一个 blockhash。发到后面时,那个 blockhash 已经接近或超过有效期。改成每笔单独取,或者在长批次中途刷新一次。
maxRetries 是干什么的,为什么要设成 0?
它控制 RPC 替你重发几次。默认值不是 0,意味着 RPC 正按一套你控制不了的节奏在重试。如果你自己写了重试循环,把它设成 0,让"什么时候重发"只由一个地方决定。
怎么衡量一个改动到底有没有效果? 把三个计数器分开统计:上链且成功、上链但失败、从未上链。看比例而不是总数。合成一个成功率会把提交侧问题和程序侧问题混在一起,数字动了你也归因不到原因上。
等多久才能判定一笔交易失败了?
等到当前区块高度越过 lastValidBlockHeight。在那之前它还可能上链;越过之后它永远不会,继续等只是在拖延你的重试。
RPC 会告诉我交易为什么没上链吗? 不会。一笔"只是没被收录"的交易不会有拒绝消息。你要从自己控制的东西去推断:blockhash 用了多久、费用相对当时行情如何、尝试了几次、以及失败是不是集中在高负载时段。
过期的交易还能救回来吗? 救不回同一笔。用新 blockhash 构造一笔新的并重新签名,那会产生新签名。过期的那份字节已经永久作废。
每次失败都该把 priority fee 调高吗? 只有当失败和全网费率上涨相关时才该。如果它反而和一天中的时段、或者某些特定事件相关,那更可能是路由问题,或者重试循环停得太早。