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,能把存储成本压到几乎为零。嗯,那才是真正的“极致优化”。