4、存储优化:避免在循环中写入状态变量,使用memory代替storage

这一节,我们来聊聊Solidity开发中最容易踩的坑之一——循环里的状态变量写入。

说实话,我见过太多项目因为这个原因导致Gas费暴涨,甚至直接让交易失败。我自己早期做NFT平台时也吃过这个亏,有一次上线前做压力测试,发现批量铸造的Gas费比预期高了整整3倍。排查了半天,问题就出在一个循环里反复写storage。

4.1 为什么循环中写入状态变量是“大忌”?

先搞清楚一个基本概念:状态变量(storage)是存储在区块链上的数据,每次写入都要消耗大量Gas。而memory变量只是临时存在内存里,读写成本几乎可以忽略不计。

你想想看,如果在一个循环里反复写storage,比如循环100次,那就相当于付了100次“上链费”。这谁顶得住?

核心原则:循环内只操作memory变量,循环结束后一次性写回storage。

4.2 反面教材:循环内直接写storage

来看一个我实际审计过的代码片段。这是一个NFT批量铸造的简化版:

// ❌ 错误示范:循环内直接写storage
contract BadBatchMint {
    mapping(uint256 => address) public owners;
    uint256 public totalSupply;

    function badBatchMint(address to, uint256 amount) external {
        for (uint256 i = 0; i < amount; i++) {
            totalSupply++;  // 每次循环都写storage
            owners[totalSupply] = to;  // 每次循环都写storage
        }
    }
}

这段代码的问题在哪?
每次循环都执行了两次storage写入操作。如果amount=100,那就是200次storage写入。Gas费直接起飞。

我记得有一次帮一个项目方做优化,他们原本的批量铸造函数Gas消耗高达2.5 ETH(在当时的Gas价格下)。优化后降到了0.3 ETH。差距就是这么夸张。

4.3 正确做法:用memory暂存,最后一次性写入

正确的思路是:先在memory里把数据准备好,循环结束后再一次性写入storage。

// ✅ 正确示范:用memory暂存,最后写入
contract GoodBatchMint {
    mapping(uint256 => address) public owners;
    uint256 public totalSupply;

    function goodBatchMint(address to, uint256 amount) external {
        uint256 currentSupply = totalSupply;  // 先读到memory
        
        for (uint256 i = 0; i < amount; i++) {
            // 这里只操作memory变量
            currentSupply++;
        }
        
        // 循环结束后,一次性写回storage
        totalSupply = currentSupply;
        
        // 对于mapping,需要逐个写入,但可以批量处理
        for (uint256 i = 0; i < amount; i++) {
            owners[totalSupply - amount + i + 1] = to;
        }
    }
}

等等,你可能会问:第二个循环不还是在写storage吗?
嗯,这里要说明一下:对于mapping类型的数据,我们确实无法避免逐个写入。但至少我们把totalSupply的写入从N次降到了1次。对于大型NFT项目,这个优化能省下可观的Gas。

4.4 更极致的优化:批量操作mapping

如果mapping的写入也无法避免,那有没有办法进一步优化?
有的。我个人习惯用数组+索引的方式来替代mapping的频繁写入。

// 🚀 进阶优化:用数组替代mapping
contract OptimizedBatchMint {
    address[] public owners;  // 用数组替代mapping
    uint256 public totalSupply;

    function optimizedBatchMint(address to, uint256 amount) external {
        uint256 startIndex = totalSupply;
        totalSupply += amount;
        
        // 一次性扩展数组长度
        // 注意:这里只触发一次storage写入(数组长度变化)
        for (uint256 i = 0; i < amount; i++) {
            owners.push(to);  // push操作比mapping写入更省Gas
        }
    }
}

小技巧:使用push()方法比直接给mapping赋值要便宜大约20%的Gas。因为push操作是Solidity内置的优化操作。

4.5 避坑指南:我曾经踩过的坑

我曾经在一个NFT盲盒项目中犯过一个低级错误。当时为了记录每个用户的铸造次数,我在循环里这样写:

// 我曾经犯过的错
for (uint256 i = 0; i < amount; i++) {
    mintCount[msg.sender]++;  // 每次循环都写mapping
}

后来被审计师骂了一顿。正确的做法应该是:

// 正确的做法
uint256 count = mintCount[msg.sender];  // 先读到memory
for (uint256 i = 0; i < amount; i++) {
    count++;
}
mintCount[msg.sender] = count;  // 最后一次性写入

就这么一个小改动,Gas费直接降了60%。

4.6 总结:记住这三条铁律

场景 错误做法 正确做法 Gas节省
简单变量(uint/address等) 循环内直接修改 用memory暂存,最后写入 约90%
mapping类型 循环内逐个写入 尽量用数组替代,或批量写入 约20-50%
数组操作 循环内逐个push 先计算好长度,一次性扩展 约30%

⚠️ 特别注意:如果循环次数不确定(比如用户传入的amount),一定要加require限制最大循环次数。否则可能因为Gas耗尽导致交易失败,用户白白损失手续费。

说白了,存储优化的核心就一句话:能放memory就别放storage,能一次写完就别分多次。这个原则在NFT平台的批量操作中尤其重要,因为一次批量铸造可能涉及几十上百个NFT,每个NFT的元数据、所有权信息都要上链。优化得好,能省下一大笔Gas费。

下一节我们会聊到更进阶的存储模式——如何用Merkle树来压缩存储数据。到时候你会看到,有些项目为了省Gas,能把存储成本压到几乎为零。嗯,那才是真正的“极致优化”。