Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
网络与协议指南阅读约 14 分钟

Solana getProgramAccounts:dataSlice、过滤器与安全分页

一种确定性、可恢复的方法,利用 getProgramAccounts 过滤器、dataSlice 和基于键的分页来枚举大型 Solana 程序账户集。

TL;DR

getProgramAccounts 是 Solana 上开销最大且最容易被误读的方法,因为 RPC 节点执行的是全账户扫描,而不是索引查找。过滤器(dataSize 和 memcmp)可以缩小结果集,但不会对其进行分页;dataSlice 会裁剪返回的字节,但不会减少账户数量。确定性扫描必须基于一个稳定、单调递增的链上字段进行分页,记录上下文 slot 和最后看到的键,并从该键恢复,而不是从 Solana 未暴露的数字偏移量恢复。提供商侧的响应上限和基于成本的请求终止行为因提供商而异,并非协议错误。账户缺少单调字段的程序无法通过 getProgramAccounts 完全分页,需要使用提供商索引产品或提供商提供的 getProgramAccountsV2 风格端点。

为什么 getProgramAccounts 是一次全账户扫描

官方 Solana getProgramAccounts 方法参考 将 getProgramAccounts 描述为返回某个程序拥有的所有账户,并可选地按 dataSize 或 memcmp 条件进行过滤。与索引查找不同,节点会遍历该程序的账户索引,并对每个候选账户评估所提供的过滤器。正是这种设计使该方法在发现方面功能强大,但在生产环境中开销高昂:工作量随程序拥有的账户数量扩展,而不是随你请求的结果集大小扩展。

由于扫描发生在 RPC 节点上,实际的失败模式很少是协议级 JSON-RPC 错误。它通常是提供商侧的响应上限、请求超时,或因成本而被终止的请求。这些行为因提供商而异,因此同一个调用可能在一个端点上成功,而在另一个端点上被截断或拒绝。请将该方法视为发现原语,而非高频查询,并从一开始就将扫描器设计为可恢复的。

账户模型本身在 Solana 账户文档中有描述:每个账户都有所有者、lamports、可执行标志、租金 epoch 和一个数据字节数组。getProgramAccounts 返回该数据数组,这就是过滤器和 dataSlice 操作字节而不是命名字段的原因。要更广泛地了解 Solana RPC 接口,请参阅 Solana 网络概览OnFinality Learn 中心

  • 节点扫描程序拥有的账户;成本随程序账户数量扩展。
  • 提供商上限和基于成本的终止行为因提供商而异,并非 JSON-RPC 错误。
  • 过滤器缩小集合;它们不会对其进行分页。
  • dataSlice 裁剪返回的字节;它不会减少返回的账户数量。

RpcResponse 上下文 slot 以及为什么每一页都必须记录它

getProgramAccounts 响应被包装在标准的 RpcResponse 信封中,其中包含一个带有 slot 的 context 对象。该 slot 标识节点用于回答请求的 bank。由于账户在请求之间会发生变化,同一逻辑扫描的两页可能在不同的 slot 上得到回答,而在此期间创建或关闭的账户可能出现在一页上而不出现在另一页上。

因此,在每一页上记录上下文 slot 不是记账;它是事后推理一致性的唯一方式。如果你的扫描跨越多个请求,请将 slot 与你处理的最后一个键一起存储。当你之后将扫描与第二个来源进行对账时,你可以在大致相同的 slot 上比较计数,而不是在一个无界窗口内比较。

commitment 选项控制节点从哪个 bank 回答。较弱的 commitment 返回更快,但可能被回滚;较强的 commitment 更稳定,但可能滞后。方法参考将 commitment 记录为请求参数,而确切的默认值和支持的级别因提供商而异。关于 slot 级一致性的相关讨论,请参阅 Solana getBlocks 与跳过 slot

  • 每一页都带有一个上下文 slot;将其与最后一个键一起存储。
  • 账户可能在页面之间发生变化,因此扫描是一系列快照,而不是一个快照。
  • commitment 影响哪个 bank 回答;默认值和支持的级别因提供商而异。

过滤器语义:dataSize、memcmp 与 AND 组合

Solana 账户模型文档 定义了账户数据的布局和所有权方式,而方法参考定义了两种过滤器。dataSize 过滤器匹配账户数据数组的字节长度。memcmp 过滤器比较账户数据中给定偏移量处的字节字符串。两者都在节点上针对完整账户数据进行评估,然后才应用任何 dataSlice 裁剪。

过滤器之间是 AND 关系。添加过滤器会缩小候选集;它永远不会对其进行分页。这是对 getProgramAccounts 最常见的误读:开发者添加一个 memcmp 过滤器,期望得到下一页,结果却得到一个更小的第一页。如果你的过滤器集太宽,你会得到一个大响应;如果太窄,你会得到一个小响应。这两种结果都不是分页。

一个实际后果是,过滤器设计是一项选择性练习。dataSize 过滤器成本低,并且对于固定布局的账户通常具有高度选择性。对判别器或已知字段使用 memcmp 过滤器更精确,但需要你知道字节偏移量。两者结合很常见:用 dataSize 选择账户类型,用 memcmp 选择该类型内的子集。

  • dataSize 匹配账户数据的字节长度。
  • memcmp 比较账户数据中给定偏移量处的字节字符串。
  • 过滤器之间是 AND 关系;添加一个过滤器会缩小集合,而不是推进游标。
  • 过滤器针对完整账户数据进行评估,在 dataSlice 之前。

memcmp 偏移量与账户结构布局契约

memcmp 偏移量是 bincode 序列化账户数据中的字节偏移量。它不是字段名,也不是逻辑索引。因此,偏移量取决于程序生成的精确账户结构布局。对于 Anchor 程序,前八个字节是账户判别器,因此 Rust 结构体中第一个出现的字段从偏移量 8 开始,而不是 0。

这使得任何根据结构体定义计算出的偏移量都成为一份带版本的契约。如果程序后来在你过滤的字段之前添加了一个字段,或者更改了字段类型,你的偏移量会静默地指向错误的字节。过滤器仍会执行;只是匹配了错误的数据。请将偏移量视为集成接口的一部分,并将其固定到程序版本。

Solana 账户文档将账户数据描述为程序拥有的不透明字节数组,这就是 RPC 层只能提供字节级过滤器的原因。对于完全避免此问题的单账户读取,请参阅 读取 Solana 账户信息与租金

  • memcmp 偏移量是 bincode 序列化账户数据中的字节偏移量。
  • Anchor 账户以 8 字节判别器开头,因此第一个结构体字段从偏移量 8 开始。
  • 根据结构体定义得出的偏移量是一份带版本的契约,布局变化时会失效。
  • 将偏移量固定到程序版本,并在程序升级后重新验证。

dataSlice 是带宽控制,而非分页机制

方法参考将 dataSlice 记录为可选的 offset 和 length,仅返回每个账户数据的该切片。它减少了传输的字节数。它不会减少返回的账户数量。使用 dataSlice 的扫描如果返回一万个账户,仍然返回一万个账户;只是每个账户更小。

这一区别很重要,因为仅靠 dataSlice 永远无法使大型扫描变得廉价。节点仍然会扫描,并且仍然会序列化包含每个匹配账户的响应。dataSlice 改变的是每个账户的负载大小,这可能使响应从超过提供商上限变为低于上限,但无法使响应从一万个账户变为一百个。

还有一个可用性陷阱:如果你切掉了排序或恢复所需的字段,结果集将无法用于分页。请选择切片以恰好覆盖你打算分页所依据的键字段,并在每个响应中保留该字段。

  • dataSlice 返回每个账户数据的 offset..offset+length。
  • 它减少传输的字节数,但不减少返回的账户数量。
  • 切掉排序或恢复键会使结果集无法使用。
  • 选择切片以覆盖你分页所依据的键字段。

dataSlice 如何与过滤器交互

过滤器在节点上针对完整账户数据进行评估。dataSlice 仅应用于返回的负载。这种顺序意味着,对你同时切掉的字段进行过滤是合法的:节点可以匹配它不会返回的字节。例如,你可以过滤偏移量 0 处的判别器,并仅返回字节 8 到 40。

实际含义是,你可以在保持响应较小的同时仍使用精确过滤器。风险在于你失去了在本地验证匹配的能力,因为匹配的字节不在响应中。如果你需要审计过滤器的正确性,请临时扩大切片或运行单独的验证查询。

这种分离也是 dataSlice 不能用于实现分页的原因。分页需要响应中有一个稳定的排序键;dataSlice 只控制你看到每个账户的哪些字节。关于另一个方法上的相关模式,请参阅 Solana getSignaturesForAddress 分页

  • 过滤器针对完整账户数据运行;dataSlice 仅裁剪返回的负载。
  • 对你切掉的字段进行过滤是合法的。
  • 如果你需要审计过滤器正确性,请临时扩大切片。
  • dataSlice 无法实现分页,因为它不对结果排序。

基于稳定键设计可恢复扫描

Solana 没有为 getProgramAccounts 暴露数字偏移量或游标。因此,任何分页方案都必须建立在账户数据内部的字段上。可行的模式是基于一个单调递增的链上字段进行分页,记录最后看到的键,并从该键恢复。每个请求过滤键大于最后一个键的账户,在本地对结果排序,并将游标推进到该批次中的最大键。

这是一种适应没有原生游标的方法的键集分页模式。只要键字段唯一且单调,它就是确定性的。如果键不唯一,你需要一个决胜条件;如果它不单调,扫描可能会遗漏或重复账户。每个响应的上下文 slot 告诉你该批次来自哪个快照。

关于通过 RPC 查询历史状态的更广泛讨论,请参阅 通过 RPC 查询 Solana 历史数据。记录 slot 和游标的同样纪律也适用于那里。

  • Solana 没有为 getProgramAccounts 暴露数字偏移量或游标。
  • 基于单调递增的链上字段分页,并从最后一个键恢复。
  • 在本地对每个批次排序,并将游标推进到最大键。
  • 将上下文 slot 与最后一个键一起记录,以便日后对账。

可运行的 Node.js 扫描器,包含 dataSize、memcmp 和 dataSlice

下面的扫描器发出带有 dataSize 过滤器、对判别器的 memcmp 过滤器以及仅覆盖键字段的 dataSlice 的 getProgramAccounts。它输出每个批次的摘要,并可以从记录的最后一个键重新运行。请将程序 ID、判别器和偏移量替换为你程序的值。

该脚本使用标准的 JSON-RPC 2.0 请求格式,并从每个响应中读取上下文 slot。它不假设任何提供商特定的扩展。如果你的提供商提供 getProgramAccountsV2 风格的端点,同样的游标逻辑适用,但请求格式因提供商而异。

// scan.js — resumable getProgramAccounts scanner
// Usage: node scan.js [lastKeyBase58]
const RPC_URL = process.env.RPC_URL || 'https://api.mainnet-beta.solana.com';
const PROGRAM_ID = process.env.PROGRAM_ID; // your program id
const DATA_SIZE = Number(process.env.DATA_SIZE || 165);
const KEY_OFFSET = Number(process.env.KEY_OFFSET || 8);
const KEY_LENGTH = Number(process.env.KEY_LENGTH || 32);
const DISCRIMINATOR_B58 = process.env.DISCRIMINATOR_B58; // optional

async function rpc(method, params) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
  });
  const json = await res.json();
  if (json.error) throw new Error(JSON.stringify(json.error));
  return json.result;
}

async function scan(lastKey) {
  const filters = [{ dataSize: DATA_SIZE }];
  if (DISCRIMINATOR_B58) {
    filters.push({ memcmp: { offset: 0, bytes: DISCRIMINATOR_B58 } });
  }
  const result = await rpc('getProgramAccounts', [
    PROGRAM_ID,
    {
      encoding: 'base64',
      commitment: 'confirmed',
      withContext: true,
      filters,
      dataSlice: { offset: KEY_OFFSET, length: KEY_LENGTH },
    },
  ]);
  const slot = result.context.slot;
  const accounts = result.value;
  const keys = accounts.map((a) => Buffer.from(a.account.data[0], 'base64'));
  keys.sort(Buffer.compare);
  const last = keys.length ? keys[keys.length - 1].toString('hex') : lastKey;
  const bytes = accounts.reduce((n, a) => n + Buffer.from(a.account.data[0], 'base64').length, 0);
  console.log(JSON.stringify({
    filter: filters,
    accountsReturned: accounts.length,
    bytesReturned: bytes,
    contextSlot: slot,
    lastKey: last,
  }));
  return last;
}

scan(process.argv[2]).catch((e) => { console.error(e); process.exit(1); });

对照第二来源验证扫描完整性

只有当你能够论证扫描是完整的时,它才有用。最便宜的交叉检查是使用一个报告相同账户数量的第二来源:提供商索引产品、提供商提供的 getProgramAccountsV2 风格端点,或在附近 slot 查询的单独 RPC 端点。比较计数,并在信任扫描之前调查任何差距。

由于账户在请求之间会发生变化,精确相等并不总是可实现。记录每个批次的上下文 slot,并在大致相同的 slot 上比较计数。如果第二来源报告的计数存在实质性差异,可能的原因是过滤器太窄、游标跳过了某个范围,或提供商上限截断了响应。

关于端点选择和故障转移行为,请参阅 RPC 端点指南(RPC Assistant)。对两个端点运行相同的扫描是检测提供商特定截断的实用方法。

  • 对照第二来源交叉检查账户数量。
  • 在大致相同的上下文 slot 上比较计数。
  • 在信任扫描之前调查差距。
  • 对两个端点运行相同的扫描,以检测提供商特定的截断。

结果表:测量你自己的端点

提供商行为因提供商而异,因此唯一可靠的数字是你针对自己端点测量得到的数字。使用固定的过滤器集运行扫描器,并为每个批次记录以下列。不要将你的数字与已发布的数字进行比较;将它们与你自己的基线随时间进行比较。

下表是一个模板。用你端点的值填充它,并将其与扫描输出一起保存。如果某个批次返回零个账户或负载被截断,请记下上下文 slot 和使用的过滤器,以便你可以重现该情况。

  • 批次索引
  • 使用的过滤器(dataSize、memcmp 偏移量/字节)
  • 返回的账户数
  • 返回的字节数
  • 上下文 slot
  • 最后一个键
  • 实际耗时
  • 提供商响应状态或错误

getProgramAccounts 分页的局限性与权衡

诚实的局限是:如果程序的账户没有单调字段,则根本无法通过 getProgramAccounts 完全分页。没有稳定的排序键,就没有可恢复的游标,任何类似偏移量的方案都会在集合变化时遗漏或重复账户。在这种情况下,读者必须使用提供商索引产品,或提供商提供的 getProgramAccountsV2 风格端点,这因提供商而异。

即使有单调键,扫描也是一系列快照,而不是单一一致视图。批次之间创建或关闭的账户可能被遗漏或重复计数。上下文 slot 让你事后推理这一点,但并不能消除它。如果你需要一致的快照,你需要一个维护该快照的索引产品。

最后,成本和速率限制是真实约束。全扫描对节点来说开销高昂,提供商可能会限制响应大小或因成本终止请求。请将扫描器设计为可恢复、记录其游标,并容忍部分批次。关于定价和服务背景,请参阅 RPC 定价API 服务

  • 没有单调字段意味着无法通过 getProgramAccounts 完全分页。
  • 扫描是一系列快照,而不是一个一致视图。
  • 提供商上限和基于成本的终止行为因提供商而异。
  • 为可恢复性和部分批次进行设计。

排查常见的 getProgramAccounts 失败

最常见的失败是响应比预期小。检查过滤器是否太窄、memcmp 偏移量在程序升级后是否指向错误的字节,或提供商是否截断了响应。批次摘要中的上下文 slot 和过滤器集是首先要检查的内容。

第二种常见失败是请求被拒绝或被终止。这通常是提供商侧的上限或成本控制,而不是 JSON-RPC 协议错误。使用更具选择性的过滤器缩小结果集,缩小 dataSlice,或切换到具有不同限制的端点。该行为因提供商而异。

第三种常见失败是扫描似乎循环或跳过。这通常意味着游标键不唯一或不单调。添加决胜条件,验证键字段确实在递增,并确认 dataSlice 仍包含键字段。关于相关的分页模式,请参阅 Solana getSignaturesForAddress 分页

  • 响应比预期小:检查过滤器选择性、memcmp 偏移量和提供商截断。
  • 请求被拒绝或终止:提供商上限或成本控制,因提供商而异。
  • 扫描循环或跳过:游标键不唯一或不单调。
  • 确认 dataSlice 仍包含键字段。

生产账户枚举的后续步骤

如果你的程序有单调键,本文中的扫描器是一个可行的起点。为最后一个键和上下文 slot 添加持久化,安排扫描,并对照第二来源交叉检查计数。如果你的程序缺少单调键,请在构建自定义变通方案之前评估提供商索引产品或 getProgramAccountsV2 风格端点。

关于端点选择和故障转移,请查看 RPC 端点指南(RPC Assistant)。关于更广泛的 Solana RPC 覆盖,请参阅 Solana 网络概览OnFinality Learn 中心。关于定价和服务详情,请参阅 RPC 定价API 服务

此处描述语义的权威主要来源是 Solana getProgramAccounts 方法参考(https://solana.com/docs/rpc/http/getprogramaccounts)和 Solana 账户文档(https://solana.com/docs/core/accounts)。提供商特定的限制和扩展应始终对照你提供商的最新文档进行确认。

  • 持久化最后一个键和上下文 slot;安排并交叉检查扫描。
  • 如果不存在单调键,请评估索引产品。
  • 对照最新文档确认提供商限制和扩展。

永远不用担心基础设施

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

开始