以太坊智能合約的 Gas 優化十大最佳實踐
撰文:Certik
以太坊主網的 Gas 費用一直是老大難問題,尤其是在網路擁塞時更為顯著。 在高峰期,用戶往往需要支付極高的交易費用。 因此,在智能合約開發階段進行 Gas 費用優化尤為重要。 優化 Gas 消耗不僅能有效降低交易成本,還能提升交易效率,為用戶帶來更經濟、更有效率的區塊鏈使用體驗。
本文將概述以太坊虛擬機器(evm)的 Gas 費機制、Gas 費優化的相關核心概念,以及開發智慧合約時進行 Gas 費優化的最佳實務。 希望透過這些內容,能為開發者提供啟發和實用協助,同時也協助一般用戶更能理解 EVM 的 Gas 費用運作方式,共同應對區塊鏈生態中的挑戰。
EVM 的 Gas 費機制簡介
在與 EVM 相容的網路中,「Gas」是指用於測量執行特定操作所需運算能力的單位。
下圖說明了 EVM 的結構佈局。 圖中,Gas 消耗分為三個部分:

來源:以太坊官網[1]
由於每筆交易的執行都需要計算資源,因此會收取一定費用以防止無限循環和拒絕服務(DoS)攻擊。 完成一筆交易所需的費用稱為「Gas 費」。
自 EIP-1559(倫敦硬分叉)生效以來,Gas 費透過以下公式計算:
基礎費會被銷毀,優先費用則作為激勵,鼓勵驗證者將交易添加到區塊鏈中。 在發送交易時設定更高的優先費用,可以提高交易被包含在下一個區塊中的可能性。 這類似於用戶向驗證者支付的一種「小費」。
1. 瞭解 EVM 中的 Gas 最佳化
當用 SOLidity 編譯智能合約時,合約會被轉換為一系列「操作碼」,即 opcodes。
任何一段操作碼(例如建立合約、進行訊息呼叫、存取帳戶儲存以及在虛擬機器上執行操作)都有一個公認的 Gas 消耗成本,這些成本記錄在以太坊黃皮書[2]中。

經過多次 EIP 的修改,其中一些操作碼的 Gas 成本已被調整,可能與黃皮書中有所偏差。 有關操作碼最新成本的詳細信息,請參考此處[3]。
2. Gas 最佳化的基本概念
Gas 優化的核心理念是在 EVM 區塊鏈上優先選擇成本效率高的操作,避免 Gas 昂貴的操作。
在 EVM 中,以下操作成本較低:
讀寫記憶體變數
讀取常數與不可變變數
讀寫本地變數
讀取 calldata 變量,例如 calldata 數組和結構體
內部函數呼叫
成本較高的操作包括:
讀寫儲存在合約儲存中的狀態變數
外部函數呼叫
循環操作
EVM Gas 費用優化最佳實務
基於上述基本概念,我們為開發者社群整理了一份 Gas 費優化最佳實踐清單。 透過遵循這些實踐,開發者可以降低智慧合約的 Gas 費消耗,降低交易成本,並打造更有效率且用戶友好的應用程式。
1. 盡量減少儲存的使用
在 Solidity 中,Storage(儲存)是一種有限資源,其 Gas 消耗遠高於 Memory(記憶體)。 每次智慧合約從儲存中讀取或寫入資料時,都會產生高額的 Gas 成本。
根據以太坊黃皮書的定義,儲存操作的成本比記憶體操作高出 100 倍以上。 例如,OPcodes mload 和 mstore 指令只消耗 3 個 Gas 單位,而儲存操作如 sload 和 sstore 即使在最理想的情況下,成本也至少需要 100 個單位。

限制儲存使用的方法包括:
將非永久性資料儲存在記憶體中
減少儲存修改次數:透過將中間結果保存在記憶體中,待所有計算完成後,再將結果分配給儲存變數。
2. 變數打包
智能合約中使用的 Storage slot(儲存槽)的數量以及開發者表示資料的方式會極大影響 Gas 費的消耗。
Solidity 編譯器會在編譯過程中將連續的儲存變數打包,並以 32 位元組的儲存槽作為變數儲存的基本單位。 變數打包是指透過合理安排變數,使多個變數能夠適配到單一儲存槽中。
左側是一個效率較低的實作方式,將消耗 3 個儲存槽;右側是一個更有效率的實作方式。

透過此細節的調整,開發者可以節省 20,000 個 Gas 單位(儲存一個未使用過的儲存槽需要消耗 20,000Gas),但現在只需要兩個儲存槽。
由於每個儲存槽都會消耗 Gas,變數打包透過減少所需的儲存槽數量來優化 Gas 的使用。
3. 最佳化資料型別
一個變數可以用多種資料型別表示,但不同的資料型態對應的操作成本也不同。 選擇合適的資料類型有助於優化 Gas 的使用。
例如,在 Solidity 中,整數可以細分為不同的大小:uint8、uint16、uint32 等。 由於 EVM 是以 256 位元為單位執行操作,使用 uint8 表示 EVM 必須先將其轉換為 uint256,而這種轉換會額外消耗 Gas。

我們可以透過圖中的程式碼來比較 uint8 和 uint256 的 Gas 成本。 UseUint() 函數消耗 120,382 Gas 單位,而 UseUInt8() 函數消耗 166,111 Gas 單位。
單獨來看,這裡使用 uint256 比 uint8 便宜。 然而,若使用我們先前建議的變數打包優化就不同了。 如果開發者能夠將四個 uint8 變數打包到一個儲存槽中,那麼迭代它們的總成本將比四個 uint256 變數更低。 這樣,智能合約就可以讀寫一次儲存槽,並在一次操作中將四個 uint8 變數放入記憶體 / 儲存體中。
4. 使用固定大小變數取代動態變數
如果資料可以控制在 32 位元組內,建議使用 bytes32 資料型別取代 bytes 或 strings。 一般來說,固定大小的變數比可變大小的變數消耗的 Gas 更少。 如果位元組長度可以限制,盡量選擇從 bytes1 到 bytes32 的最小長度。


5. 映射與陣列
Solidity 的資料清單可以用兩種資料類型表示:陣列(Arrays)和映射(Mappings),但它們的語法和結構截然不同。
映射在大多數情況下效率更高而成本更低,但數組具有可迭代性且支援資料類型打包。 因此,建議在管理資料清單時優先使用映射,除非需要迭代或可以透過資料類型打包優化 Gas 消耗。
6. 使用 calldata 取代 memory
函數參數中宣告的變數可以儲存在 calldata 或 memory 中。 兩者的主要差異在於,memory 可以被函數修改,而 calldata 是不可變的。
記住這個原則:如果函數參數是唯讀的,應優先使用 calldata 而非 memory。 這樣可以避免從函數 calldata 到 memory 的不必要複製操作。
範例 1:使用 memory
使用 memory 關鍵字時,陣列的值會在 ABI 解碼過程中從編碼的 calldata 複製到 memory。 這段程式碼區塊的執行成本為 3,694 個 Gas 單位。
範例 2:使用 calldata
當直接從 calldata 讀取值時,跳過中間的 memory 運算。 這種優化方式使執行成本降至僅 2,413 個 Gas 單位,Gas 效率提升了 35%。
7. 盡可能使用 Constant/Immutable 關鍵字
Constant/Immutable 變數不會儲存在合約的儲存中。 這些變數會在編譯時計算,並儲存在合約的字節碼中。 因此,與儲存相比,它們的存取成本要低得多,建議盡可能使用 Constant 或 Immutable 關鍵字。
8. 確保不會發生溢位 / 下溢時使用 Unchecked
當開發者能夠確定算術操作不會導致溢位或下溢時,可以使用 Solidity v0.8.0 引入的 unchecked 關鍵字,避免多餘的溢出或下溢檢查,從而節省 Gas 成本。
在下圖中,受條件限制 i