gas 机制

  • EVM 的计价规则,有助于维护网络的公平,防止资源滥用以及潜在的恶意攻击与漏洞
  • 一般单位是 gwei (1 gwei = 10^-9 ETH ), EVM 规范里定义了操作的 gas 值,复杂度越大,所需 gas 越多,详见: https://www.evm.codes/

gas 费用如何计算

  • EIP1559 之前

手续费用 = units of gas used(< gas limit) * gas price单价 (gas limit 和 gas price 由用户指定 ) 矿工收益 = 手续费用

  • EIP1559 之后

手续费用 = units of gas used(<gas limit) * (base fee + priority fee)

燃烧掉 = base fee * gas used ( base fee 是打包时动态确认的)

矿工收益 = tips fee * gas used

用户可以自己指定 gas limit 、 max priority fee (支付给矿工的最大费用)、 max fee 需要注意的是必须将参数 gasLimit 设置为一个足够的值, 比如一个合约执行过程涉及到多个操作,设置的 gas limit 不足,那么交易将会执行失败,但过程中已经执行的操作消耗的 gas 并不会退回,相当于白白损失了

合约设计层面的优化

合约设计紧凑布局

  • Solidity 的状态变量存储采用 按槽(slot)紧凑布局 的规则。
  • 每个存储槽大小为 256 位(32 字节),尽量将小类型变量(如 uint8, bool, address)按照顺序排列以共享存储槽
  • 在满足需求的情况下, 使用更小的数据类型(例如 uint8 而不是 uint256)
// 不优化:变量 a, b, c, d 分别占用一个存储槽, 总共占用 4 个槽位
uint256 a;
bool b;
uint256 c;
uint256 d;



// 优化后: a, c 共享一个槽位, b, d 共享一个槽位, 总共占用 2 个槽位
uint256 a;
uint256 c;
bool b;
uint128 d; 

减少 storage 存储操作

  • 函数传递参数时使用 calldata 替代 memory,calldata 是只读的,直接传递调用数据,Gas 消耗最低
  • 尽量将需要频繁操作的数据存储在内存(memory)中,而不是存储(storage)
  • 使用局部变量存储中间计算结果,并在计算完成后再写入 storage
  • 避免不必要的存储字段更新
// 不优化版本
function updateStorage(uint256[] memory arr) external {
    for (uint256 i = 0; i < arr.length; i++) {
        numbers[i] = arr[i]; // 每次循环都写入 storage,非常昂贵
    }
}

// 优化版本
function updateStorage(uint256[] calldata arr) external {
    uint256[] memory tempArr = numbers; // 复制到内存
    for (uint256 i = 0; i < arr.length; i++) {
        tempArr[i] = arr[i]; // 在 memory 中更新
    }
    numbers = tempArr; // 最后一次性写回 storage
}

最小化合约代码大小

以太坊 EVM 中的合约部署成本与代码大小相关

  • 减少代码重复,抽象出可以复用的逻辑, 或者通过继承基类的逻辑来避免重复代码
pragma solidity ^0.8.20;
contract Unoptimized {
    function add(uint256 x, uint256 y) public pure returns (uint256) {
        return x + y;
    }

    function subtract(uint256 x, uint256 y) public pure returns (uint256) {
        return x - y;
    }

    function multiply(uint256 x, uint256 y) public pure returns (uint256) {
        return x * y;
    }

    function divide(uint256 x, uint256 y) public pure returns (uint256) {
        require(y > 0, "Division by zero");
        return x / y;
    }
}

// 优化后 抽象 calculate 函数,减少了重复的函数实现,合约代码大小更小,部署成本更低
contract Optimized {
    enum Operation { Add, Subtract, Multiply, Divide }

    function calculate(uint256 x, uint256 y, Operation op) public pure returns (uint256) {
        if (op == Operation.Add) return x + y;
        if (op == Operation.Subtract) return x - y;
        if (op == Operation.Multiply) return x * y;
        if (op == Operation.Divide) {
            require(y > 0, "Division by zero");
            return x / y;
        }
        revert("Invalid operation");
    }
}
  • 使用库(library)和继承优化代码结构
pragma solidity ^0.8.20;

contract A {
    function square(uint256 x) public pure returns (uint256) {
        return x * x;
    }
}

contract B {
    function square(uint256 x) public pure returns (uint256) {
        return x * x;
    }
}

优化后的代码(提取公共逻辑到 library)

pragma solidity ^0.8.20;

library MathLibrary {
    function square(uint256 x) internal pure returns (uint256) {
        return x * x;
    }
}

contract A {
    using MathLibrary for uint256;

    function calculateSquare(uint256 x) public pure returns (uint256) {
        return x.square();
    }
}

contract B {
    using MathLibrary for uint256;

    function calculateSquare(uint256 x) public pure returns (uint256) {
        return x.square();
    }
}
  • 使用内联汇编(inline assembly)优化特定高频操作

数组优化

  • 数组的插入、删除、遍历操作相对较贵, 如果需要快速查找数据或存储稀疏数据,优先使用 mapping,因为它在存储和读取方面更高效
// 数组方式(不高效)
uint256[] public users;
// 映射方式(更高效)
mapping(address => bool) public isUser;
  • 数组的扩展操作需要重新分配存储槽,Gas 成本较高, 所以要避免频繁扩展动态数组,或者避免使用动态数组,改用固定大小数组
  • 在一些 Defi 项目中涉及到分红累加计算这种逻辑时, 会用数组存储每个用户的分红额度, 可以优化成累加计算,参考代码StakeModel.sol

操作逻辑优化

尽可能避免循环遍历的操作

  • 避免在单个交易中处理大规模循环,因为每次迭代都会消耗 Gas。
  • 如果循环的数据量较大,可以将操作拆分成多笔交易,使用类似 “批量处理” 的模式
// 不优化:在一个函数中处理所有数据
function processLargeData(uint256[] calldata data) external {
    for (uint256 i = 0; i < data.length; i++) {
        // 高成本操作
    }
}

// 优化:分批处理
function processBatch(uint256[] calldata data, uint256 start, uint256 end) external {
    for (uint256 i = start; i < end; i++) {
        // 高成本操作
    }
}

按位操作替代算术操作

  • 按位操作(如 &, |, ^, «, »)比算术操作(如加减乘除)更高效。
  • 如果可以用按位操作实现逻辑,尽量避免浮点计算或复杂的算术运算
// 使用按位操作代替乘法
x * 2;       // 替换为:x << 1
x / 2;       // 替换为:x >> 1

优化条件判断

  • 如果条件判断的执行路径可以优化(例如提前返回或减少嵌套),可以降低 Gas 消耗
// 不优化
function checkCondition(uint256 x) public view returns (bool) {
    if (x > 0) {
        if (x < 10) {
            return true;
        }
    }
    return false;
}

// 优化
function checkCondition(uint256 x) public view returns (bool) {
    if (x <= 0) return false;
    if (x >= 10) return false;
    return true;
}

批量操作

  • 多账户批量转账。
  • 批量更新映射或数组中的数据。
  • 批量处理计算逻辑
pragma solidity ^0.8.20;
contract SingleTransfer {
    function transferEther(address[] calldata recipients, uint256[] calldata amounts) external payable {
        require(recipients.length == amounts.length, "Length mismatch");

        for (uint256 i = 0; i < recipients.length; i++) {
            payable(recipients[i]).transfer(amounts[i]); // 每次调用 transfer,会增加大量固定开销
        }
    }
}

// 优化后 固定每个接收者的转账金额,减少数组访问成本
contract BatchTransfer {
    function batchTransferEther(address[] memory recipients, uint256 amount) public payable {
        uint256 totalAmount = recipients.length * amount;
        require(msg.value >= totalAmount, "Insufficient Ether");

        for (uint256 i = 0; i < recipients.length; i++) {
            payable(recipients[i]).transfer(amount);
        }
    }
}

批量操作中的注意事项:

  • 单个交易的 Gas 限制: 批量操作可能触及单个交易的最大 Gas 限制(约 30,000,000 Gas),可以通过分批次处理数据解决。
  • 数组长度检查: 如果涉及多个数组操作,确保数组长度一致,避免数组越界。
  • 避免溢出: 特别是在批量计算时,注意可能出现的算术溢出问题

其他优化技巧

使用 immutable 和 constant

对于不会改变的变量,使用 immutable 或 constant 修饰符,减少存储读取。

  • constant:在编译时固定,数据保存在字节码中。
  • immutable:在部署时固定,存储在代码中
uint256 constant FIXED_VALUE = 100; // 固定常量
address immutable OWNER; // 部署时初始化

constructor() {
    OWNER = msg.sender; // 初始化 immutable 变量
}

使用内联汇编(Assembly)

在关键逻辑中使用内联汇编(assembly)可以进一步优化 Gas,但需要更复杂的开发技能, 容易出错, 只有在非常高频的函数中才值得使用

function optimizedAddition(uint256 x, uint256 y) public pure returns (uint256 result) {
    assembly {
        result := add(x, y)
    }
}

减少函数调用嵌套

函数调用嵌套是指一个函数中调用其他函数,其他函数又调用更多函数,形成调用链。每次函数调用都会消耗额外的 Gas,因为:

  • EVM 需要为每个函数调用开辟新的堆栈帧
  • 如果嵌套过深,会增加调用链的成本
pragma solidity ^0.8.20;

contract NestedFunctions {
    function calculate(uint256 x) external pure returns (uint256) {
        return level1(x); // 调用 level1
    }

    function level1(uint256 x) internal pure returns (uint256) {
        return level2(x) + 1; // 调用 level2
    }

    function level2(uint256 x) internal pure returns (uint256) {
        return level3(x) * 2; // 调用 level3
    }

    function level3(uint256 x) internal pure returns (uint256) {
        return x * x; // 执行平方计算
    }
}

// 优化后的代码(逻辑展开)
contract OptimizedFunctions {
    function calculate(uint256 x) external pure returns (uint256) {
        uint256 square = x * x;       // 展开 level3
        uint256 double = square * 2; // 展开 level2
        return double + 1;           // 展开 level1
    }
}

参考