Skip to content
On this page

L1-17: Indexer(索引器)

1. 问题

从零构建自己的链上数据索引器——不使用 The Graph 或 Dune,直接监听合约事件、存储到本地数据库、并提供 REST API 供前端查询。目标:索引 ERC-20 Transfer 事件,支持按地址和代币合约查询转账记录。

2. 原因

The Graph 的托管服务可能宕机或延迟,自建索引器提供了完全的控制权:

  • 灵活性:可以实现 The Graph 不支持的自定义逻辑(复杂聚合、跨合约关联)
  • 实时性:直接监听 WebSocket 事件推送,延迟低于 Graph 的轮询模式
  • 理解底层原理:自建索引器让你理解 The Graph 在做什么——监听事件 → 解析 → 存储 → 查询
  • 数据所有权:索引数据存储在自己的数据库中,不受第三方服务可用性影响

这也是后端工程师过渡到 web3 的关键技能。

3. 方案

架构设计

以太坊节点 (WebSocket)


watchEvent (viem) ──→ 解析 Event 参数


SQLite 数据库 ──→ 存储 Transfer 记录


Express REST API ──→ /api/transfers, /api/balance/:address

数据库 Schema

sql
CREATE TABLE IF NOT EXISTS transfers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tx_hash TEXT NOT NULL,
  block_number INTEGER NOT NULL,
  log_index INTEGER NOT NULL,
  from_address TEXT NOT NULL,
  to_address TEXT NOT NULL,
  token_address TEXT NOT NULL,
  amount TEXT NOT NULL,
  timestamp INTEGER NOT NULL,
  UNIQUE(tx_hash, log_index)
);

CREATE TABLE IF NOT EXISTS indexer_state (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);

核心索引逻辑

javascript
async function processEvents(fromBlock, toBlock) {
  for (const tokenAddress of WATCHED_TOKENS) {
    const logs = await client.getLogs({
      address: tokenAddress,
      event: ERC20_TRANSFER_EVENT,
      fromBlock: BigInt(fromBlock),
      toBlock: BigInt(toBlock),
    });

    const insert = db.prepare(`
      INSERT OR IGNORE INTO transfers
      (tx_hash, block_number, log_index, from_address, to_address,
       token_address, amount, timestamp)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    `);

    const insertMany = db.transaction((logs) => {
      for (const log of logs) {
        insert.run(
          log.transactionHash,
          Number(log.blockNumber),
          log.logIndex,
          log.args.from.toLowerCase(),
          log.args.to.toLowerCase(),
          log.address.toLowerCase(),
          log.args.value.toString(),
          Math.floor(Date.now() / 1000),
        );
      }
    });
    insertMany(logs);
  }
}

链重组处理

javascript
async function handleReorgs() {
  const lastBlock = await getLastIndexedBlock();
  const safeBlock = Math.max(0, lastBlock - 12);
  db.prepare('DELETE FROM transfers WHERE block_number > ?').run(safeBlock);
  await updateLastIndexedBlock(safeBlock);
}

REST API

  • GET /api/transfers?address=0x...&limit=100 — 按地址查询转账记录
  • GET /api/transfers?token=0x...&limit=100 — 按代币合约查询
  • GET /api/balance/:address — 查询某个地址的索引余额

4. 遭遇的陷阱

4.1 WebSocket 断线重连

以太坊节点的 WebSocket 连接可能因网络问题断开。如果不实现自动重连和断点续传,索引器会丢失断线期间发生的所有事件。

4.2 链重组(Reorg)

以太坊偶尔发生链重组——之前确认的区块被回滚。索引器中存储的这些区块的事件也随之无效。最保守的策略是只索引比最新区块落后 12 个区块的数据("finalized" 状态)。

4.3 去重

同一事件可能因重试或 reorg 处理而多次被索引。UNIQUE(tx_hash, log_index) 约束 + INSERT OR IGNORE 是最简单的去重策略。

4.4 大额数值的 JavaScript 精度问题

ERC-20 的 amount 可以是任意大的数值(如 10^18 wei)。JavaScript 的 Number 类型无法精确表示。所有 amount 字段应存储为字符串(TEXT)。

5. 陷阱的原因

5.1

WebSocket 不同于 HTTP——它是长连接,网络抖动会导致连接断开。viem 的 watchEvent 内部会尝试重连,但在自定义索引器中应显式处理重连逻辑。

5.2

以太坊的共识机制允许最长约 64 个区块的重组。保守策略是永不索引最新 12 个区块的数据,确保所有已索引数据都达到了 "safe head"。

5.3

如果事件处理过程中崩溃(例如数据库写入失败),已处理的事件可能部分写入。使用事务(db.transaction)确保原子性。

6. 如何解决陷阱

  • 使用 better-sqlite3 的 WAL 模式(写入更快、支持并发读取)
  • indexer_state 表中记录最后索引的区块号,重启后从此处继续
  • 每次索引前清理最后 12 个区块的数据(保守 reorg 防护)
  • 所有数值字段使用 TEXT 类型存储(避免精度丢失)
  • 使用 db.transaction() 包装批量写入确保原子性

7. 技术要点

要点说明
事件监听createPublicClient + getLogswatchEvent
断点续传indexer_state 表记录最后区块号
链重组防护只索引落后最新区块 12 个块的数据
去重UNIQUE(tx_hash, log_index) + INSERT OR IGNORE
大数值存储所有 amount 字段存为 TEXT(字符串)
批量写入SQLite 事务包装,每次 2000 区块批次
REST APIExpress + SQLite 查询,分页+过滤

Built with AiAda