以太坊状态访问的剖析
本文深入分析了以太坊历史上的状态访问模式,使用窗口方法(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 增长而增长,因为更长的窗口捕获了更多状态。已填充读取集在整个过程中始终是写入集的一小部分。
热度随时间变化
以下面板显示了槽、账户及其合并集作为其活跃状态分母的占比:

在写入集图表中,槽的占比在合并后的时间线中呈下降趋势(活跃状态增长快于窗口内写入集),而账户占比则保持得更稳定。两者均从 2025 年末开始上升。已填充读取集 R⁺ 在每个窗口都很小,并且逐渐增长,账户方面比槽方面更多。
5.2 集中度
对于每个访问集合和窗口,由前 1% 对象捕获的访问占比。访问是按每笔交易每个对象事件计数的(§3)。

槽,前 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%。账户读取落在少量热门合约上,而槽读取则分布在许多合约的存储中。
集中度随时间变化

账户读取集中度极高,并且在整段样本中一直如此。在最新锚点(区块 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,000,T ∈ {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_diffs、nonce_diffs 和 contracts(contract_address 是新实体化的账户)。读取来自 balance_reads、nonce_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_diffs ∪ nonce_diffs 应用相同的热/冷规则,键为账户,创建是余额/nonce 0→x,更新是 x→y。contracts 被丢弃(没有 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_sweep。
collect_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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~