撰文:Certik
以太坊主網的 Gas 費用一直是老大難問題,尤其是在網絡擁堵時更爲顯著。在高峯期,用戶往往需要支付極高的交易費用。因此,在智能合約開發階段進行 Gas 費用優化尤爲重要。優化 Gas 消耗不僅能有效降低交易成本,還能提升交易效率,爲用戶帶來更加經濟、高效的區塊鏈使用體驗。
本文將概述以太坊虛擬機(EVM)的 Gas 費機制、Gas 費優化的相關核心概念,以及開發智能合約時進行 Gas 費優化的最佳實踐。希望通過這些內容,能爲開發者提供啓發和實用幫助,同時也助力普通用戶更好地理解 EVM 的 Gas 費用運作方式,共同應對區塊鏈生態中的挑戰。
EVM 的 Gas 費機制簡介
在兼容 EVM 的網絡中,「Gas」是指用於測量執行特定操作所需計算能力的單位。
下圖說明了 EVM 的結構佈局。圖中,Gas 消耗分爲三個部分:操作執行、外部消息調用以及內存和存儲的讀寫。
來源:以太坊官網[1]
由於每筆交易的執行都需要計算資源,因此會收取一定費用以防止無限循環和拒絕服務(DoS)攻擊。完成一筆交易所需的費用被稱爲「Gas 費」。
自 EIP-1559(倫敦硬分叉)生效以來,Gas 費通過以下公式計算:
Gas fee = units of gas used * (base fee + priority fee)
基礎費會被銷燬,優先費用則作爲激勵,鼓勵驗證者將交易添加到區塊鏈中。在發送交易時設置更高的優先費用,可以提高交易被包含在下一個區塊中的可能性。這類似於用戶向驗證者支付的一種「小費」。
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<length 的限制,變量 i 永遠不可能溢出。在這裏,length 被定義爲 uint256,這意味着 i 的最大值爲 max(uint)-1。因此,在未檢查代碼塊中遞增 i 進行被認爲是安全的,並更節省 Gas。
此外,0.8.0 及以上版本的編譯器已不再需要使用 SafeMath 庫,因爲編譯器本身已內置了溢出和下溢保護功能。
9. 優化修改器
修改器的代碼被嵌入到被修改過的函數中,每次使用修改器時,其代碼都會被複制。這會增加字節碼的大小並提高 Gas 消耗。以下是一種優化修改器 Gas 成本的方法:
優化前:
優化後:
在本例中,通過將邏輯重構爲內部函數_checkOwner(),允許在修改器中重複使用該內部函數,可減少字節碼大小並降低 Gas 成本。
10. 短路優化
對於||和&&運算符,邏輯運算會發生短路評估,即如果第一個條件已經能夠確定邏輯表達式的結果,則不會評估第二個條件。
爲了優化 Gas 消耗,應將計算成本低廉的條件放在前面,這樣可以有可能跳過成本高昂的計算。
附加一般性建議
1. 刪除無用代碼
如果合約中存在未使用的函數或變量,建議將其刪除。這是減少合約部署成本並保持合約體積小最直接的方法。
以下是一些實用建議:
使用最高效的算法進行計算。如果合約中直接使用某些計算的結果,那麼就應該去除這些冗餘計算過程。本質上,任何未使用的計算都應該被刪除。
在以太坊中,開發者通過釋放存儲空間可以獲得 Gas 獎勵。如果不再需要某個變量時,應使用 delete 關鍵字刪除它,或將其設置爲默認值。
循環優化:避免高成本的循環操作,儘可能合併循環,並將重複計算移出循環體。
2. 使用預編譯合約
預編譯合約提供複雜的庫函數,例如加密和散列操作。由於代碼不是在 EVM 上運行,而是在客戶端節點本地運行,因此需要的 Gas 更少。使用預編譯合約可以通過減少執行智能合約所需的計算工作量來節省 Gas。
預編譯合約的示例包括橢圓曲線數字簽名算法(ECDSA)和 SHA2-256 哈希算法。通過在智能合約中使用這些預編譯合約,開發者可以降低 Gas 成本,並提高應用程序的運行效率。
關於以太坊網絡支持的預編譯合約的完整列表,請參閱此處[4]。
3. 使用內聯彙編代碼
內聯彙編(in-line assembly)允許開發者編寫可由 EVM 直接執行的低級卻高效的代碼,而無須使用昂貴的 Solidity 操作碼。內聯彙編還允許更精確地控制內存和存儲的使用,從而進一步減少 Gas 費。此外,內聯彙編可以執行一些僅使用 Solidity 難以實現的複雜操作,爲優化 Gas 消耗提供更多靈活性。
以下是使用內聯彙編節省 Gas 的代碼示例:
從上圖可以看到,與標準用例相比,使用了內聯彙編技術的第二種用例擁有着更高的 Gas 效率。
然而,使用內聯彙編也可能帶來風險並容易出錯。因此,應謹慎使用,僅限經驗豐富的開發者操作。
4. 使用 Layer 2 解決方案
使用 Layer 2 解決方案可以減少需要在以太坊主網上存儲和計算的數據量。
像 rollups、側鏈和狀態通道等 Layer 2 解決方案能夠將交易處理從主以太坊鏈上卸載,從而實現更快和更便宜的交易。
通過將大量交易捆綁在一起,這些解決方案減少了鏈上交易的數量,從而降低了 Gas 費用。使用 Layer 2 解決方案還可以提高以太坊的可擴展性,使更多用戶和應用能夠參與網絡,而不會導致網絡超載引起擁堵。
5. 使用優化工具和庫
有多個優化工具可供使用,例如 solc 優化器、Truffle 的構建優化器和 Remix 的 Solidity 編譯器。
這些工具可以幫助最小化字節碼的大小、刪除無用代碼,並減少執行智能合約所需的操作次數。結合其他 Gas 優化庫,如 「solmate」,開發者可以有效地降低 Gas 成本並提高智能合約的效率。
結論
優化 Gas 消耗是開發者的重要步驟,既可以最小化交易成本又能提高 EVM 兼容網絡上智能合約的效率。通過優先執行節省成本的操作、減少存儲使用、利用內聯彙編以及遵循本文討論的其他最佳實踐,開發者可以有效地降低合約的 Gas 消耗。
不過,必須注意的是,在優化過程中,開發者必須謹慎操作,以防引入安全漏洞。優化代碼和減少 Gas 消耗的過程中,永遠不應犧牲智能合約固有的安全性。
[1] : https://ethereum.org/en/developers/docs/gas/
[2] : https://ethereum.github.io/yellowpaper/paper.pdf
[3] : https://www.evm.codes/
[4] : https://www.evm.codes/precompiled