第三章: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优化不是炫技,而是对用户负责。每次写合约时,多想想「这个操作能不能更省一点」,久而久之就会形成习惯。