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

构建逐块 EVM 索引器:连接区块、交易与收据

一个可重启、感知重组(reorg)的 EVM 索引器,通过 eth_getBlockByNumber 和 eth_getBlockReceipts 为每个区块组装一致的行集,并具备原子游标与连续性检查。

TL;DR

一个正确的逐块 EVM 索引器使用 eth_getBlockByNumber(blockParameter, true) 获取区块,使交易包含在区块内,然后为同一区块参数获取匹配的收据,最好通过 eth_getBlockReceipts 一次调用完成。收据按交易索引顺序返回,因此连接键是索引,transactionHash 用于交叉校验;收据内的日志属于该交易,其 logIndex 是区块全局的,因此按 (blockNumber, transactionIndex, logIndex) 排序行可使输出稳定。处理器应遍历固定的数字范围,将最后完全提交的区块号与其行在同一事务中持久化,并将确认数视为显式配置输入。本文提供一个可运行的 Node.js 循环、一个用于填写你自己端点计数的结果表,以及针对短读、重复行、排序错误、缺失收据、重组写入和速率限制失败的故障排查清单。

区块处理器必须从 JSON-RPC 接口组装什么

逐块 EVM 索引器不是日志抓取器;它是一个对账管道,为每个区块生成一组内部一致的行。标准 JSON-RPC 接口提供三个相关视图:包含交易的区块、这些交易的收据,以及嵌入每个收据中的日志。以太坊 execution-apis JSON-RPC 规范将 eth_getBlockByNumber、eth_getBlockReceipts、eth_getTransactionReceipt 和 eth_getLogs 定义为这些视图的权威方法,ethereum.org 的 JSON-RPC API 页面为应用开发者记录了相同的接口。

任务是在不跳过、不重复、不错误排序区块的情况下连接这些视图。这意味着使用 eth_getBlockByNumber(blockParameter, true) 获取区块,使交易对象包含在区块内,然后为同一区块参数获取匹配的收据。如果你的提供商支持,eth_getBlockReceipts 一次调用返回区块的所有收据;否则回退到逐交易的 eth_getTransactionReceipt。连接键是交易索引,transactionHash 作为交叉校验,绝不用时间戳或假定的排序。关于规范方法定义,请参阅 以太坊 execution-apis JSON-RPC 规范以太坊 JSON-RPC API 参考

  • 区块视图:eth_getBlockByNumber(blockParameter, true) 返回交易对象,而非哈希。
  • 收据视图:eth_getBlockReceipts(blockParameter) 按交易索引顺序返回收据;eth_getTransactionReceipt 是逐交易回退方案。
  • 日志视图:收据内的日志属于该交易,其 logIndex 是区块全局的。
  • 稳定排序:按 (blockNumber, transactionIndex, logIndex) 排序行。

为什么区块参数是正确性决策,而非风格选择

索引 'latest' 是一个移动目标:你获取的区块可能在区块调用和收据调用之间发生变化,重组可能在你写入行时重写它。处理器应遍历固定的数字范围,将最后完全提交的区块号与其行在同一事务中持久化,并将确认数或最终性深度视为显式配置输入,而非假设。这就是一个能证明自己从未跳过区块的管道与一个仅仅希望没有跳过的管道之间的区别。

区块参数还与提供商行为交互。归档要求、速率限制以及宽泛 eth_getLogs 扫描的上限因提供商而异,因此你的配置应将其作为输入暴露。如果你需要在开始范围之前检测落后于链尖的节点,请参阅 检测落后于链尖的 RPC 节点。关于端点选择和提供商权衡,以太坊 RPC 节点指南 是有用的参考。

连接契约:receipts.length、transactionHash 和区块全局 logIndex

连接函数是大多数索引器悄然出错的地方。它必须断言 receipts.length === block.transactions.length,并且每个 receipt.transactionHash 与对应的区块交易哈希匹配。如果任一断言失败,该区块就不安全,不应提交;处理器应重试获取或停止范围,而不是写入部分行。这是防止滞后节点将一个视图的区块与另一个视图的收据配对的机制。

收据按交易索引顺序返回,因此索引是连接键。收据内的日志属于该交易,但其 logIndex 是区块全局的,这意味着按收据排序日志是错误的。按 (blockNumber, transactionIndex, logIndex) 排序行才能使输出在重启和重新获取时保持稳定。关于批量收据方法本身,请参阅 使用 eth_getBlockReceipts 获取区块中的每个收据;关于日志过滤机制,请参阅 使用 eth_getLogs 和 topics 过滤事件日志

  • 在任何写入之前断言 receipts.length === block.transactions.length。
  • 对每个 i 断言 receipt.transactionHash === block.transactions[i].hash。
  • 使用 transactionIndex 作为连接键;使用 transactionHash 作为交叉校验。
  • 按 (blockNumber, transactionIndex, logIndex) 排序输出,而非按收据本地日志顺序。

可运行的 Node.js 处理器循环,带原子游标和连续性检查

下面的循环获取一个区块及其收据,通过断言连接它们,在一个事务中持久化行和游标,并检查 parentHash 连续性。它使用通用 JSON-RPC 客户端和通用 SQL 事务;请根据你的数据库调整驱动。关键属性是:获取步骤返回 {block, receipts, rows};连接步骤断言计数和哈希;持久化步骤原子地写入行和游标;连续性检查在 parentHash 与前一区块哈希不匹配时触发有界重新索引。

针对固定的数字范围运行此代码,而非 'latest'。游标是最后完全提交的区块号,它与行在同一事务中写入,因此崩溃不会使游标领先于数据。如果连续性检查失败,重新索引一个有界窗口(例如最后 N 个区块),而不是整个链。

// Node.js 18+ (ESM). Generic JSON-RPC + SQL transaction. Adapt driver to your DB.
import { JsonRpcProvider } from 'ethers'; // or any JSON-RPC client

const provider = new JsonRpcProvider(process.env.RPC_URL);
const CONFIRMATIONS = Number(process.env.CONFIRMATIONS ?? 12);
const REORG_WINDOW = Number(process.env.REORG_WINDOW ?? 64);

async function fetchBlockAndReceipts(blockNumber) {
  const block = await provider.send('eth_getBlockByNumber', [
    '0x' + blockNumber.toString(16), true
  ]);
  if (!block) throw new Error(`missing block ${blockNumber}`);

  let receipts;
  try {
    receipts = await provider.send('eth_getBlockReceipts', [
      '0x' + blockNumber.toString(16)
    ]);
  } catch (e) {
    // Fallback: per-transaction receipts when bulk method is unavailable.
    receipts = await Promise.all(
      block.transactions.map((tx) =>
        provider.send('eth_getTransactionReceipt', [tx.hash])
      )
    );
  }
  return { block, receipts };
}

function joinBlock(block, receipts) {
  if (receipts.length !== block.transactions.length) {
    throw new Error(
      `receipt count mismatch: ${receipts.length} vs ${block.transactions.length}`
    );
  }
  const rows = [];
  for (let i = 0; i < block.transactions.length; i++) {
    const tx = block.transactions[i];
    const rc = receipts[i];
    if (rc.transactionHash.toLowerCase() !== tx.hash.toLowerCase()) {
      throw new Error(`hash mismatch at index ${i}`);
    }
    rows.push({
      blockNumber: parseInt(block.number, 16),
      transactionIndex: i,
      transactionHash: tx.hash,
      from: tx.from,
      to: tx.to,
      status: rc.status,
      gasUsed: rc.gasUsed,
      logs: rc.logs.map((log) => ({
        logIndex: parseInt(log.logIndex, 16),
        address: log.address,
        topics: log.topics,
        data: log.data
      }))
    });
  }
  // Stable ordering: block-global logIndex, then transactionIndex.
  rows.sort((a, b) => a.transactionIndex - b.transactionIndex);
  for (const row of rows) row.logs.sort((a, b) => a.logIndex - b.logIndex);
  return rows;
}

async function persistBlock(db, block, rows, cursor) {
  await db.query('BEGIN');
  try {
    for (const row of rows) {
      await db.query(
        'INSERT INTO tx_rows (block_number, tx_index, tx_hash, payload) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING',
        [row.blockNumber, row.transactionIndex, row.transactionHash, row]
      );
    }
    await db.query(
      'INSERT INTO cursor (id, last_block) VALUES (1,$1) ON CONFLICT (id) DO UPDATE SET last_block = EXCLUDED.last_block',
      [cursor]
    );
    await db.query('COMMIT');
  } catch (e) {
    await db.query('ROLLBACK');
    throw e;
  }
}

async function runRange(db, fromBlock, toBlock) {
  let cursor = fromBlock - 1;
  let prevHash = null;
  for (let n = fromBlock; n <= toBlock; n++) {
    const { block, receipts } = await fetchBlockAndReceipts(n);
    if (prevHash && block.parentHash.toLowerCase() !== prevHash.toLowerCase()) {
      // Bounded re-index: step back and re-process the reorg window.
      const rewind = Math.max(fromBlock, n - REORG_WINDOW);
      console.warn(`reorg detected at ${n}; rewinding to ${rewind}`);
      n = rewind - 1;
      prevHash = null;
      continue;
    }
    const rows = joinBlock(block, receipts);
    await persistBlock(db, block, rows, n);
    cursor = n;
    prevHash = block.hash;
  }
  return cursor;
}

// Metrics assertion: expected blocks for an interval must match committed blocks.
function assertInterval(expected, committed) {
  if (expected !== committed) {
    throw new Error(`interval mismatch: expected ${expected}, committed ${committed}`);
  }
}

使用 blockHash 和 parentHash 进行重组检测和有界重新索引

blockHash 和 parentHash 让处理器能够检测重组并重新索引受影响的范围。当你获取区块 N 时,其 parentHash 应等于你已提交的区块 N-1 的哈希。如果不匹配,链已重组,你为受影响范围提交的行可能已过时。正确的响应是有界重新索引:回退一个配置的窗口,重新获取这些区块,并覆盖或版本化受影响的行。

重组写入在没有版本或重新索引标记的情况下修改已提交的行是一种常见故障。要么按 blockHash 对行进行版本化,要么用重新索引标志标记它们,以便下游消费者能够区分规范数据与孤块数据。窗口大小是配置输入,不是常量;它应反映链的观察到的重组深度和你的确认数设置。

  • 每次迭代将 block.parentHash 与先前提交的区块哈希进行比较。
  • 不匹配时,回退一个有界窗口并重新处理;不要继续向前。
  • 按 blockHash 对行进行版本化,或标记重新索引的行,以便消费者过滤孤块。
  • 将重组窗口和确认数保留为显式配置输入。

常见故障及其在管道中的表现

短读在请求在范围中途失败而循环仍然前进时悄然丢弃一个区块。症状是已提交区块号出现缺口且无错误。重启时出现重复行是因为游标在数据之前写入;症状是重复的 (blockNumber, transactionIndex) 键。排序错误是因为日志按收据而非区块全局排序;症状是 logIndex 值在区块内不单调。

缺失收据是因为区块从滞后节点获取而收据来自另一个节点;症状是收据计数不匹配或 transactionHash 不匹配。重组写入在没有版本或重新索引标记的情况下修改已提交的行,表现为下游消费者看到后来消失的交易。宽泛 eth_getLogs 扫描的速率限制或超时失败表现为间歇性 429 或超时错误;缓解模式是缩小范围、添加退避,并使用有文档限制的提供商。关于 BNB 范围限制和可靠性模式,请参阅 OnFinality Learn 中心 及其链接的可靠性页面。

  • 短读:已提交区块号出现缺口,未引发错误。
  • 重复行:游标在数据之前写入;重启时主键重复。
  • 排序错误:logIndex 在区块内不单调。
  • 缺失收据:收据计数或 transactionHash 不匹配。
  • 重组写入:已提交的行在没有版本或重新索引标记的情况下被修改。
  • 速率限制:宽泛 eth_getLogs 扫描时间歇性 429 或超时。

结果表:测量你自己端点的区块、交易和收据计数

不要信任提供商的总括数字;针对固定区块范围测量你自己的端点。用你控制的范围内观察到的计数填写下表,然后将已提交行与预期计数进行比较。这是读者自行验证的方法:数字是你的,不是我们的。如果你的端点对任何区块返回的收据少于交易,请在提交前停下来调查。

每一行使用相同的范围,以便比较有意义。如果你更换提供商或区域,请重新运行表格;提供商上限、归档要求和速率限制因提供商而异。

  • 区块范围:[start, end] — 固定数字范围,而非 'latest'。
  • 预期区块数:end - start + 1。
  • 观察到的已提交区块数:从游标表计数。
  • 预期交易数:范围内 block.transactions.length 的总和。
  • 观察到的收据数:范围内 receipts.length 的总和。
  • 不匹配计数:receipts.length !== transactions.length 的区块数。
  • 重组事件:parentHash 连续性失败计数。
  • 速率限制错误:429 或超时响应计数。

逐块索引器的故障排查清单

当管道报告不匹配或缺口时,请按此清单逐项排查。每一项都对应特定的故障模式和特定的修复方法。将清单保留在你的运行手册中,以便值班工程师无需重新推导机制即可遵循。

如果你在调试时需要针对历史状态模拟调用,带状态覆盖的 eth_call 是有用的配套技术。关于端点选择和定价权衡,请参阅 RPC 定价API 服务 页面。

  • 验证区块参数是固定数字,而非 'latest'。
  • 在写入前断言 receipts.length === block.transactions.length。
  • 断言每个 receipt.transactionHash 与区块交易哈希匹配。
  • 确认游标与行在同一事务中写入。
  • 对照先前提交的区块哈希检查 parentHash 连续性。
  • 确认日志按区块全局 logIndex 排序,而非按收据。
  • 确认区块和收据来自同一节点和同一区块参数。
  • 检查宽泛 eth_getLogs 扫描的 429 或超时错误并缩小范围。
  • 在任何提供商或区域变更后重新运行结果表。

限制、假设与权衡

此设计假设 JSON-RPC 端点对同一区块参数返回一致的区块和收据视图。它不假设 eth_getBlockReceipts 可用;回退到逐交易 eth_getTransactionReceipt 是契约的一部分。它假设你的数据库支持原子事务;如果不支持,你需要一个带有版本列的等效幂等写入模式。

权衡是真实的。逐交易获取收据比批量方法更慢且请求更密集。更大的重组窗口花费更多重新索引工作,但降低提供过时行的风险。确认数增加延迟但减少重组暴露。提供商上限、归档要求和速率限制因提供商而异,因此你的配置必须将其作为输入暴露,而非硬编码。

  • 假设对同一区块参数有一致的区块和收据视图。
  • 假设原子数据库事务或等效的幂等写入模式。
  • 逐交易收据回退比批量收据更慢且请求更密集。
  • 更大的重组窗口花费更多重新索引工作,但降低过时行风险。
  • 确认数增加延迟但减少重组暴露。
  • 提供商上限、归档要求和速率限制因提供商而异。

下一步:从可运行的循环到生产索引器

一旦循环对固定范围正确,下一步是运维:为每个间隔的已提交区块添加指标,将游标和重组窗口暴露为配置,并针对你计划使用的每个端点运行结果表。关于网络特定端点,请参阅 OnFinality 上的以太坊。关于定价和服务范围,请参阅 RPC 定价API 服务

如果你正在选择端点,以太坊 RPC 节点指南 涵盖了提供商权衡。如果你需要在开始范围之前检测落后于链尖的节点,请参阅 检测落后于链尖的 RPC 节点。关于批量收据方法和日志过滤机制,请参阅 使用 eth_getBlockReceipts 获取区块中的每个收据使用 eth_getLogs 和 topics 过滤事件日志

  • 为每个间隔的已提交区块添加指标,并在不匹配时告警。
  • 将游标、确认数和重组窗口暴露为配置。
  • 针对你计划使用的每个端点运行结果表。
  • 在任何提供商或区域变更后重新运行表格。
  • 将故障排查清单保留在你的运行手册中。

永远不用担心基础设施

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

开始