Solana 交易上链完全指南

调用返回了签名,交易却始终没进区块。讲清 Solana 上链到底指什么、交易消失的四个原因,以及怎么测出你自己的真实上链率。

BoltTx Team··21 min read
solana交易上链sendtransactionswqos交易机器人rpc

你调了 sendTransaction,拿到了签名,日志里记成成功。二十秒后有人问这笔 swap 怎么没执行,你拿签名去查,所有浏览器都返回 null

没有任何报错。这才是最让人困惑的地方 —— 交易没被拒绝,它只是从来没到过。

这是 Solana 开发里最容易误解的一环,而且是真金白银的损失。一个机器人因为 sendTransaction 没抛异常就认为自己有 98% 成功率,它测的根本不是这件事。

上链到底指什么

大多数链上,你把交易丢进 mempool,它在那儿排队,出块方迟早会捞走。慢就慢一点,但它总归在某个队列里待着。

Solana 没有公共 mempool,也没有队列。你提交之后,RPC 节点把交易转发给接下来负责出块的验证节点。如果对方没收进区块,不会有任何人替你重试。它就此消失。

所以这里有三个必须分开的状态,混在一起是很多团队踩坑的根源:

已提交。 RPC 收下了交易并返回签名。签名是本地从交易字节算出来的 —— 你在任何东西碰到网络之前就能生成它。签名不是回执。

已上链。 交易进了区块,链上存在,有 slot 号。

已成功。 上链了,而且指令执行没出错。

一笔交易可能提交了但从未上链;也可能上链了但失败了 —— 滑点超限、余额不够、程序报错。这是两类完全不同的问题,解法也不一样。

如果你的监控把这三个状态压成一个布尔值,那你什么都排查不了。

已经知道原因、只想换条路径试试?免费领个 BoltTx key 改一行就行。下面是诊断部分。

交易不上链的四个原因

一、blockhash 过期

每笔 Solana 交易都引用一个 recent blockhash,有效期大约 150 个区块 —— 正常情况下约 60 秒,负载高时更短。错过窗口,这笔交易就永久失效 —— 它不会晚点上链,它已经死了。

两种场景最容易中招:一是机器人取一次 blockhash 反复用在一批交易上,批次尾部必然过期;二是重试循环反复重发同一份签名字节,而那份字节可能几次尝试之前就过期了。

取 blockhash 要贴近签名的时刻。如果重试已经超出窗口,你需要的是重新签名,不是重发。

二、拥堵与优先级

网络繁忙时,区块空间是抢的。带更高 priority fee 的交易优先被排进去,而你用默认费用的那笔,根本排不上。

麻烦的地方在于,拥堵恰好和你最在意的时刻重合。新币上线、大额清算连锁、套利窗口打开 —— 所有人同时提交,而一笔没给 priority fee 的交易几乎没有机会。

三、你的提交路径

这一条在代码里是看不见的,所以通常最后才被查到。

从你的进程到出块方,中间要经过几跳:你的机器 → RPC 服务商 → 验证节点。每一跳都加延迟。平时这点差别无所谓;但拥堵时上链窗口只有一两个 slot 宽,它就成了决定性因素。

SWQoS(stake-weighted quality of service)在这一层起作用:验证节点按转发方的质押权重来接收转发过来的交易。质押权重不足的路径,恰恰会在区块空间最紧张的时候被降级。

四、你根本没重试

拥堵时单次提交就是掷硬币。生产级的发送方会持续重发,直到交易上链或者 blockhash 过期为止 —— 这和"调一次然后祈祷"是两种完全不同的模式。

交易中继(relay)是干什么的

交易中继位于你的后端和验证节点之间。区别在于:通用 RPC 顺带做转发,而中继只做转发这一件事。

这个区别是有实际意义的。通用 RPC 要用同一套基础设施同时扛账户读取、历史查询、程序订阅和交易发送;中继只承载签好名的交易,它的质押权重和路由都是为这一件事存在的。

对 swap 机器人、套利策略、或者有硬性开始时间的 NFT mint 来说,这就是"高负载下会退化"和"不会退化"的差别。但对索引器或者钱包余额展示来说,完全没区别 —— 手上有什么用什么就行。

一个判断方法:如果你的交易只在网络繁忙时超时,凌晨三点跑同样的代码却一切正常,那问题在路由,不在代码。

怎么测你自己的真实上链率

大多数团队答不上来"我的交易有多少比例上链了"。答不上来,就意味着你改了什么之后,根本不知道有没有变好。

测起来其实不难:

import { Connection } from "@solana/web3.js";

const connection = new Connection(RPC_URL, "confirmed");

async function didItLand(signature: string): Promise<boolean> {
  // searchTransactionHistory 会越过最近状态缓存去查,
  // 所以比较老的签名也查得到。
  const { value } = await connection.getSignatureStatuses([signature], {
    searchTransactionHistory: true,
  });

  const status = value[0];
  if (!status) return false;    // 从未上链
  if (status.err) return false; // 上链了,但指令执行失败
  return true;                  // 上链且成功
}

注意这里有两个不同原因的 false,它们要分成两个计数器:

type Outcome = "landed_ok" | "landed_failed" | "never_landed";

// never_landed  -> 提交侧问题:费用、路径、重试、过期
// landed_failed -> 程序侧问题:滑点、余额、账户状态

这两个数之间没有任何关系,修好一个对另一个毫无帮助。我们见过一个团队花两周调滑点容忍度,而真正的问题从头到尾都是 blockhash 过期 —— 他们的交易不是在链上失败,是压根没到链上。

一个真的会重试的发送逻辑

朴素写法是调一次 sendTransaction。能用的写法是一直重发,直到 blockhash 窗口关闭:

import { Connection, VersionedTransaction } from "@solana/web3.js";

async function sendUntilLanded(
  connection: Connection,
  tx: VersionedTransaction,
  lastValidBlockHeight: number,
): Promise<string | null> {
  const raw = tx.serialize();

  // skipPreflight:我们已经确认这笔交易格式没问题。
  // preflight 要花一个网络往返,而且它模拟的是当前 slot,
  // 未必是你最终落进去的那个 slot,过了也说明不了什么。
  const signature = await connection.sendRawTransaction(raw, {
    skipPreflight: true,
    maxRetries: 0, // 重试由我们自己驱动
  });

  while (true) {
    const height = await connection.getBlockHeight("confirmed");
    if (height > lastValidBlockHeight) {
      return null; // 已过期:换新 blockhash 重新签名,不要重发
    }

    const { value } = await connection.getSignatureStatuses([signature]);
    if (value[0] && !value[0].err) return signature;
    if (value[0]?.err) throw new Error(`已上链但失败: ${value[0].err}`);

    // 重发完全相同的字节是安全的。签名一样,
    // 网络最多只会收录一次。
    await connection.sendRawTransaction(raw, {
      skipPreflight: true,
      maxRetries: 0,
    });
    await new Promise((r) => setTimeout(r, 400)); // 大约一个 slot
  }
}

这里有三个地方最容易写错:

maxRetries: 0 是故意的。 RPC 自带的重试按它自己的节奏走。你自己在驱动重试,底下 RPC 也在重试,行为就变得不可预测了。

重发相同字节是安全的。 签名相同意味着最多被收录一次,不存在重复扣款的风险。

过期要重新签名,不是重发。 一旦 lastValidBlockHeight 越过去,那笔交易永久作废,只能重新构造一笔。

上链顺利时是什么样子

我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。

绝大多数交易在提交后两个 slot 内就进了区块。对策略设计的实用结论:按两个 slot 规划,而不是假设"下一个区块就能进去"。

上链率低了,按这个顺序排查

一条一条来,每条都很便宜,而且能排掉一整类问题。

先加 priority fee。 如果你现在一分钱没给,那基本就是原因。取值要根据近期网络情况算,别写死一个常数。

检查 blockhash 的时间差。 把"取 blockhash"到"提交"之间的间隔打进日志。超过几秒就值得收紧。

打开 skipPreflight,自己驱动重试。 省下一个网络往返,也不再对着错误的 slot 做模拟。

最后才看提交路径。 如果前三条都干净,拥堵时还是在丢交易,那剩下的就是到验证节点这一段路。

最后这条没法在应用层代码里解决 —— 这也正是它应该放在最后查的原因:先把便宜的排除掉。

BoltTx 在这里的位置

我们只做一件事:把签好名的交易送进区块。不做索引,不做解析历史,不做 NFT 元数据。就是投递。

提交走我们自建的四区域节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前是观察不到的。私钥始终在你手里,我们不托管资金、不代签、不修改交易内容。

定价也是一样的逻辑:你在交易里带上一笔 tip,从你自己的钱包链上支付。交易 revert 的话 tip 跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费,没有订阅。切换只需要改一行:

// 改之前
// const connection = new Connection("https://your-current-rpc.example.com");

// 改之后:选离你机器人最近的区域
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

常见问题

Solana 交易"没上链"到底是什么意思? 就是这笔交易从来没被收进任何区块。不是被拒绝,也不是被 revert,而是压根没到链上,所以拿签名去查会返回 null —— 账本上不存在这笔交易。常见原因有四个:blockhash 过期、拥堵时 priority fee 不够、提交路径慢、以及没有重试。

既然交易没上链,为什么 sendTransaction 还返回了签名? 签名是用交易字节和你的密钥在本地算出来的,在任何数据发往网络之前就已经存在。返回签名的含义是"这笔交易格式合法,我收下并准备转发",不代表它已经在链上。结果必须用 getSignatureStatuses 去确认。

Solana 的 blockhash 有效期是多久? 大约 150 个区块,正常情况下约 60 秒,负载高时更短。过期之后交易永久失效,必须用新的 blockhash 重新构造。重发原来那份字节永远不会成功。

上链率多少算正常? 没有统一标准,取决于你发什么、什么时候发。真正重要的是稳定地测量自己的数值,并观察改动之后有没有变化。而且要把指标拆成"从未上链"和"上链但失败"两项,它们的成因完全不同。

priority fee 给高一点是不是就一定能上链? 不是。它能改善你在排程里的位置,但不保证被收录。如果提交路径慢,或者 blockhash 已经接近过期,费用再高也救不回来。priority fee 只是四个因素之一,不是万能开关。

Solana 的 SWQoS 是什么? stake-weighted quality of service。验证节点按转发方的质押权重来接收转发过来的交易。拥堵时,从低质押路径过来的交易会被降级 —— 这就是为什么同一套代码,换个服务商上链率可能差很多。

发交易时该不该开 skipPreflight? 生产环境通常该开。preflight 模拟的是当前 slot,未必是你最终落进去的那个,而且要花一个网络往返。确认交易格式没问题之后就可以跳过,用 getSignatureStatuses 拿结果。

反复重发同一笔交易安全吗? 安全。相同的签名字节产生相同的签名,Solana 最多收录一次。持续重发直到 blockhash 过期,是生产级发送方的标准做法,不是什么特殊情况。

怎么区分"没上链"和"上链但失败"?getSignatureStatuses 并带上 searchTransactionHistory: true。返回 null 说明从未上链;返回结果里 err 非空,说明上链了但指令执行失败。这两个要分开统计,混在一起就看不出到底是哪类问题。

为什么新币上线的时候失败率特别高? 上线会造成拥堵,而拥堵会让四种失败模式同时叠加:区块空间被抢、priority fee 飙升、上链窗口收窄到一两个 slot。这恰恰是最能拉开提交路径质量差距的时刻。

sendTransaction 里的 maxRetries 是干什么的? 它告诉 RPC 在放弃之前替你重发几次。默认值不是 0,也就是说 RPC 正按一套你控制不了的节奏在重试。如果你自己写了重试循环,把它设成 0,让"什么时候重发"这件事只有一个地方说了算。

网络一忙我的交易就超时,该查什么? 按四个原因依次排:先看 priority fee,再看 blockhash 的时间差,然后确认到底有没有在重试,最后才看提交路径。"只在高负载时出问题"这个特征本身就指向费用和路由 —— 如果是交易构造有问题,它会稳定地失败,而不是时好时坏。

Solana 的交易中继(relay)是什么? 一种只负责把签好名的交易转发给出块方的服务。通用 RPC 要用同一套设施同时服务读取、历史和订阅,而中继只承载交易。正是这种专一,让它在拥堵时还能维持路由质量。

发送和读取需要用两家服务商吗? 很多生产环境就是这么配的:通用 RPC 负责账户读取和历史查询,专做投递的端点负责发送。两个集成点互相独立,换其中一个不用动另一个。

怎么搭一套能尽早发现上链失败的监控? 把三个计数器分开统计 —— 上链且成功、上链但失败、从未上链 —— 并且对比例告警而不是绝对值。"从未上链"的比例漂移指向费用或路由,"上链但失败"的漂移指向你的程序或者市场行情。合成一个成功率,这两件事就都看不见了。

怎么确认一笔 Solana 交易上链了?getSignatureStatuses 并带 searchTransactionHistory: true。返回 null 说明它从未进区块;返回结果里 err 非空,说明上链了但指令失败。只有 err: null 才是成功。

在浏览器上查签名返回 null 是什么意思? 这笔交易从未被收进任何区块,所以账本上不存在这条记录。这是提交侧问题,不是执行问题,要修的是费用、blockhash 时机、重试行为或路由。

Solana 交易有可能几分钟后才上链吗? 不会。一旦区块高度越过 lastValidBlockHeight(签发后约 150 个区块),交易就永久无效。Solana 上不存在延迟收录。

给的 tip 更高就能上链吗? 这两个要分开看。priority fee 是链上的出价,影响的是验证节点已经拿到交易之后怎么排程。tip 付的是投递 —— 它买的是把交易送到的那条路径,不是区块里的一个位置。而交易如果到得太晚,这两样都救不回来。

为什么测试时能上链、生产环境不行? 测试通常在网络平静时进行,那时候每条提交路径表现都一样。而生产流量往往聚集在行情剧烈的时刻 —— 那恰好是费用、重试行为和路由开始起作用的时候。

延伸阅读

这几个环节的深入版:

相关机制:

返回博客列表