---
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-Hant/blog/optimistic-concurrency-control-intro)
- [《效能指標詞典：TPS、BPS、確認延遲、最終性、衝突率》](/zh-Hant/blog/performance-metrics-glossary)
- [《為什麼單執行緒 EVM 會卡住 TPS：從歷史壅塞到執行模型》](/zh-Hant/blog/evm-single-thread-bottleneck)

本篇屬於 EVM 基礎系列，同系列另有 0.15《ABI 精讀：selector、靜態參數與動態類型》，討論呼叫資料如何編碼與解碼。
