5月28日 01:24
Solidity 智能合约如何进行 Gas 优化?有哪些常见的优化技巧?
Gas 优化是 Solidity 开发中最核心的实战能力之一,直接影响合约部署成本和用户交互费用。下面从存储、计算、函数、错误处理等维度系统梳理常见优化技巧,每一条都配有对比示例。
存储优化
Storage 操作是最昂贵的 EVM 指令,写一次 SSTORE 至少消耗 20,000 Gas,优化存储是降本的第一优先级。
变量打包:让一个 slot 装更多数据
Solidity 中每个 storage slot 为 32 字节,多个小类型变量可以打包进同一个 slot:
solidity// 未优化:占用 3 个 slot struct Bad { uint256 a; // slot 0,独占 32 字节 uint128 b; // slot 1,16 字节,剩余 16 字节浪费 uint128 c; // slot 2,16 字节,剩余 16 字节浪费 } // 优化后:只占 2 个 slot struct Good { uint128 b; // slot 0,占 16 字节 uint128 c; // slot 0,占 16 字节,与 b 共用一个 slot uint256 a; // slot 1 }
打包原则:将同组小变量声明在一起,按大小降序排列,让编译器自动合并。
选择合适的数据类型
能用 uint128 就不要用 uint256,不是因为运算更便宜(EVM 中 uint256 运算是最优的),而是因为更小的类型可以和其他变量共享 slot,省掉整 slot 的存储开销。
solidity// 不推荐:每个变量独占一个 slot uint256 public status; // slot 0,实际值远小于 2^256 uint256 public timestamp; // slot 1,时间戳最多 uint64 就够 uint256 public balance; // slot 2 // 推荐:打包存储 uint64 public timestamp; // slot 0,8 字节 uint8 public status; // slot 0,1 字节 // 3 个变量共享 slot 0,省 2 个 slot = 省 ~40,000 Gas 写入成本
用 constant 和 immutable 代替 storage 变量
solidity// 昂贵:每次读取都访问 storage uint256 public feeRate = 250; // 占 slot,SLOAD ~2,100 Gas // 优化:constant 直接内联到字节码 uint256 public constant FEE_RATE = 250; // 0 Gas 读取 // immutable 在部署时写入字节码,不占 slot uint256 public immutable CREATION_TIME; constructor() { CREATION_TIME = block.timestamp; // 部署时固定,之后 0 Gas 读取 }
用 memory 替代循环中的 storage 读取
solidityuint256[] public items; // 昂贵:每次循环都 SLOAD function sumBad() public view returns (uint256) { uint256 s = 0; for (uint256 i = 0; i < items.length; i++) { s += items[i]; // 每次迭代触发 SLOAD } return s; } // 优化:一次性加载到 memory function sumGood() public view returns (uint256) { uint256[] memory m = items; // 1 次 SLOAD uint256 s = 0; for (uint256 i = 0; i < m.length; i++) { s += m[i]; // memory 读取,~3 Gas } return s; }
计算与循环优化
缓存数组长度 + unchecked 递增
solidity// 未优化 for (uint256 i = 0; i < arr.length; i++) { ... } // 优化:缓存长度 + unchecked ++i uint256 len = arr.length; for (uint256 i = 0; i < len; ) { // ... loop body ... unchecked { ++i; } // 跳过溢出检查,省 ~80 Gas/次 }
注意:unchecked 只在确认 i 不会溢出时使用,循环次数受数组长度限制时是安全的。
避免冗余的零值初始化
solidity// 冗余:Solidity 中变量默认值为 0,显式赋零浪费 Gas uint256 count = 0; bool isActive = false; // 优化:省略初始化 uint256 count; bool isActive;
短路求值排条件顺序
solidity// && 把最容易为 false 的条件放前面 function canWithdraw(address user) public view returns (bool) { return balances[user] > 0 && isWhitelisted[user] && !frozen[user]; // 如果余额为 0(大概率),后续条件不会执行 } // || 把最容易为 true 的条件放前面 function hasAccess(address user) public view returns (bool) { return user == owner || admins[user] || moderators[user]; // owner 检查最便宜且最早命中 }
函数参数优化
外部函数用 calldata 代替 memory
solidity// 昂贵:memory 会从 calldata 复制一份数据 function processMemory(uint256[] memory data) external pure returns (uint256) { return data[0]; } // 优化:calldata 直接读取,不复制,省 ~3,800 Gas/32字节 function processCalldata(uint256[] calldata data) external pure returns (uint256) { return data[0]; }
calldata 只适用于 external 函数的只读参数,内部函数无法使用。
modifier 内部用内部函数复用逻辑
solidity// 未优化:modifier 代码会被内联到每个使用它的函数 modifier onlyAdmin() { require(msg.sender == owner, "Not owner"); require(!paused, "Paused"); require(balances[msg.sender] > 0, "No balance"); _; } // 优化:提取为内部函数,编译器可复用字节码 modifier onlyAdmin() { _checkAdmin(); _; } function _checkAdmin() internal view { require(msg.sender == owner, "Not owner"); require(!paused, "Paused"); require(balances[msg.sender] > 0, "No balance"); }
错误处理优化
用 custom error 替代 require 字符串(Solidity 0.8.4+)
solidity// 昂贵:require 的字符串会存储在链上 require(msg.sender == owner, "Not owner"); // 字符串存储成本高 require(amount <= balance, "Insufficient balance"); // 每个字符都花 Gas // 优化:custom error 只存储 selector(4 字节) error NotOwner(address caller); error InsufficientBalance(uint256 requested, uint256 available); if (msg.sender != owner) revert NotOwner(msg.sender); if (amount > balance) revert InsufficientBalance(amount, balance);
custom error 不仅省 Gas,还能携带结构化参数,方便链下解析。
事件替代 Storage
不需要在合约中查询的数据,用 event 记录而不是写入 storage:
solidity// 昂贵:每个字段都写 storage struct Transaction { address from; address to; uint256 amount; } mapping(uint256 => Transaction) public txs; // SSTORE ~20,000 Gas/字段 // 优化:用 event 记录,只在链下查询 event TransactionLogged(address indexed from, address indexed to, uint256 amount); function execute(address to, uint256 amount) external { emit TransactionLogged(msg.sender, to, amount); // ~375 Gas }
差价约 50 倍,适合审计日志、历史记录等不需要合约逻辑访问的数据。
位运算技巧
用位掩码管理多个布尔状态
solidity// 未优化:每个 bool 占 1 字节,但 EVM 操作以 32 字节为单位 bool public isPaused; bool public isFinalized; bool public isApproved; // 优化:用位运算在一个 uint256 中管理所有标志 uint256 private _flags; uint256 constant PAUSED = 1 << 0; // 0x01 uint256 constant FINALIZED = 1 << 1; // 0x02 uint256 constant APPROVED = 1 << 2; // 0x04 function setPaused() internal { _flags |= PAUSED; } function clearPaused() internal { _flags &= ~PAUSED; } function isPaused() public view returns (bool) { return _flags & PAUSED != 0; }
多个布尔状态只占一个 slot,读写都只触发一次 SLOAD/SSTORE。
2 的幂用位移代替乘除
solidityfunction double(uint256 x) public pure returns (uint256) { return x << 1; // 等价于 x * 2,但编译器通常会自动优化 }
现代 Solidity 编译器(0.8.20+)已能自动将 2 的幂乘除优化为位移,手写位移主要是语义明确性考虑,Gas 差异不大。
编译器优化配置
javascript// hardhat.config.js module.exports = { solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200 // 值越小越优化部署 Gas,值越大越优化调用 Gas } } } };
runs 参数的核心逻辑:
runs: 1— 合约只调用一次(如一次性初始化),优先优化部署体积runs: 999999— 合约高频调用(如 DEX 路由),优先优化每次调用的 Gasruns: 200— 默认值,适合大多数场景
Gas 优化速查表
| 优化技术 | 大致节省 | 适用场景 |
|---|---|---|
| 变量打包 | ~20,000 Gas/slot | 多个小变量 |
| constant/immutable | ~2,100 Gas/读取 | 固定值 |
| calldata 替代 memory | ~3,800 Gas/32字节 | external 函数参数 |
| custom error | ~50 Gas/次 | 错误处理 |
| unchecked ++i | ~80 Gas/次 | 循环递增 |
| event 替代 storage | ~19,000 Gas/条 | 链下查询的日志 |
| 缓存数组长度 | ~100 Gas/次 | 循环中 .length |
| 避免零值初始化 | ~200 Gas/条 | 声明变量 |
常用优化工具
- hardhat-gas-reporter — 测试报告中显示每个函数的 Gas 消耗
- Foundry forge gas-report — Foundry 内置 Gas 分析
- Tenderly — 链上交易 Gas 模拟与优化建议
- Remix IDE — 内置 Gas 估算,适合快速验证
实际项目中,先用 gas-reporter 定位热点函数,再针对性优化,比盲目逐条套用规则效率高得多。