Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
RPC 故障排查阅读约 13 分钟

Solana 优先费估算:生产环境中的 setComputeUnitPrice

从 getRecentPrioritizationFees 推导每笔交易的计算单元价格,附加 ComputeBudget 指令,并通过上链率验证选择。

TL;DR

Solana 优先费不是从追踪器上复制的一个数字,而是基于 RPC 数据对每笔交易做出的决策。费用公式为 ceil(计算单元上限 × 计算单元价格 / 1,000,000) lamports,因此上限和价格都是输入。getRecentPrioritizationFees 返回来自近期区块的 { slot, prioritizationFee } 样本数组,传入交易的可写账户可将样本范围限定到这些账户被写入的 slot。将结果视为分布,取高百分位而非均值,并将 lamport 预算转换为计算单元价格。然后通过记录 slot-submitted 和 slot-confirmed 来测量上链率,用滞回调整百分位,形成闭环。本文提供一个可运行的 Node.js 估算器、一张可针对你自己的端点填写的结​​果表,以及针对空样本、不支持的方法和超额支付的故障排查手册。

Solana 费用公式以及为什么计算单元上限是一个输入

Solana 交易费用被记录为固定基础费加上优先费。基础费按签名收取,而优先费计算为 ceil(计算单元上限 × 计算单元价格 / 1,000,000) lamports。权威参考是 Solana 文档中关于费用结构的页面 https://solana.com/docs/core/fees。由于计算单元上限出现在分子中,设置远高于实际使用量的上限会在相同计算单元价格下抬高你支付的费用。

这就是为什么操作顺序很重要。首先设置一个符合实际的计算单元上限,通常来自模拟,然后才选择计算单元价格。如果你跳过上限而保留默认值,你可能会为交易从未消耗的余量支付优先费。ComputeBudget 程序(ComputeBudget111111111111111111111111111111)暴露 setComputeUnitLimit 和 setComputeUnitPrice 指令;关于这些指令以及 getFeeForMessage 的 Solana JSON-RPC 文档是查询每条消息费用的权威来源。

一个实际后果是,两笔具有相同计算单元价格的交易可能支付不同的优先费。上限更紧的那笔支付更少。将上限视为正确性和成本参数,而不是形式。

该公式两部分的权威参考是 Solana 文档的费用结构页面,下面使用的示例合约在 getRecentPrioritizationFees 方法参考中指定。将这两页视为事实来源,将本文视为在其之上构建的操作流程。

  • 基础费:按签名固定,与计算无关。
  • 优先费:ceil(计算单元上限 × 计算单元价格 / 1,000,000) lamports。
  • 计算单元上限:在选择价格之前通过模拟设置。
  • 计算单元价格:你通过 ComputeBudget 程序附加的出价。

getRecentPrioritizationFees 是已支付费用的样本,而非建议出价

getRecentPrioritizationFees 方法返回一个由 { slot, prioritizationFee } 形状对象组成的数组,取自近期区块的尾随窗口。权威合约是 https://solana.com/docs/rpc/http/getrecentprioritizationfees 上的 Solana JSON-RPC 文档。关键的是,每个条目报告的是该 slot 中交易支付的优先费,而不是推荐价格。该方法是描述性的,而非规定性的。

由于响应是一个样本,正确的心智模型是分布。均值是一个糟糕的摘要,因为单个繁忙 slot 可以将其拉高,而且样本在各 slot 之间未加权。对数组排序并取高百分位可以得到一个近期有相当比例 slot 达到或超过的值,这是更稳定的出价基础。

可选的 addresses 参数改变了样本的含义。当你传入交易的可写账户时,该方法将样本范围限定到至少其中一个账户被写入的 slot。这就是与你的争用相关的分布和无关费用池之间的区别。

  • 响应形状:{ slot, prioritizationFee } 数组。
  • 语义:这些 slot 中支付的费用,而非建议价格。
  • 聚合:排序并取百分位,而非均值。
  • 范围限定:传入可写账户使样本相关。

使用 addresses 参数限定样本范围

addresses 参数是该方法的被最少使用的部分。没有它,样本会汇集来自触及无关账户的交易的费用。在一条安静的链上,该池的中位数可以合法地为零,这就是反复出现的 Stack Exchange 帖子询问为什么 getRecentPrioritizationFees 总是返回 0 的可见症状。该方法没有坏;只是样本没有限定到你关心的任何东西。

传入你的交易将要写入的可写账户。Solana 费用文档为此过滤器定义了可写账户,方法文档确认了范围限定行为。只读账户不符合条件,因此只读取一个热门程序的交易不会通过该程序的地址缩小样本。

范围限定还使估算可操作。你争用的账户的费用分布告诉你这些账户的其他写入者最近支付了什么。这就是你的交易为获得包含而竞争的群体。

  • 传入可写账户,而非只读账户。
  • 在安静的链上,未限定范围的样本可以合法地为零。
  • 限定范围的样本反映你写入账户上的争用。
  • 如果你的交易不写入任何内容,范围限定效果有限。

读取分布:百分位、窗口和未加权 slot

一旦你有了限定范围的样本,将 prioritizationFee 值升序排序并索引一个百分位。高百分位,例如第 75 或第 90 百分位,是一个常见的起点,因为它反映了近期很大比例 slot 达到的水平。确切的百分位是你根据上链率调整的策略选择,而不是通用常数。

样本在各 slot 之间未加权,这对百分位读取是一个特性。单个繁忙 slot 贡献一个观察值,因此它不能像支配均值那样支配百分位。这使得百分位选择对异常值更稳健。

尾随窗口是一个限制。如果窗口覆盖了一个不具代表性的时期,例如已知事件之前的平静期,百分位将低估事件期间所需的费用。将窗口视为近期历史估计,而非预测。

  • 升序排序,然后索引百分位。
  • 未加权 slot 限制了任何单个繁忙 slot 的影响。
  • 尾随窗口可能无法代表即将到来的拥堵高峰。
  • 百分位选择是可调策略,而非固定常数。

使用 @solana/web3.js 的可运行 Node.js 估算器

下面的估算器构建一笔交易,模拟它以获得计算单元上限,使用可写地址调用 getRecentPrioritizationFees,取一个可配置的百分位,将 lamport 预算转换为计算单元价格,并附加 ComputeBudget 指令。它使用 @solana/web3.js,并假设连接到 Solana RPC 端点。将占位账户和端点替换为你自己的。

从 lamport 预算到计算单元价格的转换反转了费用公式:price = ceil(budgetLamports × 1,000,000 / computeUnitLimit)。这使优先费在所选上限下保持等于或低于你的预算。如果你更愿意从百分位样本推导预算,直接使用采样的 prioritizationFee 作为预算。

针对你的端点运行此代码并记录输出。下一节将这些输出转化为上链率测量。

import {
  Connection,
  PublicKey,
  TransactionMessage,
  VersionedTransaction,
  ComputeBudgetProgram,
} from '@solana/web3.js';

const RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
const connection = new Connection(RPC_URL, 'confirmed');

// Replace with the writable accounts your transaction contends on.
const writableAccounts = [
  new PublicKey('11111111111111111111111111111111'),
];

function percentile(sortedAsc, p) {
  if (sortedAsc.length === 0) return 0;
  const idx = Math.min(
    sortedAsc.length - 1,
    Math.max(0, Math.ceil((p / 100) * sortedAsc.length) - 1)
  );
  return sortedAsc[idx];
}

async function estimateComputeUnitPrice({ percentileTarget = 75, budgetLamports = null }) {
  const samples = await connection.getRecentPrioritizationFees({
    lockedWritableAccounts: writableAccounts,
  });

  const fees = samples
    .map((s) => s.prioritizationFee)
    .filter((f) => Number.isFinite(f))
    .sort((a, b) => a - b);

  const sampledFee = percentile(fees, percentileTarget);
  const budget = budgetLamports ?? sampledFee;

  return { samples, fees, sampledFee, budget };
}

async function buildWithComputeBudget({ instructions, payer, percentileTarget = 75 }) {
  const { budget } = await estimateComputeUnitPrice({ percentileTarget });

  const { blockhash } = await connection.getLatestBlockhash('confirmed');
  const message = new TransactionMessage({
    payerKey: payer,
    recentBlockhash: blockhash,
    instructions,
  }).compileToV0Message();

  const probe = new VersionedTransaction(message);
  const sim = await connection.simulateTransaction(probe, { replaceRecentBlockhash: true });
  const unitsConsumed = sim.value.unitsConsumed ?? 200_000;
  const computeUnitLimit = Math.ceil(unitsConsumed * 1.2);

  const computeUnitPrice = Math.ceil((budget * 1_000_000) / computeUnitLimit);

  const withBudget = new TransactionMessage({
    payerKey: payer,
    recentBlockhash: blockhash,
    instructions: [
      ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnitLimit }),
      ComputeBudgetProgram.setComputeUnitPrice({ microLamports: computeUnitPrice }),
      ...instructions,
    ],
  }).compileToV0Message();

  return { tx: new VersionedTransaction(withBudget), computeUnitLimit, computeUnitPrice, budget };
}

export { estimateComputeUnitPrice, buildWithComputeBudget, percentile };

闭环:测量上链率并用滞回调整

选择价格只是任务的一半。另一半是验证该价格以可接受的比率使交易上链。记录每笔交易的 slot-submitted 和 slot-confirmed,然后计算 blocks-to-confirmation 作为差值。在窗口内聚合以获得上链率和 blocks-to-confirmation 中位数。

针对目标调整百分位。如果上链率低于目标,提高百分位;如果高于目标且你支付过多,降低它。添加滞回,使价格不会围绕目标振荡:要求上链率偏离一定幅度后才改变百分位,并限制每次调整的变化量。

这个闭环将静态估算转变为控制系统。百分位成为控制变量,上链率是测量输出,滞回是阻尼。没有它,你只是在猜测一个数字,永远不知道它是否正确。

  • 记录每笔交易的 slot-submitted 和 slot-confirmed。
  • 计算 blocks-to-confirmation 作为 slot 差值。
  • 当上链率低于目标时提高百分位。
  • 应用滞回以避免围绕目标振荡。

针对你自己的端点填写的结果表

下表是一个模板。针对你自己的 RPC 端点和工作负载填写;不要将任何行视为基准。目标是了解百分位选择如何映射到你的特定账户和流量下的计算单元价格、支付的费用和上链区块数。

在固定数量的交易上运行每个百分位,记录中位数和分布,然后比较。你使用的端点很重要:专用节点可能返回与共享公共端点不同的样本。如果你需要一致的采样,请查看 RPC 定价 并考虑通过 API 服务 使用专用端点。

  • 百分位:用于出价的目标百分位。
  • 计算单元价格:附加的 microLamports 值。
  • 支付的费用:来自已确认交易的优先费,以 lamports 计。
  • 上链区块数:slot-confirmed 减去 slot-submitted。
  • 上链率:在你的窗口内确认的已提交交易比例。

故障排查:空样本、不支持的方法和超额支付

空或全零的样本数组是最常见的抱怨。如果数组为空,尾随窗口可能没有你的限定范围账户的合格 slot,或者你的端点可能不支持 addresses 参数。如果数组全为零,样本可能未限定范围,或者链上这些账户很安静。重新检查你传入了可写账户,并且你的端点遵守该参数。

如果端点不暴露该方法,响应是一个 JSON-RPC 错误对象。https://jsonrpc.org/specification 上的 JSON-RPC 2.0 规范定义了错误信封:一个 code、一个 message 和可选的 data。解码该对象,而不是假设网络故障。method-not-found 代码表示端点未实现 getRecentPrioritizationFees。

超额支付通常追溯到计算单元上限设置远高于实际使用量。由于上限在费用公式的分子中,膨胀的上限在相同价格下会抬高费用。重新模拟并收紧上限。还要记住,预检失败意味着交易从未执行,因此不支付优先费;费用仅在执行时支付。关于预检和重试语义,请参阅 Solana sendTransaction 预检与安全重试Solana RPC 超时与重试策略

  • 空数组:没有合格的 slot 或不支持 addresses 参数。
  • 全零数组:未限定范围的样本或安静的账户。
  • 错误对象:根据 JSON-RPC 2.0 解码 code 和 message。
  • 超额支付:通过模拟收紧计算单元上限。
  • 预检失败:没有执行,没有优先费。

基于百分位估算的局限性和权衡

基于百分位的估算为每笔交易增加 RPC 调用。每次估算需要一次 getRecentPrioritizationFees 调用,构建交易可能需要一次模拟和一次 blockhash 获取。在大规模下,这些调用会增加延迟和负载。在工作负载允许的情况下批处理或缓存,并注意缓存过时的百分位可能会在高峰期间定价过低。

百分位无法预测拥堵高峰。它总结近期历史,尾随窗口可能无法代表接下来几秒。如果你的应用有延迟敏感的交易,考虑更高的百分位或预算上限,而不是仅依赖样本。

大规模超额支付是真实成本。每 lamport 优先费乘以许多交易会累积。权衡在于上链率和成本之间;没有单一正确的百分位。根据你自己的测量调整它,并随着网络条件变化重新审视。

  • 每笔交易额外的 RPC 调用增加延迟和负载。
  • 百分位总结历史,而非未来拥堵。
  • 超额支付在交易量上累积。
  • 根据测量的上链率调整百分位。

后续步骤:费用对账、重试和承诺级别

交易上链后,将你支付的费用与区块内容对账。Solana getBlock 奖励与交易费用 解释了优先费如何进入已产出的区块,从而闭合你的出价与观察到的费用之间的循环。将其与 Solana 承诺级别与确认 配对,以选择你认为交易最终确定的承诺级别。

关于选择费用后的发送和重试行为,请查看 Solana RPC 超时与重试策略Solana sendTransaction 预检与安全重试。如果模拟是你设置上限步骤的一部分,解码 Solana simulateTransaction 错误 有助于解释失败。

关于端点选择和网络背景,请参阅 Solana 网络页面Solana RPC API 指南(RPC Assistant)OnFinality Learn 中心 收集了本系列的相邻文章。

  • 将支付的费用与区块内容对账。
  • 选择最终确定的承诺级别。
  • 在选择费用后处理重试。
  • 选择支持该方法和 addresses 参数的端点。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始