Traces 数据集(traces / contract_creations / native_transfers / _trace_gaps)
对应 DDL:sql/traces.sql(表与两个派生 MV)+ sql/traces_block_hash.sql(block_hash 迁移, 2026-09-25 加入)。写入方是 chain-indexer follow --traces(src/trace.rs 解码、 src/sink_cli…
对应 DDL:sql/traces.sql(表与两个派生 MV)+
sql/traces_block_hash.sql(block_hash 迁移,
2026-09-25 加入)。写入方是 chain-indexer follow --traces(src/trace.rs 解码、
src/sink_clickhouse.rs 的 ClickHouseTraceIngest 落库)。
一句话:traces 是按 (block_number, tx_index, trace_address) 展开的 EVM 调用轨迹
(callTracer 口径,含顶层的顶层调用与所有子调用)。2026-09-25 起每一行都带 block_hash,
用来证明这行轨迹来自哪一个分叉——这是本文档的重点,见 §2。
1. 表结构要点
{db}.traces(完整列见 sql/traces.sql,引擎 ReplacingMergeTree(version, is_deleted),
ORDER BY (block_number, tx_index, trace_address),读取一律 FINAL + is_deleted = 0):
| 列 | 说明 |
|---|---|
block_number / block_timestamp | 区块号 / 区块时间(时间来自同一批 blocks 行) |
block_hash | 该行轨迹所属分叉的区块哈希(FixedString(32),hex() 看即可)。全 0 表示"fork 未标注"(迁移前/迁移过渡期写入的行,见 §5) |
tx_index / tx_hash | 交易在区块内的下标 / 哈希 |
trace_address | 调用树路径,[] 是顶层调用,[2, 1] 是"第 3 个子调用的第 2 个子调用" |
depth / call_type / from / to | 深度(= trace_address 长度)/ CALL·STATICCALL·DELEGATECALL·CREATE… / 调用方 / 被调方(CREATE 系是部署出的地址,可能为 NULL) |
value / gas / gas_used | UInt256 / UInt64 / UInt64,链上原始整数 |
input / output | 原始字节(hex()/unhex()),output 空串表示 tracer 没给 |
error / revert_reason | 空串 = 未回滚;revert_reason 是 require/revert 消息,可 NULL |
extra | 未提升的调用帧字段(如 Arbitrum 的 beforeEVMTransfers/afterEVMTransfers),原样 JSON |
version / is_deleted | ReplacingMergeTree 版本号 / 重组墓碑标记 |
两张派生表(同一文件里的插入触发 MV,1:1 逐行透传 version/is_deleted,
即 CORRECTNESS RULE 的 (a) 方案——不做任何 SUM/COUNT,所以 backfill 重复插入和重组墓碑
在 FINAL 上的折叠行为与 traces 完全一致,不会双计):
{db}.contract_creations:call_type IN ('CREATE','CREATE2'),creator→created_address,input是 init code、output是部署出的代码。{db}.native_transfers:value > 0的原生币内部转账(from/to/value/call_type)。- 这两张表没有
block_hash列(它们的键与traces完全一致)。需要分叉身份时按(block_number, tx_index, trace_address)join 回{db}.traces FINAL。
{db}._trace_gaps(ReplacingMergeTree(recorded_at),ORDER BY (start, end)):区间账本。
某段区块在 follow 去抓 trace 时已经滑出节点的 pruned trace window(historical state is not available,本节点实测 ~1024 块,[follow].trace_window_blocks 配 950 留余量),
这个区间就被记为一条 gap——不是 bug,是永久的既成事实:该区间的 blocks/
transactions/logs 正常写入,只有 trace 覆盖率缺失。查"哪些区间没有 trace"读这张表。
2. 重组串库问题与 block_hash 修复
2.1 原来的 bug
traces 的键里没有区块哈希,而 trace 是按区块号抓的
(debug_traceBlockByNumber)。follow 的提交顺序是:抓区块 → 抓 trace → 写入。
如果在这两步之间发生重组(链从分叉 A 切到分叉 B),按号抓到的 trace 是分叉 B 的,
而随后写入并提交的 blocks/transactions 行是分叉 A 的:两边的 block_number
一模一样,数据里没有任何字段能发现这件事,链上也不会再发生一次重组来触发回滚。
结果就是一批"用 A 的区块行配 B 的调用轨迹"的脏数据,永远留在库里。
2.2 现在的写入路径
写入方是 ClickHouseTraceIngest,两段式(为了同时保住 #63 的并发性质与分叉正确性):
-
begin_trace_batch(start, end, head)——先出发、按号抓、不落库: 批次区间一确定就调用trace::fetch_raw_traces_by_number,对[start, end)发一次debug_traceBlockByNumberJSON-RPC batch(一个批次仍然只有一个请求),与BlockSource::fetch_range并发——发请求时本批区块(以及哈希)还不存在。 拿回来的响应是RawTraceOutcome::Fetched(原始 JSON),未经任何分叉归属,到此为止。 -
finish_trace_batch(blocks)——区块到齐后逐块校验,必要时按哈希重抓:- 对每个区块调
trace::verify_and_decode_block_traces(block, raw)=trace::ensure_trace_matches_block(响应里 item 数 = 本块交易数、顺序一致、 每笔txHash逐个等于本块对应交易的哈希;debug_trace*响应不带区块身份,txHash是唯一的锚)+ 解码,行上落TraceRow::block_hash = 本块哈希; - 校验不过 → 说明这条按号响应来自别的分叉,对该块调
trace::fetch_and_decode_traces(debug_traceBlockByHash(<本块哈希>))重抓并再次校验, 通过才写; - 两次都不过 → fail closed:
finish_trace_batch返回错误,sink.write_batch不会执行,last_head()不前进,重启/下一轮重来。 窗口 gap(historical state is not available)与校验无关,按块记进_trace_gaps。
- 对每个区块调
-
行上落
block_hash:TraceRow::block_hash一路写到 ClickHouse 的traces.block_hash(CkTrace的字段与表的列按ADD COLUMN的追加位置都排在最后)。 从此"轨迹属于哪个分叉"是可查询的事实,而不是一个假设:§3 的 QA 查询直接拿它和blocks.hash比。重组的清理逻辑不变(follow 检测到重组后delete_traces_from(ancestor+1)给block_number >= ancestor+1的 trace 打墓碑——墓碑复制现在也带上block_hash)。
诚实说明每一步能证明什么:#63 的并发化决定了 trace 请求必须按号先发,
所以第一层校验能证明的只是"响应与本块交易列表一致"——两个分叉的区块如果交易列表完全
一样,单靠它区分不了(这是并发换来的代价,也是重抓路径存在的原因);而
debug_traceBlockByHash 问的是"这个哈希的块",节点只能按该块作答,没有这个盲区——
按号校验一旦亮红灯,能修就修(按哈希重抓)、修不了就整体失败,绝不写入。
副作用(有意为之):debug_traceBlockByHash 与 debug_traceBlockByNumber 在真实节点上
错误行为完全一样——对超出窗口的区块返回同一条 historical state is not available,
所以 _trace_gaps 的识别逻辑一个字没改。
2.3 与 PR #63(fix/follow-retry-and-trace-concurrency)的关系
#63 已合入 pre-dev(d530741),本 PR 就是基于它改的。两个改动的关系:
- #63 让 trace 抓取与区块抓取并发(解决 2026-09-25 08:12 UTC 的 trace 窗口缺口事故), 代价是 trace 请求发出时本批区块(以及它们的哈希)还没拿到,只能按号抓;
- 本 PR 提供"按哈希抓"(
trace::fetch_and_decode_traces,作为重抓路径)与行上block_hash,并把两者分别接在begin_trace_batch(按号先发,保住 #63 的并发)与finish_trace_batch(区块到齐后逐块校验;不过就按哈希重抓,再不过就 fail closed) 上(见 §2.2)。
所以合并后的顺序没有回到"抓区块 → 抓 trace → 写入":#63 的并发化原样保留,
finish_trace_batch 只是把"响应能否归到本分叉"这一步补上,任何一条路径都不会把
未经校验的轨迹写进 traces。
3. QA 查询
主查询:scripts/qa/traces_block_hash.sql
(替换 {db} 后执行,已带 SETTINGS max_threads = 2):
sed 's/{db}/robinhood/g' scripts/qa/traces_block_hash.sql \
| curl -sS "$CH_URL/?user=$CH_USER&password=$CH_PASSWORD" --data-binary @-它做的是:traces FINAL 与 blocks FINAL 按 block_number join,两边都取
is_deleted = 0,列出 t.block_hash != b.hash 的行并同时打印两个哈希。
迁移前写入的全 0 哈希行(§5.4)是"fork 未标注"而不是 mismatch,主查询用
block_hash != unhex(repeat('00', 32)) 把它们排除在外,需要计数时用下面的配套查询 (a);
否则它们(数量可达迁移前的全部历史)会占满 LIMIT 100 的输出窗口,把只可能出现在
区块头附近的真实 mismatch 挡掉。
空结果 = 干净;有行 = 存在"轨迹与区块来自不同分叉"的脏数据,需要用
"补算"(§5)处理该区间。
配套的三个查询(按需单独执行):
-- (a) 全 0 哈希的行:迁移前/过渡期写入,fork 未标注(不是 mismatch,分开统计)
SELECT count() AS unknown_fork_rows
FROM {db}.traces FINAL
WHERE is_deleted = 0 AND block_hash = unhex(repeat('00', 32));
-- (b) 孤儿轨迹:有 trace 行但对应区块行不存在(正常情况下应为 0)
SELECT count() AS orphan_trace_rows
FROM {db}.traces AS t FINAL
LEFT JOIN {db}.blocks AS b FINAL ON b.number = t.block_number
WHERE t.is_deleted = 0 AND b.number = 0;
-- (c) trace 覆盖明细:哪些区块完全没有 trace 行(收盘区间内应只剩 _trace_gaps 的区间)
SELECT count() AS blocks_without_traces
FROM {db}.blocks AS b FINAL
LEFT JOIN (SELECT DISTINCT block_number FROM {db}.traces FINAL WHERE is_deleted = 0) AS t
ON t.block_number = b.number
WHERE b.is_deleted = 0 AND t.block_number = 0;字段级抽查沿用 scripts/qa/check_clickhouse.py 的思路
(不复用索引器代码,直接用 Python 标准库打节点 RPC):对随机抽到的区块,用
eth_getBlockByHash 拿交易列表、用 debug_traceBlockByHash 独立抓一遍,
逐笔核对 item 数与 txHash 顺序,再比对该块的 count(DISTINCT tx_index) 与
count()(同 §4 的做法)。唯一不能靠单点 RPC 复算的是"某个分叉是否仍是规范链",
那正是 QA 主查询在做的事(对比 blocks.hash)。
4. 验证
4.1 单元测试(cargo test,含 mock RPC)
src/trace.rs::tests::fetch_by_hash(内嵌 mock JSON-RPC 服务器,
crate::test_support::start_custom,不走真实节点):
traces_are_fetched_by_block_hash_and_rows_carry_it:断言实际发出的请求是debug_traceBlockByHash且params[0]等于该块的哈希(不是区块号)、params[1]是{"tracer":"callTracer"},并断言返回的每一行block_hash都等于它;a_response_from_another_fork_is_rejected:mismatch 路径——mock 返回"同号不同分叉" 的同类响应(交易数相同、txHash不同),断言整批 fetch 报错、不产出任何行;a_response_with_a_different_transaction_count_is_rejected:item 数与交易数不等 → 报错;out_of_order_transactions_are_rejected:本块交易列表乱序(tx_index与位置不符)→ 报错, 避免"按位置当 tx_index"写错行;rows_carry_the_hash_of_the_block_they_were_fetched_for:哈希落到每一行(含子调用帧);a_by_number_answer_is_returned_raw_and_checked_against_its_block:按号抓的结果原样返回、 不落库,且verify_and_decode_block_traces拒绝"另一分叉"的那一条;asking_by_hash_recovers_a_block_whose_by_number_answer_was_the_other_fork:按号答案校验不过 时,按哈希重抓拿到的才是要写入的行(且带本块哈希)。
ClickHouse 侧(src/sink_clickhouse.rs::trace_ingest_tests,--ignored,需要本地
ClickHouse、不需要真实节点):
finish_trace_batch_retraces_by_hash_when_the_by_number_answer_is_another_fork:mock 节点 按号返回另一分叉、按哈希返回本块,走完整begin_trace_batch→finish_trace_batch, 断言落库的是按哈希重抓的那份、每行block_hash= 本块哈希、且不记 gap;delete_traces_from_tombstones_old_rows_and_rewrite_wins_on_final:墓碑复制必须带上block_hash(这条钉住 §2.2 的那条位置式INSERT INTO traces SELECT;见下方"墓碑复制" 说明)。
回归:cargo test --all-targets = 157 passed / 0 failed / 9 ignored;cargo fmt --all --check、
cargo clippy --all-targets -- -D warnings 干净。
4.2 本地端到端:follow --traces 2 分钟(真实节点 + 本地 ClickHouse)
用本 worktree 的二进制对真实节点跑 follow --traces(配置里 chain.name 指向临时
scratch 库,节点 URL 不落库、不入库),2 分钟后停掉,再跑 §3 的查询(数字见下方"实测记录"):
export CH_URL=http://127.0.0.1:8123 CH_USER=indexer CH_PASSWORD=indexer
./target/debug/chain-indexer init-schema --config scratch/<scratch>.toml
./target/debug/chain-indexer follow --traces --config scratch/<scratch>.toml --from <head-40>
# 2 分钟后 Ctrl-C / kill,然后:
sed 's/{db}/<scratch>/g' scripts/qa/traces_block_hash.sql | curl -sS "$CH_URL/?user=$CH_USER&password=$CH_PASSWORD" --data-binary @-判读:主查询必须 0 行;另外抽查若干块用独立 RPC 复算 count(DISTINCT tx_index)
与调用帧数。
4.3 实测记录(2026-09-25)
- 按哈希抓时代(rebase 到 #63 之前)的实测:下面几条是针对"全部按哈希抓"那版代码跑的, rebase 之后按哈希只剩重抓路径(§2.3),数字本身仍然成立(解码、行内容、QA 查询都不受 影响),但写入顺序已改为 §2.2 的两段式。
- 节点 API 行为:
debug_traceBlockByHash对最近 7 个区块(head-1/2/3/10/37/150/400) 返回的 item 顺序与eth_getBlockByNumber的交易数组逐一致(7/7,含 Arbitrum 每块首笔 ArbOS 系统交易);对 head-1200 的区块返回historical state is not available,与按区块号 调用返回同一条错误 →_trace_gaps的 gap 判定不受影响。 - 本地 scratch 库 end-to-end(单次
follow --traces150 秒):写入区块 72,316,019–72,316,542 的 trace,共 524 个区块 / 103,664 行。随后跑 §3 的全部查询: 主查询(block_hashvsblocks.hash)0 行、全 0 哈希行 0、孤儿行 0、uniqExact(tx_index)与blocks.tx_count不一致的区块 0、FINAL后重复键 0。 该次运行后来落后 head 约 950 块,超出trace_window_blocks,后续批次按设计写_trace_gaps(不是伪造 trace 行)——顺便验证了 gap 路径没有被这次改动破坏。 - 独立逐块核对(运行中实时抽样,避免区块滑出窗口):对区块 72,320,079(commit 后
数秒内),节点独立
debug_traceBlockByHash数出的调用帧数 103 == 库内该块行数 103;uniqExact(tx_index)7 == 节点该块交易数 7;库里该块的hex(block_hash)== 节点eth_getBlockByNumber给的规范哈希;响应的 item 顺序与节点交易数组一致。 - QA 查询的反向验证:往 scratch 库注入一行
block_hash = 0xabab…(version 取 u64::MAX) 后,主查询精确报出该行并同时打印两个哈希(随后 DROP 掉 scratch 库)——证明查询不是"永远为空"。 - rebase 到 #63 之后(
TraceIngest两段式,本 PR 最终形态)的复核:cargo test --all-targets157 passed / 0 failed / 9 ignored;cargo fmt --all --check、cargo clippy --all-targets -- -D warnings干净;- ClickHouse 侧
--ignored用例 8/8 通过(follow_sink_tests2 个 +trace_ingest_tests6 个),其中新增的finish_trace_batch_retraces_by_hash_…就是"按号答案来自另一分叉 → 拒绝 → 按哈希重抓 → 才落库"的端到端用例(mock 节点,不依赖真实 RPC); - QA 主查询在 scratch 库上做过"200 行全 0 + 100 行正确 + 1 行头部真 mismatch"的对照: 加排除谓词前返回的 100 行全是全 0 行、真 mismatch 被挤掉;加谓词后恰好 1 行 且就是那条真 mismatch,配套查询 (a) 单独数出 200 行全 0(数量与 ground truth 一致);
- 未复跑真实节点 smoke:本机到节点的隧道(
127.0.0.1:8547)无监听,公网rpc.mainnet.chain.robinhood.com对debug_traceBlockByHash/debug_traceBlockByNumber都返回-32601(不支持),因此这次只能跑到 mock 节点为止; 上面的 7/7 顺序一致、historical state is not available同错等结论来自 rebase 前那次 真实节点实测。
- 环境说明:本地 ClickHouse 是和十几个 worker 共享的小 VM,期间两次重跑被 clickhouse
的 per-query 内存上限(Code 241,
MEMORY_LIMIT_EXCEEDED,约 780 MiB)中断,与本次改动无关; 上面引用的是完整跑完的那次运行。
墓碑复制(delete_traces_from_async)
2026-09-25 复核时实测到:这条位置式 INSERT INTO traces SELECT ... , {version} AS version, 1 AS is_deleted, block_hash FROM traces FINAL WHERE ... AND is_deleted = 0 里的两个 alias
会遮蔽同名的源表列(ClickHouse 默认 prefer_column_name_to_alias = 0),于是 WHERE 里的
is_deleted 变成字面量 1、条件恒假——整条语句一行都不复制(read_rows = 0),
墓碑根本没写进去,block_hash 自然也无从谈起。现在这三个尾列不再带别名(位置式插入本来
也只看位置),复制恢复正常,上面的 delete_traces_from_tombstones_… 用例就是钉这个的。
同样形状的 alias 遮蔽当时还存在于 delete_from_async 的 blocks/transactions/logs 三条
语句里(空操作,重组回滚不会给旧分叉打墓碑);已另行修复:三条改为显式列清单、尾列不带别名,
由 follow_sink_tests::delete_from_tombstones_rows_the_new_fork_does_not_overwrite 钉住。
5. 安装与补算
5.1 新库(init-schema)
chain-indexer init-schema 会自动按顺序执行 sql/schema.sql → sql/traces.sql →
sql/traces_block_hash.sql,一条命令建出带 block_hash 的完整结构(迁移语句在新建库上
本身就是幂等的 no-op,见下)。
5.2 已有库(只跑迁移)
生产库不用重跑整个 schema,只执行 sql/traces_block_hash.sql 里那一条:
sed 's/{db}/robinhood/g' sql/traces_block_hash.sql \
| curl -sS "$CH_URL/?user=$CH_USER&password=$CH_PASSWORD" --data-binary @-- 该语句是
ALTER TABLE {db}.traces ADD COLUMN IF NOT EXISTS block_hash FixedString(32) DEFAULT ...: 只改元数据、不重写任何 part、不触发 mutation,对正在写入的follow无影响, 可重复执行。 - 追加的列一定在表的最末(
ADD COLUMN的语义),CkTrace的字段顺序和delete_traces_from_async里那条位置式INSERT INTO traces SELECT ...都按这个 顺序对齐——迁移前后都要保持这一点。 - 迁移后立刻跑一次 §3 的主查询确认(应为 0 行;迁移前写入的行是全 0,不会被算成 mismatch)。
5.3 升级顺序
- 先跑 §5.2 的迁移;
- 再换成带
block_hash的新二进制并重启follow --traces。
顺序反过来(先换新二进制、后迁移)会失败但不会写脏数据:新二进制往缺列的
traces 里插入时,ClickHouse 客户端在写前做 schema 校验,直接报
"database schema has no column named block_hash"(fail closed)。
先迁移后换二进制时,旧二进制仍能继续写(新列有 DEFAULT,客户端按列名写入、缺的列取默认值),
但那个窗口里写进去的行 block_hash 全是 0 = "fork 未标注",所以迁移与重启之间不要拖太久。
5.4 补算
- 全 0 哈希的行(迁移前/过渡期写入):这些行是老的"按区块号抓"路径写进来的,数据里
没有记录它们来自哪个分叉,因此无法事后验证。两种处理方式,按用途选择:
-
需要严格保证"每行都可校验"的分析,查询里显式排除它们,或按区间用新二进制重抓覆盖 (重抓前先给该区间打墓碑:
INSERT INTO {db}.traces SELECT ... , <now> AS version, 1 AS is_deleted, block_hash FROM {db}.traces FINAL WHERE ...,与delete_traces_from_async的做法一致); -
只想让 QA 覆盖它们:可以用
blocks.hash回填(见下),但这等价于声明"这些行来自 现在仍然是规范链的那个分叉"——只有在没有发生过影响该区间的重组时才成立。这是 一条有代价的 mutation,生产上务必按区间分批、避开高峰:-- 可选、非幂等轻量化:按区间分批回填,{from}/{to} 自行替换 ALTER TABLE {db}.traces UPDATE block_hash = (SELECT b.hash FROM {db}.blocks AS b FINAL WHERE b.number = block_number AND b.is_deleted = 0) WHERE block_number >= {from} AND block_number < {to} AND block_hash = unhex(repeat('00', 32)) SETTINGS mutations_sync = 0;
-
- 真实 mismatch(§3 主查询报出来的行):说明写入时发生了重组串库,必须重抓这些区块的 trace。定位区间 → 打墓碑 → 用新二进制重抓(重抓路径一定是按哈希抓 + 交叉校验的), 重抓后再跑一次主查询确认归零。
- gap(
_trace_gaps区间):无需补算,也不应该补——节点已经没有那段历史状态, 再抓只会拿到同一条historical state is not available。
5.5 本次实测
见 §4.3:单次 150 秒 follow --traces 覆盖 524 个区块 / 103,664 行,主查询 0 mismatch、
全 0 哈希 0 行、孤儿 0 行,运行中实时抽样的 1 个区块与节点独立复算完全一致;
另行注入坏行验证了 QA 查询确实能报出 mismatch。
方法选择器字典 + 解码交易视图(method_selectors / transactions_named)
对应文件: - schema/clickhouse/derived/016_method_selectors.sql - scripts/derived/gen_method_selectors.py
用户活跃度数据集(active users)
面向"每日活跃地址数(DAU)/ 新增地址 / 周月活跃(WAU/MAU)/ D1-D7-D30 留存"这一类 增长分析查询,按 已收盘的 UTC 自然日 维度预计算好,避免内部团队每次都要现写一遍 "排除系统地址 + 排除协议自动交易 + 去重版本 + 处理 tombstone"这一整套口径。