你的代码等了三十秒、放弃了、记了一条失败。交易在第三十一秒落块了。
★超时描述的是你的耐心,不是链的状态。★ 把它记成失败,正是账本和现实开始偏离的方式。
那条什么都没说的错误
// ★它是按计时器抛的,不是按结果抛的。★
await connection.confirmTransaction(sig, "confirmed");
// TransactionExpiredTimeoutError: Transaction was not confirmed in 30.00 seconds
这条消息很诚实,而且被广泛误读。它说的是客户端不等了。 它没说交易失败了、被拒绝了、或者不会落块。
★它触发时,下面三种情况都还可能:★
- 它已经落块了,只是确认传播得慢
- 它仍在一个有效的 blockhash 窗口里等待
- 它过期了,确实从没运行过
只有第三种是失败,而这条错误分不清你遇到的是哪一种。
区块高度才是权威
// ★错误的工具。★
setTimeout(() => markFailed(sig), 30_000);
// ★正确的。★
const height = await connection.getBlockHeight();
if (height > lastValidBlockHeight) {
// 永久无效。这时候它才是一个真实的结果。
}
★slot 产出速度会变,所以固定的秒数对应的是可变的 slot 数。★ 网络健康时三十秒可能是六十个 slot,不健康时远少于此 —— 而拥堵恰恰就是你的超时触发的时候。
一个墙上时钟的超时,必然在某个方向上是错的。 区块高度是唯一和"有效性实际如何工作"相匹配的度量。
那墙上时钟的超时该用在哪
它们不是没用 —— 它们属于传输层,不属于结果层:
// ★给 HTTP 调用设超时。★
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5_000);
try {
const sig = await sendWithSignal(raw, controller.signal);
} finally {
clearTimeout(t);
}
★一个挂住的网络调用该被放弃;一笔在途的交易不该。★ 给请求设超时,然后单独去收敛那个签名 —— 它们是两个不同的时钟在量两件不同的事。
如果交易确实是在过期、而不是在你这边超时,免费领个 BoltTx key 改一行就能测测提交路径。
发送超时这个陷阱
一次被中止的发送是最危险的超时,因为交易可能照样到达了:
try {
sig = await connection.sendRawTransaction(raw, { skipPreflight: true });
} catch (e) {
// ★不要假设它没有被提交。★
// 字节可能在响应丢失之前就已经到了节点。
}
★签名是由已签名字节确定性推出的,所以你不需要响应就能算出它。★
import bs58 from "bs58";
// tx 是 VersionedTransaction —— signatures[0] 是原始字节。
// 旧版 Transaction 要用 tx.signatures[0].signature。
const sig = bs58.encode(tx.signatures[0]); // ★发送之前就已知★
提前把它算出来,丢失的响应就不再含糊了 —— 你手上已经有可以去查的签名。发送失败后不查就重建,是通往重复执行的两条经典路径之一。
收敛到一个状态,不是收敛到一个计时器
async function resolve(connection, sig, lastValidBlockHeight) {
while (true) {
const { value } = await connection.getSignatureStatuses([sig]);
const st = value[0];
if (st?.confirmationStatus === "confirmed" ||
st?.confirmationStatus === "finalized") {
return st.err ? { status: "reverted", err: st.err } : { status: "success" };
}
if (await connection.getBlockHeight() > lastValidBlockHeight) {
const final = await connection.getSignatureStatuses([sig]);
if (final.value[0]) { await sleep(400); continue; } // ★最后一刻落块了★
return { status: "expired" };
}
await sleep(400);
}
}
★这个循环没有超时,因为它不需要。★ blockhash 窗口就是它的边界 —— 每条路径都终结于成功、revert 或过期,而其中没有一条是"我们不等了"。
如果超时还是触发了,该怎么办
在一个长期运行的服务里,你仍然会在进程层面撞上超时 —— 一次部署、一次重启、一个监控进程的限制。正确的处理是把签名交接出去,而不是去下结论:
if (elapsed > softLimit) {
await queue.push({ sig, lastValidBlockHeight, intent });
return { status: "handed_off" }; // ★不是失败★
}
★"已交接"是一个合法的状态,"失败"不是。★ 一个后台 worker 会正确地把它收敛掉,而你的指标保持诚实,因为你从没记录过一个你没观察到的结果。
对面向用户的机器人,说那句真话: "仍在确认中",而不是"失败"。一个被告知交易失败、随后又在链上看到它成功了的用户,会更不相信你的下一条消息。
这件事对你的指标做了什么
★一次被记成失败的超时,会毒化你从它算出来的每一个比率。★
| 记录成 | 实际是 | 后果 |
|---|---|---|
| 超时记为失败 | 晚一点落块了 | ★成功率被低估★ |
| 超时记为失败 | 仍在 pending | 重试时产生重复 |
| 超时记为成功 | 从没落块 | ★盈亏表里出现幽灵交易★ |
修法是把"未收敛"这个情况显式表达出来:
metrics.record({ status: "success" | "reverted" | "expired" | "unresolved" });
★unresolved 计数上升本身就是一个有用的信号★ —— 它意味着你的收敛路径正在被截断,而这和"交易落不了块"是两个不同的问题。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★对着那个数字,三十秒的超时比一次正常落块长得多得多。★ 当超时开始例行触发时,问题通常在提交路径上,而不在超时值上。
BoltTx 在这里的位置
我们负责提交。超时和确认逻辑留在你的代码里。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。
你在本地签名。我们不托管资金、不代签、不修改交易内容 —— 所以你在发送之前算出来的那个签名,就是你事后可以去查的那个签名。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
TransactionExpiredTimeoutError 是什么意思? 是你的客户端不等了,不是交易失败了。它可能已经落块、可能仍在 pending、也可能已过期 —— 这条错误分不清它们。
确认 Solana 交易时该用超时吗?
不该用它来判定结果。用区块高度和 lastValidBlockHeight 比较来给循环设边界,那和网络判定有效性用的是同一个度量。
为什么墙上时钟的超时不可靠? slot 产出速度会变,固定秒数覆盖的是可变的 slot 数。它必然偏短或偏长,取决于网络状况 —— 而它恰好在拥堵时触发。
那超时该用在哪? 传输层。 中止一个挂住的 HTTP 请求,但要单独继续收敛那个签名 —— 请求和交易是两件事,跑在两个时钟上。
发送调用超时了怎么办? 不要假设它没被提交。 发送之前就从已签名字节算出签名,然后查它的状态 —— 字节可能在响应丢失之前就到了。
怎么在发送前算出签名? 把已签名交易里的第一个签名做 base58 编码。它由字节确定性推出,所以响应丢了也不会让你手上没东西可查。
超时触发时该记录什么? 一个显式的"未收敛"状态,不是失败。然后把签名交给后台 worker,由它对着区块高度上限正确地收敛。
把超时记成失败为什么要紧? 它会低估你的成功率,还可能触发一次导致重复执行的重试。建立在你从没观察到的结果之上的指标,对不上链。
确认很慢时该跟用户说什么? 说它仍在确认中。告诉某人交易失败、而它随后在链上成功了,对信任的伤害比这次延迟本身大。
多久之后该交接? 长到大多数交易能在流程内收敛,短到不会阻塞一个请求。交接存在的意义,是让进程限制永远不必逼你去猜一个结果。
confirmTransaction 能用吗?
它很方便,而且它替你做了超时决定 —— 问题正在这里。用带区块高度边界的 getSignatureStatuses 轮询,才能拿回你需要的控制权。
我的超时触发之后,交易还会落块吗? 会,而且很常见。不管你的客户端做了什么决定,blockhash 在大约 150 个区块内都保持有效,所以晚到是预期之内而非异常。
超时之后怎么避免重复执行? 重建任何东西之前,先把原签名的状态确定下来。 重发相同字节永远安全,而在一次未被观察到的落块之后重建,正是重复的成因。
重试循环该设超时吗? 不该。blockhash 窗口已经给了它边界,每条路径都终结于成功、revert 或过期。加一个计时器,是在引入一个链上并不存在的结果。
unresolved 比例上升意味着什么? 你的收敛路径正在被重启、部署或进程限制截断,而不是交易落不了块。这是两个不同的问题,修法也不同。
超时本身会让我花钱吗? 本身不会。代价来自你接下来做什么 —— 在一次未被观察到的落块之后重建,代价是第二次执行;而记错一条失败,代价是准确性。