发一笔交易的资料到处都是。在同一分钟里发两百笔,才是大多数机器人发现自己的架构只按第一种情况设计的地方。
失败通常不是崩溃,而是缓慢的劣化:前三十笔落块了,接下来五十笔变慢,最后一百笔全部过期。
顺序发送是错误的默认做法
最直观的那个循环,也是最慢的:
// ★不要这么写。★
for (const tx of transactions) {
await connection.sendRawTransaction(tx.serialize());
}
每一轮都要等一次网络往返才开始下一轮。两百笔交易就是把两百次往返串起来,而你的 blockhash 全程都在过期。
★blockhash 是你没法商量的截止时间。★ 它大约在 150 个区块内有效。一个跑得比这个窗口还久的顺序循环,会发现后面那些交易被拒绝,而原因跟它们自身毫无关系。
有上限的并行
无上限的并行是另一个错误。朝一个 RPC 端点同时打两百个请求会被限流,而被限流的发送比慢速发送更糟 —— 因为它们根本没到网络上。
async function sendBatch(txs, connection, concurrency = 10) {
const results = [];
const queue = [...txs];
const workers = Array.from({ length: concurrency }, async () => {
while (queue.length) {
const tx = queue.shift();
if (!tx) break;
try {
const sig = await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: true,
maxRetries: 0,
});
results.push({ ok: true, sig });
} catch (e) {
results.push({ ok: false, error: e });
}
}
});
await Promise.all(workers);
return results;
}
★工作池这个形状比并发数本身更重要。★ 不管单笔发送耗时多久,它都能维持恰好 N 个请求在途 —— 而分块的 Promise.all 做不到:那种写法每一块都要等最慢的那笔完成,才开始下一块。
批量里 skipPreflight: true 基本是必需的。 preflight 会让请求数翻倍,而且拿两百笔交易去模拟当前 slot,对它们将要落块的那个 slot 说明不了什么。
maxRetries: 0 阻止 RPC 在你的循环底下跑它自己的重试节奏。两个组件按不同的时钟重发,批量行为就没法推理了。
如果你的批量已经调好了、尾部还是会过期,免费领个 BoltTx key 改一行配置就能拿来对比。
共用一个 blockhash
批量里的每一笔都可以共用一个 blockhash,通常也应该:
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const signed = payloads.map((p) => {
const tx = buildTransaction(p, blockhash);
tx.sign([payer]);
return tx;
});
一次获取,而不是两百次。★但这也意味着整个批次共用一个截止时间。★ 如果你的构建加签名循环很慢 —— 而给两百笔交易签名并不免费 —— 那你在任何东西发出去之前,就已经在烧窗口了。
// ★量一下你在准备阶段花掉了多少窗口。★
const startHeight = await connection.getBlockHeight();
const signed = buildAll(payloads, blockhash);
const readyHeight = await connection.getBlockHeight();
console.log(`准备阶段花掉 ${readyHeight - startHeight} 个区块,总共约 150 个`);
如果准备阶段的开销超过几个区块,就把签名和发送并行起来,而不是排在它前面单独跑一个阶段。
同一个签名者,不同的交易
同一个钱包发出的批量有个容易被忽略的约束:同一个手续费支付方发出的交易是彼此独立的,不是有序的。
Solana 不保证分别提交的交易的执行顺序。两笔都修改同一个账户的交易,可能按任意顺序落块,也可能落在同一个区块里、而顺序不是你选的。
★如果这些操作必须按顺序发生,那它们就不能做成批量。★ 要么把它们合并成一笔交易里的多条指令,要么串成链、每一笔都等前一笔确认。
适合批量的:
- 发给互不相同的接收方的分发
- 互不相关市场上的独立交易
- 关闭一批互不影响的账户
不适合的:
- 任何有顺序要求的操作
- 共享同一个可变账户、会互相冲突的操作
- 第二步依赖第一步结果的多步流程
限流才是真正的天花板
大多数公共 RPC 端点会限制每秒请求数,而批量是最快撞上这个上限的方式。
async function sendWithLimitAwareness(tx, connection) {
try {
return await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: true,
maxRetries: 0,
});
} catch (e) {
const msg = String(e?.message ?? e);
if (msg.includes("429") || msg.toLowerCase().includes("rate")) {
// ★退避只属于这里。★
await sleep(jitteredDelay());
return sendWithLimitAwareness(tx, connection);
}
throw e; // 不是限流;重建比重试更可能有用。
}
}
★限流要退避,争取上链不要退避。★ 这是两种相反的行为,把它们混在一起是最常见的批量 bug:一个在上链失败时退避的机器人,在一个自己无法延长的窗口里,做出的尝试更少了。
确认一个批次
逐个签名地轮询,会把两百笔的批次变成两百个轮询循环。getSignatureStatuses 一次能收最多 256 个签名:
async function confirmBatch(sigs, connection, lastValidBlockHeight) {
const pending = new Set(sigs);
const landed = new Map();
while (pending.size) {
const height = await connection.getBlockHeight();
if (height > lastValidBlockHeight) break; // ★已过期:停下,重建★
// ★rotate: slicing the same head every pass starves the tail★
const all = [...pending];
const batch = all.splice(0, 256);
all.forEach((s) => { pending.delete(s); pending.add(s); });
const { value } = await connection.getSignatureStatuses(batch);
batch.forEach((sig, i) => {
const st = value[i];
if (st?.confirmationStatus) {
landed.set(sig, st.err ? "已落块但 revert" : "成功");
pending.delete(sig);
}
});
await sleep(400); // 大约一个 slot
}
return { landed, expired: [...pending] };
}
★是三种结果,不是两种。★ 一笔交易可以成功、可以落块然后 revert、也可以根本没落块。一份只统计 sendRawTransaction 返回了多少签名的批量报告,统计的是提交量,不是结果。
上链应该是什么水平
我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。
★在批量场景里,分布比中位数更重要。★ 你的批次要等最慢的那一笔落块才算完成,所以一条又宽的尾巴,吃掉的是整个窗口,而不是一笔交易。
BoltTx 在这里的位置
批量是你这边的事,路由是我们这边的事。
提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前,传输途中观察不到。你的批量逻辑不用改,只换发送的端点。
你在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在每笔交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。
免费领 API key,没有月费:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
常见问题
怎么一次性发多笔 Solana 交易? 用有上限的并行 —— 一个维持固定数量请求在途的工作池。顺序循环会把往返串起来、烧掉 blockhash 窗口,而无上限并行会被限流。
能并行发多少笔交易? 这取决于你的端点限流,而不是 Solana。从大约十个并发发送开始,盯着 429 响应,然后往上加到看见它们为止。
批量里的交易能共用一个 blockhash 吗? 能,而且通常应该 —— 一次获取而不是很多次。但整个批次也就共用了一个过期时间,准备阶段的耗时是从同一个窗口里扣的。
批量发送的 Solana 交易是按顺序执行的吗? 不是。分别提交的交易没有顺序保证,即便来自同一个手续费支付方。如果顺序重要,把操作合并成一笔交易,或者串起来、中间等确认。
我批量里后面那些交易为什么会失败? 通常是 blockhash 过期。一个慢速顺序循环在走到列表末尾之前就花光了有效窗口,所以最后那些交易被拒绝的原因跟它们的内容无关。
批量时该用 skipPreflight 吗? 几乎总是该用。preflight 会让请求数翻倍,而且模拟的是当前 slot 而不是你将要落块的那个。开发阶段做模拟就够了。
怎么高效确认大量签名?
getSignatureStatuses 每次最多收 256 个签名。按大约一个 slot 的间隔轮询整批,签名一有结果就移出集合,而不是每笔交易跑一个循环。
批量的重试策略该怎么定? 只对限流退避。对上链失败,按大约一个 slot 的间隔重发、不加退避 —— 每次尝试都是碰上新出块方的一次新机会,而这个窗口你延长不了。
能批量发送不同钱包的交易吗? 能,而且往往有帮助,因为按账户的争用会分散开。每笔交易仍然需要各自的手续费支付方给出自己的签名。
批量时怎么避免限流?
限制并发、用 skipPreflight 把请求数砍一半、并且把确认轮询也批量化。批量场景下的限流问题,大多来自 preflight 和逐签名轮询,而不是发送本身。
批量失败该逐笔重试还是整批重试? 逐笔。批量通常是部分失败,整批重发会把尝试浪费在已经落块的交易上,同时让结果更难解读。
批量里有一笔落块但 revert 了怎么办? 它消耗了基础手续费,但没有改变状态。把它当成区别于"成功"和"过期"的第三种结果,因为原因在你的指令里,不在你的发送方式上。
是不是把指令合并到一笔交易里更快? 当这些操作彼此相关、并且能装进 1232 字节时,是的 —— 一笔交易意味着一个签名、一份手续费、以及有保证的原子性。批量是给真正独立的操作用的。
怎么判断我的批量并发数太高了? 同时看 429 比例和过期交易的占比。限流上升而过期率不动,说明你已经越过了有用的并发区间,只是在加压力而没有加吞吐。
批量会提高上链几率吗? 按单笔算不会。它减少的是总的墙上时钟耗时,从而给重试留下更多有效窗口。每一笔交易自身的上链行为没有变化。
一个批次过期前有多长时间?
从你签名用的那个 blockhash 起算,大约 150 个区块。用 getBlockHeight() 和随之返回的 lastValidBlockHeight 比较,不要用墙上时钟计时。