第三章:Gas优化核心策略

Gas优化,说白了就是帮用户省钱。我自己刚入行时,觉得只要合约能跑通就行,Gas多点少点无所谓。直到有一次,我写的一个NFT铸造合约,因为循环写得不够好,用户铸造一次花了将近0.1个ETH...嗯,那天我被社区骂得很惨。

今天我就把几个最核心的Gas优化策略,掰开揉碎了讲给你听。

3.1 变量打包:把数据塞进同一个槽位

EVM存储一个256位的槽位,成本是固定的。如果你声明了两个uint128,它们各自占一个槽位,那就是双倍费用。但如果你把它们打包进同一个槽位,就只花一份钱。

核心原则:相同大小的变量尽量挨着放,尤其是小类型变量。

// ❌ 不推荐:浪费存储槽位
contract BadPacking {
    uint128 public a;
    uint256 public b;
    uint128 public c;
}

// ✅ 推荐:a和c共享一个槽位
contract GoodPacking {
    uint128 public a;
    uint128 public c;
    uint256 public b;
}

我在项目中遇到过一个问题:有个同事把address和uint256混着放,结果每个address都独占了一个槽位。你想想看,一个address才20字节,却占256位,多浪费啊。

我的习惯:写合约前先画个存储布局图,把变量按大小排好序。小类型放一起,大类型放后面。

3.2 短路优化:让条件判断尽早结束

逻辑运算符&&和||有个特点:从左到右执行,一旦能确定结果,后面的就不执行了。这就是短路。

// ❌ 不推荐:把昂贵的操作放在前面
if (expensiveFunction() && simpleCheck()) { ... }

// ✅ 推荐:把简单的检查放在前面
if (simpleCheck() && expensiveFunction()) { ... }

为什么会这样?因为如果simpleCheck返回false,expensiveFunction根本不会执行。我曾经审计过一个合约,开发者在require里先调用了外部合约,再做余额检查。结果每次调用都多花好几千Gas。

注意:短路优化不仅适用于if语句,也适用于require和modifier。把最可能失败的条件放在最前面。

3.3 循环优化:能少跑一圈是一圈

循环是Gas消耗的大户。我见过最夸张的一个合约,循环里遍历了10000个地址,每次调用都要花掉好几个ETH。嗯,那项目后来黄了。

几个优化点:

  • 减少循环次数:能用二分查找就别用线性遍历
  • 缓存数组长度:不要每次循环都读取length
  • 避免在循环内写存储:能攒到循环外写就攒着
// ❌ 不推荐:每次循环都读length
for (uint i = 0; i < users.length; i++) {
    // 操作
}

// ✅ 推荐:缓存length
uint len = users.length;
for (uint i = 0; i < len; i++) {
    // 操作
}

我个人习惯在循环里用++i而不是i++。虽然差别不大,但能省一点是一点。你想想看,积少成多嘛。

3.4 存储读写优化:少碰存储,多用内存

EVM里,存储(storage)是最贵的操作。一次SSTORE(写存储)要花20000 Gas,而一次MSTORE(写内存)只要3 Gas。差了将近一万倍。

操作 Gas消耗 说明
SLOAD(读存储) 2100 冷读取,首次访问
SLOAD(热读取) 100 同一交易内再次访问
SSTORE(写存储) 20000 从非零写到非零
MLOAD(读内存) 3 几乎免费
MSTORE(写内存) 3 几乎免费
// ❌ 不推荐:频繁读写存储
function badUpdate(uint[] calldata data) external {
    for (uint i = 0; i < data.length; i++) {
        total += data[i];  // 每次循环都读storage
    }
    storedTotal = total;   // 最后写一次
}

// ✅ 推荐:用内存变量缓存
function goodUpdate(uint[] calldata data) external {
    uint _total = total;   // 先读到内存
    for (uint i = 0; i < data.length; i++) {
        _total += data[i]; // 内存操作,便宜
    }
    total = _total;        // 只写一次存储
}

我曾经审计过一个DeFi项目,他们在每次循环里都更新storage变量。我建议他们把中间结果存在内存里,最后再写回存储。优化后Gas消耗降低了80%。

避坑指南:如果你在函数里多次读取同一个storage变量,记得先把它缓存到内存里。尤其是mapping和数组,每次读取都挺贵的。

3.5 使用事件替代存储:能省则省

有些数据,你只是想让外部能查到,并不需要在链上实时读取。这时候,用事件(Event)比用存储变量划算得多。

// ❌ 不推荐:把历史记录存在storage里
contract BadHistory {
    struct Trade {
        address user;
        uint amount;
        uint time;
    }
    Trade[] public trades;
    
    function trade(uint amount) external {
        trades.push(Trade(msg.sender, amount, block.timestamp));
    }
}

// ✅ 推荐:用事件记录历史
contract GoodHistory {
    event Trade(address indexed user, uint amount, uint time);
    
    function trade(uint amount) external {
        emit Trade(msg.sender, amount, block.timestamp);
    }
}

事件消耗的Gas大约是存储的十分之一。而且事件还能被索引,查询起来也方便。我个人习惯:只要不是合约内部需要实时读取的数据,一律用事件。

注意:事件的数据不能被合约内部读取。如果你需要在合约逻辑里用到这些历史数据,那还是得存storage。

3.6 综合实战:一个优化前后的对比

最后,我给你看一个我实际优化过的合约片段。这是一个简单的众筹合约,优化前和优化后的对比:

// 优化前:Gas消耗约 150000
contract BadCrowdfund {
    mapping(address => uint) public contributions;
    address[] public contributors;
    
    function contribute() external payable {
        contributions[msg.sender] += msg.value;
        contributors.push(msg.sender);
        emit Contributed(msg.sender, msg.value);
    }
}

// 优化后:Gas消耗约 85000
contract GoodCrowdfund {
    mapping(address => uint) public contributions;
    address[] public contributors;
    
    function contribute() external payable {
        // 先检查,再操作(短路优化)
        require(msg.value > 0, "Zero amount");
        
        // 用内存变量缓存
        uint newAmount = contributions[msg.sender] + msg.value;
        contributions[msg.sender] = newAmount;
        
        // 只在必要时push
        if (newAmount == msg.value) {
            contributors.push(msg.sender);
        }
        
        emit Contributed(msg.sender, msg.value);
    }
}

你看,只是改了几个小地方,Gas就省了将近一半。这就是优化的魅力。

好了,这一章的内容就到这里。记住:Gas优化不是炫技,而是对用户负责。每次写合约时,多想想「这个操作能不能更省一点」,久而久之就会形成习惯。