"加个重试"是 Solana 上的标准建议,而且是对的。但重试不是一种东西 —— 适合提交交易的模式,用在处理限流上是错的,反过来也一样,而混用会让两边都更糟。
三种模式,三种用途。
先搞清楚:你在重试什么
模式完全取决于失败的是什么。有三类失败在日志里长得很像,但行为完全不同。
| 失败 | 该重试吗 | 用哪种模式 |
|---|---|---|
| 交易还没被收录 | ★该★ | ★每个 slot 一次★ |
429 限流 |
该 | ★指数退避★ |
网络超时、5xx |
该 | 指数退避 |
| ★blockhash 过期★ | ★不该 —— 重新签名★ | 重建 |
| ★上链了但报错★ | ★不该★ | 修指令 |
| 余额不足 | 不该 | 修余额 |
★后三种不可重试,而反复重试它们,正是机器人烧掉几小时费用却一无所获的原因。★
区分它们只要一次调用:
const { value } = await connection.getSignatureStatuses([sig], {
searchTransactionHistory: true,
});
if (!value[0]) { /* 还没被收录 —— 重试 */ }
else if (value[0].err) { /* ★上链了但失败 —— 不要重试★ */ }
else { /* 上链且成功 */ }
如果你的重试已经写对了、交易却仍然需要很多次尝试, 免费领个 BoltTx key 改一行就能测测路由那一侧。
模式一:每个 slot 一次
用于让交易被收录。大多数人说"重试"时指的就是这个。
async function submitUntilLanded(
connection: Connection,
raw: Buffer,
lastValidBlockHeight: number,
): Promise<string | null> {
const sig = await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0, // ★这个我们自己驱动★
});
while (true) {
const height = await connection.getBlockHeight("confirmed");
if (height > lastValidBlockHeight) return null; // ★已过期:重建★
const { value } = await connection.getSignatureStatuses([sig]);
if (value[0]) return sig; // 已上链;看 .err
// 同样的字节、同样的签名 —— 网络最多收录一次。
await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0,
});
await new Promise((r) => setTimeout(r, 400)); // 大约一个 slot
}
}
★这里不要退避。★ 退避意味着在一个本来就很短的窗口里尝试更少次,而每一次尝试都是面对一个新出块方的新机会。正确的间隔大约是一个 slot —— 更快是在同一个出块方身上浪费带宽,更慢是白白跳过机会。
为什么 maxRetries: 0。 RPC 自带的重试按一套你观察不到的节奏跑。两个组件按不同时钟重发,你既没法推理行为、也没法复现问题。只有你的循环知道什么时候过期。
模式二:指数退避 + 抖动
用于限流和传输错误。这里退避恰恰是对的,因为问题就是你问得太频繁了。
async function withBackoff<T>(fn: () => Promise<T>, max = 5): Promise<T> {
for (let i = 0; i < max; i++) {
try {
return await fn();
} catch (e) {
if (i === max - 1) throw e;
const base = 200 * 2 ** i;
// ★抖动:没有它,所有 worker 会在同一个时刻重试,
// 把造成 429 的那个尖峰原样复现。★
await new Promise((r) => setTimeout(r, base + Math.random() * base));
}
}
throw new Error("unreachable");
}
★不带退避地重试 429,会在你已经超限的那一刻把请求速率成倍放大。★ 它把一次短暂超额变成持续超额。
抖动比大多数人以为的更重要。十个都收到 429 的 worker,如果都恰好等同样长的时间,产生的是一模一样的下一个尖峰。
模式三:固定间隔
用于轮询那些按自己节奏变化的东西 —— 价格源、仓位健康度、你订阅不了的账户。
setInterval(async () => {
const state = await connection.getMultipleAccountsInfo(watched);
evaluate(state);
}, 1_000);
★这其实不算错误处理,而且它绝不该是你的提交模式。★ 在一个提交窗口里用固定 2 秒间隔,会浪费掉窗口的大部分 —— 你本来有时间尝试十几次,结果只试了三次。
代价最大的那个错误
// 看着挺合理,实际上静默无效。
for (let i = 0; i < 5; i++) {
await connection.sendRawTransaction(raw);
await sleep(2000);
}
这里有两个问题叠加。
★它在 blockhash 过期之后还在发。★ 第三到第五次尝试传输的是已经永久无效的字节。没有任何东西会报告这件事 —— 它们只是什么都没做。
★而且 2 秒的间隔浪费了窗口。★ 大约一分钟的有效期,只用来做五次尝试,其中四次可能早就是死的。
解法不是"多重试几次",而是拿 getBlockHeight 和 lastValidBlockHeight 比较,窗口关了就停。
过期要重建,不是重发
这是最常被忽略的区分。
if (height > lastValidBlockHeight) {
// ★错:这些字节永远不会再有效。★
// await connection.sendRawTransaction(raw);
// 对:取新 blockhash、重新签名、产生新签名。
const { blockhash, lastValidBlockHeight: newHeight } =
await connection.getLatestBlockhash("confirmed");
tx.message.recentBlockhash = blockhash;
tx.sign([signer]);
return submitUntilLanded(connection, tx.serialize(), newHeight);
}
重建会产生一个不同的签名,所以它真的是一笔新交易。而这在这里是安全的,恰恰因为旧的那笔已经不可能被收录了。
★如果你在旧交易过期之前就重建,两笔都可能落块。★ 只在确认过期之后才重建。
幂等性:为什么重发是安全的
一个常见顾虑,值得说清楚。
相同的签名字节产生相同的签名,而 Solana 对任何一个签名最多收录一次。同一笔交易发五十次,最多执行一次。
★不安全的是:在原交易还有效的时候用新 blockhash 重建。★ 那是同一个意图的两个不同签名,两笔都可能落块。只在过期之后重建。
怎么选
失败的是什么?
├─ 还没被收录、且仍在窗口内 → ★每个 slot 一次★
├─ 429 或传输错误 → ★指数退避 + 抖动★
├─ blockhash 过期 → ★重建,不要重发★
└─ 上链了但报错 → ★不要重试 —— 去修它★
大多数重试相关的 bug,都来自套用了错误的那一行。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★绝大多数交易落块时离有效期还远,这意味着重试是安全网、不是主要机制。★ 如果你经常需要很多次尝试,原因在费用或路由 —— 重试只是在替别的问题擦屁股。
BoltTx 在这里的位置
我们做路由,而那正是大部分重试之所以存在的原因。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前,传输途中观察不到。你的重试循环对着我们的端点,和对着别家是一样写法。
你在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
接上之后统计"每笔落块交易平均尝试了几次",看它有没有下降。
常见问题
Solana 交易该怎么重试? 在交易上链或 blockhash 过期之前,大约每个 slot 重发一次相同的签名字节。不要退避 —— 每次尝试都是面对新出块方的新机会,而窗口本来就短。
重发同一笔 Solana 交易安全吗? 安全。相同字节产生相同签名,而 Solana 对任何签名最多收录一次。反复发,最多也只执行一次。
发送的重试间隔该设多少? 大约一个 slot。更快是在同一个出块方身上浪费带宽,更慢是跳过机会。2 秒的间隔会让你的有效期大部分时间都在空转。
提交交易该用指数退避吗? 不该。退避是给限流和传输错误用的 —— 那些场景的问题是你问得太频繁。对"被收录"来说,退避意味着在本来就短的窗口里尝试更少次。
Solana 上什么时候该用指数退避?
处理 429 和传输错误时。而且要加抖动,因为十个都退避同样时长的 worker,会把触发限流的那个尖峰原样复现。
重试循环里抖动为什么重要? 没有它,所有收到错误的 worker 会在同一个时刻重试。重试本身变成了下一个尖峰,限流就永远不会缓解。
blockhash 过期了该怎么办? 用新 blockhash 重建并重新签名。旧字节已经永久无效 —— 重发它们不会有任何作用,也永远不会上链。
能在 blockhash 过期之前重建交易吗? 不安全。同一个意图的两个不同签名,意味着两笔都可能落块。只在确认旧的已过期之后再重建。
上链但报错的交易该重试吗? 不该。它到了链上、指令执行失败了 —— 滑点、余额、账户状态。重试只会复现同样的失败,并且再付一次基础费。去修成因。
为什么 maxRetries 要设成 0? 因为 RPC 自带的重试按一套你看不见的节奏跑。你的循环和 RPC 按不同时钟同时重发,行为会变得既无法推理也无法复现。
怎么判断该重试还是该重建?
拿当前区块高度和 lastValidBlockHeight 比。窗口内就重发相同字节,过了就重建。 这一个检查能避免掉大部分无效重试。
重试多少次算正常? 大多数交易在两三个 slot 内就落块,所以几次尝试是常态。如果你经常需要很多次,原因在费用或路由,而不是"再多试几次"能解决的。
重试会让我多付费吗? 只有当多于一笔落块时才会,而相同字节不可能发生这种情况。真正让你多付的是上链后失败的交易 —— 那付了基础费,重试一次再付一次。
网络超时之后该重试吗? 该,带退避。但先查签名状态 —— 交易可能已经成功提交了,只是响应没回来。
"重试"和"重发"有什么区别? 重发是把相同字节、相同签名再送一次。广义的重试可能意味着重建,那会产生一个新签名。前者是幂等的,后者只在过期之后才安全。
延伸阅读
- Solana 交易上链完全指南
- Solana 交易为什么不上链
- 处理 blockhash 过期
- Solana RPC 限流
- Solana sendTransaction 最佳实践