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

  • 数组只用于存储历史快照或只读数据,避免频繁写入和遍历。
相关推荐
木西15 小时前
使用 Hardhat V3 框架构建智能合约项目全指南
web3·智能合约·solidity
许强0xq2 天前
Robinhood的再进化:从零佣金交易到链上金融超级应用
金融·web3·区块链·智能合约·solidity·dapp·去平台化时代
天涯学馆4 天前
Solidity自毁合约:让你的区块链代码优雅谢幕
智能合约·solidity·以太坊
小攻城狮长成ing4 天前
从0开始学区块链第10天—— 写第二个智能合约 FundMe
web3·区块链·智能合约·solidity
野老杂谈5 天前
【Solidity 从入门到精通】第3章 Solidity 基础语法详解
web3·solidity
野老杂谈5 天前
【Solidity 从入门到精通】第2章 Solidity 语言概览与环境搭建
web3·区块链·智能合约·solidity·remix ide
野老杂谈6 天前
【Solidity 从入门到精通】前言
web3·智能合约·solidity·以太坊·dapp
许强0xq7 天前
Solidity 的十年与重生:从 Classic 到 Core
web3·区块链·智能合约·solidity·以太坊
小攻城狮长成ing7 天前
从0开始学区块链第12天—如何使用可见性标识符
web3·区块链·智能合约·solidity·以太坊
野老杂谈11 天前
如何快速学习智能合约开发语言 Solidity
开发语言·学习·智能合约·solidity·以太坊·区块链开发