链上索引器:扫块、重组与幂等¶
相关主题
先分清 投影 ≠ 链上事实; 领域地图见 Indexer / 节点数据。
30 秒版(开场)¶
索引器 = 持续同步链上 canonical 数据到 DB,供 API 查询。必须校验相邻块
parentHash,遇到 reorg 时回退到共同祖先并重放。Ethereum PoS 应优先区分latest、safe、finalized;“固定 N 个确认”只是部分链或 RPC 不支持 finality tag 时的风险策略。
3 分钟版(精讲深度)¶
- 是什么:Worker 从
lastBlock+1扫到 latest,解析 tx/logs 写业务表。 - 为什么:RPC 不适合直接给 C 端高并发查;且需关联链下 userId。
- 怎么做:保存块高、块 hash、parent hash 和处理状态;新区块的
parentHash必须等于本地前一 canonical 块的 hash。发现分叉后向前寻找共同祖先,在同一事务中回滚衍生数据与扫块水位,再重扫。
10 分钟版(原理 + 图示)¶
扫块水位(游标):索引器持久化的「这条链已处理到哪」进度,不是 IDE/编辑器产品名。 常见字段是
last_observed/last_safe/last_finalized;reorg 时水位回退到共同祖先后再前进。
flowchart TB
subgraph indexer[Indexer Worker]
Loop[块高水位] --> Fetch[eth_getBlockByNumber]
Fetch --> Parse[解析 txs/logs]
Parse --> DB[(MySQL/Postgres)]
end
Reorg[检测到 parentHash 不连续] --> Ancestor[寻找共同祖先]
Ancestor --> Rollback[回滚祖先之后的衍生数据]
Rollback --> Loop
表设计要点
| 表 | 关键字段 |
|---|---|
| blocks | chain_id, number, hash, parent_hash, status, processed_at |
| chain_log_observations | chain_id, block_hash, tx_hash, log_index, payload, canonical |
| deposits | business_id, chain_id, tx_hash, log_index, current_block_hash, amount, status |
| scan_watermark | chain_id, last_observed, last_safe, last_finalized |
要区分两种 identity:
- 链上“观察记录”用
(chain_id, block_hash, tx_hash, log_index)唯一,以保留同一 transaction 在 reorg 前后不同区块中的 lineage - 业务副作用可用
(chain_id, tx_hash, log_index, event_semantics)作为幂等键,但要 随 canonical 状态推进/冲正;同一 tx 重新上链不应重复入账
如果只保留 (tx_hash, log_index) 一行并覆盖 block_hash,会丢失审计历史;如果把
每个 block_hash 都当新充值,又会重复入账。该模型与
S-ARCH-04 幂等 同构。
三个容易混淆的水位
| 水位 | 含义 | 典型用途 |
|---|---|---|
| observed/latest | 当前节点看到的链头,可能 reorg | 低风险 UI 预展示 |
| safe | 在正常共识假设下不太可能被重组 | 风险可控的业务确认 |
| finalized | 除非发生严重共识故障,否则不能回滚 | 高价值入账/结算 |
业务状态不能在第一次看到日志时就直接变成“已入账”。应先落 observed 事件,再由链别、资产价值和风控策略决定在 safe 或 finalized 时发布入账事件。非 Ethereum 链需要按各自 finality 模型配置,不能把“12 确认”写成通用常量。
Reorg 处理伪代码
for {
next := repo.LastObserved(chainID) + 1
block := client.BlockByNumber(ctx, next)
parent := repo.CanonicalBlock(chainID, next-1)
if parent != nil && block.ParentHash != parent.Hash {
ancestor := findCommonAncestor(ctx, client, repo, next-1)
repo.InTx(func(tx Tx) error {
// 流水不可原地篡改;已产生的资金影响要用冲正流水。
tx.RevertDerivedAfter(chainID, ancestor.Number)
tx.MarkBlocksOrphanedAfter(chainID, ancestor.Number)
tx.SetObservedWatermark(chainID, ancestor.Number)
return nil
})
continue
}
repo.InTx(func(tx Tx) error {
tx.UpsertObservedBlock(block)
tx.InsertLogObservations(block) // observation key 包含 block_hash
tx.ProjectCanonicalEvents(block) // 业务幂等键推进 canonical 状态
tx.SetObservedWatermark(chainID, block.Number)
return nil
})
// 支持相应 JSON-RPC block tag 的节点可查询 "safe" / "finalized";
// 其他链按其共识模型使用确认数等替代策略。
safeHead, finalizedHead := client.FinalityHeads(ctx)
repo.PromoteStatus(chainID, safeHead, finalizedHead)
}
findCommonAncestor 的核心是从本地断点向前走,用“同高度的远端 canonical hash”与本地 hash 比较,直到相等;不能只删除当前一个高度。若 reorg 穿过本地 safe 水位或超过配置上限,应停止自动入账并升级告警。
生产场景¶
- 交易所充值:先展示 pending,再按链别和金额在 safe/finalized 后 credit;深度 reorg 用冲正流水处理
- NFT 铸造索引:metadata 链下 IPFS + 链上 tokenId
- 多链:每链独立扫块水位、finality 策略和 worker 池
排查与工具¶
- 指标:
indexer_lag_blocks、reorg_count、rpc_errors - 告警:lag > 50 或 10min 不前进
- 对账:链上 balance vs 库内汇总抽样
架构取舍¶
| 策略 | 延迟 | 风险 |
|---|---|---|
| observed 即展示 | 最低 | 只能作为 pending,不可直接用于高价值结算 |
| safe 后确认 | 中 | 适合多数正常风险业务 |
| finalized 后确认 | 最高 | 适合高价值、不可逆结算 |
深挖问答¶
- 重组回滚多深? → 不猜固定深度;一直回退到共同祖先。穿过 safe/finalized 或超过风控上限时升级告警。
- eth_getLogs 范围限制? → 按 provider 限额分片、重试并校验连续性。历史日志依赖节点保留对应区块体/receipt 与 provider 的历史窗口,不等同于“必须有 archive state”;启用 history pruning 的节点也可能已经删除旧 receipt。
- 和 MQ? → 索引后发
DepositConfirmed事件驱动下游(S-SOL-03)。 - 并发扫块? → 单链单一扫块水位串行推进;按段并行需严格顺序合并。
反模式与事故¶
- 只存 blockNumber 不存 hash → reorg 无法检测
- 只比较“同高度旧 hash”而不校验 parentHash → 正常前向同步无法及时识别分叉
- 无 UNIQUE 约束 → 重复入账
- catch-up 无并发上限、退避和 provider 限流 → RPC 被限流或封禁
代码示例¶
Worker 用 context + graceful shutdown;见 S-CODE-03 优雅退出。