导语:从"能查到"到"查得快"
区块浏览器能告诉你某一笔交易的详情,但回答不了"过去 30 天所有 DEX 的日活地址数"这类问题。链上数据是追加式账本:数据全公开,但格式面向执行而非分析——一笔 Uniswap 交易在链上只是几十字节的 calldata 加上若干 Transfer 日志,要变成"这笔 swap 用了多少 ETH"必须经过解码、归集与建模。
本文拆解链上数据分析的完整链路,对比 Dune、Flipside 与自建仓库三条路线的取舍,并给出把查询成本压下来的具体手段。
一句话总结:链上分析的本质是"把面向执行的字节流,重建成面向分析的关系模型",所有工程难点都围绕解码、归集、增量这三件事。
1. 数据源:链上到底有什么
1.1 四类原始数据
| 数据类别 | 来源 | 内容 |
|---|---|---|
| Block | eth_getBlockByNumber | 时间戳、gas 用量、base fee |
| Transaction | 区块内的 tx 列表 | from/to、value、input calldata、status |
| Receipt / Log | eth_getTransactionReceipt | 事件日志(topics + data)、gas used |
| Trace | debug_traceTransaction | 内部调用、CREATE、SELFDESTRUCT |
前两类是"表层语义",后两类才是分析的深水区:日志决定了合约发生了什么事件,trace 决定了资金在合约内部怎么流动。
1.2 日志与 topics 的结构
每个事件日志由 address、topics[] 和 data 组成。topics[0] 是事件签名的 keccak256 哈希,其余 topic 是被索引的参数:
Transfer(address indexed from, address indexed to, uint256 value)
topics[0] = keccak256("Transfer(address,address,uint256)")
= 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
topics[1] = from(左补零到 32 字节)
topics[2] = to
data = value(未索引参数,ABI 编码)
因此"解码一笔 ERC-20 转账"就是从 topics[1]、topics[2] 取地址、从 data 取金额,并把这个 0xddf252ad... 的签名映射回 Transfer。
1.3 为什么必须解码
未解码的日志对分析毫无用处。解码需要 ABI(Application Binary Interface),而 ABI 的来源有两类:
- 合约源码已验证(Etherscan 上有源码与 ABI);
- 或通过 4byte 数据库反查函数选择器(selector)。
实践中约有 5%~10% 的合约未验证源码,这类合约的调用只能停留在"原始 calldata"层面,是链上分析的一大盲区。
2. 解码与归集:把字节变成表
2.1 函数选择器与事件签名
# 用 cast 计算选择器与事件哈希
cast sig "swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"
# 0x38ed1739
cast keccak "Transfer(address,address,uint256)"
# 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
拿到选择器后,就能把一笔 input 的前 4 字节映射到函数名与参数列表:
from eth_abi import decode
from eth_utils import keccak
def decode_call(abi, data: bytes):
selector = data[:4]
for item in abi:
if item.get("type") != "function":
continue
sig = f"{item['name']}({','.join(i['type'] for i in item['inputs'])})"
if keccak(text=sig)[:4] == selector:
types = [i["type"] for i in item["inputs"]]
return item["name"], decode(types, data[4:])
return None, None
2.2 Trace 归集:还原资金流
只看 Transfer 日志会漏掉大量场景:闪电贷、批量路由、内部转账。要完整还原资金流必须解析 trace。以一次 Uniswap V3 swap 为例,trace 树形结构如下:
CALL router.swapExactInput
├─ STATICCALL pool.slot0 # 读价格
├─ CALL pool.swap # 真正换币
│ ├─ CALL token0.transfer
│ └─ CALL token1.transfer
└─ CALL refund
归集的目标是把这棵树压平为一行"用户 A 用 X 个 token0 换到 Y 个 token1,路由经过 pool P"。这需要按 traceAddress 重建父子关系并做净额计算。
2.3 表模型设计
一个可用的链上仓库通常有这几张核心表:
| 表 | 粒度 | 关键字段 |
|---|---|---|
blocks | 每块一行 | number, timestamp, base_fee |
transactions | 每 tx 一行 | hash, from, to, status, gas_used |
logs | 每 log 一行 | tx_hash, log_index, address, topics |
traces | 每调用一行 | tx_hash, trace_address, call_type |
decoded_events | 每事件一行 | 事件名 + 参数列 |
前四张是原始层(raw),第五张是解码层(decoded)。在解码层之上再建业务层(如 dex.trades、lending.borrows),供分析者直接查询。
3. Dune:SQL 即分析
3.1 抽象层次
Dune 把上述链路全部封装,暴露四个抽象层:
| 层 | 名称 | 说明 |
|---|---|---|
| 原始 | ethereum.blocks/transactions/logs/traces | 未解码字节 |
| 解码 | ethereum.logs_decoded | 按 ABI 解出的参数 |
| 策展 | dex.trades、nft.trades | 团队维护的业务视图 |
| 用户 | dune.<user>.result_* | 用户自己的查询结果表 |
3.2 典型查询
统计某 DEX 近 7 天的日交易量:
SELECT
date_trunc('day', block_time) AS day,
COUNT(*) AS trades,
SUM(amount_usd) AS volume_usd
FROM dex.trades
WHERE block_time >= now() - interval '7' day
AND project = 'uniswap'
AND blockchain = 'ethereum'
GROUP BY 1
ORDER BY 1;
Dune 的引擎是 Trino(原 PrestoSQL) 加自研的列式存储,因此查询语法接近标准 SQL,但对 SELECT * 与全表扫描非常敏感。
3.3 成本与配额
Dune 的付费模式按查询消耗的 credits 计费,与扫描的数据量正相关。压成本的三条硬规则:
- 始终带分区过滤(
block_time/block_number),让引擎能剪枝; - 避免
SELECT *,列式存储下只取需要的列能省数倍; - 用物化视图(Materialized View) 把重查询落表,后续查询读结果表。
-- 把重查询物化为视图,定时刷新
CREATE MATERIALIZED VIEW dune.myteam.daily_dex_volume AS
SELECT block_date, project, SUM(amount_usd) AS vol
FROM dex.trades
WHERE block_date >= date '2024-01-01'
GROUP BY 1, 2;
3.4 通过 API 把查询接进产品
Dune 提供 REST API,可以把查询结果直接喂给仪表盘或风控系统:
# 执行查询并轮询结果
curl -X POST "https://api.dune.com/api/v1/query/<QUERY_ID>/execute" \
-H "X-Dune-API-Key: $DUNE_KEY"
# 拉取执行结果
curl "https://api.dune.com/api/v1/execution/<EXECUTION_ID>/results" \
-H "X-Dune-API-Key: $DUNE_KEY" | jq '.result.rows[:3]'
注意 API 查询按执行次数计费且结果有缓存窗口,因此高频调用的场景应把结果落到自己的库或缓存里,而不是每次现算。
4. Flipside 与自建仓库
4.1 Flipside 的差异
Flipside 与 Dune 最大的区别在数据策展方式:Flipside 由社区(“Bounties”)与团队共同维护数据集,强调数据质量审核与跨链标准化,查询引擎同样是 Trino 系。对分析者而言,两者的差异主要是:
| 维度 | Dune | Flipside |
|---|---|---|
| 数据集风格 | 团队策展 + 用户视图 | 社区策展 + 审核流程 |
| 查询语言 | Trino SQL | Trino SQL |
| 跨链覆盖 | 多链 | 多链,偏标准化 |
| 自建空间 | 可上传表 | 支持 API 导出 |
4.2 什么时候必须自建
以下场景托管平台满足不了,必须自建仓库:
- 需要私有数据(内部持仓、风控名单)与链上数据 join;
- 需要亚秒级刷新(套利、清算监控);
- 需要自定义解码(未验证合约、私有协议);
- 成本敏感(大规模历史回填,托管平台按量计费会失控)。
4.3 自建的技术栈
采集层:Erigon / Reth 全节点 → 提取 blocks/txs/receipts/traces
传输层:Kafka 或直接写对象存储(Parquet)
存储层:ClickHouse(分析) + PostgreSQL(元数据)
计算层:dbt 做建模与测试
调度层:Airflow / Dagster 做增量编排
服务层:Metabase / Superset 做可视化
存储引擎选 ClickHouse 分析引擎
是当前主流:它的 MergeTree 家族对"按块号范围扫描 + 高基数维度聚合"这类链上查询特别友好,压缩率通常能到 5~10 倍。
4.4 增量同步的关键设计
链上数据是追加式的,因此同步必须支持断点续传与重组(reorg)回滚:
-- ClickHouse:按块号建分区,便于回滚与剪枝
CREATE TABLE transactions (
block_number UInt64,
block_time DateTime,
tx_hash String,
from_addr String,
to_addr Nullable(String),
value UInt256,
status UInt8
)
ENGINE = ReplacingMergeTree
PARTITION BY intDiv(block_number, 1000000) -- 每百万块一个分区
ORDER BY (block_number, tx_hash);
-- 回滚:删掉受影响的尾部块再重放
ALTER TABLE transactions DELETE WHERE block_number >= 20000000;
ReplacingMergeTree 配合 ORDER BY 中的主键,能在重放时幂等覆盖同一笔交易,避免重复计数。这是自建仓库最容易被忽略、却最容易出错的细节。
5. 数据质量与常见陷阱
5.1 五个高频坑
| 陷阱 | 表现 | 对策 |
|---|---|---|
| 重复计数 | 重放导致同一 tx 出现两次 | 主键去重 / ReplacingMergeTree |
| 未解码调用 | 统计漏掉部分协议 | 补 ABI 或按 selector 归类 |
| 内部转账遗漏 | 资金流对不上 | 引入 trace 归集 |
| 时间戳不一致 | 跨链对比错位 | 统一用 UTC 区块时间 |
| 代币精度 | 金额差 10^18 倍 | 按 decimals 归一化 |
5.2 代币精度与金额归一化
不同 ERC-20 的 decimals 不同(USDC 是 6,DAI 是 18),直接相加是错的。正确做法是先归一化到 18 位或统一到法币:
SELECT
SUM(amount / pow(10, decimals)) AS amount_normalized
FROM token_transfers t
JOIN tokens tk ON tk.address = t.token_address;
5.3 与索引层的分工
链上分析仓库与链上数据索引 解决的是不同层次的问题:索引层(The Graph)面向单点精确查询(某个地址的持仓、某个 NFT 的元数据),响应快但聚合能力弱;仓库面向全量聚合分析,扫描量大但能回答"全局"问题。成熟团队通常两者并用:dApp 前端查索引,风控与报表查仓库。
5.4 数据测试:把质量约束写进 CI
分析仓库一旦出错,下游的报表与风控全错,因此数据测试和代码测试同样重要。用 dbt 可以声明式地写断言:
models:
- name: dex_trades
columns:
- name: tx_hash
tests: [unique, not_null]
- name: amount_usd
tests:
- not_null
tests:
- dbt_utils.expression_is_true:
expression: "block_time >= '2020-01-01'"
unique 直接拦住重复计数,not_null 拦住解码失败,expression_is_true 拦住明显的数值异常。把这些测试挂进每日调度,任何一次数据回归都会在报表出错之前被拦下。
6. 查询性能优化清单
- 分区剪枝:所有查询必须带
block_time或block_number范围; - 预聚合:日/小时粒度的汇总表,避免每次扫全量;
- 列裁剪:只 SELECT 需要的列,列式存储收益巨大;
- 物化视图:重查询落表,按需刷新;
- 字典编码:地址这类高基数字段用字典,降低存储与聚合成本;
- 避免 JOIN 大表:优先在写入时宽表化(denormalize)。
-- 反例:全表扫描 + SELECT *
SELECT * FROM ethereum.transactions WHERE to = '0x...';
-- 正例:分区过滤 + 列裁剪 + 覆盖索引思路
SELECT block_time, tx_hash, value
FROM ethereum.transactions
WHERE block_time >= now() - interval '1' day
AND to = '0x...';
小结
链上数据分析的工程链条可以概括为采集 → 解码 → 归集 → 建模 → 服务五步,每一层的难点各不相同:采集要处理 reorg,解码要补 ABI,归集要重建 trace 树,建模要防重复计数,服务要压查询成本。
选型上,探索性分析与一次性研究用 Dune/Flipside 最快,几分钟就能出结果;需要私有数据、低延迟或成本控制时再自建仓库,并优先把 ClickHouse + dbt 这套成熟组合用起来。真正决定分析质量的是数据模型,而不是查询引擎——把 decoded_events 与 dex.trades 这类中间层设计好,上层 SQL 自然简洁。若需要更细粒度的调用级数据,可参考EVM 追踪与调试
中关于 trace 采集与解析的部分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。