高频调用时,微小的单次开销也会反复累积。做智能合约Gas优化的常见方法,最好先看真实交易路径,而不是先改代码风格:同一函数可能因存储状态、调用参数和执行分支不同,消耗差异很大。以下按通常的投入产出顺序说明。
先测量:找到常走且昂贵的路径
先列出调用次数较多的外部函数,例如 ERC-20 合约中的 transfer、approve,或业务合约里的批量登记、结算入口。用 Foundry 的 forge test --gas-report 查看测试函数的Gas消耗,再补充覆盖常见状态的测试:首次写入、已有余额、边界参数及批量输入。测试结果受 Solidity 版本、编译设置和执行状态影响,应在相同配置下比较。
- 记录基准版本、编译器版本、optimizer 设置和测试数据。
- 为高频入口建立代表性测试,分别测首次调用与重复调用。
- 一次只改一类逻辑,比较Gas报告,并检查测试是否仍覆盖权限、边界和事件。
没有这一步,局部代码看起来更短,也不代表链上执行更省。
通常优先处理存储读写和重复计算
减少重复读取
EVM的存储访问成本明显高于多数栈上计算。以同一函数多次读取 balances[user] 为例,可先读入局部变量,在本次执行中复用;若后续逻辑需要更新,再按原有权限和检查顺序写回。Ethereum 的相关Gas规则会区分冷、热访问;Berlin规则下,首次冷读与同一交易内再次热读的基础费用不同,但具体交易总费用还取决于其他操作。
避免不必要的状态写入
如果新值与旧值相同,且语义允许,可避免重复写入。对于计数器、余额等状态,先确认跳过写入不会漏掉必须发出的事件、校验或业务效果。存储写入费用会随旧值、新值及交易中的访问情况变化,不能只凭某个固定数字估算。这个方向通常比压缩几条算术语句更值得先查。
再看数据传递、循环与变量布局
让只读输入留在 calldata
外部函数只需读取动态数组或字符串时,可考虑使用 calldata,避免不必要地复制到内存;如果函数要修改数据,则需按逻辑使用 memory 或其他适当位置。修改前检查所有调用点和接口兼容性,并用目标编译器重新测试。
控制循环工作量,谨慎打包变量
批量函数应避免在循环内重复读取相同状态或重复计算不变表达式,也要限制输入规模,防止单笔交易因工作量过大而无法执行。状态变量打包可减少部分存储槽使用,但只有类型、排列和读写模式合适时才可能有益;频繁单独更新的字段若被打包,反而可能产生额外读写。修改存储布局时,还要特别检查代理合约等升级场景的兼容性。
优化顺序:按收益、风险和验证成本排序
智能合约Gas优化的常见方法可以按“先量化、再减少昂贵操作、最后微调”落实。编译器 optimizer 的 runs 参数也值得做对照:不同设置可能在部署成本与运行成本之间取舍,适合高频调用的配置不一定适合只部署少量、很少调用的合约。
- 先做:测量热点函数,减少重复存储读取和确实多余的状态写入。
- 再做:检查 calldata、循环结构,以及可以复用的局部结果。
- 最后评估:变量打包、unchecked 算术和编译器优化参数。unchecked 只适用于已证明不会溢出的运算,不能为了省Gas省略安全检查。
- 复核:在相同编译与测试环境下重跑报告,并对照功能、安全和边界测试。
结论是,先优化被反复执行的存储操作,再处理数据复制和循环,通常比追求代码表面简短更可靠。每项智能合约Gas优化的常见方法都应以基准测试和语义不变为前提。
常见问题
代码行数变少,Gas一定会下降吗?
不一定。Gas取决于编译后的EVM操作和运行状态,应以同配置基准测试为准。
是否应该把所有变量都打包?
不应。只有字段布局和读写方式合适时才考虑,并检查存储布局兼容性。
unchecked 能否用于所有计数器?
不能。只有能证明运算不会溢出或下溢时才适用,否则可能改变安全语义。