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

大规模扫描 BNB Smart Chain 日志:eth_getLogs 范围限制与安全分页

了解如何在 BNB Smart Chain 上对 eth_getLogs 进行分页,避免触及范围上限、超时或静默缺口,从而让索引器构建完整的 ERC-20 转账历史。

TL;DR

BNB Smart Chain 上的 eth_getLogs 受提供商施加的区块范围上限和超时限制,因此扫描大量历史需要按固定窗口分页、持久化游标,并按 (transactionHash, logIndex) 去重。节点必须扫描请求范围内的每个区块,因此宽范围会产生 O(blocks) 的工作量和内存开销,并经常因“block range too large”错误或超时而失败。通过实验发现你的端点的真实上限,在 429/超时时使用带抖动的退避,并重新扫描确认尾部以修复重组日志。对于持续实时使用,请使用订阅或索引 API;对于海量全历史扫描,请使用归档端点或基于范围的索引器。

为什么 eth_getLogs 不是全历史查询

eth_getLogs 方法接受 fromBlocktoBlockaddress[]topics[][],并返回匹配的日志。根据 Ethereum execution-apis 规范,节点必须扫描请求范围内的每个区块,并反序列化任何匹配的日志。这意味着宽范围会产生 O(blocks) 的工作量和内存开销,而不是 O(results)。

BNB Smart Chain 的区块数量非常庞大,因为它多年来每隔几秒就产生一个区块,并且承载着来自 ERC-20 活动的大量日志。因此,一个简单的 fromBlock: 0, toBlock: 'latest' 查询既昂贵又很可能被拒绝。大多数提供商会返回明确的错误,例如“block range too large”或超出限制的消息,并且历史上在 bnb-chain/bsc GitHub issue #113 中记录过一个大约几千个区块的具体限制。有些端点会静默截断而不是报错,这更糟糕,因为它会产生看起来正确但实际不完整的扫描。

  • 宽范围会产生 O(blocks) 的工作量和内存开销,而不是 O(results)。
  • 提供商上限有文档记录/因提供商而异;切勿假设一个通用数字。
  • 静默截断是最危险的失败模式,因为它看起来像成功。

范围上限的表现形式:错误、超时或截断

当范围过宽时,端点可能返回 JSON-RPC 错误,消息类似于“block range too large”或“limit exceeded”。它也可能在 HTTP 层超时,返回 504 或连接重置。第三种可能是静默截断:节点返回日志的子集而没有错误,因此你的索引器记录了不完整的历史。

由于这些行为因提供商和端点层级而异,你必须将上限视为所使用端点的经验属性。BNB Smart Chain RPC 可靠性与超时 页面介绍了超时和速率限制如何与重试交互,而 BNB Smart Chain RPC 端点(RPC Assistant) 页面列出了你可以测试的端点。

  • 显式错误:“block range too large”或超出限制。
  • 超时:HTTP 504 或连接重置。
  • 静默截断:返回的日志少于预期且没有错误。

正确策略:带持久化游标的固定窗口分页

按区块范围使用固定窗口分页,例如 500–2000 个区块,根据你的端点进行调整。设置 fromBlock = lastScanned + 1toBlock = min(lastScanned + window, latest)。成功完成一页后,将 lastScanned 推进到 toBlock 并持久化。重启时,从持久化的游标恢复,这样你永远不会重新扫描或跳过区块。

将游标持久化到持久存储中,而不是内存中。如果你在处理日志之前持久化游标,重启时可能会重新处理;如果你在处理之前持久化,可能会跳过。安全的模式是在同一事务中写入日志和游标,或者通过复合键去重使日志写入幂等。

  • 窗口大小是一个可调参数,而不是常量。
  • 仅在完全成功的一页之后才推进。
  • 持久化游标并确保写入幂等。

通过实验发现你的端点的真实上限

不要相信博客文章中的数字。在你的端点上二分搜索成功且无错误或超时的窗口大小。从一个可行的小窗口开始,将其加倍直到失败,然后在最后一次成功和第一次失败之间缩小范围。将结果记录在配置文件中,并在更换提供商或层级时重新测试。

当你遇到 429 或超时时,使用指数退避和抖动进行退避。一个简单的时间表是 250 毫秒、500 毫秒、1 秒、2 秒、4 秒,上限为 30 秒,并带有最多 250 毫秒的随机抖动。这避免了惊群重试,并与 BNB Smart Chain RPC 可靠性与超时 页面中的指导一致。

  • 二分搜索窗口:加倍直到失败,然后缩小范围。
  • 按端点和层级记录发现的上限。
  • 在 429/超时时使用指数退避加抖动进行退避。

缩小查询范围:地址和主题

始终通过 address(代币合约)和 topics(Transfer 事件签名和索引的 from/to)来缩小范围。Transfer 事件签名是 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef。按主题过滤可减少节点必须反序列化和返回的数据,从而降低超时概率并减少带宽。

主题和过滤器语义的链无关机制在 使用 eth_getLogs 和主题过滤事件日志 中介绍。使用该页面了解过滤器语法;使用本页面了解 BNB 特定的扫描策略。

  • 按代币合约地址过滤。
  • 按 Transfer 事件签名和索引的 from/to 过滤。
  • 更窄的过滤器可降低超时风险和带宽。

排序、去重和重组修复

(blockNumber, logIndex) 对结果排序,并按复合键 (transactionHash, logIndex) 去重。重组可能会重新发出或移动日志,因此同一笔逻辑转账可能会以不同的区块号或日志索引出现。复合键去重可防止重复计数,同时允许合法的重新发出替换过时的条目。

在每次遍历时重新扫描确认尾部,例如最后 N 个区块,以修复重组日志。永远不要将来自尖端区块的日志视为最终。N 的大小取决于你的风险承受能力和链的重组深度;一个常见的起点是 12–64 个区块,但你应该为你的用例进行测量。

  • 按 (blockNumber, logIndex) 排序。
  • 按 (transactionHash, logIndex) 去重。
  • 每次遍历重新扫描确认尾部;永远不要最终确定尖端日志。

可运行的 Node.js:带退避和游标的分页转账扫描

以下脚本在大范围上分页扫描代币的 Transfer 日志,具有可配置的窗口、429/超时时的指数退避、持久化游标和复合键去重。它打印进度和一个结果表,你可以为你的端点填写。将 RPC URL 和代币地址替换为你自己的。

使用 Node.js 18+(全局 fetch)运行它。为简单起见,游标存储在 JSON 文件中;在生产环境中使用数据库事务。

// scan_transfers.js
// Usage: node scan_transfers.js
// Requires Node.js 18+ (global fetch)

const fs = require('fs');

const RPC_URL = process.env.RPC_URL || 'https://your-bnb-endpoint';
const TOKEN = process.env.TOKEN || '0x...';
const WINDOW = Number(process.env.WINDOW || 1000);
const CONFIRMATION_TAIL = Number(process.env.CONFIRMATION_TAIL || 32);
const CURSOR_FILE = './cursor.json';
const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';

function loadCursor() {
  if (fs.existsSync(CURSOR_FILE)) return JSON.parse(fs.readFileSync(CURSOR_FILE));
  return { lastScanned: 0, seen: {} };
}

function saveCursor(c) {
  fs.writeFileSync(CURSOR_FILE, JSON.stringify(c));
}

async function rpc(method, params, attempt = 0) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
  });
  if (res.status === 429 || res.status === 504) {
    const delay = Math.min(30000, 250 * 2 ** attempt) + Math.random() * 250;
    console.warn(`retry ${attempt + 1} after ${Math.round(delay)}ms (status ${res.status})`);
    await new Promise(r => setTimeout(r, delay));
    return rpc(method, params, attempt + 1);
  }
  const json = await res.json();
  if (json.error) {
    const msg = JSON.stringify(json.error);
    if (/range|limit|too large/i.test(msg) && attempt < 6) {
      const delay = Math.min(30000, 250 * 2 ** attempt) + Math.random() * 250;
      console.warn(`range error, backing off ${Math.round(delay)}ms`);
      await new Promise(r => setTimeout(r, delay));
      return rpc(method, params, attempt + 1);
    }
    throw new Error(msg);
  }
  return json.result;
}

async function latestBlock() {
  const hex = await rpc('eth_blockNumber', []);
  return parseInt(hex, 16);
}

async function getLogs(from, to) {
  return rpc('eth_getLogs', [{
    fromBlock: '0x' + from.toString(16),
    toBlock: '0x' + to.toString(16),
    address: TOKEN,
    topics: [TRANSFER_TOPIC]
  }]);
}

async function main() {
  const cursor = loadCursor();
  const latest = await latestBlock();
  let scanned = 0, found = 0, retries = 0;
  let from = cursor.lastScanned + 1;
  while (from <= latest) {
    const to = Math.min(from + WINDOW - 1, latest);
    let logs;
    try {
      logs = await getLogs(from, to);
    } catch (e) {
      console.error(`page ${from}-${to} failed: ${e.message}`);
      break;
    }
    for (const log of logs) {
      const key = `${log.transactionHash}:${log.logIndex}`;
      if (!cursor.seen[key]) {
        cursor.seen[key] = true;
        found++;
      }
    }
    cursor.lastScanned = to;
    saveCursor(cursor);
    scanned += to - from + 1;
    console.log(`scanned ${from}-${to} | logs ${logs.length} | total ${found}`);
    from = to + 1;
  }
  // Re-scan confirmation tail to repair reorgs
  const tailFrom = Math.max(0, cursor.lastScanned - CONFIRMATION_TAIL + 1);
  const tailLogs = await getLogs(tailFrom, cursor.lastScanned);
  console.log(`tail re-scan ${tailFrom}-${cursor.lastScanned} | logs ${tailLogs.length}`);
  console.log('\nResults Table (fill in for your endpoint):');
  console.log('| blocks scanned | logs found | window size | retries |');
  console.log(`| ${scanned} | ${found} | ${WINDOW} | ${retries} |`);
}

main().catch(e => { console.error(e); process.exit(1); });

结果表:针对你自己的端点进行测量

使用下表记录你的端点实际做了什么。使用不同的 WINDOW 值运行脚本,并注意错误或超时开始出现的位置。这是了解你的上限的唯一可靠方法。

记录端点 URL、窗口大小、页面是否成功、返回的日志数量以及重试次数。至少对三个窗口大小重复,以找到边界。

  • | 端点 | 窗口 | 成功? | 日志 | 重试 |
  • |----------|--------|----------|------|---------|
  • | https://... | 500 | 是 | ... | 0 |
  • | https://... | 1000 | 是 | ... | 0 |
  • | https://... | 2000 | 否(范围过大) | 0 | 2 |

何时 eth_getLogs 分页是错误的工具

对于持续实时使用,请使用 WebSocket 订阅或提供商的索引 API。BNB Smart Chain RPC 端点(RPC Assistant) 页面和 API 服务 页面描述了选项。对于海量全历史扫描,请考虑归档端点加上基于范围的或第三方索引器。

在修剪过的全节点上,旧日志需要归档访问。BNB Smart Chain 历史 RPC 与归档数据 页面解释了归档要求。如果你正在扫描不同的链,通过 RPC 查询 Solana 历史数据 页面展示了类似的方法。

  • 实时:使用订阅或索引 API。
  • 全历史:使用归档端点或基于范围的索引器。
  • 修剪过的节点无法提供旧日志。

常见故障和故障排查清单

最常见的故障是“范围过大”、请求超时、429 速率限制、看起来正确但实际是截断的空页面、重组后的重复或缺失日志,以及查询修剪过的全节点以获取旧日志。每个都有不同的修复方法。

在更改代码之前,请完成以下清单。大多数问题是端点或窗口大小问题,而不是逻辑错误。

  • 范围过大:减小窗口大小并重新测试。
  • 超时:减小窗口、添加退避或使用专用端点。
  • 429:使用抖动退避并降低并发。
  • 空页面:对照具有已知转账的已知区块进行验证。
  • 重组后的重复/缺失:重新扫描确认尾部并去重。
  • 旧日志缺失:切换到归档端点。

限制、权衡和后续步骤

按区块范围分页简单且可移植,但比索引 API 慢,并且需要你管理游标和重组修复。窗口大小是请求数量和失败风险之间的权衡。去重存储随历史大小增长,因此请规划修剪或数据库索引。

接下来,查看 BNB Smart Chain RPC 可靠性与超时 页面了解重试模式,BNB Smart Chain 历史 RPC 与归档数据 页面了解归档访问,以及 OnFinality Learn 中心 了解相关指南。有关端点选项和定价,请参阅 RPC 定价BNB Smart Chain 网络页面

  • 分页可移植但比索引 API 慢。
  • 窗口大小在请求数量和失败风险之间权衡。
  • 去重存储会增长;规划修剪或索引。

永远不用担心基础设施

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

开始