Solidity Gas 优化技巧详解
目录
- 变量顺序优化与合并槽
- [使用 constant 和 immutable](#使用 constant 和 immutable "#%E4%BD%BF%E7%94%A8-constant-%E5%92%8C-immutable")
- 精确声明函数可见性
- 避免无限制的循环
- 用事件替代未引用变量
- 减少链上数据存储(IPFS/链下存储)
- 代理合约复用实现
- 链下计算,链上验证
- [用 Merkle 树等结构链上验证](#用 Merkle 树等结构链上验证 "#%E7%94%A8-merkle-%E6%A0%91%E7%AD%89%E7%BB%93%E6%9E%84%E9%93%BE%E4%B8%8A%E9%AA%8C%E8%AF%81")
- [必须用数组时的 gas 优化技巧](#必须用数组时的 gas 优化技巧 "#%E5%BF%85%E9%A1%BB%E7%94%A8%E6%95%B0%E7%BB%84%E6%97%B6%E7%9A%84-gas-%E4%BC%98%E5%8C%96%E6%8A%80%E5%B7%A7")
变量顺序优化与合并槽
📌 Solidity 的存储以 32 字节为一个 slot(槽),多个小于 32 字节的变量可以合并进同一个 slot,减少存储消耗。
📦 Packing(打包)机制原理
- Solidity 会自动将相邻的小变量(如 uint8、uint16、address、bool 等)打包到同一个 32 字节的 slot,只要它们的总大小不超过 32 字节。
- 如果变量声明顺序不合理(比如 uint256 和 uint8 交错),Solidity 会为每个变量单独分配 slot,导致浪费空间和 gas。
🧩 代码示例
solidity
// 不推荐:会浪费存储空间
contract BadPacking {
uint128 a; // 占用 slot 0 的前 16 字节
uint256 b; // 单独占用 slot 1
uint128 c; // 单独占用 slot 2
}
// 推荐:小变量连续声明,合并进同一 slot
contract GoodPacking {
uint256 b; // slot 0
uint128 a; // slot 1 前 16 字节
uint128 c; // slot 1 后 16 字节,与 a 打包
}
- 在 GoodPacking 中,a 和 c 被"打包"进同一个 slot(slot 1),节省了一个 slot 的存储空间和 gas。
⚠️ 注意事项
- 只有相邻的小变量才能被打包,变量顺序很重要。
- 建议将同类型的小变量(如多个 uint128、uint64、bool)声明在一起。
- 虽然打包能省 gas,但过度打包可能影响代码可读性和后期升级(如结构体扩展)。
⚠️ 尽量使用 uint256,EVM 32位系统,32字节,256位, EVM 原生支持,运算更高效。
使用 constant 和 immutable
📌 constant
和 immutable
变量在部署后不可更改,节省存储和访问 gas。
为什么节省 gas?
constant
变量在编译时直接写入字节码,访问时无需读取存储(storage),gas 极低。immutable
变量在部署时赋值,部署后不可更改,值存储在合约字节码的特殊位置,访问比普通 storage 变量便宜。- 普通变量每次访问都要读取 storage,gas 成本高。
constant 与 immutable 的区别
constant
:- 必须在声明时赋值,值在编译期确定。
- 只能用于字面量(如数字、字符串、地址等)。
- 编译后直接内联到字节码,访问几乎不消耗 gas。
immutable
:- 可以在构造函数中赋值,部署后不可更改。
- 适合部署时才能确定的值(如合约 owner、部署时间等)。
- 存储在合约字节码的特殊位置,访问比普通变量便宜,但略高于 constant。
示例
solidity
// 普通变量,每次访问都要读取 storage,gas 高
uint256 public fee = 1 ether;
// constant:编译期确定,访问几乎免费
uint256 public constant FEE = 1 ether;
// immutable:部署时确定,访问便宜
uint256 public immutable deployTime;
constructor() { deployTime = block.timestamp; }
⚠️ 优先使用 constant,只有在部署时才能确定的值再用 immutable。
精确声明函数可见性
📌 明确使用 external
、public
、internal
、private
,可减少不必要的接口生成和 gas 消耗。
可见性修饰符及 gas 差异原理
external
:只能被合约外部调用,参数直接从 calldata 读取,gas 最省(适合大数组参数)。public
:可被外部和内部调用,参数会从 calldata 拷贝到 memory,gas 稍高。internal
:只能被本合约和继承合约内部调用,不生成外部接口,通常用于库函数,gas 低。private
:仅本合约内部可见,不生成外部接口,gas 低。
⚠️
external
比public
省 gas,尤其是参数为数组/字符串时,因为public
会自动生成 memory 拷贝。
示例
solidity
// external 适合外部调用且参数大
function setData(uint[] calldata arr) external { ... }
// public 可外部和内部调用,参数会拷贝到 memory
function getData() public view returns (uint) { ... }
// internal 仅合约内部/继承可用
function _helper() internal pure returns (uint) { ... }
// private 仅本合约可用
function _secret() private view returns (uint) { ... }
gas 差异举例
solidity
contract GasTest {
// external 省 gas
function fooExternal(uint[] calldata arr) external pure returns (uint) {
return arr.length;
}
// public 会拷贝到 memory,gas 更高
function fooPublic(uint[] memory arr) public pure returns (uint) {
return arr.length;
}
}
实际测试:调用 fooExternal 比 fooPublic 便宜,尤其是大数组参数。
避免无限制的循环
📌 循环次数不受限会导致 gas 爆表甚至失败,应避免链上大循环。
⚠️ 为什么要避免变量线性增长?
- 如果合约中有数组、映射等数据结构,其长度或元素数量随着用户操作不断增加,那么涉及遍历、批量操作时,gas 消耗会线性增长。
- 以太坊有单笔交易 gas 上限,变量线性增长最终会导致某些操作(如遍历、清理、分红等)gas 超标,合约"卡死"。
- 最佳实践是每次操作只处理固定数量的数据,或采用链下批量处理+链上验证的方式。
🧩 代码示例
solidity
// 不推荐:随着 users 数量增长,gas 线性增加
address[] public users;
function batchReward() external {
for (uint i = 0; i < users.length; i++) {
// 给每个用户发奖励
}
}
// 推荐:每次只处理一个或固定数量
function reward(address user) external {
// 只给单个用户发奖励
}
✅ 最佳实践
- 尽量避免链上大数组、全量遍历。
- 设计时让每次操作的 gas 消耗是常数级,不随数据量增长。
- 对于需要批量处理的场景,采用"分批处理"或"链下计算+链上验证"模式。
用事件替代未引用变量
📌 合约中未被引用的变量会浪费存储,建议用事件(event)记录。
示例
solidity
// 不推荐
uint256 public unused;
// 推荐
event Action(address indexed user, uint256 value);
function doSomething(uint256 value) external {
emit Action(msg.sender, value);
}
减少链上数据存储(IPFS/链下存储)
📌 大量数据(如图片、文档)建议存储在 IPFS、Arweave 等链下,链上只存哈希。
示例
solidity
// 存储 IPFS 哈希
string public ipfsHash;
function setHash(string calldata hash) external {
ipfsHash = hash;
}
代理合约复用实现
📌 代理合约是一种设计模式,通过将合约逻辑和数据分离,实现逻辑代码的复用和升级,显著节省大规模部署时的 gas。
主要代理模式
- 透明代理(Transparent Proxy):OpenZeppelin 标准,数据和逻辑分离,升级安全,常用于可升级合约。
- UUPS 代理(Universal Upgradeable Proxy Standard):更轻量的升级代理,升级逻辑由实现合约自身管理。
- 最小代理(Minimal Proxy/Clones):EIP-1167 标准,极致节省部署 gas,适合大规模批量部署。
gas 优化原理
- 代理合约只需部署一份逻辑合约(实现合约),后续每次部署只需部署一个极小的代理合约(几十字节),代理通过 delegatecall 复用逻辑合约代码。
- 大规模部署时,节省了重复部署逻辑代码的 gas。
🧩 代码示例
1. 透明代理(OpenZeppelin)
solidity
// 逻辑合约(实现合约)
contract Logic {
uint public value;
function setValue(uint v) external { value = v; }
}
// 部署 ProxyAdmin、TransparentUpgradeableProxy,指向 Logic
// 只需部署一次 Logic,后续只部署 Proxy
2. UUPS 代理
solidity
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract Logic is UUPSUpgradeable {
// ...
function _authorizeUpgrade(address) internal override onlyOwner {}
}
// 只需升级实现合约,代理合约保持不变
3. 最小代理(Clones)
solidity
import "@openzeppelin/contracts/proxy/Clones.sol";
address implementation = ...;
address clone = Clones.clone(implementation); // clone 只几十字节,极省 gas
⚠️ 注意事项
- 代理合约部署时只需重新部署代理本身,逻辑合约只需部署一次。
- 代理合约的存储布局、升级安全需严格遵循 OpenZeppelin 等标准。
- 最小代理适合批量部署同构合约(如 NFT、工厂模式等)。
链下计算,链上验证
📌 复杂计算在链下完成,链上只做验证,减少 gas 消耗。
⚠️ 为什么不推荐链上大数组?
- 数组遍历和操作的 gas 成本随长度线性增长,数据量大时极易超出单笔交易 gas 上限,导致合约"卡死"。
- 链上存储数组元素本身也很昂贵。
- 任何需要全量遍历的操作(如分红、批量转账)都不适合用大数组。
✅ 替代方案
- 链表/可迭代映射:用 mapping + 指针实现链表结构,每次只处理一个或少量节点,gas 恒定。
- 链下计算,链上验证:批量操作在链下完成,链上只做结果验证(如 Merkle 树、批量签名等)。
🧩 代码示例
solidity
// 不推荐:大数组遍历
aaddress[] public users;
function batchReward() external {
for (uint i = 0; i < users.length; i++) {
// gas 随 users.length 增长
}
}
// 推荐:链表结构,每次只处理一个节点
struct Node {
address user;
uint next;
}
mapping(uint => Node) public nodes;
uint public head;
function processNode(uint nodeId) external {
// 只处理单个节点,gas 恒定
}
总结
- 数组适合小数据量、只读场景,不适合大规模链上批量操作。
- 链表/可迭代映射和链下计算+链上验证是更安全、可扩展的设计。
- 这样可以保证每次操作的 gas 消耗是常数级,合约不会因数据量增长而"卡死"。
用 Merkle 树等结构链上验证
📌 用 Merkle 树等结构链上验证大数据集归属,无需存储全部数据。
示例
solidity
// Merkle Proof 验证
function verify(bytes32 leaf, bytes32[] calldata proof, bytes32 root) external pure returns (bool) {
bytes32 hash = leaf;
for (uint i = 0; i < proof.length; i++) {
hash = keccak256(abi.encodePacked(hash, proof[i]));
}
return hash == root;
}
必须用数组时的 gas 优化技巧
📌 某些业务场景下必须用数组时,可以采用以下技巧减少 gas 消耗:
1. 尾删法(Swap and Pop)
- 删除数组中某个元素时,将其与最后一个元素交换,然后 pop 删除最后一个元素,操作 gas 恒定。
- 适用于不要求数组顺序的场景。
solidity
uint[] public arr;
function removeAt(uint index) external {
require(index < arr.length, "out of bounds");
arr[index] = arr[arr.length - 1]; // 用最后一个元素覆盖要删除的
arr.pop(); // 删除最后一个元素
}
2. 分批处理/分页遍历
- 每次只处理数组的一部分,避免单笔交易 gas 超标。
solidity
function batchProcess(uint start, uint count) external {
uint end = start + count;
if (end > arr.length) end = arr.length;
for (uint i = start; i < end; i++) {
// 处理 arr[i]
}
}
3. mapping+数组混合结构
- 数组只存 key,实际数据用 mapping 存储,查找/删除更高效。
solidity
uint[] public keys;
mapping(uint => Data) public dataMap;
function removeKey(uint index) external {
uint key = keys[index];
// ... 处理 dataMap[key]
keys[index] = keys[keys.length - 1];
keys.pop();
delete dataMap[key];
}
4. 只读/快照用途
- 数组只用于存储历史快照或只读数据,避免频繁写入和遍历。