Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
基础设施与运维阅读约 14 分钟

Solana getProgramAccounts:构建可恢复的账户集索引器

将一次性的 getProgramAccounts 扫描转变为持续正确、可恢复的索引,借助 slot 游标、缺口填补和删除对账。

TL;DR

生产级 Solana 索引器不是一次 getProgramAccounts 调用;它是在记录 slot 处取得的快照,加上保持该快照最新的增量流,两者绝不允许静默不一致。快照的身份是其各页中 context.slot 的最小值,因为对不断推进的链进行可恢复扫描会跨越一个 slot 范围。slot 游标不是区块高度游标:slot 可能被跳过,因此游标必须是用 getBlocks 遍历的 slot 范围,而不是整数递增。accountSubscribe 从当前 slot 开始,无法回填快照 slot 与订阅 slot 之间的缺口,因此该缺口必须通过有界重扫来闭合。删除检测需要按所有权和存活状态对账,而不是按扫描成员资格,因为已关闭的账户会直接从下一次扫描中消失。本文假设你已阅读关于过滤器语义和可恢复键序扫描的姊妹页面,不再重复该内容。

索引器契约:快照加流

完整的 getProgramAccounts 扫描会在记录的 slot 处建立程序账户集的快照。增量流保持该快照最新。契约是两者绝不静默不一致:每次写入都携带版本,每次重启都能证明必须重放哪个流范围。

扫描的请求和响应契约以 Solana JSON-RPC 文档中关于 getProgramAccounts 的说明 为准,包括 RpcResponse 的 context slot、dataSize/memcmp 过滤器数组、dataSlice 以及 withContext 封装。流式接口在 Solana JSON-RPC websocket 文档 中针对 accountSubscribe 和 logsSubscribe 有说明。

本文刻意不再讲授过滤器语义、memcmp 偏移量、作为带宽控制的 dataSlice 或可恢复键序扫描。这些基础内容属于上面链接的先决页面,并在后续步骤中再次提及。

  • 快照:一次完整扫描,其页面以稳定的账户键为键,并标记 slot。
  • 流:accountSubscribe 用于逐账户变更,从当前 slot 开始。
  • 对账:区分已关闭账户与仅未出现在某页中的账户的过程。

context slot 作为快照的身份

getProgramAccounts 返回一个 RpcResponse,其 context.slot 是该页读取时的 slot。正确的快照记录其各页中 context slot 的最小值,而不是最大值,也不是墙上时钟时间。对不断推进的链进行分页的可恢复扫描会跨越一个 slot 范围,因此最早的 slot 是唯一能保证不遗漏该 slot 与快照完成之间任何变更的值。

记录最大值会静默跳过第一页和最后一页之间发生的变更。记录墙上时钟时间更糟:它与链状态没有定义的关系,也无法针对 getBlocks 重放。

  • 记录所有页面中 min(context.slot) 作为快照 slot。
  • 将快照 slot 与游标一起持久化,以便重启时知道流必须从何处恢复。
  • 绝不用区块高度或墙上时钟时间替代 context slot。

为什么 slot 游标不是区块高度游标

slot 可能被跳过,因此游标不能是整数递增。游标必须是用 getBlocks 遍历的 slot 范围,getBlocks 返回范围内已产生的区块。getBlockHeight 是另一个量,不能用作游标,如 Solana JSON-RPC 文档中关于 getSlot、getBlockHeight 和 getBlocks 的说明 所述。

没有产生区块的 slot 不能作为游标位置。如果你的游标是整数递增,被跳过的 slot 要么使索引器停滞,要么导致它跳过真实状态。用 getBlocks 遍历范围使被跳过的 slot 显式化,并让缺口填补逻辑正确处理它们。

  • 游标 = 一个 slot 范围,而不是单个整数。
  • 使用 getBlocks 枚举范围内已产生的区块。
  • 将被跳过的 slot 视为需要检测的缺口,而不是要忽略的错误。

使用持久化游标构建快照

枚举页面,以稳定的账户键为每页设键,并持久化 (账户键, slot) 对。使用记录的 slot 来决定重启时必须从何处重放哪个增量流。扫描本身是先决页面的可恢复键序扫描;这里我们只添加 slot 标记和持久化。

下面的代码展示了带 context slot 捕获和游标写入的快照循环。它假设使用姊妹文章中的分页辅助函数,并聚焦于索引器生命周期。

const { Connection, PublicKey } = require('@solana/web3.js');

async function snapshot(connection, programId, store) {
  let cursor = await store.getCursor();
  let minSlot = null;
  let page = 0;

  while (true) {
    const res = await connection.getProgramAccounts(new PublicKey(programId), {
      withContext: true,
      filters: [],
      dataSlice: { offset: 0, length: 0 },
      // pagination via key-order scan is handled by the sibling helper
      ...(cursor ? { before: cursor } : {})
    });

    const slot = res.context.slot;
    if (minSlot === null || slot < minSlot) minSlot = slot;

    for (const { pubkey, account } of res.value) {
      await store.upsert(pubkey.toBase58(), { slot, data: account.data });
    }

    if (res.value.length === 0) break;
    cursor = res.value[res.value.length - 1].pubkey.toBase58();
    await store.setCursor(cursor);
    page += 1;
  }

  await store.setSnapshotSlot(minSlot);
  return { minSlot, page };
}

module.exports = { snapshot };

使用 accountSubscribe 保持索引最新

accountSubscribe 传递逐账户变更,但订阅从当前 slot 开始,因此无法回填快照 slot 与订阅 slot 之间的缺口。该缺口必须通过有界重扫来闭合。通过记录订阅 slot 并使用 getBlocks 遍历已产生区块来重扫范围 [snapshotSlot, subscriptionSlot] 来界定它。

有界重扫不是第二次完整扫描;它是对缺口的有针对性重放。其大小是两个 slot 之间的差值,可测量且可设上限。如果缺口超过上限,则回退到全新快照,而不是让缺口无界增长。

  • 在 websocket 确认时记录订阅 slot。
  • 使用 getBlocks 遍历已产生区块来重扫 [snapshotSlot, subscriptionSlot]。
  • 为缺口设上限;如果超过上限,则获取全新快照。

按所有权和存活状态检测删除

已关闭的账户会从扫描中消失,因此天真的“用我刚扫描的内容替换我的表”索引器会静默删除实时数据,而天真的合并索引器会永远保留已删除的账户。正确的方法是按所有权和存活状态对账:扫描不再返回且 getAccountInfo 报告不存在的账户已被关闭。

账户模型以及关闭账户对其 lamports 和所有者意味着什么,在 Solana 关于账户所有权和账户模型的文档 中有说明。用它来区分已关闭账户与仅因页面出错而未返回的账户。

  • 已关闭:扫描不再返回它,且 getAccountInfo 报告不存在。
  • 未返回:页面出错或被截断;不要删除。
  • 按所有权和存活状态对账,而不是按扫描成员资格。

以账户键为键、带 slot 版本控制的幂等写入

同一账户会同时来自快照和流,因此写入路径必须是以账户键为键、以 slot 为版本的 upsert。这也使重放无害:带有较旧 slot 的重放流消息被忽略,较新的 slot 会覆盖。

下面的代码展示了 upsert 和删除对账过程。它刻意保持小巧,以便放入现有存储。

async function upsert(store, accountKey, { slot, data }) {
  const existing = await store.get(accountKey);
  if (existing && existing.slot >= slot) return; // stale replay
  await store.put(accountKey, { slot, data });
}

async function reconcileDeletions(connection, store, programId) {
  const known = await store.allKeys();
  for (const key of known) {
    const info = await connection.getAccountInfo(new PublicKey(key));
    if (info === null) {
      await store.delete(key);
    } else if (!info.owner.equals(new PublicKey(programId))) {
      await store.delete(key); // ownership changed
    }
  }
}

module.exports = { upsert, reconcileDeletions };

可运行的 Node.js 索引器,带缺口填补和对账

完整循环结合了快照、持久化游标、accountSubscribe 流、有界缺口填补重扫和删除对账过程。针对你自己的端点运行它,并填写下一节的结果表。

流处理器通过同一个 upsert 写入,因此快照和流汇聚到一个版本化存储。对账过程按计划运行,并在每次重启后运行。

const { Connection, PublicKey } = require('@solana/web3.js');
const { snapshot } = require('./snapshot');
const { upsert, reconcileDeletions } = require('./store');

async function run(connection, programId, store) {
  const { minSlot } = await snapshot(connection, programId, store);

  const subId = connection.onProgramAccountChange(
    new PublicKey(programId),
    async (keyedAccountInfo, context) => {
      await upsert(store, keyedAccountInfo.accountId.toBase58(), {
        slot: context.slot,
        data: keyedAccountInfo.accountInfo.data
      });
    },
    'confirmed'
  );

  const subscriptionSlot = await connection.getSlot('confirmed');
  const gap = await connection.getBlocks(minSlot, subscriptionSlot);
  for (const blockSlot of gap) {
    // bounded re-scan of the gap range
    await snapshotRange(connection, programId, store, blockSlot);
  }

  setInterval(() => reconcileDeletions(connection, store, programId), 60_000);
  return subId;
}

module.exports = { run };

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

针对你自己的端点进行测量,并记录下面的值。不要依赖已发布的数字;重点是刻画你的端点在你的工作负载下的行为。

在固定窗口内运行索引器,然后填写每一行。快照 slot 跨度是各页中最大和最小 context slot 之间的差值。缺口填补范围是订阅 slot 与快照 slot 之间的差值。

  • 快照 slot 跨度:各页中 max(context.slot) - min(context.slot)。
  • 页面数:快照中 getProgramAccounts 页面的数量。
  • 流延迟:订阅 slot 减去最后一次流式写入的 slot。
  • 缺口填补范围:订阅 slot 减去快照 slot。
  • 对账删除数:对账过程移除的账户数量。

故障模式与故障排除

对某个端点的响应上限来说过大的扫描要么报错,要么截断。端点在不报错的情况下截断扫描是最危险的情况,因为快照看起来完整但实际不完整。通过将页面数与已知良好基线比较,并检查最后一页为空来检测它。

在移动链上分页是预期内的;最小 context slot 会处理它们。必须检测订阅中断并重新运行缺口填补。扫描与流在同一键上相遇导致的速率限制压力在 Solana RPC 速率限制和 429 错误 中有说明。

  • 响应上限:减小页面大小或使用 dataSlice 缩小负载。
  • 静默截断:验证最后一页为空且页面数与基线匹配。
  • 订阅中断:从最后记录的 slot 重新运行有界缺口填补。
  • 速率限制:错开扫描和流,并在 429 时退避。

限制、权衡和成本模型

这种设计在构造上就是最终一致的:快照和流会收敛,但总有一个索引落后于链的窗口。它不是针对非常大的程序账户集的专用索引器的替代品,在那种情况下完整扫描成本过高,需要专门的摄取管道。

成本模型主要由完整扫描和对账过程主导。流相对便宜。使用 RPC 定价 进行估算,并考虑 API 服务 获取托管访问。关于网络背景,请参阅 Solana

  • 最终一致:对大多数读取路径可接受,但不适用于严格一致性。
  • 不是针对非常大的账户集的专用索引器的替代品。
  • 成本主要由完整扫描和对账主导;流很便宜。

后续步骤和相关阅读

从先决条件 Solana getProgramAccounts:dataSlice、过滤器和安全分页 开始,然后扩展到 Solana getSignaturesForAddress 分页Solana getBlocks、跳过 slot 和索引器缺口检测

关于账户和租金读取,请参阅 读取 Solana 账户:租金和代币账户。关于端点行为,请参阅 Solana RPC API 指南(RPC Assistant)OnFinality Learn 中心

  • 先决条件:过滤器语义和可恢复键序扫描。
  • 相关:签名分页、跳过 slot 缺口检测、账户/租金读取。
  • 运维:速率限制、RPC 定价、API 服务、Solana 网络页面。

永远不用担心基础设施

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

开始