你的 Solana 交易没上链?四步查出原因

一步步找出交易消失的真正原因。怎么区分 blockhash 过期、费用不够、还是路由问题,每一步该跑什么检查。

BoltTx Team··18 min read
solana交易上链调试blockhashpriority-fee交易机器人

上周还好好的,自己测也没问题。然后网络一忙,交易就开始凭空消失 —— 没有报错,没有 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 的时机尽量贴近签名。如果重试已经越过 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 调高吗? 只有当失败和全网费率上涨相关时才该。如果它反而和一天中的时段、或者某些特定事件相关,那更可能是路由问题,或者重试循环停得太早。

延伸阅读

返回博客列表