安全地缓存 Solana RPC 响应

什么能缓存、什么永远不能,以及为什么缓存 blockhash 会把省下的一个请求变成一笔发出去的废交易。

BoltTx Team··13 min read
solana缓存rpc限流blockhash交易机器人

缓存 RPC 响应是待在限流以内最便宜的办法。它同时也是发出一批本来就落不了块的交易最便宜的办法。

★区别完全在于你缓存的是哪一个调用。★

按"答案变得多快"来决定

调用 多久变一次 能否缓存
getMinimumBalanceForRentExemption ★几乎不变★ ★能,永久★
代币 mint 的 decimals ★创建后不再变★ ★能,永久★
PDA 推导 ★永不变★ ★能,永久★
getLatestBlockhash 每个 slot ★不能 —— 用后台刷新★
账户数据 每个 slot 短暂,或者干脆不
getBalance 每个 slot ★做判断时不能★
getSignatureStatuses ★变化正是你要的★ ★永远不能★

前三项是白拿的收益。★给定大小的租金豁免在实践中是个协议常量,而一个 mint 的 decimals 创建之后不可能改★ —— 把它们缓存整个进程生命周期是正确的。

const rentCache = new Map<number, number>();

async function rentExempt(space: number) {
  if (!rentCache.has(space)) {
    rentCache.set(space, await connection.getMinimumBalanceForRentExemption(space));
  }
  return rentCache.get(space)!;
}

blockhash 不是缓存

这是最要紧的那个区分,而"缓存"这个词正是困惑的来源。

★你绝对应该避免在热路径里去取 blockhash。你也绝对不该拿一个已经过期的出来用。★ 这两句听起来矛盾,其实不是 —— 答案是一个后台刷新器,不是一个 TTL 缓存。

let current = await connection.getLatestBlockhash("confirmed");

setInterval(async () => {
  try {
    current = await connection.getLatestBlockhash("confirmed");
  } catch { /* 保留上一个值;它仍在有效窗口内 */ }
}, 5_000);                          // ★远在约 150 个区块的窗口以内刷新★

function getBlockhash() {
  return current;                   // ★总是新鲜的,而且从不发网络请求★
}

★TTL 缓存会过期,于是要么取不到值,要么更糟 —— 返回一个已经超出有效窗口的值。★ 后台刷新器永远有一个可用的值,而且永远是近期的。这个差别体现出来就是:那些用过期缓存条目构建的交易报 Blockhash not found。

永远把 lastValidBlockHeight 和哈希存在一起。 它是告诉你的重试循环何时该停下的东西,而一个没有它的缓存 blockhash,是一笔你无法正确放弃的交易。

如果你的缓存没问题、交易还是落得晚,免费领个 BoltTx key 改一行就能测测提交路径。

minContextSlot 防止时光倒流

服务商跑的是节点池,而它们并非完美同步。连续两个请求可能打到处于不同 slot 的节点上 —— 于是一个余额可能看起来先减少、再增加,而链上什么都没发生。

const { context, value } = await connection.getAccountInfoAndContext(addr);
const seenSlot = context.slot;

// ★之后的请求:拒绝任何更旧的。★
const next = await connection.getAccountInfo(addr, {
  minContextSlot: seenSlot,
});

★minContextSlot 让节点报错,而不是端给你一个陈旧的视图。★ 对一个增量维护状态的机器人来说,时光倒流比报错更糟 —— 报错你能处理,而一次静默的回退会污染你从它推导出的一切。

会静默出错的缓存键

一个值得知道的缓存 bug,因为它在爆之前完全不可见:

// ★坏了:commitment 不在键里。★
// const key = `account:${address}`;

// ★正确。★
const key = `account:${address}:${commitment}`;

同一个账户在 processed 和 finalized 下读到的是不同的值。★一个只按地址做键的缓存,会返回先到的那个 —— 于是同一段代码路径的返回值,取决于它之前跑过什么。★

dataSlice、过滤器、编码方式同理。只要某个参数会改变响应,它就该进键里。

永远不要缓存的东西

签名状态。 你之所以轮询,正是因为期待答案会变。 一个被缓存的状态,是一个永远不会终止的确认循环。

用来做判断的余额。 展示用的想缓存就缓存。★但一笔按缓存余额算规模的交易,是按过去算的 —— 而链上检查会拒绝它。★

模拟结果。 simulateTransaction 跑的是当前状态。一个被缓存的结果描述的是一个已经过去的 slot。

任何你马上要花掉的东西。 要记住的模式是:缓存你读来做判断的东西,绝不缓存你用来提交前把关的东西。 提交时的检查属于链上、属于指令内部 —— 在那里它不可能陈旧。

否定结果需要单独一条规则

缓存"这个账户不存在",正是打新机器人出错的地方:

// ★对任何新东西都危险。★
if (cache.has(`missing:${ata}`)) return null;

★一个现在不存在的账户,下一个 slot 可能就存在了。★ 对代币账户来说这是常态,而一个缓存了"不存在"的机器人,会在它早已被创建之后很久,仍然相信它不存在。

否定结果要么只缓存极短时间、要么干脆不缓存,而且对那些预期会出现的地址永远不要缓存。

上链应该是什么水平

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

★缓存移除的是你发送之前的往返;发送之后它什么都不做。★ 一笔用完美缓存输入构建出来的交易,仍然和其它任何交易一样争夺收录。

BoltTx 在这里的位置

我们负责提交,不管读取。你给账户数据和派生值用什么缓存,保持原样。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。因为端点互相独立,读取流量和缓存未命中耗尽不了你交易发出去的那条路径。

你在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费:

const sender = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

常见问题

哪些 Solana RPC 响应可以安全缓存? 那些不会变的值:给定大小的租金豁免、mint 的 decimals、以及 PDA 推导结果。任何按 slot 变化的东西需要的是刷新策略,不是 TTL。

该缓存 getLatestBlockhash 吗? 不该做成 TTL 缓存。用后台定时器刷新它,这样热路径既不发网络请求,也不会返回一个超出有效窗口的值。

开了缓存之后为什么报 Blockhash not found? 因为一个被缓存的 blockhash 活过了它约 150 个区块的窗口。后台刷新器不会有这个问题,因为它始终持有一个近期的值,而不是让一个旧值过期。

minContextSlot 是干什么的? 拒绝那些落后于你已见 slot 的节点响应。 它防止服务商的节点池端给你一个看起来在倒退的视图。

我的余额为什么有时候会倒退? 连续的请求打到了节点池里处于不同 slot 的节点上。把你观察到的最高 slot 作为 minContextSlot 传进去,让节点报错而不是返回陈旧数据。

commitment 该进缓存键吗? 该。同一个账户在 processed 和 finalized 下读到的值不同,所以键里不带 commitment,就会返回先到的那个响应。

能缓存签名状态吗? 不能。你轮询它正是因为期待答案会变,所以一个被缓存的状态会产出一个永远不收敛的确认循环。

缓存账户数据安全吗? 短暂地、用于展示是安全的。用来算交易规模不安全 —— 一个基于缓存状态做的决策,会被那个对着当前状态运行的链上检查拒绝。

能缓存模拟结果吗? 不能。模拟跑的是当前 slot,所以一个被缓存的结果描述的是一个已经过去的状态,对你将要落块的那个 slot 什么都说明不了。

该缓存"某个账户不存在"吗? 最多极短时间,而且对预期会出现的地址永远不要。一个现在不存在的代币账户,常常片刻之后就被创建了,而缓存的"不存在"会比事实活得更久。

账户数据该缓存多久? 短于"这个值开始变得重要"所需的时间。 如果一个陈旧的值会改变某个决策,那它就压根不该被缓存 —— 改成把检查放到链上。

缓存对限流有帮助吗? 对那些反复读取的不变值,帮助很大。大部分限流压力来自重复获取常量和轮询,而这两者分别由缓存和订阅解决。

与其轮询,我该缓存什么? 什么都不缓存 —— 直接把轮询本身换成订阅。accountSubscribe 会推送变化,它比轮询便宜,也比任何缓存新鲜。

该缓存 PDA 推导吗? 该,而且可以永久缓存。同样的种子永远产出同样的地址,而推导要跑一个搜索循环,所以缓存省掉的是为一个不变答案反复消耗的 CPU。

dataSlice 和过滤器需要进缓存键吗? 需要。任何会改变响应的参数都必须是键的一部分,否则你会把一小片数据端给一个要完整数据的调用方。

真正的安全检查该放在哪? 链上,指令内部。 一个由程序强制的最小输出量不可能陈旧 —— 这正是"决策之前用缓存输入"可以接受的原因。

延伸阅读

← 返回博客列表