以太坊状态访问的剖析

weiihann 发布于 2026-06-28 阅读 12

本文深入分析了以太坊历史上的状态访问模式,使用窗口方法(1到365天)研究账户和存储槽的读写行为。主要发现:约55%的写入是创建后永不触及的状态增长;读取中83-93%是存在性探测(返回零值);状态访问高度集中,前1%的读取账户捕获96-98%的访问量;30天窗口内94%的更新写入发生在活跃状态上,仅3%的状态保持活跃。文章还基于EIP-8295提出了状态分层方案,建议30天阈值作为活跃与非活跃状态的分界,以平衡gas成本和状态规模。

1. 引言

每一笔以太坊交易都会读取和写入链的状态的一部分:账户余额、nonce、代码以及存储槽。随着链的增长,越来越多的状态长时间未被触及,因此有几个提案研究了处理状态增长的不同方法,例如将休眠状态与活跃状态分离、使休眠状态过期以及创建新形式的状态。为了最好地帮助研究,本报告旨在回答以下问题:

  • 在以太坊的历史中,状态的访问和创建是什么样的?
  • 在给定的时间段内,有多少状态被触及?
  • 写入主要是创建、更新还是删除?
  • 读取是获取真实数据,还是仅仅探查是否存在?
  • 活动的集中程度如何?
  • 协议内的状态分层方案会有多有效?

2. 总结

  • 写入是状态增长,而非变更。 大约 55% 的已写入槽被创建一次后便再未触及。占写入事件三分之二的更新集中在少量、被反复命中的热数据集上。
  • 读取主要是存在性探查。 按不同槽计数,只读集合中的 83–93% 返回了不存在的值。
  • 状态访问极其集中。 自 2022 年以来,顶级 1% 的读取账户捕获了约 96–98% 的读取访问,主要由稳定币、去中心化交易所和区块构建者主导。
  • 热集本质上就是写入集。 即使包含已填充的读取,它也只比写入集多出 4–9%。
  • 写入年龄分层能够廉价地覆盖更新 Gas。 在 30 天窗口内,94% 的槽更新 SSTOREs(账户为 97%)已经位于热状态上,因此非活跃溢价仅影响 3–6% 的更新。
  • 约 30 天的窗口是最佳平衡点。 它覆盖了约 94% 的热更新,同时仅保持约 3% 的状态为活跃。更宽的窗口几乎无法带来额外的覆盖,却会使活跃状态大幅增加。

3. 数据与方法

T 是以天为单位的窗口长度。全文中的窗口表均终止于主网区块 24,870,000,并且 §5.1§6 还将在合并后的历史中每周重放这些表。窗口为 T ∈ {1, 7, 14, 30, 60, 90, 180, 365} 天。对象类型为账户和存储槽。

所有源表均提取自 Xatu

集合
写入(账户) canonical_execution_balance_diffs, canonical_execution_nonce_diffs, canonical_execution_contracts(账户创建,以 contract_address 为键)
写入(槽) canonical_execution_storage_diffs
读取(槽) canonical_execution_storage_reads
读取(账户,直接) canonical_execution_balance_reads, canonical_execution_nonce_reads
读取(账户,派生) canonical_execution_address_appearances

集合定义

对于每个 (T, object_type)

  • W:在窗口中被创建、修改或删除的对象。
  • R:在窗口中仅被读取,从未被写入的对象。
  • R⁺:R 中那些读取返回非零(已填充)值的对象。
  • R∪W = W + R:在窗口中被触及的所有对象。R⁺∪W = W + R⁺ 是已填充的热集合,在 §5.1 中用作热度度量。

我们用 |W| 表示 W 中的对象数量,|R| 同理。

实际上,每个被写入的对象在同一个窗口中也会被读取。槽的 SSTORE 之前会先有一次读取,发送者的 nonce 会在写入它的交易验证时被读取。因此 R 只计算那些在 W 之外增加内容的显式读取(例如 SLOAD)。

粒度与已知缺口

  • 系统调用状态未记录。 每个区块对 EIP-4788 信标根、EIP-2935 区块哈希历史以及 EIP-7002/7251 请求队列合约的协议写入没有出现。
  • 共识层提款未记录。 验证者提款会向执行层地址存入资金但不经过 EVM 写入,因此仅提款的接收者不在 W 中。这约有数万个地址,远低于账户写入集的 1%。

4. 状态访问与创建的样子

一个槽或账户可以通过多种方式被触及。本节试图探索状态写入和读取的模式。

4.1 写入结构

一次存储写入是三种转换之一,大多数被写入的槽在创建一次后便再未触及。接下来是两个视图:全历史事件总数,然后是在一个窗口内写入的槽按生命周期的分解。

全链历史的写入事件

从第一个状态活动(区块约 46k,2015 年 7 月)到区块 24,870,000 的每一个写入事件。

槽写入事件(总共 92.0 亿):

转换 事件数 占比
更新 (x→y) 6,109,404,842 66.4%
创建 (0→x) 2,323,710,153 25.3%
删除 (x→0) 765,554,231 8.3%

账户写入事件:

来源 指标 事件数 占比
余额变动 (85.5 亿) 调整 (x→y) 7,965,568,085 93.1%
充值 (0→x) 385,657,967 4.5%
清空 (x→0) 203,518,204 2.4%
nonce 变动 (34.2 亿) 后续 3,043,409,094 89.0%
首次使用(从 0 开始) 376,865,812 11.0%
合约创建 创建 100,078,703 不适用

有两件事很突出:

  • 写入流量以更新为主:所有历史槽写入事件中超过 66% 是更新。
  • 曾经创建的所有槽中有三分之一已被删除。

一个已写入槽的生命周期

每个写入事件是三种转换类型之一(值为每笔交易的净值,§3):

  • C(创建):0 → x,空槽被设置。
  • U(更新):x → y,已设置槽的值发生变化。
  • D(删除):x → 0,已设置槽被清除。

然后根据每个已写入槽在窗口内经历的转换类型进行分类,一个槽可能多次经历同一种类型:

  • C:创建一次,之后未被触及。第二次创建需要先有删除,因此一个 C 槽恰好有一次创建。
  • U:更新一次或多次,从未被创建或删除。在窗口之前已存在并在原地修改的槽。
  • D:删除一次,从未被创建或更新。一个预先存在的槽被清除。
  • C+U:创建一次,然后更新一次或多次,未被删除。由于没有删除,它恰好有一次创建,+U 涵盖一次或多次更新。
  • C+D:创建并删除但从未更新,一个或多个生与死的循环。
  • U+D:更新一次或多次,然后删除。在窗口之前已存在。
  • C+U+D:创建、更新一次或多次并删除,一个完整的生命周期。

下表显示了在合并后周度扫描中的平均构成,每个周度锚点权重相等:

| T (天) | C | C+U | U | C+U+D | C+D | U+D | D | |---:|---:|---:|---:|---:|---:|---:| | 30 | 53.3% | 8.2% | 14.4% | 2.8% | 12.1% | 0.8% | 8.4% | | 90 | 54.9% | 9.2% | 11.4% | 3.3% | 12.9% | 0.7% | 7.7% | | 180 | 55.5% | 10.0% | 9.4% | 3.7% | 13.6% | 0.6% | 7.2% | | 365 | 55.4% | 11.4% | 7.0% | 4.2% | 14.7% | 0.5% | 6.8% |

槽写入构成随时间变化

创建在每个窗口都占主导地位,约 |W| 的 55%。 大多数槽在窗口内被初始化后再未触及,这是状态增长而非变更。C 也是波动最大的类别。在 T=30 时,它每周在 38% 和 68% 之间波动,在 2024 年活动激增期间下降,之后恢复。

C+D 是最大的混合类别,约占 |W| 的 12–15%。 这些是在窗口内诞生并消亡的短暂槽。该比例在整个时间线的每个窗口中都保持稳定。

在更长的窗口中,创建占主导地位更明显。 包含创建的类别(C, C+U, C+D, C+U+D)在 T=30 时约占 |W| 的 76%,在 T=365 时约占 86%,而纯就地更新(U)从 14% 减半到 7%。更长的窗口捕获了每个槽更多的出生,因此写入集看起来更具增长驱动性,回溯得越远越明显。

4.2 读取结构

读取可以有两种不同的返回值:零或非零。与写入类似,接下来是两个视图:全历史总数和随时间变化的按窗口划分。

全链历史的读取事件

全历史中的每个读取事件。

槽读取事件(总共 236.9 亿,是写入事件的 2.6 倍):

返回值 事件数 占比
非零 16,552,716,483 69.9%
7,138,221,664 30.1%

账户读取事件:

来源 指标 事件数 占比
余额读取 (151.0 亿) 非零 9,845,422,896 65.2%
5,251,717,095 34.8%
nonce 读取 (136.4 亿) 非零(递增后,永不为 0) 13,637,765,012 100%
出现 (419.8 亿) 内部调用目标/调用者 各 157.4 亿 各 37.5%
交易发送者/费用接收者/交易接收者 各 33.9 亿 各 8.1%
合约创建者/新合约 各约 1 亿 各 0.24%
自毁调用者/退款接收者 各约 6000 万 各 0.14%

费用接收者是获得交易优先费 credited 的区块提议者,而非共识层提款(那些未被记录)。

如图所示,大多数账户和槽的读取事件都是非零的。这些读取总数包括与写入耦合的读取,因为每次写入之前都会有一次读取(每个 SSTORE 对应一个 SLOAD)。与写入耦合的读取数恰好等于写入数,因此对于槽来说,它们约占所有读取事件的 35%,剩下的约 65% 是超出写入集的真正读取。

读取返回什么

R 中的每个读取返回 zero(空槽探查,“这个槽被设置了吗?”)或 nonzero(已填充数据)。R 只包含在窗口中被读取但未被写入的对象。作为 |R| 的占比,在合并后周度扫描中取平均值:

T (天) 仅返回零 仅返回非零
30 82.5% 17.5%
90 87.1% 12.8%
180 89.8% 10.1%
365 92.6% 7.4%

槽读取构成随时间变化

R 大部分是空槽探查。 只有约 7–18% 的 R 是实际已填充的读取,并且随着窗口变宽,这个比例会缩小。

这与上面全历史表中的 69.9% 非零并不矛盾。它们计数不同。69.9% 是按事件:在所有曾经执行的 SLOAD 中,约 70% 命中了已填充槽,因为一小部分热门的已填充槽被反复读取。这里约 7–18% 是按不同槽在只读集合 R 中:大多数在窗口内仅被读取的槽是一次性的存在性探查,每个只命中一次。按读取事件加权,已填充读取占主导地位。按对象加权,空探查确实占据了更大的份额。

5. 热度和集中度

本节检查有多少状态是“热的”(在窗口中被触及)以及访问在对象间的集中程度。

5.1 热度:多少状态是活跃的

每个值是合并后周度扫描的平均值,每个窗口一个。只读列是 R⁺,只计数返回非零(已填充)值的读取,因此空槽探查被排除在热集合之外。

(活跃槽的平均占比):

T (天) W R⁺ R⁺∪W
30 2.82% 0.21% 3.03%
90 7.55% 0.39% 7.95%
180 13.60% 0.55% 14.15%
365 24.17% 0.70% 24.88%

账户(活跃账户的平均占比):

T (天) W R⁺ R⁺∪W
30 3.30% 0.46% 3.76%
90 7.94% 1.07% 9.01%
180 13.45% 1.79% 15.24%
365 23.08% 2.51% 25.59%

合并(槽和账户相对于总状态的平均占比):

T (天) W R⁺ R⁺∪W
30 2.91% 0.26% 3.16%
90 7.63% 0.51% 8.14%
180 13.58% 0.77% 14.36%
365 23.99% 1.04% 25.03%

热集合随 T 增长而增长,因为更长的窗口捕获了更多状态。已填充读取集在整个过程中始终是写入集的一小部分。

热度随时间变化

以下面板显示了槽、账户及其合并集作为其活跃状态分母的占比:

热度随时间变化,写入集 W 热度随时间变化,已填充读取 R⁺ 热度随时间变化,已填充热集合 R⁺∪W

在写入集图表中,槽的占比在合并后的时间线中呈下降趋势(活跃状态增长快于窗口内写入集),而账户占比则保持得更稳定。两者均从 2025 年末开始上升。已填充读取集 R⁺ 在每个窗口都很小,并且逐渐增长,账户方面比槽方面更多。

5.2 集中度

对于每个访问集合和窗口,由前 1% 对象捕获的访问占比。访问是按每笔交易每个对象事件计数的(§3)。

集中度前 1%,槽 集中度前 1%,账户

槽,前 1% 的访问占比:

T (天) W R R∪W
1 48.0% 65.7% 56.4%
30 62.3% 83.8% 72.3%
90 66.1% 87.1% 76.1%
365 68.2% 87.7% 77.9%

账户,前 1% 的访问占比:

T (天) W R R∪W
1 40.8% 78.9% 57.5%
30 60.5% 96.0% 77.7%
90 64.0% 98.0% 82.0%
365 69.0% 98.7% 86.4%

R 的集中度在任何地方都高于 W。 在 T=365 天时,前 1% 的 R 槽看到了约 88% 的槽读取访问,前 1% 的 R 账户看到了约 98%。少数几个流行的合约吸收了几乎所有的读取操作。

集中度随 T 增长。 更宽的窗口会拉入访问次数较少的尾部键,因此头部的相对权重上升。从 T=1 天到 T=30 天的跳跃最为剧烈(槽 R 约 18 个百分点,账户 R 约 17 个百分点),之后趋于平缓。

账户的集中程度远高于槽。 在 T=30 天时,前 1% 的 R 账户捕获了 96% 的访问,而槽为 84%。账户读取落在少量热门合约上,而槽读取则分布在许多合约的存储中。

集中度随时间变化

集中度随时间变化,前 1% 占比

账户读取集中度极高,并且在整段样本中一直如此。在最新锚点(区块 24,870,000)处,前 1% 的 R 账户持有 96–98% 的读取访问,并且在更宽的窗口中,它在 2022–2023 年已经达到了这个水平(T=90 约 93%,T=180 约 95%,T=365 约 97%)。唯一移动的是较短的 30 天窗口,从约 87% 攀升至约 96%,因为读取流量集中到一小部分被大量调用的合约上。槽的集中度较低且增长更温和(在 T=365 时从约 78% 到约 88%)。

是谁占据了顶端

在 T=365 天窗口(区块 24,870,000)中,按所有来源的访问计数排名的前十名最常被访问 (R∪W) 的账户:

排名 账户 访问次数 它是什么
1 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97 11.9 亿 Titan Builder
2 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 7.29 亿 USDC
3 0xdadb0d80178819f2319190d340ce9a924f783711 7.29 亿 BuilderNet
4 0xdac17f958d2ee523a2206206994597c13d831ec7 6.15 亿 USDT
5 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 5.79 亿 WETH
6 0x43506849d7c04f9138d1a2050bbf3a0c054402dd 4.28 亿 未知
7 0x396343362be2a4da1ce0c1c210945346fb82aa49 2.41 亿 QuasarBuilder
8 0x000000000004444c5dc75cb358380d2e3de08a90 2.17 亿 Uniswap V4 PoolManager
9 0x609e0f0cb16e53878ba5e959a22fc7fcd81b124a 2.09 亿 未知
10 0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5 2.05 亿 beaverbuild

头部一分为二。区块构建者(Titan, BuilderNet, QuasarBuilder, beaverbuild)排在列表顶部,因为在合并后,每笔交易都会向区块的费用接收者存入信用。这是因为构建者是其构建的每个区块中每笔交易的费用接收者。合约才是真正频繁访问的账户:稳定币(USDC, USDT),WETH,以及 Uniswap V4 单例合约。

因此账户集中度的头部是一个混合体:由费用存入信用拉高的一些主导构建者,加上吸收了大部分调用流量的少数合约。

在同一窗口中,其存储槽访问次数最多的十个合约:

排名 合约 槽访问次数 它是什么
1 0xdac17f958d2ee523a2206206994597c13d831ec7 13.1 亿 USDT
2 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 7.99 亿 USDC
3 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 3.32 亿 WETH
4 0x06450dee7fd2fb8e39061434babcfc05599a6fb8 3.17 亿 XEN
5 0x000000000004444c5dc75cb358380d2e3de08a90 1.33 亿 Uniswap V4 PoolManager
6 0xc7bbec68d12a0d1830360f8ec58fa599ba1b0e9b 5400 万 Uniswap V3 USDC/USDT 池
7 0x2b591e99afe9f32eaa6214f7b7629768c40eeb39 5200 万 HEX
8 0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb 5100 万 Morpho
9 0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2 4500 万 Aave V3 Pool
10 0xe0554a476a092703abdb3ef35c80e0d76d32939f 4400 万 Uniswap V3 USDC/ETH 池

代币余额和授权映射(USDT, USDC, WETH),去中心化交易所(Uniswap V4 单例合约和两个繁忙的 Uniswap V3 池),借贷(Aave V3, Morpho),以及高换手率的铸造/质押代币(XEN, HEX)。这些合约的存储被读取和写入的次数远高于其他任何合约。

6. EIP-8295:一个状态分层反事实

前面的章节描述了在以太坊历史过程中状态访问和创建的样子。本节探讨状态分层方案的有效性。

EIP-8188 为每个账户和存储槽添加了一个 last_written_block 字段,这是一个共识层元数据,记录了每部分状态最后被修改的时间。它本身不改变任何 Gas 成本。EIP-8295 是基于该元数据构建的分层方案。它将最近写入的状态定价为低价(活跃),将长期休眠的状态定价为高价(非活跃),并将其活跃度阈值视为一个滚动的 T 天窗口。以下各节将该层作为反事实进行建模。下文中的“活跃”和“非活跃”均指此。

6.1 热更新覆盖率

定义

对于每个窗口,将每个更新事件分类为热或冷:

  • :该对象在同一个窗口中已被更早地创建或更新。
  • :该更新是该对象在窗口中的第一个暖化事件。删除不计入暖化。

因此,一个对象在窗口内的首次创建或更新可能是冷的,而其后每次更新都是热的。

覆盖率

每个值是合并后周度扫描中热更新事件的平均百分比。

T (天) 槽 % 热 账户 % 热
1 85.2% 92.0%
7 91.2% 95.1%
30 94.1% 97.0%
90 95.7% 98.0%
180 96.7% 98.6%
365 97.7% 99.0%

热更新覆盖率随时间变化,槽与账户对比

活跃层很好地覆盖了更新 Gas。 在 T=30 天时,约 94% 的槽更新 SSTOREs 保持了低价的活跃价格,在 T=90 天时约为 96%,因此非活跃溢价仅影响约 3–6% 的槽更新。即使是 1 天的窗口也覆盖了 85% 的槽更新 Gas 和 92% 的账户更新 Gas。

在低端,槽对窗口比账户更敏感。 将窗口从 30 天减少到 1 天使槽覆盖率从 94% 降至 85%,但账户覆盖率仅从 97% 降至 92%。账户状态(热门合约)在短窗口内被多次重写,因此几乎每个账户更新都已经有当天的先前写入。

收益迅速饱和。 槽覆盖率从 T=30 天时的约 94% 上升到 T=365 天时的约 98%,仅增加 +4 个百分点,而窗口扩大了 12 倍;账户则更加平坦(约 97% 到约 99%)。超过约 30 天后,对更新 Gas 的影响甚微。

约 30 天的窗口是最佳平衡点。 从 T=30 天到 T=365 天仅购买到 +3.6 个百分点的槽覆盖率(94.1% 到 97.7%),但必须保持活跃的热集从活跃槽的 2.8% 增长到 24.2%§5.1),大约是便宜层中状态量的 9 倍,却几乎未覆盖更多 Gas。大约 30 天的阈值捕获了几乎所有的热更新 Gas,同时使活跃集保持很小,这正是分层方案所期望的。

6.2 读取侧周期提升

在假设的扩展方案下,如果对非活跃对象的第一次读取也会提升其周期,使得读取对用户来说像写入一样,那么哪些对象需要支付该成本?

不良用户体验集是其窗口内第一个事件是非零读取的对象。例如,一个槽的第一个事件是返回已填充值的 SLOAD,或者一个账户的第一个事件是返回非零的余额或 nonce 读取。作为第一个事件的写入或零读取不会产生成本,因为写入已经刷新了元数据,而零读取不会填充元数据。

EIP-8188 仅在写入时更新写入年龄,从不因读取而更新。一个在读取时也提升周期的方案将产生与写入相同的成本,因此我们希望了解它会造成多大的影响。

槽:首次操作分类

R∪W 中槽按其第一个事件是什么的占比:

T (天) 第一个 = 写入 第一个 = 零读取 第一个 = 非零读取
30 67.90% 26.32% 5.77%
90 69.34% 26.57% 4.09%
180 69.86% 26.93% 3.21%
365 70.28% 27.37% 2.35%

在 T=30 天时,R∪W 中 5.8% 的槽会受到读取侧提升的影响,它们的第一个事件是已填充的 SLOAD。在 T=365 天时,这一比例降至 2.4%,因为更宽的窗口更可能包含更早的写入。

账户:首次操作分类

R∪W 中账户按其第一个事件是什么的占比:

T (天) 第一个 = 写入 第一个 = 零读取 第一个 = 非零读取
30 89.65% 0.91% 9.44%
90 89.67% 1.04% 9.29%
180 89.96% 1.14% 8.89%
365 90.91% 1.19% 7.90%

不良用户体验集在 T=30 天时约占热账户的 9%,在 T=365 天时缓降至约 8%。零读取带在整个过程中保持很小(约 1%)。

首次操作 = 非零读取随时间变化

在时间线上,不良用户体验集始终是少数,随着只读集中填充了更多已填充账户而温和上升,在 2023 年初和 2025 年出现短窗口的波动,达到约 20%。

结论

以太坊的状态增长远快于其变更。大多数写入创建了新的状态,此后便不再触及,并且在任何固定窗口中活跃的状态占比随着链的老化而持续下降。结果是大量休眠状态被每个有状态节点永远携带,而真正的活动则集中在少数热门应用主导的小型热集上。

这正是使活跃状态与休眠状态分离值得的形态。基于写入年龄的分层将几乎所有与 Gas 相关的写入活动放在一个小的活跃集中,30 天的窗口已经覆盖了约 94% 的更新 Gas,同时仅保持约 3% 的状态为热,并将其余标记为非活跃。由于休眠尾部随着链的增长而增长,区别对待它的价值会随时间复合。

分层是这一理念的一种表达。相同结构支撑着状态过期、部分无状态化以及任何不再将所有状态视为同等活跃的设计。活跃集很小且有界,因此以太坊应该基于这种不对称性来可持续地扩展其状态。


附录 A:SQL 查询

所有查询均针对每个 (T, object_type) 在尾部区块范围 bn_lo = anchor − T·7200, bn_hi = anchor 内运行(合并后节奏为 7,200 区块/天,锚点 24,870,000T ∈ {1, 7, 14, 30, 60, 90, 180, 365})。完整构建器位于 state_access/queries_v2.py

槽直方图(热度、写入/读取结构、集中度)

存储槽键是 (contract_address, slot)。写入来自 storage_diffs,读取来自 storage_reads。内部 UNION ALL 用 is_w / is_r 标记每一行,外部 GROUP BY 在 cityHash64(address, slot) 上对每个键求和,最后的 GROUP BY 折叠为 (slice, n_w, n_r, n_keys)。分片分区(w_only / r_only / rw)在 Python 中重新映射到 W / R 集合:W = w_only ∪ rw,R = r_only,R∪W = 所有三个。

WITH per_key AS (
    SELECT
        h,
        sum(is_w) AS n_w,
        sum(is_r) AS n_r
    FROM (
        SELECT cityHash64(address, slot) AS h, 1 AS is_w, 0 AS is_r
        FROM canonical_execution_storage_diffs
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT cityHash64(contract_address, slot) AS h, 0 AS is_w, 1 AS is_r
        FROM canonical_execution_storage_reads
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
    )
    GROUP BY h
)
SELECT
    multiIf(n_w > 0 AND n_r > 0, 'rw',
            n_w > 0,             'w_only',
                                 'r_only') AS slice,
    n_w,
    n_r,
    count() AS n_keys
FROM per_key
GROUP BY slice, n_w, n_r
ORDER BY slice, n_w, n_r

槽类型直方图(§4.1 / §4.2)

相同的按键 GROUP BY 形状,但用五个类型计数器代替两个。每个写入事件按 (from_value, to_value) 分为 create / update / delete,每个读取按返回的 value 分为 zero / nonzero。输出:每个不同的 (n_w_create, n_w_update, n_w_delete, n_r_zero, n_r_nonzero) 计数元组一行,带有 n_keys

WITH per_key AS (
    SELECT
        h,
        sum(is_w_create)  AS n_w_create,
        sum(is_w_update)  AS n_w_update,
        sum(is_w_delete)  AS n_w_delete,
        sum(is_r_zero)    AS n_r_zero,
        sum(is_r_nonzero) AS n_r_nonzero
    FROM (
        SELECT
            cityHash64(address, slot) AS h,
            toUInt8(from_value =  '0x000…0' AND to_value != '0x000…0') AS is_w_create,
            toUInt8(from_value != '0x000…0' AND to_value != '0x000…0') AS is_w_update,
            toUInt8(from_value != '0x000…0' AND to_value =  '0x000…0') AS is_w_delete,
            toUInt8(0) AS is_r_zero,
            toUInt8(0) AS is_r_nonzero
        FROM canonical_execution_storage_diffs
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT
            cityHash64(contract_address, slot) AS h,
            toUInt8(0) AS is_w_create,
            toUInt8(0) AS is_w_update,
            toUInt8(0) AS is_w_delete,
            toUInt8(value =  '0x000…0') AS is_r_zero,
            toUInt8(value != '0x000…0') AS is_r_nonzero
        FROM canonical_execution_storage_reads
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
    )
    GROUP BY h
)
SELECT
    n_w_create, n_w_update, n_w_delete, n_r_zero, n_r_nonzero,
    count() AS n_keys
FROM per_key
GROUP BY n_w_create, n_w_update, n_w_delete, n_r_zero, n_r_nonzero
ORDER BY n_w_create, n_w_update, n_w_delete, n_r_zero, n_r_nonzero

W_mixed 分解 (§4.1) 和 R 返回值拆分 (§4.2) 在 Python 中从此直方图派生,无需额外查询。

账户直方图(热度、集中度)

账户键是 cityHash64(address)。写入来自 balance_diffsnonce_diffscontractscontract_address 是新实体化的账户)。读取来自 balance_readsnonce_reads 和过滤掉非 ERC* 关系的 address_appearances。与槽查询相同的外部聚合。

WITH per_key AS (
    SELECT
        h,
        sum(is_w) AS n_w,
        sum(is_r) AS n_r
    FROM (
        SELECT cityHash64(address) AS h, 1 AS is_w, 0 AS is_r
        FROM canonical_execution_balance_diffs
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT cityHash64(address) AS h, 1 AS is_w, 0 AS is_r
        FROM canonical_execution_nonce_diffs
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT cityHash64(contract_address) AS h, 1 AS is_w, 0 AS is_r
        FROM canonical_execution_contracts
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT cityHash64(address) AS h, 0 AS is_w, 1 AS is_r
        FROM canonical_execution_balance_reads
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT cityHash64(address) AS h, 0 AS is_w, 1 AS is_r
        FROM canonical_execution_nonce_reads
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
        UNION ALL
        SELECT cityHash64(address) AS h, 0 AS is_w, 1 AS is_r
        FROM canonical_execution_address_appearances
        WHERE meta_network_name = 'mainnet'
          AND block_number BETWEEN {bn_lo} AND {bn_hi}
          AND relationship IN ('call_from', 'call_to', 'tx_from', 'tx_to',
                               'miner_fee', 'factory', 'create',
                               'suicide_refund', 'suicide')
    )
    GROUP BY h
)
SELECT
    multiIf(n_w > 0 AND n_r > 0, 'rw',
            n_w > 0,             'w_only',
                                 'r_only') AS slice,
    n_w,
    n_r,
    count() AS n_keys
FROM per_key
GROUP BY slice, n_w, n_r
ORDER BY slice, n_w, n_r

活跃状态总计(分母)

SELECT block_number, accounts, storages
FROM execution_state_size
WHERE meta_network_name = 'mainnet' AND block_number <= 24870000
ORDER BY block_number DESC
LIMIT 1

在锚点处:1,552,604,459 个槽,379,632,901 个账户,总计 1,932,237,360 个。

热更新覆盖率(§6.1)

对于每个 T,将 [anchor − T·7200, anchor] 中的每个更新 SSTORE 事件分类,看同一槽在同一个窗口中是否有任何更早的创建或更新事件。使用一个 GROUP BY 在 cityHash64(address, slot) 上,没有 JOIN 也没有窗口函数。

WITH slot_events AS (
    SELECT
        cityHash64(address, slot) AS h,
        toUInt64(block_number) * 1000000000
            + toUInt64(transaction_index) * 100000
            + toUInt64(internal_index) AS event_order,
        (from_value != '0x000…0' AND to_value != '0x000…0') AS is_update,
        (from_value =  '0x000…0' AND to_value != '0x000…0') AS is_create
    FROM canonical_execution_storage_diffs
    WHERE meta_network_name = 'mainnet'
      AND block_number BETWEEN {bn_lo} AND {bn_hi}
),
per_slot AS (
    SELECT
        h,
        countIf(is_update) AS n_update,
        -- argMinIf 返回具有最小 event_order 的行中 is_update 的值
        -- 在 (is_create OR is_update) 为真的行中。删除行被过滤掉。
        argMinIf(is_update, event_order, is_create OR is_update) AS first_cu_is_update
    FROM slot_events
    GROUP BY h
    HAVING n_update > 0
)
SELECT
    sum(n_update) AS total_updates,
    sum(n_update - toUInt64(first_cu_is_update)) AS warm_updates,
    sum(toUInt64(first_cu_is_update)) AS cold_updates,
    round(100.0 * warm_updates / total_updates, 4) AS pct_warm
FROM per_slot

event_order(block_number, transaction_index, internal_index) 打包成一个单调递增的 UInt64(区块编号容易容纳,25M × 1e9 ≈ 2.5e16,远低于 UInt64 最大值)。

账户模拟(account_update_coverage)对 balance_diffsnonce_diffs 应用相同的热/冷规则,键为账户,创建是余额/nonce 0→x,更新是 x→ycontracts 被丢弃(没有 transaction_index,并且合约创建已经产生了 nonce 0→1 的 diff),event_order 打包 (block, tx_index, op_priority),创建在更新之前。

首次操作分类(§6.2)

对于 R∪W 中的每个对象,找到其在窗口中的最早事件,按 (block_number, transaction_index) 排序并分类。槽和账户都使用 UNION-ALL-然后-argMin 的形状,没有 JOIN:contracts 被丢弃(与 nonce_diffs 冗余),address_appearances 得到一个块内末尾的排序位置(它总是输掉同块内的平局,匹配其最低优先级)。

-- 槽 — slot_first_op
WITH slot_events AS (
    SELECT
        cityHash64(address, slot) AS h,
        toUInt64(block_number) * 1000000 + toUInt64(transaction_index) * 10 + 0 AS event_order,
        toUInt8(0) AS is_read,
        toUInt8(0) AS read_is_nonzero
    FROM canonical_execution_storage_diffs
    WHERE meta_network_name='mainnet' AND block_number BETWEEN {bn_lo} AND {bn_hi}
    UNION ALL
    SELECT
        cityHash64(contract_address, slot) AS h,
        toUInt64(block_number) * 1000000 + toUInt64(transaction_index) * 10 + 1 AS event_order,
        toUInt8(1) AS is_read,
        toUInt8(value != '0x000…0') AS read_is_nonzero
    FROM canonical_execution_storage_reads
    WHERE meta_network_name='mainnet' AND block_number BETWEEN {bn_lo} AND {bn_hi}
),
per_slot AS (
    SELECT
        h,
        argMin(is_read,         event_order) AS first_is_read,
        argMin(read_is_nonzero, event_order) AS first_read_is_nonzero
    FROM slot_events
    GROUP BY h
)
SELECT
    count() AS total_slots,
    sum(toUInt8(first_is_read = 0)) AS first_is_write,
    sum(toUInt8(first_is_read = 1 AND first_read_is_nonzero = 0)) AS first_is_zero_read,
    sum(toUInt8(first_is_read = 1 AND first_read_is_nonzero = 1)) AS first_is_nonzero_read
FROM per_slot;

-- 账户 — account_first_op(优先级打包到 event_order 中,使得在相同 (block, tx_idx) 下,断点顺序为 write > nonzero_read > zero_read > appearance_read)
WITH all_events AS (
    SELECT cityHash64(address) AS h,
        toUInt64(block_number) * 10000000 + toUInt64(transaction_index) * 10 + 0 AS event_order,
        'write' AS op
    FROM canonical_execution_balance_diffs
    WHERE meta_network_name='mainnet' AND block_number BETWEEN {bn_lo} AND {bn_hi}
    UNION ALL  -- nonce_diffs (write, +0)
    UNION ALL  -- balance_reads: +1 if balance!=0 else +2, op nonzero_read/zero_read
    UNION ALL  -- nonce_reads:   +1 if nonce!=0   else +2, op nonzero_read/zero_read
    UNION ALL  -- address_appearances: event_order = block*10000000 + 9999999 (end-of-block),
               --   op appearance_read; 不需要 tx_index,总是输掉同块平局
)
SELECT count() AS total_accounts,
       sum(toUInt8(op='write'))           AS first_is_write,
       sum(toUInt8(op='nonzero_read'))    AS first_is_nonzero_read,
       sum(toUInt8(op='zero_read'))       AS first_is_zero_read,
       sum(toUInt8(op='appearance_read')) AS first_is_appearance_read
FROM (SELECT h, argMin(op, event_order) AS op FROM all_events GROUP BY h);

完整 SQL 在 state_access/queries_v2.py 中(slot_first_op, account_first_op)。

全历史事件总计(§4.1/§4.2 历史子节)

queries_v2.py 中的八个构建器(slot_write_event_totals, slot_read_event_totals, account_{balance,nonce}_{write,read}_totals, account_contract_create_totals, account_appearance_read_totals),每个都是在一个包含性区块范围上的简单 countIf 聚合,没有按键 GROUP BY,没有 JOIN。计数是可加的,因此 collect_v2_history.py[0, 24,870,000] 平铺成 1M 块大小的块,在合并块 (15,537,394) 处分割,在 ethpandaops 下面运行读取种类,在本地节点上面运行(写入表是全历史的本地表),并将每个块的计数持久化到 history_event_totals.parquet(可按 (kind, chunk) 恢复,失败块会分成两半重试,因此计数不会丢失)。每个构建器也返回 n_total,以便可以对照行数验证度量分区。

-- 代表性形状(槽写入;其他仅在表和谓词上不同)
SELECT
    countIf(from_value =  '0x000…0' AND to_value != '0x000…0') AS n_create,
    countIf(from_value != '0x000…0' AND to_value != '0x000…0') AS n_update,
    countIf(from_value != '0x000…0' AND to_value =  '0x000…0') AS n_delete,
    count() AS n_total
FROM canonical_execution_storage_diffs
WHERE meta_network_name = 'mainnet' AND block_number BETWEEN {bn_lo} AND {bn_hi}

附录 B:输出

state_access/data/v2/
  slot_histogram.parquet         # 每个 T 的原始 (slice, n_w, n_r, n_keys)(输入)
  account_histogram.parquet
  slot_typed_histogram.parquet   # 用于 §4.1 / §4.2 的类型化槽直方图
  slot_update_coverage.parquet   # 每个 T 的热/冷更新拆分,用于 §6.1
  slot_first_op.parquet          # §6.2 首次操作分类(槽)
  account_first_op.parquet       # §6.2 首次操作分类(账户)
  q1_warmth_{slot,account,combined}.parquet         # 集合大小 + 占活跃状态的百分比
  q1_warmth_slot_typed.parquet                       # 类型化槽 W/R 分解
  q1_warmth_slot_mixed_decomp.parquet                # W_mixed 子类别
  q3_concentration_{slot,account}.parquet
  q1_warmth_{slot,account,combined}.png
  q1_warmth_slot_{W,R}_typed.png                     # 类型化槽堆叠区域
  q1_warmth_slot_mixed_decomp.png                    # W_mixed 6 路分解
  slot_update_coverage.png                           # 最新锚点热/冷更新(未嵌入;§6.1 使用随时间变化的视图)
  slot_first_op.png                                  # 最新锚点槽首次操作(未嵌入;§6.2 使用随时间变化的视图)
  account_first_op.png                               # 最新锚点账户首次操作(未嵌入;§6.2 使用随时间变化的视图)
  q3_concentration_top1_{slot,account}.png
  history_event_totals.parquet                       # 全历史每块事件计数
  history_event_totals_summary.parquet               # 每 (kind, metric) 总计 + 时代拆分
  history_event_totals_{writes,reads}.png            # 全历史事件混合堆叠条形图
  sweep_w{30,90,180,365}.parquet                     # 第三部分:每个锚点一行宽
  sweep_summary.parquet                              # 第三部分:所有窗口合并(长格式)
  sweep_warmth_{W,Rp,RpW}.png                        # §5.1 热度随时间变化(W / R⁺ / W+R⁺)
  sweep_write_composition.png                        # §4.1 写入生命周期构成随时间变化
  sweep_read_composition.png                         # §4.2 读取构成随时间变化
  sweep_concentration.png                            # §5.2 集中度随时间变化
  sweep_update_coverage.png  sweep_first_op.png      # §6 策略随时间变化

历史扫描:随时间变化的视图

两个标量汇总 SQL 构建器(queries_v2.py 中的 slot_sweep_summary, account_sweep_summary)重用了热度和结构直方图的按键 CTE,但将分类推入外部 SELECT,因此每个 (anchor, T) 单元格返回一行约 20 个计数,而不是 10 万行的直方图。集中度(§5.2)通过一个平局感知的精确 top-N 频段缩减进行处理(sweep_concentration.py)。§6.1§6.2 重用现有的标量构建器。

驱动脚本 collect_v2_sweep.py 以从新到旧的顺序遍历周度锚点(使最新锚点首先落地并自我验证),使用原子性 temp+rename 为每个锚点检查点一行宽,并在 DatabaseError 时重试并回退,以应对节点的 OOM 重启。config_v2.py 中的 anchors_v2(T) 生成向下取整的周度网格。

重现:uv run python -m state_access.v2.collect && uv run python -m state_access.v2.collect_history && uv run python -m state_access.v2.collect_sweep && uv run python -m state_access.v2.analysis && uv run python -m state_access.v2.analysis_sweepcollect_v2(T, object_type) 单元格恢复,collect_v2_history(kind, chunk) 恢复,collect_v2_sweep(T, anchor) 恢复。删除一个 parquet 文件以强制重新拉取。验证检查(可加性 |R∪W|=|W|+|R|,分区求和,单调性,块平铺,以及与最新锚点数的每个窗口相等性)位于 analysis_v2 / analysis_v2_sweep 中。

  • 原文链接: github.com/weiihann/xatu...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论