Appearance
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 + getLogs 或 watchEvent |
| 断点续传 | indexer_state 表记录最后区块号 |
| 链重组防护 | 只索引落后最新区块 12 个块的数据 |
| 去重 | UNIQUE(tx_hash, log_index) + INSERT OR IGNORE |
| 大数值存储 | 所有 amount 字段存为 TEXT(字符串) |
| 批量写入 | SQLite 事务包装,每次 2000 区块批次 |
| REST API | Express + SQLite 查询,分页+过滤 |