链上事件索引与指标实践

# 链上事件索引与指标实践

索引器把链上发生的事,整理成数据库能快速查询的数据;看板是否可信,取决于补扫、去重、重组处理和指标口径。

TVL、活跃地址、交易量以及 Dune、The Graph、ZAN 等工具的介绍,复用 Web3 数据分析。本篇补工程过程,不重新罗列平台。

# 交易、日志、Trace 有什么区别

交易是外部提交的执行入口;日志是合约主动 emit 的记录;Trace 是执行过程中内部调用等细节。一次交易可以调用多个合约并产生多条日志,所以日志条数不等于交易数。Trace 通常需要节点执行或调试能力,不是普通 receipt 中的 logs。

非匿名事件的 topic0 通常是事件签名哈希,其他 topics 保存 indexed 参数,data 保存非 indexed 参数。动态类型的 indexed 参数存的是哈希,不能当作原文直接解码。过滤时还要指定链和合约地址,不能只认事件名称。

# 解析一条事件

下面是独立的 TypeScript 示例,依赖 viem。日志由本地构造,只演示 ABI 解码,不冒充真实链上事件。

import { decodeEventLog, padHex, parseAbi, toEventSelector } from 'viem';

const abi = parseAbi(['event Completed(address indexed learner)']);
// 与前面教学合约保持同一事件;地址只是本地测试输入。
const learner = '0x000000000000000000000000000000000000bEEF' as const;
const event = decodeEventLog({
  abi,
  topics: [
    toEventSelector('Completed(address)'), // topic0:事件签名哈希。
    padHex(learner, { size: 32 }), // indexed address 左补零到 32 字节。
  ],
  data: '0x', // 此事件没有非 indexed 参数,因此 data 为空。
});
console.log(event.eventName, event.args.learner);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

真实场景把 RPC 返回的 topics、data 传入解码器,并保存 blockNumber、blockHash、txHash、logIndex 与合约地址。ABI 改版时,应按部署地址和版本选择正确解码规则。

# 怎样保证停机后不漏数据

  1. 从合约部署区块开始,按区块范围查询日志,窗口大小适配 RPC 的限制。
  2. 将原始事件和业务投影放在数据库事务中处理,同步更新扫描游标。
  3. 处理失败时不推进游标;重启后重复扫描同一区间,用唯一键去重。
  4. 对短重组保留区块哈希,检测到不一致时回到共同祖先,把旧分支事件标记为失效并重建投影。
  5. WebSocket 只负责低延迟提醒,定期扫描负责补漏。扫描落后多少区块需要监控。

只按 (txHash, logIndex) 去重不足以区分全部链和分支;原始日志可用 (chainId, blockHash, txHash, logIndex) 标识观察记录,再用 canonical 状态表示当前是否有效。业务效果还要有自己的幂等键,避免同一业务被重发交易重复兑现。

重组回滚不能机械把数据库状态改回去就结束。已经发送的通知、链下发货或其他不可逆动作可能需要补偿,所以高风险副作用应在确认策略满足之后再触发。

# 原始事件、业务投影和游标为什么一起提交

假设正在扫描区块 101~110,事件已写入,但进程在保存游标前崩溃。重启会再次扫描这段区间,原始事件唯一键和业务幂等键应阻止重复效果。反过来,如果游标先推进到 110,事件还没提交就崩溃,重启可能跳过这段数据,造成永久遗漏。

因此同一批的原始事件、投影变更和游标应原子提交;若解码遇到未知版本,可保留原始数据并阻止相关投影推进,或按明确的隔离策略处理,不能无声跳过还把游标向前推。

生产中还应保存扫描区块的哈希和父哈希,包括没有相关日志的区块。只保存事件所在区块,会让某些空区块重组缺少连续核对依据。大区间请求失败就缩小窗口重试,不把 RPC 返回错误当成这段没有事件。

# 一次两区块重组具体怎么恢复

假设已处理 100-A → 101-A → 102-A,后来发现规范链变成 100-A → 101-B → 102-B。共同祖先是 100-A:

  1. 暂停相关投影推进,核查父哈希找到共同祖先,而不是固定回退一格。
  2. 将 101-A、102-A 的原始事件标记为非 canonical,保留审计记录。
  3. 撤销或重建它们影响的购买投影,把游标回到共同祖先;恢复过程本身需要事务和幂等。
  4. 顺序扫描 101-B、102-B,重新处理有效事件,再继续追赶。

例如旧分支发过一次购买事件,新分支没有该购买,数据库不能继续保持有效购买。若同一交易后来重新打包到新分支,要按新的观察记录重新判定;业务幂等记录也必须与分支有效性协调,不能一个永久已处理标记把合法重建挡住。

邮件、发货和外部结算无法随数据库直接回滚。不可逆效果要尽量等待确认策略满足后再触发,仍出现异常时则走业务补偿与对账;不要用删除日志掩盖已经发生的外部效果。

# 什么时候自己扫描,什么时候用索引服务

事件较少、业务规则可控时,自建按区间扫描器容易掌握确认和恢复逻辑,但要维护 RPC 限流、游标与重组。使用 The Graph 等索引方案可减少部分查询和索引工作,仍要理解数据延迟、支持网络及最终性口径。

无论采用哪种,资金结果都不能只看一张延迟未知的看板。保留链、区块、事件来源和扫描进度,关键结算可按风险增加 RPC 回读或对账;第三方索引结果不是天然实时且永远正确。

# 一条能跑的指标 SQL

下面可直接在 PostgreSQL 中执行。输入是内联样本,不依赖未知表;同一笔交易有两条日志,因此事件数与交易数不同。

WITH events(chain_id, tx_hash, log_index, buyer, amount, canonical) AS (
  VALUES
    (1, 'tx-a', 0, 'alice', 10::numeric, true),
    (1, 'tx-a', 1, 'alice', 20::numeric, true),
    (1, 'tx-b', 0, 'bob',   15::numeric, true),
    (1, 'tx-old', 0, 'carol', 99::numeric, false)
)
SELECT
  count(*) AS event_count,                       -- 3 条有效日志。
  count(DISTINCT (chain_id, tx_hash)) AS tx_count, -- 2 笔交易,不按日志数计算。
  count(DISTINCT buyer) AS active_addresses,      -- 2 个地址,不等于 2 个真人。
  sum(amount) AS total_amount                    -- 45;样本假定相同币种和单位。
FROM events
WHERE canonical; -- 排除已经离开规范链的记录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14

迁移到 Dune 时,使用对应链的已解码事件表或 raw logs,并按实际字段、SQL 方言和金额类型改写。不能把上面的样本列名当作 Dune 官方表结构。

# 指标常见误读

指标 必须说清什么
活跃地址 统计买家、交易发送者还是任意参与者;一个人可有多个地址,机器人也会活跃
交易量 是买卖成交额还是普通转账额;避免一笔 swap 的多跳重复计数
TVL 统计哪些资产、什么价格与时间点;不能只累计存入而忽略提取和价格变化
成功率 分母是否包括拒签、未广播、revert;不要只统计已经成功上链的数据
延迟 起止点是什么:点击到回执、回执到索引、索引到页面是不同指标

地址标签和聚类只能提供行为线索,不能直接证明现实身份。看板展示数据时间范围、刷新时间、价格来源与链确认策略,再抽样对照区块浏览器或原始 RPC。

# 面试时可以这样回答

只监听合约事件,为什么还是可能漏单?参考答案

监听连接会断,RPC filter 也可能失效。我的主流程会保存区块游标,按范围补扫日志,把事件处理和游标更新放在同一事务里;重复扫描靠幂等去重,重组则按区块哈希识别并重建投影。实时订阅负责快,持续补扫负责不漏。

已经做了事件唯一键,为什么还要处理重组?参考答案

唯一键只解决同一观察记录重复处理,不判断它是否仍在规范链上。我会保存区块哈希和父哈希,发现分叉后回到共同祖先,标记旧事件无效并重建投影。业务幂等也要配合回滚和重新纳入,否则可能保留失效权益,或阻止新分支上的合法购买。

# 官方参考