Gas 优化一

Solidity Gas 优化技巧详解


目录


变量顺序优化与合并槽

📌 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

📌 constantimmutable 变量在部署后不可更改,节省存储和访问 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。


精确声明函数可见性

📌 明确使用 externalpublicinternalprivate,可减少不必要的接口生成和 gas 消耗。

可见性修饰符及 gas 差异原理

  • external:只能被合约外部调用,参数直接从 calldata 读取,gas 最省(适合大数组参数)。
  • public:可被外部和内部调用,参数会从 calldata 拷贝到 memory,gas 稍高。
  • internal:只能被本合约和继承合约内部调用,不生成外部接口,通常用于库函数,gas 低。
  • private:仅本合约内部可见,不生成外部接口,gas 低。

⚠️ externalpublic 省 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. 只读/快照用途

  • 数组只用于存储历史快照或只读数据,避免频繁写入和遍历。
相关推荐
这个懒人1 个月前
Solidity语言基础:区块链智能合约开发入门指南
javascript·python·智能合约·solidity
Blockchina1 个月前
区块链交易自动化新时代:实战体验 Maestro 智能机器人
web安全·web3·区块链·智能合约·solidity
Blockchina2 个月前
第 4 章 | Solidity安全 权限控制漏洞全解析
安全·web3·区块链·智能合约·solidity
Blockchina2 个月前
第十四章 | DeFi / DAO / GameFi 项目高级实战
web3·区块链·智能合约·solidity
谭光志2 个月前
如何估算和优化 Gas
web3·区块链·solidity
0x派大星2 个月前
打造更安全的区块链资产管理:Solidity 多重签名机制详解
安全·web3·区块链·智能合约·solidity
Blockchina2 个月前
第十五章 | Layer2、Rollup 与 ZK 技术实战解析
python·web3·区块链·智能合约·solidity
Blockchina2 个月前
第 2 章 | 智能合约攻击图谱全景解析
web3·区块链·智能合约·solidity·区块链安全