Solidity Gas 优化技巧终极清单
目录
- 一、为什么 Gas 优化重要 + 核心原则
- 二、存储优化
- 三、部署 Gas 优化
- 四、跨合约调用优化
- 五、设计模式优化
- 六、Calldata 优化
- 七、汇编技巧
- 八、编译器与代码层面优化
- 九、危险技巧(不推荐)
- 十、已过时的技巧
- 十一、关键提醒
- 十二、动手练习项目:Gas 优化对拍器 GasGolf
一、为什么 Gas 优化重要 + 核心原则
EVM 上每个操作都明码标价,存储操作尤其昂贵。优化的本质是:减少存储写入、减少存储读取、减少不必要的计算和调用。
四条贯穿全文的核心原则:
- 永远先基准测试(benchmark)——编译器行为依版本和上下文而变,很多”技巧”在新版编译器里已自动完成甚至适得其反;
- 避免零到非零的存储写入(22,100 gas,最贵的操作);
- 缓存存储读取——每个值做到恰好一次 SLOAD、一次 SSTORE;
- 谨慎用复杂度换 Gas——可读性和安全性同样重要。
二、存储优化
| # | 技巧 | 要点与数字 |
|---|---|---|
| 1 | 避免零到一的存储写入 | 零→非零写入 22,100 gas(20,000 写 + 2,100 冷访问),非零→非零仅 5,000。OZ 重入锁用 1/2 而非 0/1 正是为此 |
| 2 | 缓存存储变量 | 读存储每次 ≥100 gas,写更贵。uint256 _n = number; 后多次用 _n,做到一读一写 |
| 3 | 打包相关变量 | 用位移把多个变量塞进一个 32 字节槽。手动用 uint160 存两个 uint80 比依赖 EVM 自动打包更高效 |
| 4 | 打包结构体 | 结构体成员按声明顺序连续存储。按大小排序(大的在前)减少占用槽数。uint64 + address(160 位)= 224 位可共享一槽 |
| 5 | 字符串保持 <32 字节 | <32 字节的字符串把 length*2 存在槽的低位字节;≥32 字节存 length*2+1 且数据另存(keccak256 定位),多一次读 |
| 6 | 不变量用 immutable/constant | 直接嵌入字节码,不占存储,读取零 SLOAD 成本 |
| 7 | 用 mapping 代替数组 | 数组读取有长度边界检查(约 2,102 gas)。get(0) 数组 4,860 gas vs mapping 2,758 gas |
| 8 | 数组用 unsafeAccess | OZ Arrays.sol 的 unsafeAccess() 跳过冗余长度检查(确保索引有效时) |
| 9 | 用位图代替 bool | 一个槽存 256 个标志位。空投/白名单标记”已用”的最佳实践 |
| 10 | 大数据用 SSTORE2/SSTORE3 | SSTORE 约 690 gas/字节,合约字节码仅 200 gas/字节。SSTORE2 把数据当不可变字节码部署,EXTCODECOPY 读取 |
| 11 | 用存储指针而非 memory | User storage _u = users[id] 比 User memory 省约 5,000 gas,只读用到的字段,不拷整个结构体 |
| 12 | 避免 ERC20 余额归零 | 频繁清空再充值触发重复的零→一写入。留一点”灰尘”保持非零 |
| 13 | 从 n 倒数到 0 | 最终存储状态为 0 时有退款,倒数触发退款机制抵消 Gas |
| 14 | 时间戳/区块号用小类型 | uint48 时间戳够用几百万年,uint32 区块号也够,与其他变量打包 |
三、部署 Gas 优化
| # | 技巧 | 要点 |
|---|---|---|
| 1 | 用 nonce 预测合约地址 | 地址可由部署者地址 + nonce 确定性计算(LibRLP)。互相依赖的合约在构造函数里直接引用预测地址,省 setter。47,158 vs 49,291 gas |
| 2 | 构造函数设为 payable | 非 payable 构造函数含隐式 require(msg.value==0),payable 省约 200 部署 gas |
| 3 | 优化 IPFS 元数据哈希 | Solidity 追加 51 字节元数据,挖更多零字节或用 --no-cbor-metadata 省 10,000+ gas |
| 4 | 一次性合约在构造函数 selfdestruct | 多部署器等一次性合约可在构造函数末尾自毁(EIP-6780 保留构造函数内 selfdestruct) |
| 5 | modifier vs 内部函数权衡 | modifier 内联字节码(运行时更大、调用更便宜);内部函数跳转共享代码(运行时更小、调用更贵)。modifier 每次省约 24 gas 但复用时多 35,000+ 部署 gas |
| 6 | 用 Clone/Metaproxy 部署 | EIP-1167/3448 把实现地址存字节码,省部署。代价:每次调用的 delegatecall 开销 |
| 7 | admin 函数设为 payable | 同样省 msg.value 检查 |
| 8 | 自定义错误比 require 字符串小 | 自定义错误只存错误签名哈希的前 4 字节,require 字符串要 64+ 字节 |
| 9 | 用现成的 CREATE2 工厂 | 省去自定义部署代码开销 |
四、跨合约调用优化
| # | 技巧 | 要点 |
|---|---|---|
| 1 | 用转账钩子代替”先授权后拉取” | ERC1155 回调、ERC721 safeTransfer、ERC1363 transferAndCall,用 data 字段传参,省一次合约交互 |
| 2 | 用 fallback/receive 代替 deposit() | payable 的 receive/fallback 处理 ETH,fallback 可 abi.decode 解参数 |
| 3 | 用 EIP-2930 访问列表 | 预热存储槽和地址,每槽省 200 gas,跨合约/代理必备(上一篇详解) |
| 4 | 缓存外部调用结果 | 避免重复调昂贵的预言机(如 Chainlink),结果存内存/存储复用 |
| 5 | Router 里实现 multicall | Uniswap Router 模式:multicall(bytes[] calldata) 用 delegatecall 批量调用,保留 msg.sender/value |
| 6 | 单体架构避免合约调用 | 合约调用昂贵,能单合约就别拆多合约 |
五、设计模式优化
- 用 multidelegatecall 批量交易:保留环境变量(msg.sender、msg.value)地批量调本合约多个函数;
- 用 ECDSA 签名代替默克尔树:默克尔证明随规模增大耗 calldata,签名更便宜(白名单/空投);
- 用 ERC20Permit 合并授权和转账:签名授权,接收方一笔交易 permit + transferFrom;
- 高吞吐应用用 L2 消息传递:桥到 Polygon/Optimism/Arbitrum;
- 适用时用状态通道:链下签名更新状态,只把最终结果上链;
- 投票委托省 Gas:ERC20Votes,只有受托人投票,减少投票交易数;
- NFT 用 ERC1155 比 ERC721 便宜:ERC721 的 balanceOf 每次铸/转加存储开销,ERC1155 单 ID 最大供应 1 实现非同质化而无此开销;
- 多个 ERC20 用一个 ERC1155/ERC6909:单合约管理多种类代币(ERC6909 无回调);
- UUPS 比透明代理高效:透明代理每次调用检查 msg.sender==admin,UUPS 只在升级函数检查;
- 考虑 OZ 的替代品:Solmate、Solady(Solady 强调汇编优化)。
六、Calldata 优化
- (安全地)用虚荣地址:前导零的地址省 calldata(如 Seaport
0x0000...14dC),仅当地址是函数参数时有效。用 CREATE2 + 足够随机性安全生成(避免 Profanity 漏洞); - 避免 calldata 里用有符号整数:补码表示让小负数(-1 = 0xffff…)calldata 昂贵,尽量用无符号;
- calldata 通常比 memory 便宜:直接从 calldata 读函数输入比拷到 memory 便宜,只在需要修改时才用 memory;
- L2 上考虑打包 calldata:Solidity 不自动打包 calldata,应用层打包省 L2 calldata 成本(Dencun blobs 后尤其值得)。
七、汇编技巧
| # | 技巧 | 节省 |
|---|---|---|
| 1 | 汇编 revert 带错误信息 | 避免内存扩展和类型检查,省约 300 gas |
| 2 | 汇编做跨合约调用复用内存 | 用 scratch space(0x00-0x40)避免内存扩展,省约 220 gas |
| 3 | 无分支的 min/max | z := xor(x, mul(xor(x, y), gt(y, x))) 代替三元运算符(条件跳转贵) |
| 4 | 用 SUB 或 XOR 做不等检查 | if sub(caller(), owner) {revert()} 代替 eq,某些上下文省操作码(测两者) |
| 5 | 汇编做 address(0) 检查 | if iszero(_caller) 省约 90 gas |
| 6 | selfbalance 比 address(this).balance 便宜 | selfbalance() 有时更便宜(编译器可能已优化) |
| 7 | ≤96 字节的哈希和事件用汇编 | 用 scratch space 不扩展内存。记录三个 uint256 省约 3,355 gas,哈希省约 1,048 gas |
| 8 | 多次外部调用复用内存 | 第一次调用的参数编码在 scratch space,第二次复用,省约 2,000 gas |
| 9 | 创建多个合约复用内存 | 把创建码存内存一次,多次 create() 复用,省约 1,000 gas |
| 10 | 用位判断奇偶而非取模 | x & 1 == 0 代替 x % 2 == 0,位运算比取模便宜 |
八、编译器与代码层面优化
- 优先严格不等号(但要测):
</>一般比<=/>=便宜(EVM 无 ≤/≥ 操作码); - 拆分 require 的布尔条件:
require(x>0 && y>0)→require(x>0); require(y>0);早退避免第二次求值; - 拆分 revert 的或条件:同理,分开 if 避免无谓求值;
- 总是用命名返回值:编译器对在 return 语句声明的变量生成更高效代码;
- 反转被否定的 if-else:
if(!cond) a() else b()→if(cond) b() else a()省取反操作码; - 用 ++i 而非 i++:i++ 返回旧值(两个栈值),++i 省 gas(循环里明显);
- 适当用 unchecked 数学:默认检查溢出,循环计数器/已校验输入用 unchecked 跳过检查;
- 写 Gas 最优 for 循环:
for(uint i; i<n;){...; unchecked{++i;}}(注:0.8.22 起编译器自动做); - do-while 比 for 略便宜;
- 避免不必要的类型转换:<uint256 的类型(含 bool、address)除非打包否则更低效;
- 短路布尔:
&&/||短路,把更可能为 false 的条件放前面; - 非必要不用 public 变量:public 自动生成 getter,增字节码,用 private + 手动 getter;
- 优先很大的 optimizer runs 值:如
--optimize-runs 100000,用编译时间换运行时效率; - 高频函数取最优名字:函数选择器是签名 keccak256 前 4 字节,前导零多的选择器(如
0x06fdde03)省 calldata,给高频函数挑这样的名字; - 位移比乘除 2 的幂便宜:
x<<1代替x*2,x>>1代替x/2; - 有时缓存 calldata 更便宜:复用同一值多次时;
- 用无分支算法:避免条件跳转,用数学公式;
- 只用一次的内部函数内联:省函数分派的跳转开销;
- >32 字节的数组/字符串用哈希比较相等:
keccak256(a)==keccak256(b); - 幂和对数用查找表:预计算存数组,以存储换计算;
- 乘法/内存操作用预编译合约:0x01-0x08 预编译(如 ECRECOVER、SHA256)更便宜;
n*n*n可能比n**3便宜:显式连乘让编译器优化中间结果。
九、危险技巧(不推荐)
- 用 gasprice() 或 msg.value 传信息(非标准、易错);
- 操纵环境变量 coinbase()/block.number(仅测试,不可production);
- 用 gasleft() 做分支逻辑(不可预测、不安全);
- send() 不检查成功(静默丢 ETH);
- 所有函数都 payable(巨大安全风险,用户误转 ETH);
- 外部库跳转(绕过正常调用机制,脆弱);
- 追加字节码做子程序(极脆弱,升级/分析时崩)。
十、已过时的技巧
- external 比 public 便宜——现代编译器已优化掉差异;
!= 0比> 0便宜——编译器优化已消除差异(仍可测)。
十一、关键提醒
- 永远基准测试替代方案——编译器行为依上下文和版本而变;
- 首要避免零到一存储写入(22,100 gas);
- 缓存存储读取,每个值恰好一次 SLOAD 一次 SSTORE;
- 谨慎用复杂度换 Gas——可读性和安全性优先;
- 用
--via-ir测试——某些优化在 IR 管线下行为不同甚至适得其反。
十二、动手练习项目:Gas 优化对拍器 GasGolf
项目目标
实现同一个功能的”朴素版”和”优化版”两套合约,用 Foundry 的 gas report 量化每一项优化的真实节省,建立”基准测试驱动优化”的肌肉记忆。部署到 Sepolia。
合约要求
实现一个简化的空投/白名单领取系统,分两版对拍:
1. NaiveAirdrop.sol(朴素版,故意低效)
bool[] public claimed;(每个 bool 占一槽);mapping(address => uint256) public allowlist;(数值额度);require(condition, "long error string here");字符串错误;- 用数组 + 长度检查;
- 普通 for 循环(i++、checked 数学);
- 状态变量都 public。
2. OptimizedAirdrop.sol(优化版,应用清单技巧)
- 位图记录已领取(
mapping(uint256 => uint256) bitmap,1 槽 256 标志); - 自定义错误代替字符串;
- 打包结构体存活动配置(uint128 amount + uint64 deadline + address,按大小排序);
- immutable 存代币地址、merkle root/签名者;
- 缓存存储变量到 memory 后运算;
- unchecked 循环计数器,
++i; - ECDSA 签名白名单代替数组遍历;
- private 变量 + 必要的手动 getter;
- 构造函数 payable。
测试与基准要求(Foundry)
test_BothCorrect:两版功能完全一致(同样的领取结果);test_GasReport:用forge test --gas-report或vm.snapshotGas,逐项对比:- 位图 vs bool 数组的领取标记 Gas;
- 自定义错误 vs 字符串 revert 的部署和触发 Gas;
- 打包结构体 vs 未打包的读写 Gas;
- 缓存存储 vs 重复 SLOAD;
- unchecked 循环 vs checked;
test_ZeroToOneWrite:测量首次写入(零→非零)22,100 gas,对比保持非零(用 1/2 技巧);test_BitmapPacking:256 个地址的标志只占用 1 个存储槽;test_SignatureVsArray:ECDSA 验证 vs 数组遍历白名单的 Gas 对比;- 输出一份 Markdown 表格记录每项优化的节省量。
Sepolia 部署与验证步骤
- 部署 NaiveAirdrop 和 OptimizedAirdrop;
- 对比两者的部署 Gas(Etherscan 上看 deployment cost);
- 各自执行一次领取,对比交易 Gas;
- 用
forge snapshot生成 .gas-snapshot 文件,记录基线; - 把每项优化的节省量整理成表,验证”位图""自定义错误""打包”是大头。
进阶挑战(可选)
- 挑 3 个汇编技巧(如 ≤96 字节事件、无分支 max、汇编 revert)各写一个对拍函数,量化节省;
- 用
--via-ir重新跑基准,找出哪些手动优化被 IR 管线”反向优化”了(变慢或无差异),理解”先基准测试”的真谛; - 写注释回答:为什么”零到一存储写入”是 22,100 gas?这 22,100 由哪两部分构成,倒数到 0 的退款机制如何抵消它?