---
id: 23
title: "三类存储别搞混：Memory、Storage 与 Transient Storage"
slug: 0-5-three-kinds-of-storage
date: 2026/09/22
summary: 同一份 32 字节数据放在 memory、storage 还是 transient storage，gas 成本能差两个数量级，生命周期也完全不同。下面沿归属、寿命与计价三条线拆开三类存储，说明重入锁、临时数组与状态变量各自该放在哪里。
keywords: EVM Memory,EVM Storage,Transient Storage,EIP-1153,SSTORE
heroImage: /images/articles/photos/0-5-three-kinds-of-storage.jpg
---

一个重入锁写在 storage 里，冷槽首次从 0 写到 1 要付 2100 的冷访问附加费加 20000 的 Gsset，同一交易内再写回 0 再付 100，毛消耗约 22200 gas；这笔写入同时产生 19900 gas 退款，而退款上限是整笔交易消耗的 1/5。换成一枚 transient storage 变量，两次写入各 100 gas，且不依赖退款计数器。功能完全一致，毛成本相差两个数量级上下。

EVM 里有三类可写数据区：memory（内存）、storage（持久化存储）与 transient storage（瞬态存储，EIP-1153）。它们都用 32 字节字读写，都能在合约里被赋值，归属单位、存活时间与计价规则却互不相同。放错位置通常不会报错，只会让状态在预期之外消失，或者让 gas 账单高出十倍。要回答的问题是：三类存储各自由谁拥有、活多久、按什么规则计价，以及各自适合承载什么。

## 生命周期：帧、账户与交易是三个不同尺度

Memory 归执行帧（execution frame）所有。一次 CALL、DELEGATECALL、STATICCALL 或 CREATE 进入的上下文就是一个帧，帧进入时内存从零开始，帧返回或回滚后整块丢弃。父帧的内存不会被子帧看到，子帧的内存也不会回传给父帧；要在两帧之间传数据，只能走 calldata 与 returndata。

Storage 归账户所有。它以 256 位槽位到 256 位值的映射挂在账户名下，写进去的内容进入账户的 storage trie，成为世界状态的一部分，跨交易、跨区块长期存在。零值不会被写入 trie，所以把槽位清零会移除对应节点，这一点直接决定了退款规则的设计。

Transient storage 同样归账户所有，但作用域是一条交易。同一笔交易内，该账户的所有帧共享同一份瞬态存储：内层调用写入的值，外层调用读得到。帧回滚时，该帧范围内的写入一并回滚，行为与 storage 一致；而帧正常返回时不会回滚，这一点与 memory 相反。交易结束时，所有瞬态存储无条件清零。归属规则还有一处例外：DELEGATECALL 与 CALLCODE 的瞬态存储归调用方（发起指令的合约），CALL 与 STATICCALL 则归被调用方。

三个尺度可以这样记：内存以帧为单位，storage 以账户加永久为单位，transient storage 以账户加交易为单位。

| 维度 | Memory | Storage | Transient Storage |
|------|--------|---------|-------------------|
| 归属 | 执行帧 | 账户 | 账户 |
| 存活时间 | 帧进入时创建，帧结束丢弃 | 持久，写入世界状态 | 交易结束清零 |
| 寻址 | 字节地址，按 32 字节字扩展 | 256 位槽位 | 256 位槽位 |
| 跨内部调用 | 不共享 | 共享 | 同账户所有帧共享 |
| 帧回滚 | 随帧丢弃 | 回滚该帧写入 | 回滚该帧写入 |
| 单次写入成本 | 3 gas 加扩展费 | 100 至 20000 加冷访问费 | 固定 100 gas |

## Memory：按字扩展，扩容成本是二次的

Memory 按字节寻址，但分配以 32 字节字为粒度。访问一个尚未触及的字会触发扩展，扩展费按总占用计算，公式是 C_mem(a) = 3a + ⌊a² / 512⌋，a 是字数；实际扣费为扩展后的 C_mem 减去扩展前的 C_mem。MSIZE 只增不减，帧内无法释放已分配的内存。

这个式子前一半是线性项，后一半是二次项。a < 23（即 704 字节）时二次项被向下取整为 0，成本看着温和；越过这个点之后增长加快。下面两个绝对数值都按黄皮书式 (328) 推算，基准是单帧从空内存一次性扩展到位，不含 MLOAD、MSTORE 的基础成本：32 KB 内存对应 a = 1024，扩展费 3 × 1024 + 1024² / 512 = 5120 gas；1 MB 内存对应 a = 32768，扩展费 98304 + 2097152 ≈ 219 万 gas。单个帧仅凭扩展内存就能消耗掉数百万 gas，这通常会成为帧内堆大缓冲区的硬约束。

MLOAD 与 MSTORE 的基础成本是 3 gas（Gverylow），扩展费另计。读取从未写过的内存会返回 0，但仍然要为新触及的字支付扩展费，因为分配动作已经发生。这也是稀疏写入高地址特别贵的原因：只写一个值到很远的偏移，中间所有字都按已分配计费。

临时数组、ABI 编解码缓冲、哈希函数的输入都放在 memory 里。Solidity 中的 memory 变量、memory 数组与 memory 结构体都落在这块区域。下面这段代码把长度一次分配好，避免在循环中反复触碰新字：

```solidity
// 一次性扩展到 n 个字，之后写入不再产生扩展费
uint256[] memory buf = new uint256[](n);
for (uint256 i = 0; i < n; ++i) {
    buf[i] = i;
}
```

边界很清楚：memory 通过 CALL 传递给子帧的是内容副本，指针本身只在当前帧内有效。把跨帧共享的中间量放进 memory，等于假设子帧能看到父帧的内存，而这个假设不成立。

实际工程里更常见的选择是绕开 memory。大块数据用 calldata 传入、用 returndata 传出，成本按字节计价且不占用本帧内存；只有需要在帧内反复读写、或需要构造哈希输入时，才有必要把它展开到内存。这个取舍解释了为什么很多合约把计算放在外部脚本或子调用里，再通过返回值汇总，让单帧内的缓冲区保持小规模。

## Storage：写进账户的 storage trie，写比读贵一个量级

世界状态里每个账户挂一棵 storage trie，槽位是 256 位键，值是 256 位字，零值不入树。读取走 SLOAD。根据 EIP-2929（柏林升级，区块 12,244,000，2021 年 4 月 15 日）引入的冷热访问机制，首次访问某个 (地址, 槽位) 对属于冷访问，收 2100 gas（Gcoldsload）；已在本次交易中访问过的属于热访问，收 100 gas（Gwarmaccess）。冷热集合是交易作用域的，作用域回滚时集合也回滚。

写入走 SSTORE，成本由 EIP-2200 的净计量规则决定，同时看三个值：槽位在本交易开始时的原始值、当前值、即将写入的新值。柏林之后的常数是：冷槽位额外收 2100；原始值等于当前值（本交易尚未改过该槽）时，从 0 写成非零收 Gsset = 20000，从非零写成另一个值或写成 0 收 Gsreset = 2900；槽位已被本交易改过（原始值不等于当前值）时，只收一次热访问的 100 gas；新值等于当前值的空写也收 100。

退款规则被 EIP-3529（伦敦升级，区块 12,965,000，2021 年 8 月 5 日）收紧了。非零改写为零的退款从 15000 降到 4800（SSTORE_RESET_GAS 加 ACCESS_LIST_STORAGE_KEY_COST），SELFDESTRUCT 的退款被取消，单笔交易的总退款上限压到 gas_used // 5。原始值为 0、本交易内先写非零再写回 0 的模式仍然产生 19900 gas 退款（20000 减 100），但同样受 1/5 上限约束。

| 操作（柏林 / 伦敦口径） | Gas |
|--------------------------|-----|
| SLOAD，冷访问 / 热访问 | 2100 / 100 |
| SSTORE，原始值等于当前值，0 写非零 | 20000，另加冷访问 2100 |
| SSTORE，原始值等于当前值，非零改非零或改零 | 2900，另加冷访问 2100；改零时退款 4800 |
| SSTORE，槽位已被本交易改过 | 100 |
| 重入锁 0 → 1 → 0（同一交易） | 毛 22200，退款 19900 |

跨交易要保持的东西放在 storage：余额、所有权、配置、累计计数器。代价有两层，一层是 gas，另一层是状态膨胀，所有全节点都要长期保存这些槽位。退款容易造成“写回 0 就免费”的错觉，实际口径是：退款只在交易结束后结算，且最多抵掉总消耗的 20%。一笔仅消耗 30000 gas 的交易，理论上只能拿回 6000 gas 的退款。

## Transient Storage：跨内部调用有效，交易结束即清空

EIP-1153 在坎昆升级（区块 19,426,587，2024 年 3 月 13 日）引入 TLOAD（0x5c）与 TSTORE（0x5d）。寻址方式与 SLOAD、SSTORE 相同：32 字节地址指向 32 字节值。两者每次操作的固定成本都是 100 gas，没有冷热区分，没有退款，也不需要为将来的清除预留成本，因为在规范里它们从不落盘。

行为上与 storage 的差异集中在三处。时间尺度上，交易结束即清零，值不会被序列化到任何持久结构里。回滚语义上，帧回滚会回滚该帧的写入，这与 storage 一致，与 memory 不同（memory 在帧返回或回滚时整体丢弃）。上下文限制上，TSTORE 在 STATICCALL 中会抛异常，TLOAD 允许。另外，EIP-1153 明确豁免了 EIP-2200 对 SSTORE 的限制：TSTORE 不要求 gasleft 大于 2300 的调用补贴。

这套设计直接冲着“帧间通信”而来。在 EIP-1153 之前，合约之间传递临时状态要么走 CALL 的入参与返回值（中间的不可信合约可能篡改），要么走 storage 写入（贵，且依赖退款）。退款在 EIP-3529 被压到 gas_used 的 1/5 之后，小额交易基本收不回成本。一次 0 → 1 → 0 的锁写入向退款计数器累加 19900 gas，按 gas_used // 5 的上限反算，整笔交易需要约 99500 gas 才能把退款全额拿回；这个 99500 是按 EIP-3529 上限公式推算的整笔 gas_used，不是 EIP 原文给出的数值。EIP-1153 正文从另一个角度给出作者估计，原话是交易需在“其他操作”上花约 80k gas，才能拿到一把重入锁的全额退款。两个数字口径不同但互相吻合：99500 减去这把锁自身约 22200 gas 的毛消耗约为 77300，与作者对“其他操作”的 80k 估计属同一量级。瞬态存储不参与退款计数器，因而绕开了这个门槛。

重入锁、单交易授权、回调结束时的余额平衡检查、代理合约向下游传递元数据，都适合放在这里。Solidity 从 0.8.28 起支持 transient 值类型状态变量，EVM 版本需要设为 cancun；引用类型（数组、映射、结构体）以及局部变量、参数都不支持，需要手写内联汇编。下面是最小形态的重入锁：

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28; // EVM 版本需为 cancun

contract TransientLock {
    uint256 transient entered;

    modifier nonReentrant() {
        require(entered == 0, "reentrant");
        entered = 1; // TSTORE，100 gas
        _;
        entered = 0; // 同一交易内的后续调用会读到 0
    }

    function withdraw() external nonReentrant {}
}
```

如果编译器版本或目标链不满足条件，可以直接用汇编，语义完全一致：

```solidity
assembly {
    tstore(0, 1)       // 槽 0 写 1，100 gas
    let v := tload(0)  // 读回，100 gas
}
```

## 误用的四种典型后果

需要跨交易读取的状态放进 memory 或 transient storage，症状是“状态丢了”。交易结束后值清零，下一次调用读到默认值 0，代码不报错，业务逻辑却已经错了。这类 bug 在本地测试里常常看不出来，因为测试往往在同一笔交易或同一个模拟环境里完成。

只在单笔交易内有效的中间量放进 storage，症状是成本失控，而且成本取决于交易规模。重入锁与临时授权在功能上可以用 storage 实现，经济上却要等交易足够大才能收回退款；交易越小，实际净成本越接近毛成本。

跨调用共享的中间量放进 memory，症状是子帧读不到。CALL 会切换执行帧，子帧拿到的是独立的、从零开始的内存；父帧写入的内容不会出现在子帧里。唯一合法的传递路径是 calldata 与 returndata。

用 transient storage 替代 memory 里的映射，症状是重入时出现意外行为。EIP-1153 在安全考量里专门提醒过这一点：瞬态存储不会在调用返回时被丢弃，如果把它当内存映射用，同一交易内的重入调用会看到上一轮遗留的值。除了语义问题，单次 100 gas 的成本也远高于内存写入。

还有一种容易忽略的误用是忘记清零。重入锁写入 1 之后如果在某些分支上提前返回而不写回 0，同一交易内的后续调用会被这个锁永久挡住。EIP-1153 的规范说得很直白：只有在这些槽位确实要被交易内的后续调用使用时，才应该留下非零值。

## 边界与不确定的地方

正文所有 gas 数字都标了口径：柏林升级（2021 年 4 月 15 日，区块 12,244,000）之后的冷热访问价格、伦敦升级（2021 年 8 月 5 日，区块 12,965,000）之后的退款规则、坎昆升级（2024 年 3 月 13 日，区块 19,426,587）引入的瞬态存储。EVM 不是冻结规格，跨链部署前需要逐个确认目标链实现了哪些 EIP：停在伦敦或更早的 EVM 兼容链上，TLOAD 与 TSTORE 会直接作为非法操作码中止执行。

Solidity 的瞬态存储支持存在一个已修复的编译器缺陷：0.8.28 到 0.8.33 在启用 IR 管线（--via-ir）时，如果同一编译单元里既用 delete 清理瞬态变量，又存在对同值类型的持久化 storage 清理，生成的 Yul 清理辅助函数会因同名而被复用，从而发出错误的操作码（该用 TSTORE 时用了 SSTORE，或相反），0.8.34 修复。缺陷只影响 IR 管线，legacy 管线不受影响。用到瞬态变量且走 via-ir 的项目，编译版本需要落在修复区间之外。

有两件事没有公开的时间表，只能标为不确定。瞬态存储的 100 gas 是 EIP-1153 定下的当前值，未来硬分叉是否调整，目前没有公开排期，也没有已进入流程的提案。状态树的实现（例如 Verkle 树的推进）会改变 storage 读取的真实成本基础，EIP-2929 的动机里也写明，重设计数据库布局、让客户端直读存储会进一步压低最坏处理时间；但计价常量仍由 EIP-2929 与 EIP-3529 给定，两者之间可能长期存在偏差。这两点都给不出可引用的量化结论，只能作为方向性判断保留。

## 资料来源

- EIP-1153: Transient storage opcodes，https://eips.ethereum.org/EIPS/eip-1153
- EIP-2929: Gas cost increases for state access opcodes，https://eips.ethereum.org/EIPS/eip-2929
- EIP-3529: Reduction in refunds，https://eips.ethereum.org/EIPS/eip-3529
- EIP-2200: Structured Definitions for Net Gas Metering，https://eips.ethereum.org/EIPS/eip-2200
- Ethereum Yellow Paper，附录 G 费率表、式 (328) 内存计价函数，https://ethereum.github.io/yellowpaper/paper.pdf
- ethereum.org 操作码参考（TLOAD、TSTORE 各 100 gas），https://ethereum.org/en/developers/docs/evm/opcodes/
- Solidity 0.8.28 发布说明（transient 值类型状态变量支持），https://www.soliditylang.org/blog/2024/10/09/solidity-0.8.28-release-announcement/
- Solidity 文档：Transient Storage（EVM 版本要求 cancun、引用类型与局部变量暂不支持），https://docs.soliditylang.org/en/latest/contracts.html#transient-storage
- Solidity 瞬态存储清理辅助函数冲突缺陷（0.8.28 至 0.8.33，0.8.34 修复），https://www.soliditylang.org/blog/2026/02/18/transient-storage-clearing-helper-collision-bug/
- ethereum.org 网络升级历史（各分叉区块高度与日期），https://ethereum.org/en/history/

## 延伸阅读

- [《乐观并发控制（OCC）入门：从数据库到链上执行》](/zh/blog/optimistic-concurrency-control-intro)
- [《性能指标词典：TPS、BPS、确认延迟、最终性、冲突率》](/zh/blog/performance-metrics-glossary)
- [《为什么单线程 EVM 会卡住 TPS：从历史拥堵到执行模型》](/zh/blog/evm-single-thread-bottleneck)

本篇属于 EVM 基础系列，同系列另有 0.15《ABI 精读：selector、静态参数与动态类型》，讨论调用数据如何编码与解码。
