Fees 数据集
来源:schema/clickhouse/derived/013_fees.sql。给内部用 SQL 查询交易手续费的团队用:单笔交易费用(tx_fees 视图)、按天/按类型的费用统计、每日按地址汇总。
来源:schema/clickhouse/derived/013_fees.sql。给内部用 SQL 查询交易手续费的团队用:单笔交易费用(tx_fees 视图)、按天/按类型的费用统计、每日按地址汇总。
字段来源与计算公式
原始字段全部来自 {db}.transactions(回执已合并进这张表,见 sql/schema.sql):
gas_used、effective_gas_price(Nullable(UInt256))、gas_used_for_l1(Nullable(UInt64),Arbitrum 适配器专有字段)。
fee_wei = gas_used * effective_gas_price
l1_fee_wei = gas_used_for_l1 * effective_gas_price
l2_fee_wei = fee_wei - l1_fee_wei全部是 UInt256 整数乘法,没有任何浮点、没有任何缩放——两个因子都是链上原始整数(gas 单位 × wei/gas 单位),乘积精确无损。
Arbitrum gas 记账简述
Arbitrum(Orbit L2)的每笔交易费用由两部分组成:L2 执行成本(EVM 执行、存储,跟以太坊 L1 是一回事)+ L1 calldata 成本(把这笔交易的数据发布到 L1 要付的钱)。关键点是:L1 成本不是按 L1 自己的 gas 单位计价的——arbOS 的 L1 pricer 把预估的 L1 calldata 字节成本换算成"等价 L2 gas",就是回执里的 gasUsedForL1;所以 gas_used(含 L1 部分的总 L2 gas)和 gas_used_for_l1(其中 L1 部分)用的是同一个 effective_gas_price 相乘,不需要另外查 L1 base fee。官方说明见
Arbitrum docs: How Arbitrum pricing works — L1 gas pricing。
存储模型(2026-09 磁盘瘦身改版)
改版背景:共享磁盘只剩 ~60G 可用,而按全链推算 tx_fees 这张逐笔物化镜像表要 ~59G、erc20_balance_moves 台账要 ~72G,两者合计装不下。本次改版把逐笔镜像全部去掉:
| 对象 | 旧实现 | 新实现 |
|---|---|---|
tx_fees | ReplacingMergeTree 物化表 + insert-trigger MV + 一次性回填 INSERT,~59G | 普通 VIEW,查询时从 {db}.transactions FINAL 现算同样的 16 列 |
daily_fee_stats / daily_fee_stats_by_type / daily_fee_by_address | 刷新 MV 从物化 tx_fees FINAL 聚合 | 表结构不变;刷新 MV 改为直接从 {db}.transactions FINAL 聚合(不经过任何物化镜像) |
tx_fees 的 16 列与旧物化表同名、同顺序、同类型(含 version UInt64/is_deleted UInt8;from/to 显式 CAST 成旧表的 FixedString(20)/Nullable(FixedString(20)),因为 transactions 里是 LowCardinality),所以既有查询不用改:SELECT ... FROM {db}.tx_fees WHERE ... 继续可用;FINAL/is_deleted = 0 写法在视图上仍然是安全的(视图内部已经 FINAL + is_deleted=0 去重,FINAL 在视图上是 no-op,不会报错也不会改变结果)。l2_fee_wei 内部是 UInt256 - UInt256(ClickHouse 会推导成 Int256),外面套了 toUInt256() 保持旧表的 UInt256 类型——链上恒有 gas_used_for_l1 <= gas_used,差不会为负。
表结构
tx_fees(逐笔,普通 VIEW)
CREATE VIEW {db}.tx_fees AS
SELECT block_number, tx_index, hash AS tx_hash, block_timestamp, type, status,
from, to, gas_used,
assumeNotNull(effective_gas_price) AS effective_gas_price,
assumeNotNull(gas_used_for_l1) AS gas_used_for_l1,
toUInt256(gas_used) * toUInt256(assumeNotNull(effective_gas_price)) AS fee_wei,
toUInt256(assumeNotNull(gas_used_for_l1)) * toUInt256(assumeNotNull(effective_gas_price)) AS l1_fee_wei,
toUInt256(...fee - l1...) AS l2_fee_wei,
version, is_deleted
FROM {db}.transactions AS t FINAL
WHERE t.is_deleted = 0
AND t.effective_gas_price IS NOT NULL
AND t.gas_used_for_l1 IS NOT NULL;正确性:视图直接读源表 FINAL,transactions 的重复插入(backfill 续跑)与重组墓碑(is_deleted=1 高 version 行)在读取时就已经折叠/丢弃,不存在"第二份物理数据算错/双重计数"的可能(CORRECTNESS RULE 的 fail-safe 形态)。
effective_gas_price/gas_used_for_l1 为 NULL 的行被 WHERE 排除(fail-closed),不会默认成 0——本链族的合并回执实测两个字段总是有值(见下方验证),一旦出现 NULL 说明原始 loader 漏填了这笔回执,宁可缺一行让人发现,也不要悄悄算出一个错误的 0 手续费。
查询写这一列时必须带源表别名限定(t.effective_gas_price):本项目钉住的 ClickHouse 26.9 分析器会把 WHERE 里的未限定列名绑定到同名的 SELECT 别名上,于是 effective_gas_price IS NOT NULL 会变成 assumeNotNull(effective_gas_price) IS NOT NULL(恒真,因为 assumeNotNull 是非 Nullable 类型),NULL 回执行反而被放进来按 fee_wei = 0 计入——正好是这个视图要避免的"悄悄算成 0 手续费"。26.9.1.1629 上实测:同一份数据(4 笔 NULL 回执行 + 100 笔正常交易)不带 t. 限定返回 104 行、带限定返回 100 行。同类坑在 010_contracts.sql 有记录(那里是 tombstone INSERT 被自家别名打掉)。
daily_fee_stats / daily_fee_stats_by_type / daily_fee_by_address(收盘日聚合,CORRECTNESS RULE 方案 (b))
三张都是可刷新物化视图(REFRESH EVERY 1 DAY OFFSET 1 HOUR RANDOMIZE FOR 10 MINUTE),每次刷新直接从 {db}.transactions FINAL 重新计算全部已收盘 UTC 日(toDate(block_timestamp) < today()),然后整表原子替换(已实测确认:TO 目标表的可刷新 MV 每次刷新是整表替换,不是追加——重复刷新幂等)。
选这个方案而不是"插入触发 MV 累加计数器"的原因:SUM/COUNT 类聚合如果用插入触发 MV 直接对 transactions 的原始 INSERT 做累加,backfill 续跑的重复插入、重组的墓碑行都会被重复计入/无法冲正,造成双重计数——这是本项目 CORRECTNESS RULE 点名要避免的坑。改成"只在收盘日之后,从 FINAL 整段重新聚合",天然对重复插入和迟到的重组免疫(UTC 边界附近的罕见重组会在下一次定时刷新时自动修正,不需要人工补算)。
daily_fee_stats:date粒度总计(tx_count、total_fee_wei、total_l1_fee_wei、total_l2_fee_wei、l1_share、median_effective_gas_price)。daily_fee_stats_by_type:(date, type)粒度,同样的费用口径按 tx type 拆开。daily_fee_by_address:(date, from)粒度的tx_count/total_fee_wei,用来查"某天付费最多的地址"——没有做成固定的"Top 10"表,把完整的按地址汇总落下来,查询时自己挑 N 和排序。
三个 SELECT 的金额表达式与 tx_fees 视图内联的表达式完全一致(读 transactions 而不是视图,见 013 文件头),NULL 过滤也一致(视图侧靠源表限定,见上一节),所以它们的行集与视图完全一致;本地对同一份数据核对过三张表按日期/类型/地址加总与视图口径逐列相等。
median_effective_gas_price 用的是 quantileExact(0.5),不是 median()(近似算法,实测与精确中位数不一致——见下方"验证")。l1_share 是展示用比例(Float64),不是金额,允许非整数。
查询成本
- 视图 = 查询时展开成对
transactions FINAL的扫描:transactions是全链最大表(生产上 ~824M 行 / ~104 GiB 压缩),比原来查 59G 的物化镜像更贵,但换来省掉 59G 磁盘。 - 按
block_number(排序键前缀)或toDate(block_timestamp)过滤可以分区/主键剪枝;按from/to/tx_hash点查仍能吃到transactions自带的 bloom_filter skip index(idx_from/idx_to/idx_hash,GRANULARITY 1)。全表级聚合(不带任何过滤)要扫全表,只在确实需要时用。 - "某天付费最多的地址"不要对视图做全表 group by,直接查
daily_fee_by_address。
已知边界情况(真实链上数据验证过)
在 Robinhood 链(chain id 4663)区块 [72,000,000, 72,003,000) 的真实回填数据里观测到:
- type
0x6a(106,ArbitrumInternalTx,"系统交易"):gas/gas_used恒为 0 →fee_wei = 0。样本区间内 3020 笔,sum(fee_wei)精确等于 0。这类交易是 arbOS 每个区块开头写入的内部记账交易(比如 L1 base fee 更新),不是用户发起的,不产生任何手续费。 - Retryable ticket(type
0x69=105 提交 /0x68=104 执行重试):样本区间内各 4 笔。gas_used_for_l1观测值均为 0——L1 calldata 成本似乎在创建 retryable ticket 的存款交易那一步就已经收取,提交(105)和重试执行(104)本身只产生 L2 执行费。value转账发生在 104(重试执行)而不是 105(提交),提交交易本身 value=0。这是从样本观测到的现象,写在这里供后续核对;没有再往下深挖 arbOS 存款交易的记账细节。 - 失败交易(
status=0):不从费用统计里剔除。样本区间内 type=0 的 14506 笔里有 13485 笔失败(status=0),但这些交易依然实打实地消耗了 gas、付了钱——EVM 语义就是失败交易的状态变更会回滚,但已消耗的 gas 费用不退。tx_fees/daily_fee_stats对status不做任何过滤,所有交易(无论成败)的手续费都计入。
验证
改版等价性:新视图 vs 旧物化表(本地真实数据,逐行精确)
本地 scratch 库回填真实区块 [72,000,000, 72,003,000)(3000 个区块、37,532 笔交易),先按改版前的 013 建物化表并快照 FINAL,再按改版后的 013 建视图,两边逐行 EXCEPT 双向比对:
new_minus_old = 0 rows, old_minus_new = 0 rows (37,532 / 37,532 行完全一致,16 列含类型全同)
view: n=37532 total_fee_wei=180786510327052000 l1_fee_wei=278795103612000 l2_fee_wei=180507715223440000改版前 013 对同一份数据的 eth_getBlockReceipts 逐笔比对结论(67 笔跨 7 种类型,0 mismatch)针对的原始字段没有变,视图输出的数值与旧物化表逐行一致,所以那批结论继续成立。
聚合:刷新 MV 的输出 vs 对旧物化表的独立聚合
daily_fee_stats* 只收 < today() 的收盘日,而样例区块全部还在同一个未收盘 UTC 日内,所以用一份把过滤条件改成 <= today() 的测试副本在本地触发刷新,并把结果与"直接对旧物化表快照 group by"逐列对比:
MV 刷新结果 : 2026-09-25 n=37532 total_fee_wei=180786510327052000 l1_fee_wei=278795103612000
l2_fee_wei=180507715223440000 l1_share=0.001542123375840628 median_egp=39676000
旧表快照聚合: 同上,逐列精确相等
daily_fee_stats_by_type: 7 个 type 的 total_fee/l1/l2/tx_count 加总 = 同一天日总额(精确)
daily_fee_by_address: 6573 个地址的 total_fee/tx_count 加总 = 同一天日总额(精确)median_effective_gas_price 用 quantileExact(0.5)(精确 39676000),此前实测 median() 别名给的是近似值 39680000,两者不一致,所以这里坚持 quantileExact。
安装幂等:install_all.sh 连续跑两次
./scripts/derived/install_all.sh <scratch> --backfill 连跑两次,tx_fees 行数/总额不变(37,532 行、total_fee_wei 不变),与旧物化表快照的逐行差异仍为 0;daily_fee_stats 三个 MV 无异常(system.view_refreshes.exception 为空)。第二次运行会按下面的安装步骤把三张日聚合 MV 先 drop 再建(各触发一次整表替换刷新),刷新后行数/数值仍不变。
NULL 回执行的 fail-closed 回归(视图 WHERE 必须带源表限定)
合成数据:200 笔正常交易 + 4 笔 effective_gas_price/gas_used_for_l1 为 NULL 的回执行(1998-07-09,形状同线上漏填回执行字段的交易)。同一份数据在 26.9.1.1629 上:
改版前 013(物化表) : tx_fees FINAL 204 行(4 笔 NULL 回执行被算成 fee_wei=0);daily_fee_stats 1998-07-09 tx_count=4 / total_fee_wei=0
改版后 013 视图 WHERE 未加限定 : 视图 204 行(4 笔 fee_wei=0),日聚合按 transactions 正确过滤 → 1998-07-09 整天消失,视图与日聚合行集不一致
改版后 013 视图 WHERE 带 t. 限定 : 视图 200 行、1998-07-09 无行;daily_fee_stats 只剩 2026-09-20(tx_count=200、total_fee_wei=4219900420546700),与视图逐日和逐列精确相等
新视图 vs 旧物化表逐行 EXCEPT : new-minus-old=0、old-minus-new=4(恰好是那 4 笔 NULL 回执行);16 列名称/顺序/类型全同即:视图的 NULL 过滤只有在 WHERE 写成 t.effective_gas_price / t.gas_used_for_l1 时才真正生效;否则 tx_fees 与 daily_fee_stats* 会读两套行集,第 4 步的验收查询(sum(fee_wei) vs total_fee_wei)在含 NULL 回执行的日子里必然对不上。
安装与补算
- 先决条件:
{db}.transactions已经建好且有数据(chain-indexer init-schema+chain-indexer backfill,见仓库根 README)。 - 替换
{db}后整体执行013_fees.sql(sed 's/{db}/robinhood/g' 013_fees.sql | clickhouse-client --multiquery,或用 HTTP 接口时按语句拆开逐条 POST——HTTP 接口一次只接受一条语句)。- 改版迁移:文件开头的
DROP TABLE IF EXISTS会删掉旧的 insert-trigger MV(mv_tx_fees)、59G 物化镜像(tx_fees),以及三张日聚合的旧刷新 MV(mv_daily_fee_stats*)。DROP TABLE对表和视图都适用,重复执行安全;数据不丢,视图随时从transactions精确重算,日聚合表在重建的 MV 首次刷新时被整表替换。 - 三张日聚合 MV 必须先 drop 再建:
CREATE MATERIALIZED VIEW IF NOT EXISTS在 MV 已存在时是静默 no-op(26.9.1.1629 实测:换一个 body 重新执行,body 不变),而 prod 上这三张 MV 是改版前建的、body 里读的还是{db}.tx_fees。不 drop 的话,prod 会继续跑旧 body、和全新安装跑出两套不同实现(旧 body 过滤条件也因此不同)。 - 重新执行本文件是幂等的:视图被删掉重建,
daily_fee_stats*表保持IF NOT EXISTS,三张 MV 重建后各自只做一次整表替换刷新,不会重复计数。
- 改版迁移:文件开头的
daily_fee_stats*是可刷新 MV,建 MV 当下会立即跑一次初始刷新(大概率因为"今天"还没收盘、写入 0 行,属预期行为),之后每天 01:00 UTC(±10 分钟随机抖动)自动刷新。手动触发一次:SYSTEM REFRESH VIEW {db}.mv_daily_fee_stats(同理另外两个视图),SYSTEM WAIT VIEW {db}.mv_daily_fee_stats等它跑完;SELECT * FROM system.view_refreshes WHERE database = '{db}'看状态/下次时间/异常。- 验证:对任意已收盘日期 D,
SELECT sum(fee_wei) FROM {db}.tx_fees WHERE toDate(block_timestamp) = D应精确等于daily_fee_stats里那天的total_fee_wei;daily_fee_stats_by_type按 type 分组求和、daily_fee_by_address按 from 分组求和,两者各自加总也应精确等于同一天的total_fee_wei。注意视图是"实时"的(含今天),聚合表只到最后一个收盘日。
查询示例
某天付费最多的 10 个地址(走聚合表,不要扫视图):
SELECT hex(from) AS address, tx_count, total_fee_wei
FROM {db}.daily_fee_by_address FINAL
WHERE is_deleted = 0 AND date = '2026-09-24'
ORDER BY total_fee_wei DESC
LIMIT 10;单笔/单块手续费(视图按 (block_number, tx_index) 主键剪枝):
SELECT tx_hash, fee_wei, l1_fee_wei, l2_fee_wei
FROM {db}.tx_fees
WHERE block_number = 72000000
ORDER BY tx_index;最近 30 个收盘日、按 tx type 拆开的费用趋势 + L1 占比:
SELECT s.date, s.l1_share, t.type, t.total_fee_wei, t.tx_count
FROM {db}.daily_fee_stats_by_type t FINAL
JOIN {db}.daily_fee_stats s FINAL ON s.date = t.date
WHERE t.is_deleted = 0 AND s.is_deleted = 0 AND s.date >= today() - 30
ORDER BY s.date, t.type;已知局限
tx_fees是视图:每次查询实时展开成对transactions FINAL的扫描/剪枝,不再有独立物理表可以针对 fees 查询单独调优;按地址做的全表级分析请优先用daily_fee_by_address。daily_fee_stats*每次刷新都是从transactions FINAL重新聚合全部已收盘日期,成本随总历史数据量增长(且transactions比原来的物化镜像大)。当前/短期数据量下没问题;真的变慢了,修法是改成只刷新最近 N 天的窗口(+ 一次性脚本回填更早的历史日期),费用公式本身不用动。- Retryable(type 104/105)观测到
gas_used_for_l1恒为 0 的现象只是从样本数据里看到的,没有往下追 arbOS 存款交易的记账代码路径确认原因;如果后续有 traces/deposit 相关数据集覆盖到这块,值得回来交叉验证。
失败交易分析数据集(Failed Transactions Analytics)
来源:schema/clickhouse/derived/020_failed_txs.sql。 面向内部使用 SQL 分析链上交易失败原因、分类合约 revert 原因、以及监控合约日级故障率(Failure Rate)的工程与分析团队。
ArbOS L1 计价历史数据集(arbos_l1_pricing / daily_l1_base_fee_stats)
来源:schema/clickhouse/derived/022_arbos_l1_pricing.sql。 面向内部用 SQL 查询 Arbitrum Orbit L1 计价状态、L1 区块对齐与跨链批量发布历史的团队。