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 读取

solidity
uint256[] 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 的幂用位移代替乘除

solidity
function 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 路由),优先优化每次调用的 Gas
  • runs: 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 定位热点函数,再针对性优化,比盲目逐条套用规则效率高得多。

标签:Solidity