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

Sui queryTransactionBlocks:无缺口、无重复的游标分页

一次可恢复、可去重的 suix_queryTransactionBlocks 遍历,能够承受游标失效,并用摘要水位证明连续性。

TL;DR

Sui 的列表端点(包括 suix_queryTransactionBlocks)返回的是 { data, nextCursor, hasNextPage } 这样的分页信封,而不是偏移量和总数。游标是不透明令牌:你原样传回,绝不构造、解码或对其做算术推进。前向分页按升序遍历,descendingOrder 会翻转遍历方向,但游标仍然编码位置。由于结果是在特定检查点或 epoch 读取的,且游标可能跨越修剪边界而失效,长时间遍历必须持久化最后一个游标,通过检查点限界重启来容忍失效,并按交易摘要去重。本文构建一个可运行、可恢复的分页器,并展示如何用摘要水位证明连续性。

suix_queryTransactionBlocks 的分页信封及其游标契约

Sui JSON-RPC 列表端点返回统一的分页信封:data 中的结果数组、不透明的 nextCursor,以及布尔值 hasNextPage。suix_queryTransactionBlocks 的 Sui JSON-RPC API 参考记录了这一结构,同样的契约也适用于 suix_queryEvents 和 sui_getCheckpoints。你的分页器应把该信封视为判断遍历位置的唯一依据。

游标是不透明的。Sui 关于游标分页的文档将游标描述为需要原样传回的令牌;你不得解析它、对它做加法,或根据摘要或序列号合成一个。任何对游标做算术的代码都依赖于可能随时变化的实现细节。

前向分页默认使用升序。设置 descendingOrder 会翻转遍历方向,但游标仍然编码该遍历中的位置,因此整个遍历必须保持相同的排序。中途混用方向是缺口和重复的常见来源。

  • data:本次请求返回的交易区块页。
  • nextCursor:下一页的不透明令牌;遍历耗尽时为 null。
  • hasNextPage:是否还有下一页;不要从 data.length 推断。
  • descendingOrder:翻转遍历方向;对同一次遍历保持恒定。

为什么交易区块要用游标分页取代偏移分页

偏移分页请求从 offset 开始的 limit 行。在活跃账本上,新的交易区块不断追加,因此你开始时位于偏移 1000 的那一行,在你请求下一页时可能已位于偏移 1005。这种位移会同时产生缺口(跳过的行)和重复(看到两次的行)。游标分页避免了这一点,因为游标把下一次读取锚定在有序结果集中的某个位置,而不是某个计数。

偏移分页还会随着偏移量增大而性能下降:后端在返回该页之前必须跳过越来越多的行。游标分页让后端可以从索引位置恢复,这就是它成为账本级枚举推荐模式的原因。代价是你无法跳到任意页,也无法仅凭信封计算总数。

对于交易区块枚举,实际后果是:可恢复遍历是一个状态机——持久化游标、请求下一页、追加结果,重复直到 hasNextPage 为 false。如果你想在构建之前了解更广泛的背景,OnFinality Learn 中心汇集了相关的 Sui RPC 模式。

  • 偏移:按计数定位;在并发追加下不稳定;大偏移时性能下降。
  • 游标:按不透明令牌定位;在追加下稳定;不支持随机访问或总数。
  • 对于全账本遍历,游标分页是唯一能保证连续性的选择。

使用 suix_queryTransactionBlocks 的最小前向遍历

从最小的正确循环开始:请求一页、追加 data、读取 nextCursor,并在 hasNextPage 为 false 时停止。此示例针对 Sui 端点使用 Sui TypeScript SDK;请替换为你自己的端点 URL。如果你正在选择提供商,RPC Assistant 中的 Sui RPC 指南涵盖了端点选择和方法可用性。

注意,循环从不检查游标。它存储游标并原样发回。这就是全部契约。如果你发现自己想知道游标里面是什么,你很可能是在试图解决一个本应属于你自己水位逻辑的问题。

import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';

const client = new SuiClient({ url: getFullnodeUrl('mainnet') });

async function walkAll(filter = {}) {
  const out = [];
  let cursor = null;
  let hasNextPage = true;

  while (hasNextPage) {
    const page = await client.queryTransactionBlocks({
      filter,
      cursor,
      limit: 50,
      order: 'ascending',
      options: { showEffects: true },
    });

    out.push(...page.data);
    cursor = page.nextCursor;
    hasNextPage = page.hasNextPage;
  }

  return out;
}

walkAll({}).then((txs) => console.log('blocks:', txs.length));

按输入对象、变更对象和交易类型过滤

suix_queryTransactionBlocks 接受一个过滤对象,在分页之前缩小结果集。文档中记录的过滤变体包括 InputObject、ChangedObject、FromAddress、ToAddress、FromAndToAddress、TransactionKind 和 MoveFunction。服务端过滤比全量遍历后在客户端过滤既更快也更正确,因为此时游标跟踪的是过滤后的结果集,而不是未过滤的账本。

一个微妙之处:游标与你使用的过滤器绑定。如果你在遍历中途更改过滤器,游标就不再指向同一个有序结果集,连续性也就无从定义。请将过滤器与游标一起持久化,以便恢复的遍历使用完全相同的查询。这也是检查点限界重启必须重新应用相同过滤器的原因。

对于以对象为中心的索引,跟踪对象的变更历史时通常需要 ChangedObject,而 InputObject 捕获消费该对象的交易。这一区别对去重很重要,因为同一笔交易可能出现在多个过滤器下。

  • InputObject:将该对象用作输入的交易。
  • ChangedObject:变更了该对象的交易。
  • TransactionKind:限制为可编程交易、共识提交序言等。
  • MoveFunction:限制为对特定 package::module::function 的调用。

将游标和过滤器持久化为可恢复状态

可恢复遍历需要持久状态:最后一个游标、过滤器、排序和水位。将它们一起存储,这样重启就不会意外地把游标与不同的过滤器配对。文件、Redis 键或数据库行中的一个小 JSON 记录就足够了。如果你要将其接入托管管道,API 服务页面描述了 OnFinality 如何暴露 Sui RPC 端点。

水位是连续性证明。记录你追加的最后一个交易摘要,以及(如果响应暴露的话)读取该页时的检查点或 epoch。恢复时,将新页的第一个摘要与水位比较:如果匹配,遍历是连续的;如果不匹配,说明存在缺口或回退,必须进行对账。

每页之后都持久化,而不是在结束时。遍历中途崩溃最多应丢失一页的进度,而水位会准确告诉你之前的位置。

import fs from 'node:fs';

const STATE = './sui-walk-state.json';

function loadState() {
  if (!fs.existsSync(STATE)) return null;
  return JSON.parse(fs.readFileSync(STATE, 'utf8'));
}

function saveState(state) {
  fs.writeFileSync(STATE, JSON.stringify(state, null, 2));
}

// state shape:
// {
//   "cursor": "opaque-token-or-null",
//   "filter": { "ChangedObject": "0xabc..." },
//   "order": "ascending",
//   "watermarkDigest": "...",
//   "watermarkCheckpoint": "12345678",
//   "seen": ["digest1", "digest2"]
// }

用检查点限界重启处理游标失效

游标可能失效。Sui 文档指出,结果是在特定检查点或 epoch 读取的,游标可能无法跨越修剪边界,因此恢复的游标可能被拒绝,或静默地指向不同的位置。安全的应对方式是检查点限界重启:丢弃无效游标,从水位中选取一个下界检查点,并从那里重新向前遍历,同时对照已存储的摘要去重。

检查点限界重启不是全量重扫。你从最后一个已知良好的检查点重启,其范围受水位落后修剪边界多远的限制。如果水位较新,重启窗口就小。如果水位陈旧,窗口就会变大,这就是频繁持久化很重要的原因。

通过捕获 RPC 错误,并对照水位验证恢复页的第一个摘要来检测失效。不匹配是重启的信号,而不是追加的信号。Sui RPC 超时一文涵盖了请求因传输原因而非游标原因失败的相关情况。

  • 游标错误时:丢弃游标,保留过滤器和排序,从水位检查点重启。
  • 摘要不匹配时:视为缺口或回退;从水位检查点重启。
  • 反复失效时:减小页大小并更频繁地持久化,以缩小重启窗口。

按交易摘要去重并用水位证明连续性

与重组相邻的读取可能使某个摘要重新出现:一笔已在你消费过的页中的交易,可能在重启或重组后再次出现。按交易摘要去重,摘要是交易区块的稳定标识。保留一个有界的近期已见摘要集合,如果遍历跨越重启,则使用持久集合。

水位是连续性证明。每页之后,将 watermarkDigest 设为最后追加的摘要,将 watermarkCheckpoint 设为读取该页时的检查点。恢复时,断言新页的第一个摘要在相同排序下是水位的后继。如果不是,说明存在缺口,必须从水位检查点重启。

这种组合——用不透明游标定位、用摘要集合去重、用水位保证连续性——正是让遍历无缺口、无重复且不依赖任何游标内部结构的原因。

async function resumableWalk(client, filter, order = 'ascending') {
  let state = loadState() ?? {
    cursor: null,
    filter,
    order,
    watermarkDigest: null,
    watermarkCheckpoint: null,
    seen: [],
  };

  const seen = new Set(state.seen);
  const appended = [];
  let duplicates = 0;
  let hasNextPage = true;

  while (hasNextPage) {
    let page;
    try {
      page = await client.queryTransactionBlocks({
        filter: state.filter,
        cursor: state.cursor,
        limit: 50,
        order: state.order,
        options: { showEffects: true },
      });
    } catch (err) {
      // Cursor invalidated: restart from the watermark checkpoint.
      state.cursor = null;
      saveState(state);
      continue;
    }

    for (const tx of page.data) {
      const digest = tx.digest;
      if (seen.has(digest)) {
        duplicates += 1;
        continue;
      }
      seen.add(digest);
      appended.push(tx);
      state.watermarkDigest = digest;
    }

    state.cursor = page.nextCursor;
    state.seen = [...seen].slice(-10000);
    saveState(state);

    hasNextPage = page.hasNextPage;
  }

  return { appended, duplicates };
}

结果表:测量页数、交易数、重复数和耗时

测量你自己的端点,而不是相信任何已发布的数字。针对固定过滤器和固定检查点窗口运行分页器,并记录下面的计数器。目标是确认丢弃的重复数很小且稳定,并且耗时大致随遍历页数线性增长。

为每次运行填写此表。比较不同页大小的运行,以了解请求数与每请求延迟之间的权衡。如果重复数激增,说明重启窗口太大或水位陈旧。

  • 遍历页数:成功的 suix_queryTransactionBlocks 调用次数。
  • 返回交易数:各页 data.length 的总和。
  • 丢弃重复数:已存在于已见集合中的摘要。
  • 耗时毫秒:整个遍历的耗时。
  • 重启次数:触发的游标失效重启次数。
| Run | Filter | Page size | Pages walked | Txs returned | Duplicates dropped | Restarts | Wall ms |
|-----|--------|-----------|--------------|--------------|--------------------|----------|---------|
| 1   |        | 50        |              |              |                    |          |         |
| 2   |        | 100       |              |              |                    |          |         |
| 3   |        | 200       |              |              |                    |          |         |

排查缺口、重复和游标错误

症状:同一摘要出现在连续两页中。原因:遍历中途更改了过滤器或排序,或重启时未去重地重读了某页。修复:保持过滤器和排序恒定,并始终按摘要去重。

症状:两页之间缺少某个摘要。原因:游标被算术推进,或重启跳过了水位检查点。修复:绝不构造游标;从水位检查点重启并向前重新遍历。

症状:长时间暂停后 RPC 返回游标错误。原因:游标越过了修剪边界。修复:捕获错误、丢弃游标,并从水位检查点重启。如果频繁发生,减小页大小并更频繁地持久化。Sui RPC 速率限制与计算一文涵盖了失败模式是限流而非游标状态的相关情况。

  • 重复:检查过滤器/排序稳定性和去重。
  • 缺口:检查游标算术和重启逻辑。
  • 游标错误:检查修剪边界和水位新鲜度。
  • 限流:在归咎于游标之前先检查速率限制和退避。

局限性:修剪、epoch 边界和排序权衡

游标分页不是快照。结果是在特定检查点或 epoch 读取的,游标可能跨越修剪边界而失效。跨越 epoch 边界的遍历即使没有报错,也可能需要对照检查点限界重启进行对账。请为此做好规划,而不是把它当作异常。

降序对于跟踪近期活动很有用,但它不能替代稳定快照。如果你需要一致的视图,请将遍历限制在检查点范围内,并接受重启后可能需要重读该范围。当你需要持续交付而非有界遍历时,Sui 检查点流式传输:gRPC 账本服务一文涵盖了流式替代方案。

最后,游标分页不提供总数,也不支持随机访问。如果你的产品需要计数或页码,请从你控制的索引单独计算,而不是从 RPC 信封中获取。

  • 无快照保证:结果是在检查点或 epoch 读取的。
  • 修剪边界可能使游标失效;从水位检查点重启。
  • 仅凭信封无法获得总数,也无法随机访问。
  • 降序会翻转遍历,但不会创建稳定快照。

下一步:事件分页、交易效果和提供商选择

交易区块枚举是三个相关 Sui RPC 模式之一。与之对应的机制是事件分页,见通过 RPC 查询 Sui 事件:过滤器和游标分页,它使用相同的信封和游标契约。如果你需要解析交易改变了什么,请参阅Sui RPC 交易效果:objectChanges 和 balanceChanges。

如果需要持续交付而非有界遍历,检查点流式传输更合适。关于端点选择和容量规划,请查看Sui RPC 速率限制与计算和RPC 定价。Sui 网络页面列出了 OnFinality 支持的 Sui 网络,Sui RPC 指南涵盖了每个端点的方法可用性。

一个实用的下一步是针对两个提供商运行上面的结果表,并比较丢弃的重复数和重启次数。这个比较,而不是已发布的基准,才是对你的工作负载真正重要的数字。

  • 事件分页:相同的信封,相同的游标契约。
  • 交易效果:枚举后解析 objectChanges 和 balanceChanges。
  • 检查点流式传输:持续交付而非有界遍历。
  • 提供商选择:在你自己的端点上测量重复数和重启次数。

永远不用担心基础设施

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

开始