数据新鲜度总览(data_freshness)
{db}.data_freshness 是一张 VIEW(不是物化视图,不需要定时刷新):
{db}.data_freshness 是一张 VIEW(不是物化视图,不需要定时刷新):
面向直接写 SQL 的内部团队,一条 SQL 看清"每张原始表 / 派生表各自追到了哪个块(或哪一天)、 落后链头多少秒、表里有多少行、trace 覆盖到哪",用来回答"我现在能不能信任这张表"。
DDL:schema/clickhouse/derived/033_freshness.sql
(表名/列名/口径的权威定义在该文件头部注释里,本文是中文说明 + 验证记录)。
1. 怎么用
-- 全量总览(表不多,直接看)
SELECT * EXCEPT (database, checked_at) FROM {db}.data_freshness ORDER BY table_name;
-- 只挑落后的(比如超过 1 分钟)
SELECT table_name, max_block_number, blocks_behind, seconds_behind, rows_estimate
FROM {db}.data_freshness
WHERE seconds_behind > 60
ORDER BY seconds_behind DESC;
-- 链头 vs indexer 自己(backfill/follow)追到哪:用来区分"链头自己没跟上"还是"某张表没跟上"
SELECT DISTINCT follow_last_block, follow_lag_blocks FROM {db}.data_freshness;
-- trace 覆盖(只有 traces / _trace_gaps 两行有这几列)
SELECT table_name, max_block_number, trace_min_block, trace_max_block, trace_gap_ranges, trace_gap_blocks
FROM {db}.data_freshness WHERE table_name IN ('traces', '_trace_gaps');线上真实输出(robinhood,2026-09-25 16:53,26.9.1.1629,只读执行本文件的 SELECT,未建视图)。
当时线上只装了原始表 + 001/002,所以列表里其它派生表一个都没出现(缺表降级,见 §4):
| table_name | category | key_kind | max_block_number | max_time (UTC) | seconds_behind | blocks_behind | rows_estimate | follow_last_block | follow_lag_blocks |
|---|---|---|---|---|---|---|---|---|---|
| blocks | raw | block | 72313256 | 2026-09-25 14:53:22 | 0 | 0 | 66011632 | 65669999 | 6643257 |
| transactions | raw | block | 72313256 | 2026-09-25 14:53:22 | 0 | 0 | 760061905 | 65669999 | 6643257 |
| logs | raw | block | 72313256 | 2026-09-25 14:53:22 | 0 | 0 | 3138290467 | 65669999 | 6643257 |
| traces | raw | block | 72313256 | 2026-09-25 14:53:22 | 0 | 0 | 44948030 | 65669999 | 6643257 |
| erc20_transfers | derived | block | 72313256 | 2026-09-25 14:53:22 | 0 | 0 | 4849236 | 65669999 | 6643257 |
| erc721_transfers | derived | block | 72313250 | 2026-09-25 14:53:21 | 1 | 6 | 192098 | 65669999 | 6643257 |
| _indexer_progress | internal | block | 65669999 | 2026-09-17 20:56:31 | 669411 | 6643257 | 6567 | 65669999 | 6643257 |
| _trace_gaps | internal | block | 72264458 | 2026-09-25 13:31:45 | 4897 | 48798 | 1799 | 65669999 | 6643257 |
traces 行还带 trace_min_block = 72050949、trace_max_block = 72313256、
trace_gap_ranges = 1799、trace_gap_blocks = 7476(= sum(end - start);同一段区间早期版本
写成 sum(end - start + 1),把每段都多算一个块,报成 9275 = 7476 + 1799,已修)。
_trace_gaps 那一行按"缺口段是 [start, end)"重算过:max_block_number = max(end) - 1 = 72264458
(原先是 exclusive 的 max(end) = 72264459),blocks_behind 随之 48797 → 48798。
该行的 max_time/seconds_behind 是那次读取时块 72264459 的时间,块 72264458 的实际时间早
1–2s(一个出块间隔),表里保留原读数以对应同一次读取。
上面 _indexer_progress 那行是真实情况的一个例子,值得注意:blocks 已经到 72313256,
但 _indexer_progress 里最大的段 end - 1 只有 65669999(该库的 backfill 是分段/倒序推进的,
标记落后于已落库数据),所以 follow_lag_blocks 显示 664 万。这类差距说明的是
"段提交标记没跟上",而不是"数据没到"——判断某张表新不新请看它自己的
max_block_number/blocks_behind/seconds_behind。所以视图里两个信号都保留。
2. 列语义
| 列 | 含义 |
|---|---|
database / table_name | 库名(= {db})/ 表名,和 system.tables.name 一致 |
category | raw(blocks/transactions/logs/traces)、derived(派生表)、internal(_indexer_progress/_trace_gaps) |
key_kind | block=按块推进、day=按天聚合、time=按时间桶/时间戳推进、none=维度表(只看行数) |
max_block_number | 该表里最大的 block_number(blocks 表是 number)。物理行的上界。两个内部表按半开区间给"最后一个块":_indexer_progress/_trace_gaps 都是 max(end) - 1(段/缺口区间是 [start, end)) |
max_day | 按天表(daily_*/throughput_* 之外)最大的 day/date/cohort_date |
max_time | 这张表最新数据对应的链上时间:block 表取"该块号在 blocks 里的 timestamp";day 表取当天 00:00:00 UTC;time 表取自己的时间列。空表、以及 key 块还没进 blocks 时是 NULL(不给 1970-01-01) |
seconds_behind | blocks 最新块时间 − max_time(秒)。块表上基本等于"落后多少秒";max_time 为 NULL 时也是 NULL |
blocks_behind | blocks 最大块号 − max_block_number(只有 key_kind='block' 的行有值)。max_block_number 超过链头(key 块还没进 blocks,max_time 也因此为 NULL)时给 NULL,不给负数——表是否领先链头看 max_block_number 自己;key 块只是落在还没补算的区间里(但仍小于链头)时照常给出正数差 |
days_behind | blocks 最新块所在日期 − max_day(只有 key_kind='day' 的行有值;按天表时间上必然 ≥1 天) |
rows_estimate / bytes_estimate | 该表物理行数/字节数估算(system.tables.total_rows/total_bytes 元数据,不扫数据)。含尚未合并的重复行与 reorg tombstone |
follow_last_block | _indexer_progress 已提交段的最大 end 减 1;段区间是 [start, end)(end 不含,见 src/follow.rs),所以这就是 backfill/follow 自己追到的最后一个块。全局值,每行相同 |
follow_lag_blocks | blocks 最大块号 − follow_last_block(全局值)。>0 说明是 indexer 没跟上链头,不是某张派生表的问题 |
trace_min_block / trace_max_block | traces 表里最小/最大 block_number(物理行)。只有 traces 行有值 |
trace_gap_ranges / trace_gap_blocks | _trace_gaps 去重后(FINAL)的缺口段数 / 缺口总块数(sum(end - start),半开区间,见 src/trace.rs;写成 +1 会把每段多算一个块)。只有 traces / _trace_gaps 行有值;_trace_gaps 为空时是 0,表示"没有缺口",不是"未知" |
checked_at | 本行计算时刻(now()),用来判断有没有读到缓存的旧结果 |
口径上有意为之的地方:
- 空表不显示
0:空表上max(UInt64)会返回0、max(Date)会返回1970-01-01, 容易看成真实值。视图里凡rows_estimate = 0的行,max_block_number/max_day/max_time一律给NULL(traces未开 trace 时就是这个形态:行在、max_block_number = NULL、rows_estimate = 0)。 - key 块还没进
blocks也不显示假值:max_time是拿 key 块号去blocks点查出来的 (LEFT JOIN),未命中时必须给NULL。这不是边缘情况——tokens.fetched_block来自节点的eth_blockNumber(链头),而 follow 是按批提交的,所以tokens领先已提交的blocks是常态; trace 缺口段、以及领先链头的派生表同理。未命中时不特判的话 ClickHouse 会用默认值 0 填右侧, 变成max_time = 1970-01-01、seconds_behind ≈ 17.9 亿秒、blocks_behind负数, 这些假行还会排在上面 §1 那条"只看落后的"查询的最前面。 - 不用
FINAL:max(block_number)就是"这张表已经收到哪个块的数据",重复插入的副本、 未清理的 reorg tombstone 都不改变这个上界。视图不做去重、不做聚合累加,因此不存在 ReplacingMergeTree 重复计数问题(对比004_daily_chain_stats.sql里为什么必须用可刷新 MV)。 - 维度表只有行数:
erc1155_balances、event_signatures、method_selectors、oracle_feeds这类表没有块/时间键(或键没有"推进"含义),key_kind='none',只给rows_estimate。 - 列在列表里但库里没有 → 该行不出现(不报错);库里装了列表里没有的表 → 不会出现,
需要在
033_freshness.sql的静态列表里加一行后重跑(见 §4)。
3. 性能:全部走元数据,线上实测每张表 3–25ms
要求是 prod 规模下 < 1s。这里所有取值都是 max() / min() / system.tables 元数据,
没有 count() / count() FINAL 全表扫描:
- ClickHouse 对
max(列)/min(列)单列聚合会用 part 级列 min/max 元数据直接定位到 装着最大值的那个 granule——和该列是不是排序键首列无关。 - 不能用
maxOrNull()/argMax()代替max():本地 800 万行表实测,max()只读 8 行、maxOrNull()读满 8,000,000 行(拿不到该优化),argMax()同理。
2026-09-25 线上 robinhood(26.9.1.1629,max_threads=2)实测:
| 查询 | 表行数 | 耗时 | read_rows |
|---|---|---|---|
SELECT max(number) FROM robinhood.blocks | 64,936,090 | 4ms | 127 |
SELECT max(block_number) FROM robinhood.transactions | 746,864,343 | 3ms | 143 |
SELECT max(block_number) FROM robinhood.logs | 3,079,176,496 | 4ms | 125 |
SELECT max(block_number) FROM robinhood.erc20_transfers | 4,751,404 | 3ms | 11 |
SELECT max(block_number) FROM robinhood.traces | 43,867,869 | 3ms | 13 |
erc20_transfers 的 ORDER BY 是 (token, block_number, log_index),block_number 不是首列,
普通写法要全扫一列——实测只读 11 行,就是上面那个优化。
另一处必须避免的写法(第一版踩过,已修):每行的"链上时间"不能写成相关标量子查询
-- 错误:线上实测无法 decorrelate,退化成 blocks 全表扫描
(SELECT max(timestamp) FROM {db}.blocks WHERE number = m.key_block)线上一跑就是 12–18s、read_rows = 65,871,320(blocks 全表)。现在改成"先把需要的块号收集起来、
再按主键 IN 点查 blocks"(033_freshness.sql 里的 bt 子查询)。修完之后:
- 线上
robinhood(72.3M 块、logs31.4 亿行、system.tables上 8 张表)整条视图 0.35–0.48s / read_rows ≈ 35.5k(max_threads=2,2026-09-25 实测,只读); - 本地 4970 块 scratch 库整表
SELECT *0.13–0.18s。
4. 缺表降级(安装顺序不敏感)
VIEW 定义里没法拼动态表名,所以列表是静态的;但每一格取值都写成:
if((SELECT count() FROM system.tables WHERE database='{db}' AND name='X') > 0,
(SELECT max(block_number) FROM {db}.X),
CAST(NULL AS Nullable(UInt64)))ClickHouse 对 if() 里的标量子查询是运行时求值(本地 26.9.1 实测:守卫为假时,即使该表
根本不存在也不报错;守卫为真才会去解析该表)。因此:
- 没装的派生表:只是"不出现在结果里",不会让整个视图报错——这就是为什么本文件可以 在任何安装阶段执行,也不需要严格前置依赖;
- 新增派生表之后重跑一次
033_freshness.sql(CREATE OR REPLACE VIEW,幂等)即可把它加进列表。
5. 安装与补算
没有补算:data_freshness 是纯查询视图,不落数据、不需要回填,也没有"建视图之前的历史
数据"问题(每次查询都是当前状态)。安装就是一条语句:
# 1) 替换 {db} 后执行(只建一个 VIEW,不建表、不写数据)
sed 's/{db}/robinhood/g' schema/clickhouse/derived/033_freshness.sql | clickhouse-client --multiquery
# 2) 之后每次新增/删改派生表后重跑一次同一条命令即可(幂等)依赖与顺序:
-
硬依赖:
system.tables(存在性守卫 + 行数估算)和{db}.blocks(链头 + 每行的链上时间)。 其余表都是可选的:traces、_indexer_progress、_trace_gaps以及所有派生表缺了, 对应取值就是NULL,视图仍然能查。 -
建议在每批派生表(001–032 等)装完之后各跑一次,把新表纳入列表; 重复跑不会影响任何已有对象。
-
权限:查询者需要
SELECT目标库 +SELECT system.tables。本仓线上indexer账号 没有system.parts/system.parts_columns的 SELECT 权限(ACCESS_DENIED,见docs/perf/clickhouse-compression.md的"账号权限"一节), 所以行数估算用system.tables.total_rows/total_bytes而不是system.parts—— 二者同源,实测相等;若你的账号确实有system.parts权限,等价写法是:SELECT table AS table_name, sum(rows) AS rows_estimate, sum(bytes_on_disk) AS bytes_estimate FROM system.parts WHERE database = '{db}' AND active GROUP BY table -
与
033_freshness.sql无关的其它派生表补算(003/006/…)照各自文件执行; 本视图只反映"当前物理状态",补算完成后重新查一次即可。
6. 本地验证(scratch 库,真实数据)
环境:本地 ClickHouse 26.9.1.1629,scratch 库只 dump 了 4970 个真实区块
(71350000-71352000 2000 块 + 72305000-72307970 2970 块;共 44,513 笔交易、
172,209 条 logs),装了 001_erc20_transfers、002_erc721_transfers、
004_daily_chain_stats、005_daily_token_stats,其余列表里的表不存在。
| 校验项 | 视图 | 独立计算 | 结果 |
|---|---|---|---|
blocks max block / rows | 72307969 / 4970 | max(number)/count() FINAL | 一致 |
transactions max block / rows | 72307969 / 44513 | 同上 | 一致 |
logs max block / rows | 72307969 / 172209 | 同上 | 一致 |
erc20_transfers max block / rows | 72307969 / 77405 | 同上 | 一致 |
erc721_transfers max block / rows | 72307966 / 3605 | 同上 | 一致 |
daily_chain_stats max_day / rows | 2026-09-24 / 1 | max(day) FINAL | 一致 |
daily_token_stats max_day / rows | 2026-09-24 / 590 | max(day) FINAL | 一致 |
daily_chain_stats.blocks(09-24) | 2000 | count() FROM blocks FINAL WHERE toDate(timestamp)='2026-09-24' | 一致(2000) |
daily_token_stats.volume(09-24) | 257650837301750193262681347072 | sum(amount) FROM erc20_transfers FINAL 当天 | 完全相等(整数,无浮点) |
blocks 最新块时间 | 2026-09-25 14:44:30 | 节点 RPC eth_getBlockByNumber(0x44F2E01,false).timestamp = 2026-09-25 14:44:30 UTC | 一致 |
| 按天表归属 | max_day = 2026-09-24 | 节点 RPC 块 71351999 时间 = 2026-09-24 11:58:47 UTC | 一致 |
follow_last_block/follow_lag_blocks | 72307969 / 0 | _indexer_progress 最大 end=72307970([start,end),减 1) | 一致 |
| trace 覆盖(合成 2 条 trace 行 + 1 段缺口 71000000-71000099) | trace_min=71350010、trace_max=71351990、gap_ranges=1、gap_blocks=100 | 造数时写入的值 | 一致 |
| 缺表降级 | DROP TABLE daily_token_stats 后视图仍返回 9 行、该行消失、无报错 | — | 通过 |
| 查询耗时 | 本地 4970 块 scratch 库整表 SELECT * 0.13–0.18s | 线上 robinhood(66M blocks / 3.14B logs)只读执行同一条 SELECT:0.35–0.48s、read_rows≈35.5k | 满足 <1s |
| 线上真实输出 | 见 §1 的 8 行(含 _trace_gaps 1799 段 / 7476 块、traces 覆盖 72050949–72313256) | — | 与线上 system.tables 行数、max() 单查一致 |
(scratch 库与其中所有对象验证后已 DROP DATABASE;线上只做了只读的
SELECT max(...) 与 system.tables 查询,没有写任何数据。)
6.1 缺口计数与"key 块不在 blocks 里"的修复(2026-09-26)
独立复核时发现两处口径错误,都已按上面的语义修掉(只有视图定义与本文档改动):
trace_gap_blocks多算了一个块/段:_trace_gaps是半开区间[start, end), 段内块数是end - start,原写法sum(end - start + 1)把每段都多算 1—— §1 那次线上读数 1799 段报 9275 块,真实值是 7476(= 9275 − 1799), 和docs/qa/2026-09-25-prod-dryrun.md用sum(end - start)勾稽出的 6916 块是同一套口径。 顺带把_trace_gaps行的max_block_number从 exclusive 的max(end)改成max(end) - 1。- key 块不在
blocks里会退化成1970-01-01:LEFT JOIN未命中时 ClickHouse 用默认值 0 填右侧的非 Nullable 列,max_time变成 epoch、seconds_behind≈ 17.9 亿秒、blocks_behind为负。tokens.fetched_block取节点链头(src/tokens.rs),领先已提交的blocks是常态, 所以这些假行会长期占据"只看落后的"查询的头部。现在未命中时max_time/seconds_behind一律NULL;max_block_number超过链头时blocks_behind也给NULL(落在未补算区间、但仍小于 链头时照常给正数)。
本地回归(26.9.1.1629,scratch 库;blocks 1000 块、head=72039999):
| 场景 | 修复前 | 修复后 | 独立计算 |
|---|---|---|---|
缺口 (71000000,71000100) + (71000200,71000201) | trace_gap_blocks = 103 | 101 | sum(end - start) = 101 ✔ |
同上的 _trace_gaps.max_block_number | 71000201(exclusive end) | 71000200(最后一个缺块) | max(end) - 1 ✔ |
tokens 行 fetched_block = 72040500(领先链头 501 块) | max_time = 1970-01-01、seconds_behind = 1790320439、blocks_behind = -501 | 三列都是 NULL | 块 72040500 不在 blocks 里 → 未知 ✔ |
缺口段落在已索引区间内 (72039500,72039501) | — | max_time = 块 72039500 的时间、seconds_behind = 499 | 块在 blocks 里 → 正常解析 ✔ |
整表查询耗时(含 1,000,000 行 erc20_transfers) | — | max(block_number) 只读 4 行;整条视图最大单点读 1000 行(blocks 点查),不随表大小增长 | 元数据优化仍在 ✔ |
Robinhood Chain 数据集分析师实战手册(SQL Cookbook)
截至当前 origin/pre-dev 分支,本项目已经在 ClickHouse 中构建了涵盖原始区块链数据、事件解码、协议行为解析以及链上宏观增长的全套数据资产。所有数据表在每条链上独立成库(以 robinhood 为标准库名,跨链查询时替换库名即可)。
吞吐数据集(throughput_minute / throughput_hour)
对应 SQL:schema/clickhouse/derived/021_throughput.sql。 给 dashboard/研究提供按分钟、按小时的链吞吐指标:出块数、交易数、TPS、gas 用量、 出块间隔(均值/分位数)、用户交易占比。只依赖 {db}.blocks / {db}.transact…