How to optimize Gas consumption when writing smart contracts?

Hans-Helmut Kraus
Hans-Helmut Kraus
Ethereum smart contract auditor and security expert; 以太坊智能合约审计师与安全专家。

好的,没问题!咱们来聊聊这个话题,这可是每个 Solidity 开发者都绕不开的坎。Gas 优化做得好,不仅能让你的 DApp 更受用户欢迎(谁也不想花冤枉钱),也是你技术水平的体现。


编写智能合约时,如何优化Gas消耗?老司机带你省钱!

嘿,朋友!刚开始写智能合约?那咱们得聊聊 Gas 这个小妖精。

在以太坊上跑代码,就像开车。每一步操作(比如存个数据、做个计算)都要耗“油”(Gas),而这个“油”是要花真金白银(ETH)买的。你的合约代码写得越“笨重”,用户用起来就越“耗油”,手续费就越高。久而久之,就没人愿意用你的产品了。

所以,Gas 优化不是什么高深莫测的技术,它就是省钱的艺术。下面我给你分享一些我压箱底的实战技巧,保证通俗易懂。

一、 精打细算你的“仓库”(Storage)—— 这是最花钱的地方!

以太坊最贵的操作就是往链上存东西(写入 storage)。把它想象成一个极其昂贵的云端硬盘,存一个字节都肉疼。

1. 数据打包 (Struct Packing)

以太坊虚拟机(EVM)处理数据是按“槽”(Slot)来的,一个槽有32字节(256位)。

错误示范:

// 这会占用两个槽,因为uint256自己就占满了32字节
struct User {
    uint128 id;      // 占用一个新槽
    uint256 balance; // 占用另一个槽
    uint128 timestamp; // 又占用一个新槽
}

省钱做法:

// 这样只占用两个槽
struct User {
    uint128 id;      // 和下面的timestamp打包在一个槽里
    uint128 timestamp; // (128+128 = 256位 = 32字节 = 1个槽)
    uint256 balance; // 自己占用一个槽
}

一句话总结: Solidity 会把能塞进一个32字节“箱子”的变量打包到一起。所以,把小尺寸的变量(比如 uint128, bool, address)挨着写,它们就会被自动打包,从而节省存储空间。

2. 尽量使用 memorycalldata
  • storage:链上硬盘,永久存储,最贵。
  • memory:内存,函数执行完就没了,次贵。
  • calldata:函数外部调用的参数存放区,只读,最便宜。

原则: 如果一个数据只是在函数执行过程中临时用一下,或者只是作为参数传进来读一下,千万不要把它存到 storage 里。

省钱做法:

// external 函数的数组参数,用 calldata 最省钱
function sum(uint[] calldata numbers) external pure returns (uint) {
    uint total = 0;
    // 这里 numbers 没有被复制到 memory,直接从 calldata 读取,省了一大笔
    for(uint i = 0; i < numbers.length; i++) {
        total += numbers[i];
    }
    return total;
}
3. 使用 constantimmutable

如果一个变量的值从一开始就确定了,再也不会变,那就用这两个关键字。

  • constant:编译时就确定的常量(比如一个固定的费率 uint public constant RATE = 5;)。
  • immutable:部署时(在 constructor 里)才确定的“常量”(比如项目方的钱包地址)。

好处: 这些变量的值会直接“写死”在合约的字节码里,读取它们的时候不涉及 storage 操作,Gas 消耗极低。就像把信息刻在石头上,看一眼就行,不用每次都去仓库里翻箱倒柜。

二、 优化你的“行动逻辑”(Execution)—— 让代码跑得更轻快

除了存储,代码的执行逻辑也消耗Gas。

1. external 优于 public

如果一个函数只会被外部账户调用(比如用户通过钱包调用),而不是被合约内部的其他函数调用,那就把它声明为 external

为什么? public 函数的参数会被自动拷贝到 memory 中,而 external 函数的参数直接从 calldata 读取,省去了拷贝的开销。这个小改动,积少成多。

2. 小心循环(Loops)

循环是 Gas 杀手,特别是当循环的次数不确定或可能非常大时。如果你的循环是基于一个数组,而这个数组可以被用户无限撑大,那迟早有一天会因为 Gas 超出区块上限而无法执行。

原则:

  • 避免在循环里执行存储操作(SSTORE)。
  • 尽量让用户分批次处理数据,而不是一次性在一个循环里处理所有。
3. 使用 unchecked 数学运算 (Solidity 0.8+)

从 Solidity 0.8.0 版本开始,所有的数学运算都会默认检查上溢和下溢,这会增加一点点 Gas 开销。

如果你 100% 确定 你的运算不会溢出(比如,i++ 在一个 for 循环里,你知道 i 永远不会达到 uint 的最大值),你可以用 unchecked 代码块把它包起来。

省钱做法:

// 在你非常有把握的情况下使用
unchecked {
    for (uint i = 0; i < length; i++) {
        // ... 这里的 i++ 不会进行溢出检查,更省 Gas
    }
}

警告: 这就像开车关掉了ABS,你得自己对安全负责。用错了会导致灾难性的后果!

三、 架构层面的“降维打击”

有时候,再怎么优化代码细节,也不如从架构上思考。

1. 善用事件(Events)

不是所有数据都需要存在链上的 storage 里。很多时候,我们只是想“通知”外界发生了某件事。

例子: 一个用户完成了一笔交易。你真的需要把交易的每一条细节都存到 storage 里吗?不一定。

省钱做法:

event Trade(address indexed user, uint amount, uint price);

function executeTrade(uint amount, uint price) external {
    // ... 执行交易逻辑 ...

    // 把交易细节作为“日志”发射出去,而不是存起来
    emit Trade(msg.sender, amount, price);
}

链下的服务(比如你的网站前端)可以监听这些事件,然后把数据存到传统的数据库里供查询。这样既达到了记录历史的目的,又节省了海量的 Gas。

记住: 智能合约是你的“状态机”,只负责维护最重要的核心状态。事件是你的“广播系统”,负责通知世界发生了什么。

总结:省Gas的终极奥义

  1. 少存储,多计算:存储是天价,计算相对便宜。
  2. 链上做验证,链下做计算:复杂计算(如排序、数据处理)可以放到链下完成,然后把结果和证明提交到链上,让合约只做一个简单的验证工作。
  3. 终极大法:能不上链的,就别上链!

希望这些经验之谈能帮你写出更高效、更省钱的合约。挖矿不易,能省则省嘛!祝你编码愉快!