Gas 优化指南:如何降低 Solidity 代码中的交易成本

在这里插入图片描述

在兼容 EVM 的链上编写智能合约,不仅关乎正确性和安全性,还关乎效率。链上执行的每条指令都会消耗 gas,而每单位 gas 都对应着真实成本。优化不佳的合约可能导致用户交易费用过高、用户体验下降,甚至在网络高峰期加剧网络拥堵。

本文将深入探讨 gas 优化技巧。在研究具体模式和技巧之前,我们先统一术语:

Gas:衡量以太坊上计算工作量的单位。每个操作码(opcode)、存储读写和内存操作都会消耗预定义数量的 gas。

Gas 价格(gwei):愿意为每单位 gas 支付的费用,以 gwei 为单位(1 gwei = 10⁹ wei)。

小费(优先费):一笔额外费用(EIP-1559 中的 maxPriorityFeePerGas),用于激励矿工更快打包交易,在基础费之上支付。

交易费用:因交易类型(Legacy、EIP-2930、EIP-1559)而异,但本质上取决于 gas 使用量和有效 gas 价格。在所有情况下,减少 gas 使用量都会降低总成本。

了解了所有交易类型并编写了几个智能合约后,我们来看看如何优化合约的 gas 使用,以及为什么这一点很重要。

本文将介绍:

  1. EVM gas 成本基础
  2. 如何测量 gas 使用量
  3. 常见优化模式
  4. 高级技巧

掌握这些基础知识后,我们就能找出合约中 gas 消耗最多的地方,并让每个操作更经济。

EVM gas 成本基础

在进行优化之前,必须了解操作码和数据结构层面的 gas 消耗来源。从宏观上看,gas 成本主要来自四个方面:

存储(SSTORE / SLOAD)

以太坊 gas 费用按操作码和数据操作计算。以下是大部分 gas 消耗的详细分类,数据基于 EIP-2200 和 [EIP-2929]:

合约存储中的持久化键值槽位。最昂贵的操作包括:

SSTORE(写入):使用 EIP-2200 中定义的动态计量方式:

  • 零 → 非零(original_value == 0):SSTORE_SET_GAS = 20000 gas
  • 非零 → 不同非零(original_value != 0):SSTORE_RESET_GAS = 5000 gas
  • 值 == 当前值:冷访问消耗 800 gas,热访问消耗 100 gas(无状态变更)(柏林硬分叉引入了 EIP-2929,如之前的文章所述,热访问可享受折扣)
  • 退款(非零 → 零):SSTORE_CLEARS_SCHEDULE = 15000 gas 返还

SLOAD(读取):冷访问消耗 800 gas,热访问消耗 100 gas(EIP-2929)

注意:存储写入是目前最昂贵的操作。复用存储位置或批量写入可节省每笔交易数万个 gas。

gas 成本示例:基于 EIP-2200 官方摘要,转换为伪代码

SLOAD_GAS              = 800        
SSTORE_SET_GAS         = 20000      
SSTORE_RESET_GAS       = 5000       
SSTORE_CLEARS_SCHEDULE = 15000      
GAS_STIPEND            = 2300       

function performSSTORE(originalValue, currentValue, newValue):
    
    if gasleft() ≤ GAS_STIPEND:
        revert("out of gas")

    
    if currentValue == newValue:
        deductGas(SLOAD_GAS)
        return

    
    if originalValue == currentValue:
        
        if originalValue == 0:
            deductGas(SSTORE_SET_GAS)      
        else:
            deductGas(SSTORE_RESET_GAS)    

        if newValue == 0:
            refund(SSTORE_CLEARS_SCHEDULE)  

    else:
        
        deductGas(SLOAD_GAS)

        if originalValue ≠ 0:
            if currentValue == 0:
                removeRefund(SSTORE_CLEARS_SCHEDULE)  
            if newValue == 0:
                refund(SSTORE_CLEARS_SCHEDULE)        

        
        if originalValue == newValue:
            if originalValue == 0:
                refund(SSTORE_SET_GAS - SLOAD_GAS)
            else:
                refund(SSTORE_RESET_GAS - SLOAD_GAS)

注意:original_value 是交易开始时存储槽位的值。current_value 是执行此 SSTORE 前槽位的值,反映同一交易中之前的写入操作。

内存(Memory)

  • 扩展成本:每 32 字节字 3 gas,加上二次项 ⌊a² ÷ 512⌋,在访问之前未使用的内存时触发(在以太坊黄皮书中规定)。
    解释:这会增加一个小的超线性惩罚,因此当分配更多内存时,边际成本会缓慢增加(例如,64 个字的情况下,⌊64²/512⌋ = ⌊4096/512⌋ = 额外 8 gas)。
C_mem(a) = G_memory·a + ⌊a² ÷ 512⌋
  • 读写:扩展后,访问内存成本较低(每字 3 gas)。

注意:通过预先设定数组大小或对大型输入使用 calldata,可最小化内存扩展。

调用数据(Calldata)

  • 成本:访问函数输入数据时,每字节 3 gas(不可变且不可扩展,在以太坊黄皮书中规定)。
  • 影响:对于包含大型数组或结构体的函数,使用 calldata 而非 memory 可避免内存扩展成本。

栈和其他操作码

  • 算术运算(ADD、MUL 等):3 gas
  • 环境操作(CALLER、BALANCE):根据操作码不同,2–400 gas
  • 加密操作(ECRECOVER):约 3000 gas
  • 日志(LOGn):基础 375 gas + 主题/数据成本(每字节 8 gas)

通过理解这些类别和具体的 gas 成本,你可以识别合约中的热点,特别是存储写入和内存扩展,并进行有针对性的优化以降低交易费用。

如何测量 gas 使用量

分析 gas 使用量是实现有效优化的第一步:没有准确的数据,你可能会把精力放在次要成本上,而忽略主要的 gas 消耗点。在本节中,我们将介绍多种链下和链上测量技术,它们共同构成了一套全面的工具,用于建立 gas 使用基准。

使用 Foundry 进行链下测量(构建智能合约时)

以下是在测试套件中进行链下测量的方法:

本例中,我们将使用 Foundry 和一个智能合约(/src/Storage.sol),该合约来自之前的一篇博客文章。

pragma solidity ^0.8.12;

contract Storage {
    struct my_storage_struct {
        uint256 number;
        string owner;
    }

    my_storage_struct my_storage;


    function store(my_storage_struct calldata new_storage) public {
        if (new_storage.number > 100) {
            revert("Number too large");
        }
        my_storage = new_storage;
    }

    function retrieve() public view returns (my_storage_struct memory){
        return my_storage;
    }
}

以及来自(/test/Storage.t.sol)的测试代码:

pragma solidity ^0.8.12;

import "forge-std/Test.sol";
import "../src/Storage.sol";

contract StorageTest is Test {
    Storage public storageContract;

    function setUp() public {
        storageContract = new Storage();
    }

    function testStoreStruct() public {
        Storage.my_storage_struct memory input = Storage.my_storage_struct({
            number: 25,
            owner: "bob"
        });

        storageContract.store(input);
    }
   
    function testStoreStructReverts() public {
        Storage.my_storage_struct memory input = Storage.my_storage_struct({
            number: 101,
            owner: "too much"
        });
        
        vm.expectRevert("Number too large"); 
        storageContract.store(input);
    }

}

有了这个智能合约及其测试,我们可以检查执行 store 方法将消耗多少 gas。为此,我们运行:

forge test --gas-report

我们应该会看到类似如下的输出:

(点击或按 Enter 查看完整尺寸图片)

我们看到的内容:

  • 运行 store() 函数的测试套件在不同条件下被调用了两次。(一次使用 SSTORE,另一次在 store 检查处失败)
  • 最小值(22535 gas):成本较低的情况(函数调用在 if 检查处失败)。(记住在第一篇博客中提到,基础交易成本为 21000 gas,这里额外消耗了约 1.5k gas 用于读取和检查值)
  • 最大值(67908 gas):成本较高的情况(函数成功执行并写入内存),需支付 20000 gas 的 SSTORE 全额成本,加上冷访问附加费和函数自身的操作码开销。
  • 平均值/中位数(45221 gas):两次运行的中点,可作为典型调用的大致预期。

注意:可以通过运行以下命令来运行特定方法:forge test --match-test testStoreStruct --gas-report

执行前测量

当合约已部署,且我们想检查特定输入的 gas 使用量时,可以使用此方法。

本例中,我们使用之前的智能合约 /src/Storage.sol 并部署它。

我们使用 Anvil(来自 Foundry)来分叉区块链:

anvil

然后部署智能合约:

forge create src/Storage.sol:Storage \
  

注意:从 anvil 的输出中选择一个私钥

输出将类似于:

[⠊] Compiling...
No files changed, compilation skipped
Deployer: <your-address>
Deployed to: <deployed-contract-address>
Transaction hash: <tx-hash>

现在我们来估算交易的 gas 成本:

export SIG=$(cast sig "store((uint256,string))")

export ARGS=$(cast abi-encode "store((uint256,string))" "(25,\"bob\")")


export CALLDATA="${SIG}${ARGS#0x}"




cast rpc eth_estimateGas \
  '{"from":"<your-address>","to":"contract-address","value":"0x0","data":"'"$CALLDATA"'"}' \
  'latest' \
  --rpc-url http://127.0.0.1:8545

注意:这里我们使用了 Foundry 的 CLI 工具,但同样的方法也适用于在任何 EVM 链上对已部署合约的调用。

执行后测量

发送签名交易后,我们可以在链上查看该交易实际消耗的 gas 量。

我们使用之前的示例,通过 Foundry CLI 工具发布交易(或按照之前文章中的方法创建实际区块链交易):

cast send <your-deployed-contract-address>\
  "store((uint256,string))" \
  '(25,"bob")' \
  --rpc-url http:
  --private-key <your-key>

在输出中,你应该会看到类似如下的内容:

...
transactionHash      0xe69ac7819787dc5ca8e5e5b0420936cdde171fce8760931bce5e0fc03e68cbac
...

然后我们通过交易收据查看实际使用的 gas 量:

cast rpc eth_getTransactionReceipt \
  '"<your-transaction-hash>"' \
  --rpc-url http://127.0.0.1:8545


"gasUsed":"0x10944" 

常见优化模式

在本节中,我们将介绍降低 Solidity 代码 gas 消耗的实用技巧。每种模式都针对前面提到的主要成本类别之一。

最小化存储写入

  • 变量打包:将多个 uintXbool 组合到单个 32 字节槽位中。例如:
struct Packed {
    uint128 a;
    uint64 b;
    uint64 c;
}
  • 条件写入:仅在新值与旧值不同时更新状态:
if (x != newX) {
    x = newX;
}

缓存存储读取

  • 先读取到内存变量,然后在本地复用:
uint256 localVar = storageVar;

对外部数组使用 Calldata

  • 将大型外部输入标记为 calldata 而非 memory,以避免内存扩展成本:
function batchTransfer(address[] calldata recipients) external {
    
}

循环中的 unchecked 算术

  • 在安全的情况下跳过溢出检查,例如简单的递增循环:
for (uint256 i = 0; i < n; ) {    
    
    unchecked { i++; } 
}

短路与逻辑简化

  • 使用提前返回和组合条件,减少不必要的操作:
function withdraw(uint256 amount) external {    
    if (amount == 0) return;    
    require(balances[msg.sender] >= amount, "Insufficient");
    
}

链下预计算常量

  • 硬编码不变的值,而非在链上计算:
uint256 constant RATE_MULTIPLIER = 1e18;

利用 Immutable 变量

  • 对于在构造函数中设置一次的值,使用 immutable 以节省存储读取成本:
address public immutable owner; 
constructor() {    
    owner = msg.sender; 
}

优化事件与存储的使用

  • 使用事件进行临时日志记录,而非将数据写入合约存储:
event TransferLogged(address from, address to, uint256 amount); // 发送 TransferLogged() 事件而非存储到数组

在热点路径使用汇编

  • 内联汇编可减少函数调用开销并实现自定义优化,但应谨慎使用:
assembly {
    
}

注意:还有更多可探索的优化技巧,但这些是编写合约时需要牢记的基础。此外,尽可能避免对动态数组使用循环,输入过大时可能会导致 out of gas 错误。

高级技巧

除了常见模式,这些高级技术也有助于进一步节省 gas(此处不详细展开,仅做简要说明供探索):

  • 访问列表(Access List):我们在这篇博客文章中已经了解过。
  • 最小代理(EIP-1167):部署小型代理合约,通过委托调用(delegate call)指向共享实现,与完整合约相比,大幅降低部署 gas 成本。
  • SSTORE2:将大型静态字节数组直接存储在合约字节码中,通过 extcodecopy 读取,避免为只读数据进行昂贵的存储写入。
  • Gas 退款优化:调整状态变更顺序以最大化退款资格。例如,仅在最终使用后清除存储槽位,避免将槽位重置回非零值。
  • 位运算标志打包:使用位掩码和移位将多个布尔值或小型枚举组合到单个 uint256 中,以最小化使用的存储槽位数。
  • 构造函数预计算:将大量计算移至链下,将预计算值传入构造函数,减少部署期间的链上算术和存储成本。
  • 批量处理与多调用聚合:使用 multicall 模式(我们将在后续博客中介绍)将多个逻辑操作聚合到一笔交易中,分摊每次调用的开销并降低总 gas 消耗。
  • 高级汇编模式:在热点循环、自定义哈希或位运算中使用内联汇编,可绕过 Solidity 的安全检查并节省 gas,但会降低可读性。

总结

你现在已经实际了解了 EVM 链上 gas 成本的构成——从主导交易费用的昂贵存储写入,到内存、调用数据和常见操作码的隐性成本。你还知道如何可靠地测量 gas 使用量,包括通过链下 RPC 估算、链上收据和测试中的报告工具。有了这些数据,你可以应用经过验证的优化模式——最小化存储写入、利用 calldata、使用 unchecked 循环等——立即降低 gas 消耗。对于需要更精细处理的场景,访问列表、最小代理和代码内数据策略等高级技术可提供进一步的节省。

你现在可以做的事:

  • 使用 Foundry 或 RPC 工具分析你的合约,识别 gas 热点。
  • 将 gas 报告集成到测试套件中,尽早发现性能退化。
  • 对最昂贵的函数应用优化模式
  • 每次更改后测量影响,验证节省效果并指导进一步改进。

通过这种方法,你将确保你的智能合约在任何兼容 EVM 的网络上都能实现更快的执行、更低的费用和更好的用户体验。


💪欢迎加入 OpenBuild 开发者交流群,和更多开发者一起探讨技术、交流进步!
图片

作者:Andrey Obruchkov
原文:https://medium.com/@andrey_obruchkov/gas-matters-how-to-reduc…
编者注:本文内容仅代表原作者观点,旨在分享技术知识。由于编者水平有限,不保证翻译的完全精确性、完整性和时效性。内容仅供参考,不构成任何建议。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值