state_getKeysPaged 是 Substrate 存储的一种正向遍历、前缀限定、无游标的分页原语。它返回最多 count 个以给定前缀开头的十六进制编码存储键,从排他性的 startKey 之后开始,并在特定区块上求值。要遍历完整的存储映射,你需要将 pallet 和存储项名称的 twox128 哈希作为前缀传入,将最后返回的键作为 startKey 回传,并在页面长度小于 count 时停止。由于排序不保证按字典序,你不能假设客户端游标是单调的;相反,应依赖节点自身的遍历顺序,并固定区块以避免混合状态。本指南涵盖方法签名、键构造、可运行的 Node.js 示例、值批量读取以及可复现的验证方法。
方法签名与分页语义
state_getKeysPaged 方法接受四个参数:prefix(十六进制字符串)、count(整数)、startKey(十六进制字符串,可选)和 at(区块哈希,可选)。它返回一个数组,包含最多 count 个以 prefix 开头的十六进制编码存储键,页面从 startKey(排他性)开始,并在区块 at 上求值。如果省略 startKey,遍历将从匹配前缀的第一个键开始。该方法在 polkadot.js Substrate JSON-RPC 参考 中有文档说明,并且是 Substrate JSON-RPC API 的一部分。
分页通过将最后返回的键作为下一次调用的 startKey 回传来驱动。你持续进行,直到结果长度小于 count 或为空。这是一种无游标模式:节点在调用之间不维护服务器端状态,因此你必须自己管理遍历循环。由于该方法受前缀限定,它只返回与你提供的精确字节前缀相同的键。这使其非常适合遍历单个存储映射,但也意味着错误的前缀要么返回空,要么溢出到不相关的存储项。
at 参数将查询固定到特定区块。如果省略,节点将在最新区块上求值,而最新区块可能在调用之间发生变化。为了在多个页面之间保持一致的遍历,你应始终传入相同的区块哈希。这是有文档记录的行为:状态读取仅在你传入的区块上保持一致。有关区块特定读取的更多信息,请参阅 Polkadot RPC 在特定区块获取外部交易和事件。
prefix:十六进制编码的存储键前缀(例如,pallet 和 item 的 twox128 哈希)。count:每页返回的最大键数。startKey:从此键之后开始的十六进制编码键(排他性);第一页省略。at:用于求值的区块哈希;省略则使用最新区块(不推荐用于遍历)。
构造正确的存储键前缀
Substrate 存储键由 pallet 名称和存储项名称的 twox128 哈希拼接而成。对于存储映射,映射键随后被 SCALE 编码并追加。你传给 state_getKeysPaged 的前缀必须是映射键之前的精确字节。对于像 System.Account 这样的映射,前缀是 twox128('System') ++ twox128('Account')。这是 32 字节(16 + 16)。传入较短的前缀(例如,仅 pallet 哈希)将遍历该 pallet 中的所有存储项,这可能不是你想要的结果。
Substrate 存储文档 解释了 twox128 是一种非加密哈希,因其速度和低碰撞概率而被使用。pallet 和 item 名称是 ASCII 字符串。你可以使用 @polkadot/util-crypto 或任何 twox128 实现来计算这些哈希。生成的前缀在传给 RPC 方法时是不带 0x 前缀的十六进制字符串吗?实际上,RPC 期望带有 0x 前缀的十六进制字符串。始终验证前缀长度:对于映射,它应该是 32 字节(64 个十六进制字符加上 0x)。
如果你不小心使用了更宽泛的前缀,你可能会检索到同一 pallet 中其他存储项的键。例如,仅使用 twox128('System') 将返回 Account、Events、BlockHash 等的键。这很少是想要的。要限定到单个映射,始终拼接两个哈希。你可以解码运行时元数据以确认确切的存储项名称及其前缀;请参阅 Substrate state_getMetadata 与运行时版本。
const { xxhashAsHex } = require('@polkadot/util-crypto');
function storageMapPrefix(pallet, item) {
const palletHash = xxhashAsHex(pallet, 128);
const itemHash = xxhashAsHex(item, 128);
return palletHash + itemHash.slice(2); // remove 0x from second hash
}
const prefix = storageMapPrefix('System', 'Account');
console.log('Prefix:', prefix); // 0x... (32 bytes)驱动分页而不产生间隙或循环
要遍历完整映射,开始时省略 startKey(或设为 null)。调用 state_getKeysPaged(prefix, count, startKey, at)。如果返回的数组长度等于 count,将 startKey 设置为数组中的最后一个键并重复。如果长度小于 count,则已到达末尾。如果数组为空,则映射为空或前缀错误。这个循环是安全的,因为 startKey 是排他性的:节点在其内部遍历顺序中返回严格位于给定键之后的键。
关键的是,你不能假设键按字典序返回。Substrate 存储 trie 是 Merkle Patricia Trie,遍历顺序由 trie 的结构决定,不保证按原始键字节排序。假设单调排序(例如,用 > 或 < 比较键)的客户端游标将会有 bug。相反,始终将最后返回的键作为下一个 startKey,无论其值如何。这是 polkadot.js 参考 中记录的模式。
如果你需要稍后恢复遍历,可以存储最后一个键和区块哈希。但是,如果链已推进,该区块的状态可能在已修剪的节点上不再可用。对于长时间遍历,考虑固定到最近的最终确定区块,并在节点的修剪窗口内完成遍历。有关状态查询的更多信息,请参阅 Polkadot state_queryStorageAt 存储变更。
- 开始时省略
startKey或设为null。 - 当返回长度 === count 时循环。
- 将
startKey设置为上一页的最后一个键。 - 当长度 < count 或为空时停止。
- 永远不要在客户端排序或比较键;依赖节点顺序。
解码返回的键并读取值
每个返回的键都是一个十六进制字符串。要解码映射键,你必须剥离已知前缀(32 字节的 pallet+item 哈希),然后根据映射的键类型对剩余字节进行 SCALE 解码。键类型在运行时元数据中定义。例如,System.Account 使用 AccountId32 作为键,即 32 字节。剥离前缀后,你就得到了 SCALE 编码的键。你可以使用 @polkadot/types 从元数据创建类型并解码它。
一旦你有了解码后的键,就可以读取相应的值。你可以为每个键单独调用 state_getStorage,但对于大型映射来说效率低下。相反,使用 state_queryStorageAt 并传入键数组和区块哈希。该方法在一次往返中返回所有键的值,速度更快并减少了 RPC 调用次数。该方法在 polkadot.js 参考 中有文档说明。
批量处理时,请注意最大请求大小。一些提供商限制每次 state_queryStorageAt 调用的键数量。常见的做法是以 100–500 个键为块进行批处理。如果你只需要几个值,也可以使用 state_getStorage 进行单独读取。有关批量读取的深入探讨,请参阅 Polkadot state_queryStorageAt 存储变更。
const { ApiPromise, WsProvider } = require('@polkadot/api');
const { xxhashAsHex } = require('@polkadot/util-crypto');
async function iterateMap(pallet, item, pageSize = 100) {
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.polkadot.io') });
const prefix = xxhashAsHex(pallet, 128) + xxhashAsHex(item, 128).slice(2);
const at = (await api.rpc.chain.getHeader()).hash;
let startKey = null;
let allKeys = [];
let page;
do {
page = await api.rpc.state.getKeysPaged(prefix, pageSize, startKey, at);
allKeys.push(...page);
if (page.length > 0) startKey = page[page.length - 1];
} while (page.length === pageSize);
console.log(`Total keys: ${allKeys.length}`);
// Batch read values
const values = await api.rpc.state.queryStorageAt(allKeys, at);
console.log(`Values fetched: ${values.length}`);
await api.disconnect();
}
iterateMap('System', 'Account').catch(console.error);存储类型与节点版本差异
state_getKeysPaged 方法历史上接受一个 storageKind 参数(例如,'value' 表示主 trie,'child' 表示子 trie)。在现代 Substrate 节点中,此参数通常已弃用,或被用于子存储的单独方法所取代。确切行为有文档记录 / 因提供商和节点版本而异。你应该查阅你的节点的 RPC 文档或针对你所针对版本的 polkadot.js 参考。
如果你正在处理子 trie(例如,合约存储),你可能需要使用 state_getChildKeysPaged 或类似方法。前缀语义保持不变,但 trie 不同。始终根据你的节点元数据验证方法签名。有关运行时元数据解码,请参阅 Substrate state_getMetadata 与运行时版本。
如有疑问,请使用小页面大小和已知映射进行测试。如果该方法返回关于未知参数的错误,你的节点可能不支持 storageKind。在这种情况下,省略它并使用默认值(value trie)。
将遍历范围限定到单个映射与更宽泛的前缀
你传入的前缀决定了范围。完整的 twox128 pallet+item 前缀精确限定到一个存储映射。仅 pallet 前缀(pallet 名称的 twox128)限定到该 pallet 中的所有存储项。较短的前缀(例如,前 16 字节)如果哈希碰撞,可能会溢出到其他 pallet,尽管 twox128 碰撞极不可能。最安全的方法是始终为特定映射使用完整的 32 字节前缀。
如果你有意遍历 pallet 中的所有存储,可以使用 pallet 哈希作为前缀。但是,请注意返回的键将包含不同的存储项,你需要从前缀中解码每个键的项名称以了解它属于哪个映射。这很少有必要,并且容易出错。
要验证你的前缀,你可以使用较小的 count 调用 state_getKeysPaged 并检查返回的键。它们都应以前缀开头。如果不是,则前缀错误。你也可以使用 state_getMetadata 列出所有存储项及其前缀。
- 完整映射前缀:twox128(pallet) ++ twox128(item) — 32 字节。
- Pallet 范围前缀:twox128(pallet) — 16 字节。
- 始终验证返回的键以前缀开头。
- 使用元数据确认确切的项名称。
检测不完整遍历的可复现方法
为确保遍历完整,你需要一种独立的方法来验证键的总数。一种方法是将分页键数与来自其他来源的可信总数进行比较,例如区块浏览器或节点暴露的后代计数。例如,System.Account 的账户数量已知,可以与区块浏览器交叉核对。如果你的计数明显偏低,你的遍历可能因 bug 或速率限制而提前停止。
另一种方法是使用不同的页面大小(例如,100 和 500)运行遍历两次,并比较生成的键集。如果它们不同,则遍历逻辑有缺陷。你还可以计算所有键的校验和(例如,拼接排序后的键的 SHA-256),并在多次运行之间进行比较。这是一种可复现的方法来检测间隙或重复。
要进行更严格的检查,你可以在小型映射上使用非常大的 count(例如,10000)调用 state_getKeysPaged 方法,并将结果与分页结果进行比较。如果匹配,则分页正确。请注意,某些提供商可能会拒绝大的 count 值,因此请谨慎使用。
- 将分页计数与可信总数(例如,区块浏览器)进行比较。
- 使用不同的页面大小运行并比较键集。
- 计算所有键的校验和并在多次运行之间比较。
- 对于小型映射,使用单个大页面作为参考。
结果表:针对你自己的端点进行测量
由于延迟、吞吐量和速率限制因提供商而异,你应该针对自己的端点测量 state_getKeysPaged 的性能。下表提供了记录测量结果的模板。用你自己的结果填写。不要依赖通用基准;你的网络条件和提供商限制很重要。
要收集数据,请使用不同的页面大小运行上面的 Node.js 示例,并记录每次完整遍历所花费的时间。使用秒表或 console.time。还要记录进行的 RPC 调用次数(等于页面数)。如果遇到 HTTP 429 错误,请记下页面大小和错误发生的时间。这将帮助你调整页面大小和退避策略。
对于生产系统,考虑使用专用的 API 服务 或 RPC 定价 计划,以匹配你预期的请求量。OnFinality 的 Polkadot 网络 端点支持 state_getKeysPaged,可用于测试。始终遵守速率限制并实现指数退避。
- 页面大小:每个请求的键数。
- 总键数:映射中的键总数。
- 页面数:总 RPC 调用次数。
- 总时间:完整遍历的挂钟时间。
- 错误:遇到的任何 429 或超时错误。
state_getKeysPaged 的限制与权衡
startKey 的排他性和排序语义有文档记录 / 因客户端而异。虽然一般模式是一致的,但某些节点实现可能在处理排他性起始或遍历顺序方面有细微差异。始终针对你的目标节点进行测试。polkadot.js 参考 是 JavaScript API 的权威来源,但底层 RPC 行为由 Substrate 节点定义。
使用许多小页面遍历大型映射很慢。每个页面都是一次单独的往返,因此受速率限制的端点如果超出限制将返回 HTTP 429。这是一个基本的权衡:较大的页面大小减少了往返次数,但增加了响应大小和内存使用。你必须找到适合你的提供商和网络的平衡点。
状态读取仅在你传入的区块上保持一致。如果你省略 at 或在页面之间使用不同的区块,你可能会混合来自不同区块的状态,导致视图不一致。始终为整个遍历将 at 固定到单个区块哈希。此外,不稳定的前缀或重命名 pallet 或存储项的运行时升级将完全使你的前缀失效。升级后,你必须从新元数据重新推导前缀。有关运行时版本的更多信息,请参阅 Substrate state_getMetadata 与运行时版本。
- 排序不保证;不要假设字典序。
- 许多小页面 = 许多往返 = 速率限制风险。
- 状态一致性要求将
at固定到一个区块。 - 运行时升级可能改变前缀;从元数据重新推导。
- 大页面大小可能触及响应大小限制。
常见遍历失败的故障排除
如果第一次调用返回空数组,请检查你的前缀。它可能不正确,或者映射可能为空。使用 state_getMetadata 验证 pallet 和 item 名称。如果收到关于无效参数的错误,请确保你的十六进制字符串格式正确,带有 0x 前缀,并且 count 是正整数。
如果你的遍历提前停止,请检查你是否正确地将页面长度与 count 进行比较。一个常见的 bug 是在页面为空时停止,但如果映射大小恰好是 count 的整数倍,最后一页将是满的,下一次调用将返回空。你应该在页面长度小于 count 时停止,而不是在为零时停止。另外,确保你将 startKey 更新为上一页的最后一个键。
如果遇到 HTTP 429 错误,请减小页面大小或在请求之间添加延迟。实现指数退避。如果你使用的是共享端点,考虑升级到专用计划。有关 RPC 最佳实践的更多信息,请参阅 Polkadot RPC 指南(RPC Assistant)。
- 第一页为空:检查前缀和映射是否存在。
- 提前停止:确保循环条件是
length === count。 - 429 错误:减小页面大小、添加退避或升级计划。
- 结果不一致:将
at固定到单个区块哈希。 - 升级后前缀无效:从新元数据重新推导。
后续步骤:将遍历集成到你的应用程序中
现在你已经了解了机制,可以将 state_getKeysPaged 集成到你的应用程序中。首先编写一个小脚本遍历已知映射,例如 System.Account,并与区块浏览器核对计数。然后,将该模式适配到你的特定存储映射。使用 OnFinality Learn 中心 获取更多关于 Substrate RPC 方法的指南。
对于生产使用,考虑缓存结果并增量更新。由于状态仅在某个区块上保持一致,你可以在每个新的最终确定区块上遍历并对比变更。这比每次重新遍历整个映射更高效。你还可以使用 state_queryStorageAt 获取已更改键的值。有关存储变更的更多信息,请参阅 Polkadot state_queryStorageAt 存储变更。
最后,始终监控你的 RPC 使用情况和错误率。如果你正在构建高吞吐量应用程序,考虑使用专用的 API 服务 或查看 RPC 定价 以确保你有足够的容量。OnFinality 提供可靠的 Polkadot 网络 端点,支持这些方法。
- 使用已知映射进行测试并验证计数。
- 缓存结果并在新区块上增量更新。
- 监控 RPC 使用情况和错误率。
- 对于高吞吐量,使用专用端点。
- 更多信息请参阅 Polkadot RPC 指南(RPC Assistant)。