BlockVectra

数据新鲜度总览(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_namecategorykey_kindmax_block_numbermax_time (UTC)seconds_behindblocks_behindrows_estimatefollow_last_blockfollow_lag_blocks
blocksrawblock723132562026-09-25 14:53:220066011632656699996643257
transactionsrawblock723132562026-09-25 14:53:2200760061905656699996643257
logsrawblock723132562026-09-25 14:53:22003138290467656699996643257
tracesrawblock723132562026-09-25 14:53:220044948030656699996643257
erc20_transfersderivedblock723132562026-09-25 14:53:22004849236656699996643257
erc721_transfersderivedblock723132502026-09-25 14:53:2116192098656699996643257
_indexer_progressinternalblock656699992026-09-17 20:56:3166941166432576567656699996643257
_trace_gapsinternalblock722644582026-09-25 13:31:454897487981799656699996643257

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 一致
categoryraw(blocks/transactions/logs/traces)、derived(派生表)、internal(_indexer_progress/_trace_gaps)
key_kindblock=按块推进、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_behindblocks 最新块时间 − max_time(秒)。块表上基本等于"落后多少秒";max_time 为 NULL 时也是 NULL
blocks_behindblocks 最大块号 − max_block_number(只有 key_kind='block' 的行有值)。max_block_number 超过链头(key 块还没进 blocks,max_time 也因此为 NULL)时给 NULL,不给负数——表是否领先链头看 max_block_number 自己;key 块只是落在还没补算的区间里(但仍小于链头)时照常给出正数差
days_behindblocks 最新块所在日期 − 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_blocksblocks 最大块号 − follow_last_block(全局值)。>0 说明是 indexer 没跟上链头,不是某张派生表的问题
trace_min_block / trace_max_blocktraces 表里最小/最大 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.blocks64,936,0904ms127
SELECT max(block_number) FROM robinhood.transactions746,864,3433ms143
SELECT max(block_number) FROM robinhood.logs3,079,176,4964ms125
SELECT max(block_number) FROM robinhood.erc20_transfers4,751,4043ms11
SELECT max(block_number) FROM robinhood.traces43,867,8693ms13

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 块、logs 31.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) 之后每次新增/删改派生表后重跑一次同一条命令即可(幂等)

依赖与顺序:

  1. 硬依赖:system.tables(存在性守卫 + 行数估算)和 {db}.blocks(链头 + 每行的链上时间)。 其余表都是可选的:traces、_indexer_progress、_trace_gaps 以及所有派生表缺了, 对应取值就是 NULL,视图仍然能查。

  2. 建议在每批派生表(001–032 等)装完之后各跑一次,把新表纳入列表; 重复跑不会影响任何已有对象。

  3. 权限:查询者需要 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
  4. 与 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 / rows72307969 / 4970max(number)/count() FINAL一致
transactions max block / rows72307969 / 44513同上一致
logs max block / rows72307969 / 172209同上一致
erc20_transfers max block / rows72307969 / 77405同上一致
erc721_transfers max block / rows72307966 / 3605同上一致
daily_chain_stats max_day / rows2026-09-24 / 1max(day) FINAL一致
daily_token_stats max_day / rows2026-09-24 / 590max(day) FINAL一致
daily_chain_stats.blocks(09-24)2000count() FROM blocks FINAL WHERE toDate(timestamp)='2026-09-24'一致(2000)
daily_token_stats.volume(09-24)257650837301750193262681347072sum(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_blocks72307969 / 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)

独立复核时发现两处口径错误,都已按上面的语义修掉(只有视图定义与本文档改动):

  1. 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。
  2. 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 = 103101sum(end - start) = 101 ✔
同上的 _trace_gaps.max_block_number71000201(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 点查),不随表大小增长元数据优化仍在 ✔

本页目录