你的 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 调高吗? 只有当失败和全网费率上涨相关时才该。如果它反而和一天中的时段、或者某些特定事件相关,那更可能是路由问题,或者重试循环停得太早。

延伸阅读

← 返回博客列表