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

eth_getLogs 区块范围限制与安全分块

一种可移植的算法,用于读取大型 EVM 日志范围,能够适应任何服务商的单次请求上限,并证明结果完整。

TL;DR

eth_getLogs 方法在规范中接受一个包含端点的区块范围,但服务商会强制执行未公开的单次请求上限,这些上限会以实现定义的错误或 HTTP 拒绝的形式出现。由于该上限实际上是一个成本限制,因此较窄的地址/主题过滤器可以覆盖比宽泛过滤器更广的范围。本文介绍一种可移植的自适应分块算法,该算法在范围失败时将跨度减半,区分能力错误与瞬时错误,并通过去重和排序保证来合并结果。文章还提供了一种对账方法和一个可运行的 Node.js 示例,以便您测量自己端点的行为。

eth_getLogs 参数对范围查询的含义

以太坊 JSON-RPC 规范将 eth_getLogs 定义为一种返回与过滤器匹配的日志对象数组的方法。过滤器对象接受 fromBlock、toBlock、address、topics 和 blockHash。范围是包含端点的:fromBlock 和 toBlock 都包含在扫描中。规范指出,过滤器是针对区块范围进行评估的,并且 blockHash 过滤器与 fromBlock 和 toBlock 互斥。如果您提供 blockHash,节点会忽略任何范围参数,仅返回该单个区块的日志。

特殊字符串 latest 是一个移动的目标。它解析为节点处理请求时的当前头部。对于可重现的历史扫描,切勿使用 latest 作为边界。相反,应一次性解析当前头部,存储该区块号,并将其用作整个扫描的固定 toBlock。这使扫描具有确定性,并允许您稍后根据已知的区块哈希进行对账。

过大的范围不是规范错误。JSON-RPC 2.0 规范将 -32700 到 -32600 范围内的错误码定义为解析和无效请求错误,但将 -32000 到 -32099 范围留给实现定义的服务器错误。服务商使用此空间来表示范围超出了其策略。有些会返回数字代码,如 -32005 或 -32000,并附带类似“query returned more than 10000 results”或“block range too large”的消息。其他则在 HTTP 层以 400 或 413 状态拒绝,尚未到达 JSON-RPC 层。这就是为什么同一个扫描在一个端点上有效,而在另一个端点上失败,并出现不同的消息,且通常代码相同。

  • fromBlock 和 toBlock 是包含端点的;两个端点都会被扫描。
  • blockHash 与 fromBlock 和 toBlock 互斥。
  • latest 是一个移动的目标,不适合可重现的历史扫描。
  • 范围上限是服务商策略,不是协议错误,会以实现定义的 -32000 范围或 HTTP 拒绝的形式出现。

为什么上限是成本限制,而不是区块数量

单次请求上限不是固定的区块数量。它是一个成本限制。节点必须对范围内的每个区块执行过滤器,并返回每个匹配的日志。成本与扫描的区块数量加上返回的日志数量成正比。使用较窄的地址和主题过滤器的扫描产生的匹配日志更少,因此成本更低,即使范围很宽。使用宽泛过滤器的扫描产生更多日志,并更早达到上限。

这有一个实际后果:较窄的过滤器可以覆盖比宽泛过滤器更广的范围。如果您正在索引具有特定事件签名的单个合约,您可能能够在一次请求中扫描数万个区块。如果您正在扫描所有地址的所有日志,您可能被限制在几百个区块。该上限有文档记录或因服务商而异,并且可能在没有通知的情况下更改。切勿将区块数量硬编码为通用常量。

成本模型也解释了为什么会出现超时。已经处于负载下的节点可能需要更长时间来执行宽范围查询,并返回超时或 5xx 错误,即使范围在服务商文档记录的上限之内。这是瞬时故障,不是能力故障。区分两者对于正确的退避策略至关重要。

  • 上限是基于扫描区块和返回日志的成本限制。
  • 较窄的过滤器比宽泛过滤器能承受更宽的范围。
  • 超时和 5xx 错误是瞬时的;范围错误是确定性的。

具有明确停止条件的自适应分块算法

目标是设计一种可移植的算法,能够适应任何服务商未公开的上限。从配置的最大跨度开始,执行查询,并对结果进行分类。结果类别包括:成功、范围过大、超时或 5xx、以及速率限制。在范围失败时,将跨度减半,并重试相同的游标位置。继续直到跨度达到一个区块。如果单区块查询仍然因范围错误而失败,则记录失败并将游标前进一个区块,以避免无限循环。

在超时或 5xx 时,不要将跨度减半。请求是可行的,但节点速度较慢。在退避间隔后重试相同的跨度。在速率限制(HTTP 429)时,如果存在 Retry-After 头,则遵守它,或使用指数退避。不要为瞬时错误减少跨度,因为那会不必要地增加请求数量并加剧速率限制。

算法必须有明确的停止条件和明确的失败记录。一个常见的错误是在任何错误上无限重试。这会将确定性的范围错误变成无限循环。相反,在可配置的瞬时错误重试次数后,将块记录为失败并继续。失败记录应包含 fromBlock、toBlock、span、错误代码和错误消息,以便您以后诊断端点的行为。

async function adaptiveScan({ endpoint, address, topics, fromBlock, toBlock, maxSpan, maxRetries }) {
  const results = [];
  const failures = [];
  let cursor = fromBlock;
  let span = maxSpan;

  while (cursor <= toBlock) {
    const chunkTo = Math.min(cursor + span - 1, toBlock);
    let attempt = 0;
    let success = false;

    while (attempt <= maxRetries) {
      try {
        const logs = await rpcCall(endpoint, 'eth_getLogs', [{
          fromBlock: '0x' + cursor.toString(16),
          toBlock: '0x' + chunkTo.toString(16),
          address,
          topics
        }]);
        results.push(...logs);
        success = true;
        break;
      } catch (err) {
        const code = err.code;
        const message = (err.message || '').toLowerCase();
        const isRangeError = code === -32005 || code === -32000 || message.includes('range') || message.includes('too large') || message.includes('more than');
        const isRateLimit = err.status === 429;
        const isTransient = err.status >= 500 || message.includes('timeout') || isRateLimit;

        if (isRangeError && span > 1) {
          span = Math.max(1, Math.floor(span / 2));
          attempt = 0;
          break;
        }
        if (isTransient && attempt < maxRetries) {
          const delay = isRateLimit && err.retryAfter ? err.retryAfter * 1000 : Math.pow(2, attempt) * 1000;
          await new Promise(r => setTimeout(r, delay));
          attempt++;
          continue;
        }
        failures.push({ fromBlock: cursor, toBlock: chunkTo, span, code, message: err.message });
        success = true;
        break;
      }
    }

    if (!success) {
      failures.push({ fromBlock: cursor, toBlock: chunkTo, span, code: 'unknown', message: 'retries exhausted' });
    }
    cursor = chunkTo + 1;
  }

  return { results, failures };
}

区分能力故障与瞬时故障

能力故障是确定性的。如果您将相同的 fromBlock、toBlock、address 和 topics 发送到同一个端点,您每次都会得到相同的范围错误。瞬时故障是非确定性的。超时或 429 可能在下次尝试时使用相同参数成功。退避策略不得应用于能力故障,因为重试不可能的请求会浪费时间,并可能触发速率限制。

要对错误进行分类,请检查 JSON-RPC 错误代码和 HTTP 状态。范围错误通常以 JSON-RPC 错误形式出现,代码在 -32000 范围内。超时和 5xx 错误以 HTTP 错误或包含“timeout”或“gateway”消息的 JSON-RPC 错误形式出现。速率限制以 HTTP 429 和 Retry-After 头形式出现。如果错误不明确,请记录完整响应并再次测试相同参数。如果错误重现,则将其视为能力故障。

实用规则:在范围错误时,将跨度减半并重试相同的游标。在超时或 5xx 时,在退避后重试相同的跨度。在 429 时,遵守 Retry-After 并重试相同的跨度。切勿为瞬时错误将跨度减半,因为那会增加请求数量并可能加剧速率限制。

  • 能力故障:使用相同参数可确定性地重现。
  • 瞬时故障:超时、5xx 或 429;重试可能成功。
  • 仅对范围错误将跨度减半;对瞬时错误进行退避。

完整性保证及其为何不是自动的

分块扫描不会自动产生完整、有序、去重的日志集。当游标重叠时,两个相邻块可能合法地返回相同的日志。如果您按块大小而不是 chunkTo + 1 推进游标,或者服务商返回了重新扫描区块的日志,就会发生这种情况。您必须按元组 (blockNumber, logIndex, transactionIndex) 去重,并按相同元组排序,以产生确定性顺序。

链重组可能使已提交到存储的块失效。如果重组移除了包含日志的区块,您存储的日志现在就成了孤立的。对账方法必须检测到这一点。一种方法是存储每个地址/主题对处理的最高区块,并在每次遍历时重新扫描尖端后面的可配置确认深度。另一种方法是验证总日志计数单调递增,并且没有日志引用不再规范的区块。

完整性还要求您永远不跳过区块。如果某个块失败,而您在没有记录失败的情况下推进游标,就会出现缺口。失败记录不是可选的。它是存在缺口的证据,也是针对性重新扫描的起点。有关对账的更深入讨论,请参阅逐块 EVM 索引器对账

  • 按 (blockNumber, logIndex, transactionIndex) 去重。
  • 按相同元组排序以获得确定性顺序。
  • 重组可能使已提交的块失效;重新扫描尖端后面的确认深度。
  • 切勿在未记录失败的情况下将游标推进越过失败的块。

证明扫描完整的对账方法

对账是证明您存储的日志与链上实际内容匹配的过程。它不是一次性检查。它必须在每次遍历时运行。该方法有四个部分:重新运行一小部分块并比较,存储每个地址/主题对处理的最高区块,验证总日志计数单调递增,并重新扫描尖端后面的可配置确认深度。

重新运行样本块是最强的检查。随机选择一组先前处理过的块,重新执行相同的 eth_getLogs 查询,并将返回的日志与您存储的日志进行比较。如果集合不同,则存在缺口或孤立日志。样本大小可以很小,但每次遍历都应非零。有关对账模式的更广泛讨论,请参阅逐块 EVM 索引器对账

存储每个地址/主题对处理的最高区块可以让您无需重新扫描所有内容即可恢复。它还可以让您检测重组是否已将尖端向后移动。如果当前头部低于您存储的最高处理区块,则发生了重组,您必须从新头部减去确认深度处重新扫描。验证总日志计数单调递增可以捕获意外删除。重新扫描尖端后面的确认深度可以捕获比一个区块更深的重组。

  • 重新运行随机样本块并比较结果。
  • 存储每个地址/主题对处理的最高区块。
  • 验证总日志计数单调递增。
  • 每次遍历时重新扫描尖端后面的可配置确认深度。

可运行的自适应分块扫描 Node.js 示例

以下 Node.js 脚本针对作为参数传递的端点 URL 执行自适应分块扫描。它打印每个块的输出,包括 fromBlock、toBlock、span、status、logCount 和 elapsedMs。它还断言合并后的日志数组严格有序且无重复。使用 node scan.js <endpoint> <fromBlock> <toBlock> <address> <topic0> 运行它。

该脚本使用上一节中的 adaptiveScan 函数。它添加了一个合并步骤,按 (blockNumber, logIndex, transactionIndex) 去重,并按相同元组排序。最后的断言在合并数组不是严格有序或包含重复项时抛出错误。这为您提供了端点行为的可重现测量。

由于上限因服务商和链而异,您应该对使用的每个端点运行此脚本。每个块的输出就是您的证据。记录首次出现范围错误时的跨度、错误代码和错误消息。这就是您的端点在该过滤器选择性下的有效上限。

const https = require('https');

function rpcCall(endpoint, method, params) {
  return new Promise((resolve, reject) => {
    const url = new URL(endpoint);
    const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
    const req = https.request({
      hostname: url.hostname,
      port: url.port || 443,
      path: url.pathname,
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
    }, res => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        try {
          const json = JSON.parse(data);
          if (json.error) {
            const err = new Error(json.error.message);
            err.code = json.error.code;
            err.status = res.statusCode;
            reject(err);
          } else {
            resolve(json.result);
          }
        } catch (e) {
          const err = new Error('parse error: ' + data.slice(0, 200));
          err.status = res.statusCode;
          reject(err);
        }
      });
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

async function adaptiveScan({ endpoint, address, topics, fromBlock, toBlock, maxSpan, maxRetries }) {
  const results = [];
  const failures = [];
  let cursor = fromBlock;
  let span = maxSpan;

  while (cursor <= toBlock) {
    const chunkTo = Math.min(cursor + span - 1, toBlock);
    let attempt = 0;
    let success = false;
    const start = Date.now();

    while (attempt <= maxRetries) {
      try {
        const logs = await rpcCall(endpoint, 'eth_getLogs', [{
          fromBlock: '0x' + cursor.toString(16),
          toBlock: '0x' + chunkTo.toString(16),
          address,
          topics
        }]);
        results.push(...logs);
        console.log(`from=${cursor} to=${chunkTo} span=${span} status=ok logs=${logs.length} ms=${Date.now() - start}`);
        success = true;
        break;
      } catch (err) {
        const code = err.code;
        const message = (err.message || '').toLowerCase();
        const isRangeError = code === -32005 || code === -32000 || message.includes('range') || message.includes('too large') || message.includes('more than');
        const isRateLimit = err.status === 429;
        const isTransient = err.status >= 500 || message.includes('timeout') || isRateLimit;

        if (isRangeError && span > 1) {
          console.log(`from=${cursor} to=${chunkTo} span=${span} status=range_error code=${code} msg=${err.message}`);
          span = Math.max(1, Math.floor(span / 2));
          attempt = 0;
          break;
        }
        if (isTransient && attempt < maxRetries) {
          const delay = isRateLimit && err.retryAfter ? err.retryAfter * 1000 : Math.pow(2, attempt) * 1000;
          console.log(`from=${cursor} to=${chunkTo} span=${span} status=transient code=${code} retry=${attempt + 1} delay=${delay}ms`);
          await new Promise(r => setTimeout(r, delay));
          attempt++;
          continue;
        }
        console.log(`from=${cursor} to=${chunkTo} span=${span} status=failed code=${code} msg=${err.message}`);
        failures.push({ fromBlock: cursor, toBlock: chunkTo, span, code, message: err.message });
        success = true;
        break;
      }
    }

    if (!success) {
      failures.push({ fromBlock: cursor, toBlock: chunkTo, span, code: 'unknown', message: 'retries exhausted' });
    }
    cursor = chunkTo + 1;
  }

  return { results, failures };
}

function mergeLogs(logs) {
  const seen = new Set();
  const merged = [];
  for (const log of logs) {
    const key = `${log.blockNumber}:${log.logIndex}:${log.transactionIndex}`;
    if (!seen.has(key)) {
      seen.add(key);
      merged.push(log);
    }
  }
  merged.sort((a, b) => {
    const bn = parseInt(a.blockNumber, 16) - parseInt(b.blockNumber, 16);
    if (bn !== 0) return bn;
    const li = parseInt(a.logIndex, 16) - parseInt(b.logIndex, 16);
    if (li !== 0) return li;
    return parseInt(a.transactionIndex, 16) - parseInt(b.transactionIndex, 16);
  });
  return merged;
}

(async () => {
  const [endpoint, fromBlock, toBlock, address, topic0] = process.argv.slice(2);
  const { results, failures } = await adaptiveScan({
    endpoint,
    address,
    topics: [topic0],
    fromBlock: parseInt(fromBlock),
    toBlock: parseInt(toBlock),
    maxSpan: 10000,
    maxRetries: 3
  });
  const merged = mergeLogs(results);
  console.log(`total logs=${results.length} merged=${merged.length} failures=${failures.length}`);
  for (let i = 1; i < merged.length; i++) {
    const prev = merged[i - 1];
    const curr = merged[i];
    const prevKey = `${prev.blockNumber}:${prev.logIndex}:${prev.transactionIndex}`;
    const currKey = `${curr.blockNumber}:${curr.logIndex}:${curr.transactionIndex}`;
    if (prevKey >= currKey) throw new Error('ordering violation at index ' + i);
  }
  console.log('ordering and deduplication assertions passed');
})();

用于测量自己端点的结果表

由于上限因服务商和链而异,您必须自己测量。使用上面的脚本生成结果表。对您使用的每个端点运行它,使用相同的地址和主题过滤器,并记录首次出现范围错误时的跨度。还要记录错误代码和消息。此表将成为您为每个端点配置 maxSpan 的参考。

该表应包含以下列:端点、链、地址、topic0、尝试的 maxSpan、首次失败的跨度、错误代码、错误消息和备注。用您自己的测量值填写。不要依赖其他服务商发布的数字,因为上限可能在没有通知的情况下更改,并且因过滤器选择性而异。

如果您使用多个端点进行冗余,请分别测量每个端点。在一个端点上有效的扫描可能在另一个端点上失败,并出现不同的消息,且通常代码相同。为每个端点持久化测量到的 maxSpan,以便您的扫描器无需人工干预即可适应。

  • 端点:您正在测试的 RPC URL。
  • 链:网络名称或链 ID。
  • 地址和 topic0:测试中使用的过滤器选择性。
  • 尝试的 MaxSpan:配置中的起始跨度。
  • 首次失败的跨度:首次出现范围错误时的跨度。
  • 错误代码和消息:端点返回的确切值。
  • 备注:任何观察到的速率限制、超时或服务商特定行为。
| Endpoint | Chain | Address | topic0 | maxSpan attempted | First failing span | Error code | Error message | HTTP status | Elapsed ms |
|---|---|---|---|---|---|---|---|---|---|
| ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ |
| ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ |
| ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ |
| ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ | ____ |

与生产流水线的集成

分块与生产流水线的其余部分以三种方式交互。首先,块结果应按块提交,这样崩溃不会丢失整个扫描。如果您将所有日志缓冲在内存中并在最后提交,那么在 90% 时崩溃会丢失所有内容。在同一事务中提交每个块的日志及其游标位置。其次,必须限制并行度以避免触发速率限制。不要超过端点文档记录的并发数。如果端点没有记录并发限制,请从一两个并行请求开始,仅在测量后增加。

第三,块大小应按端点持久化,因为上限因服务商而异。将测量到的 maxSpan 与端点 URL 一起存储。当扫描器启动时,读取持久化的值并将其用作起始跨度。如果发生范围错误,将跨度减半并更新持久化的值。这使扫描器随着时间的推移自我调整。

有关端点选择和并发性的更广泛讨论,请参阅 RPC 端点指南(RPC Assistant)。有关超时特定的故障排除,请参阅如何修复 RPC 超时错误

  • 在同一事务中提交每个块的日志和游标位置。
  • 将并行度限制在端点文档记录的并发数内。
  • 为每个端点持久化测量到的 maxSpan,并在范围错误时更新它。

常见分块故障的故障排除

最常见的故障是在确定性范围错误上无限重试循环。如果您的扫描器不断将跨度减半但从未达到一个区块,请检查您的停止条件是否为 span > 1,以及您是否在失败后推进游标。另一个常见故障是由于将游标推进越过失败的块而导致的缺口。始终记录失败并稍后重新扫描。

第二种故障模式是合并后的排序违规。当您仅按 blockNumber 排序而忽略 logIndex 和 transactionIndex 时,就会发生这种情况。同一区块中的两个日志可以具有相同的 blockNumber 但不同的 logIndex 值。按完整元组排序。第三种故障模式是来自重叠块的重复日志。如果您按 chunkTo + 1 推进游标,您不应该看到重复项,但服务商可能返回重新扫描区块的日志。按完整元组去重。

第四种故障模式是由无界并行度引起的速率限制。如果您看到 HTTP 429 响应,请减少并行度并遵守 Retry-After。不要为 429 将跨度减半,因为那会增加请求数量并加剧速率限制。有关超时和速率限制处理的更多信息,请参阅如何修复 RPC 超时错误

  • 无限重试循环:检查停止条件和游标推进。
  • 缺口:记录失败并稍后重新扫描。
  • 排序违规:按 (blockNumber, logIndex, transactionIndex) 排序。
  • 重复项:按完整元组去重。
  • 速率限制:减少并行度并遵守 Retry-After。

限制与权衡

上限因服务商和链而异,通常未公开,并且可能在没有通知的情况下更改。今天有效的分块算法明天可能需要调整。为每个端点持久化测量到的 maxSpan 有所帮助,但这不是保证。您必须监控新的错误代码和消息。

区块边界扫描无法看到被重组掉的区块中的日志。如果日志是在不再规范的区块中发出的,您的扫描将不会返回它。对于规范扫描来说,这是正确的行为,但这意味着您存储的日志在重组后可能变得过时。对账方法通过在尖端后面重新扫描确认深度来解决这个问题。

分块扫描不是原子的。它在不同时间读取不同的区块。如果您需要时间点快照,必须根据固定的区块哈希进行对账。使用 eth_getBlockByNumber 在扫描开始时解析头部区块哈希,并将该哈希用于任何基于 blockHash 的查询。对于范围查询,存储头部区块号并将其用作固定的 toBlock。这为您提供了该区块处链的一致视图。

有关 BSC 特定的范围限制和大规模扫描,请参阅 BSC eth_getLogs 范围限制和大规模扫描。有关事件和主题过滤,请参阅 eth_getLogs 事件和主题过滤

  • 上限因服务商和链而异,通常未公开,并且可能更改。
  • 区块边界扫描无法看到被重组掉的日志。
  • 分块扫描不是原子的;对于快照,请根据固定的区块哈希进行对账。

生产日志扫描的后续步骤

首先使用上面的脚本测量端点的有效上限。为您使用的每个端点填写结果表。然后使用测量到的 maxSpan 配置扫描器,并实现具有明确停止条件和失败记录的自适应分块算法。在每次遍历中添加对账方法。

如果您正在评估服务商,请比较他们文档记录的并发限制和错误行为。RPC 定价页面和 API 服务页面描述了 OnFinality 的产品。有关 EVM 网络的总体概述,请参阅以太坊网络。有关更多集成和开发指南,请参阅 OnFinality Learn 中心

最后,将上限视为一个移动的目标。定期重新测量,并在任何服务商公告后重新测量。持久化测量值,并在发生范围错误时自动更新它们。这使您的扫描器无需人工干预即可保持弹性。

  • 测量端点的上限并填写结果表。
  • 实现具有明确停止条件和失败记录的自适应分块。
  • 在每次遍历中添加对账。
  • 定期重新测量并为每个端点持久化测量值。

永远不用担心基础设施

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

开始