Solidity中的Gas优化最佳实践

1. 为什么需要 Gas 优化

在以太坊上开发智能合约时,Gas 是一个绕不开的概念。 它既不是单纯的“手续费”,也不仅仅是网络拥堵时的临时成本,而是对合约设计质量的一种长期约束

很多开发者第一次关注 Gas,往往是在以下场景中:

  • 合约部署费用异常高
  • 用户调用某个函数时频繁 Out of Gas
  • 同样的功能,不同实现方式的成本差异明显

这些问题通常并非出现在业务逻辑上,而是源于对 EVM 成本模型缺乏直觉

1.1 Gas 的两层含义

在讨论优化之前,必须先区分两个容易混淆的概念:

  • gas used:执行一笔交易实际消耗的 Gas 单位数量
  • gas price:你愿意为每个 Gas 单位支付的价格

合约代码本身只能影响 gas used,而无法控制 gas price。

这意味着:

  • 网络拥堵会推高 gas price,但不会改变合约的 gas used
  • 一个设计不佳的合约,在任何网络环境下都会更贵
  • 在 gas price 较高的时期,低效设计的成本差距会被进一步放大

因此,Gas 优化并不是为了“赌网络状况”,而是为了让每次执行尽可能少地消耗 Gas 单位

1.2 为什么“功能正确”并不等于“成本合理”

在传统软件中,只要程序运行正确,性能问题往往可以后置优化。 但在智能合约中,性能就是成本

一个合约即使:

  • 没有安全漏洞
  • 功能完全符合预期
  • 能通过所有测试

仍然可能因为以下原因变得难以使用:

  • 某些函数在链上执行成本过高
  • 高峰期交易失败率上升
  • 长期来看,用户为相同功能支付了不必要的费用

这类问题往往不是“写错了代码”,而是在设计阶段忽略了 Gas 的结构性成本

1.3 Gas 优化的目标是什么

Gas 优化并不是追求“极致便宜”,而是服务于三个更现实的目标:

  1. 降低长期使用成本 高频调用的函数,哪怕节省几百 Gas,长期也会累积显著差异。

  2. 提高交易成功率 Gas 消耗越可控,越不容易在复杂路径中触发 Out of Gas。

  3. 提升成本的可预测性 让调用者更容易估算所需 Gas,减少不确定性。

这也是为什么 Gas 优化通常应当优先作用在:

  • 高频路径
  • 核心业务逻辑
  • 用户直接支付成本的函数

1.4 何时不应该过度优化

需要明确的是,Gas 优化有明显的边际递减效应

以下情况通常不值得:

  • 为了节省极少量 Gas,引入复杂且晦涩的写法
  • 在低频、冷路径上做大量微优化
  • 牺牲安全检查或可读性来换取微小收益

合理的原则是:

先写出安全、清晰、可维护的代码,再在“真正昂贵的地方”做优化。

在大多数情况下,理解并避免高成本结构,比记住零散技巧更重要。

1.5 接下来要做什么

接下来的章节将从最基础的问题开始:

  • EVM 到底在为什么操作收费
  • 哪些指令最贵,哪些几乎可以忽略
  • 为什么 storage 读写是 Gas 成本的核心

理解这些原理之后,后续的所有优化实践都会变得自然,而不是依赖记忆规则。

2. Gas 成本模型

在讨论具体的优化技巧之前,有必要先建立一个清晰的成本直觉:EVM 并不是所有操作都同样昂贵。 很多看起来“简单”的 Solidity 代码,之所以 Gas 消耗很高,原因往往不在业务逻辑本身,而在于它触发了高成本的底层指令。

理解这一节内容的目标只有一个: 知道哪些操作值得被重点避免或合并,哪些操作几乎可以忽略不计。

2.1 什么是指令级收费

EVM 是一台基于栈的虚拟机。Solidity 代码在部署或调用前,会被编译为一系列 EVM 指令(opcode),例如:

  • ADD、SUB、LT 等算术或比较指令
  • MLOAD、MSTORE 等内存操作
  • SLOAD、SSTORE 等 storage 操作
  • CALL、DELEGATECALL 等外部调用

Gas 的计算完全发生在指令层面,而不是在 Solidity 语法层面。这意味着:

  • 一行 Solidity 代码可能对应多条指令
  • 不同写法即使“看起来一样”,编译后的指令序列也可能不同
  • Gas 的差异,来自指令类型和数量,而不是代码长度

因此,Gas 优化本质上是在做一件事: 让高成本指令执行得更少。

2.2 成本层级

从成本角度,可以粗略把 EVM 中的操作分为几个层级(从低到高):

  • 纯计算(算术、比较、位运算)
  • 内存(memory)读写
  • calldata 读取
  • storage 读取(SLOAD)
  • storage 写入(SSTORE)
  • 外部调用与返回大量数据

其中最重要的一点是:

storage 操作的成本,远高于绝大多数计算操作。

这也是为什么很多 Gas 优化最终都会指向同一个方向: 减少 storage 的访问次数。

2.3 Storage 是什么,为什么这么贵

在 Solidity 中,所有状态变量都会存储在 storage 中。 从 EVM 的视角来看,storage 是一张巨大的键值表:

  • key:storage slot 的位置
  • value:32 字节的数据

storage 的特点是:

  • 数据是永久存在的
  • 会影响全局状态树
  • 所有全节点都必须对其状态达成共识

每一次 storage 的写入,都意味着对整个系统状态的一次修改,这正是它昂贵的根本原因。

2.4 读取 storage 的真实成本

当你读取一个状态变量时,例如:

uint256 x = count;

编译后的关键指令是 SLOAD

自 EIP-2929 之后,SLOAD 的成本分为两种情况:

  • 冷访问(cold access): 在一次交易中,第一次访问某个 storage slot
  • 热访问(warm access): 在同一交易中,再次访问已经读过的 slot

直觉上可以理解为:

  • 第一次读取某个状态变量,EVM 需要“把它带进来”
  • 后续再读同一个变量,成本会降低,但仍然不便宜

即使是热访问,SLOAD 的成本也明显高于内存或算术操作。

2.5 为什么写 storage 是最贵的操作之一

当你修改一个状态变量时,例如:

count = count + 1;

这并不是一次简单的“加一”,而是一个完整的读–改–写过程:

  1. SLOAD:读取原始值
  2. 执行加法
  3. SSTORE:写入新值

SSTORE 的成本取决于写入前后的状态,例如:

  • 从 0 写成非 0:成本最高
  • 从非 0 改为非 0:次之
  • 从非 0 改为 0:成本较低,并可能获得退款

这些规则的存在,本质上是为了鼓励合约释放不再使用的状态。

2.6 一行 Solidity 代码背后的真实执行过程

来看一个非常常见的写法:

count += 1;

从 Solidity 的角度看,它只是一次简单的自增。 但从 EVM 的角度看,它通常意味着:

  • 一次 SLOAD
  • 一次加法指令
  • 一次 SSTORE

如果在同一个函数中多次写出类似代码:

count += 1;
count += 1;
count += 1;

你并不是做了三次加法,而是触发了:

  • 3 次 SLOAD
  • 3 次 SSTORE

这正是 Gas 消耗迅速放大的原因。

2.7 建立一个关键直觉

到这里,可以总结出一个非常重要的直觉:

  • 算术和逻辑运算通常不是 Gas 的瓶颈
  • storage 的读写,才是 Gas 成本的核心来源
  • 多次重复访问同一个 storage slot,是最常见也最容易忽视的浪费

一旦你建立起这个直觉,后续关于缓存变量、合并写入、storage packing 等优化方式,都会显得顺理成章,而不是技巧堆砌。

3. 减少 Storage 读写

在理解了 EVM 的成本模型之后,Gas 优化的优先级其实已经非常清晰: 只要能减少 storage 的读写次数,几乎一定能获得显著的 Gas 收益。

所有的gas优化技巧大多围绕一个核心目标展开: 让 SLOAD 和 SSTORE 尽可能少地执行。

3.1 为什么 Storage 优化具有最高性价比

前面我们讨论过:

  • 算术运算非常便宜
  • 内存操作成本中等
  • storage 写入是最昂贵的操作之一

这意味着:

  • 省掉一次 SSTORE,往往比优化十几行计算代码更有价值
  • 优化 storage 访问,收益通常是数量级上的

因此,在实际工程中,Gas 优化的顺序应当是:

  1. 先看是否能减少 storage 读写
  2. 再考虑循环、参数、位运算等次级优化

3.2 缓存多次读取:把多次 SLOAD 变成一次

最常见、也最容易忽视的低效写法,是在同一个函数中多次读取同一个状态变量。

例如:

function increment() external {
    require(count < max, "too large");
    count = count + 1;
    emit Updated(count);
}

在这段代码中,count 实际上被读取了多次:

  • require 中读取一次
  • 自增时读取一次
  • 事件参数中再读取一次

更高效的写法是先将其缓存到局部变量:

function increment() external {
    uint256 c = count;
    require(c < max, "too large");
    c = c + 1;
    count = c;
    emit Updated(c);
}

这样做的结果是:

  • storage 只读一次
  • storage 只写一次
  • 后续操作都在内存中完成

这种改动几乎不影响可读性,却能显著减少 Gas。

3.3 合并多次写入:避免重复的 SSTORE

另一类常见问题,是在同一个函数中多次写入同一个状态变量。

例如:

function update(uint256 x) external {
    value += x;
    if (x > threshold) {
        value += bonus;
    }
}

表面上看,这段代码逻辑清晰,但它可能会对 value 执行多次 SSTORE。

更合理的写法是先在内存中完成所有计算:

function update(uint256 x) external {
    uint256 v = value;
    v += x;
    if (x > threshold) {
        v += bonus;
    }
    value = v;
}

原则可以总结为一句话:

不要在逻辑分支中反复写 storage,先算清楚,再一次性写回。

3.4 循环中的 storage 读写陷阱

循环是 storage 读写最容易被放大的地方。

考虑下面的写法:

function sum(uint256[] calldata arr) external {
    for (uint256 i = 0; i < arr.length; i++) {
        total += arr[i];
    }
}

在这个循环中:

  • 每次迭代都会读取并写入 total
  • 如果数组长度为 n,就会触发 n 次 SLOAD 和 n 次 SSTORE

更高效的写法是:

function sum(uint256[] calldata arr) external {
    uint256 t = total;
    uint256 len = arr.length;

    for (uint256 i = 0; i < len; i++) {
        t += arr[i];
    }

    total = t;
}

这种写法的改动非常小,但对 Gas 的影响会随着数组长度线性放大。

3.5 避免在循环中写 storage 的设计思路

在设计合约时,应当尽量避免以下模式:

  • 在不受限的循环中写 storage
  • 每次迭代都更新状态
  • 循环次数由外部输入完全控制

更好的替代方案包括:

  • 先在内存中计算,再一次性写回
  • 设计批处理接口,但限制每次调用的最大数量
  • 将复杂计算移到链下,只在链上验证结果

这些并不是“写法技巧”,而是设计阶段就应当考虑的结构性问题

3.6 用 mapping 替代数组

在合约里,“数组还是 mapping”并不只是编码风格差异,而是成本模型差异:

  • 数组常见操作(查找、去重、删除某个元素)通常需要遍历,成本是 O(n),并且容易触发不受限循环
  • mapping 的读写是按 key 直接定位,成本接近 O(1),更稳定、更可控

场景 1:成员判断(contains)

不推荐:用数组存储成员并在链上查找

address[] public members;

function isMember(address a) public view returns (bool) {
    for (uint256 i = 0; i < members.length; i++) {
        if (members[i] == a) return true;
    }
    return false;
}

问题:

  • 每次判断都要遍历
  • 成员越多越贵
  • 最坏情况下可能 Out of Gas

推荐:用 mapping 做存在性判断

mapping(address => bool) public isMember;

function addMember(address a) external {
    isMember[a] = true;
}

function removeMember(address a) external {
    isMember[a] = false;
}

优点:

  • 判断存在性是 O(1)
  • 成本可预测
  • 不需要循环

场景 2:需要“可枚举”的集合(既要 O(1) 判断,又要列出所有成员)

很多业务既需要 isMember[a] 这种 O(1) 判断,也需要枚举所有成员(给前端展示)。这时可以用“mapping + 数组”组合结构:

mapping(address => bool) public isMember;
address[] public memberList;
mapping(address => uint256) private indexPlusOne; // 下标+1,0 表示不存在

function addMember(address a) external {
    if (indexPlusOne[a] != 0) return; // 已存在
    isMember[a] = true;
    memberList.push(a);
    indexPlusOne[a] = memberList.length; // 存的是 index+1
}

function removeMember(address a) external {
    uint256 idxPlusOne = indexPlusOne[a];
    if (idxPlusOne == 0) return;

    uint256 idx = idxPlusOne - 1;
    uint256 last = memberList.length - 1;

    if (idx != last) {
        address lastAddr = memberList[last];
        memberList[idx] = lastAddr;
        indexPlusOne[lastAddr] = idx + 1;
    }

    memberList.pop();
    indexPlusOne[a] = 0;
    isMember[a] = false;
}

解释:

  • mapping 负责 O(1) 判断与定位
  • array 负责枚举
  • 删除用 swap-and-pop,避免 O(n) 移动

注意:

  • 这种结构会引入额外存储(索引 mapping),但换来的是操作复杂度和成本可控,通常非常值得

什么时候不该用数组

如果你发现你在数组上做这些操作,基本就该考虑 mapping:

  • contains/查找
  • 去重
  • 删除指定元素
  • 防重复写入
  • 任何“长度可能增长且由用户输入驱动”的遍历逻辑

一句话总结:

数组适合“顺序数据”和“按下标访问”,mapping 适合“按 key 查询/去重/存在性判断”。当你需要查找或删除时,mapping 往往更省 Gas,也更安全。

4. 数据位置与函数接口设计

在减少了不必要的 storage 读写之后,下一类非常值得关注的优化点是: 函数的接口设计,包括参数的数据位置(data location)和函数的可见性(visibility)。

这些选择通常不会改变业务逻辑,但却会直接影响:

  • 是否发生不必要的数据拷贝
  • 是否触发额外的编码 / 解码
  • 函数调用在 EVM 中走的是哪条路径

合理的接口设计,往往是“低风险、高收益”的 Gas 优化。

4.1 三种数据位置的成本直觉

在 Solidity 中,引用类型(数组、struct、string、bytes)必须显式或隐式指定数据位置:

  • calldata:只读,位于调用数据中
  • memory:可读写,函数执行期间存在
  • storage:永久存储在链上

从成本角度,可以建立一个简单直觉:

  • calldata 读取:便宜
  • memory 读写:中等
  • storage 读写:昂贵

因此,一个基本原则是:

能用 calldata 就不要用 memory,能用 memory 就不要用 storage。

4.2 external 函数与 calldata

对于只从外部调用的函数,最推荐的写法是:

function process(uint256[] calldata data) external {
    // 使用 data
}

原因在于:

  • external 函数的参数天然来自 calldata
  • 使用 calldata 不需要将参数复制到 memory
  • 对于大数组或复杂结构,拷贝成本差异非常明显

相反,如果写成:

function process(uint256[] memory data) public {
    // 使用 data
}

即使你并未修改 data,编译器仍然需要:

  • 将 calldata 中的数据完整复制到 memory
  • 为此支付额外的 Gas 成本

4.3 什么时候必须使用 memory

calldata 的限制也非常明确:只读

一旦你的函数需要:

  • 修改数组内容
  • 排序
  • 去重
  • 动态构造新数组

就必须使用 memory。

例如:

function normalize(uint256[] calldata data) external returns (uint256[] memory) {
    uint256[] memory result = new uint256[](data.length);
    for (uint256 i = 0; i < data.length; i++) {
        result[i] = data[i] / 2;
    }
    return result;
}

这里的关键点是:

  • 输入参数使用 calldata(避免拷贝)
  • 输出结果使用 memory(必须可写)

这是一种非常常见、也非常合理的组合。

4.4 函数可见性的 Gas 含义

函数可见性不仅影响可调用范围,也会影响 Gas。

可以从以下角度理解:

  • external:直接从 calldata 读取参数,最省 Gas
  • public:参数会被复制到 memory,成本更高
  • internal:编译期内联或直接跳转,最便宜
  • private:与 internal 类似,但仅限当前合约

一个重要结论是:

public 并不是“内外通用的最优选择”。

4.5 external 入口 + internal 实现

在实际工程中,最推荐的模式是:

  • 对外暴露的函数使用 external
  • 将核心逻辑提取到 internal 函数中

例如:

function update(uint256 x) external {
    _update(x);
}

function _update(uint256 x) internal {
    // 核心逻辑
}

这样做的好处包括:

  • external 函数使用 calldata,参数拷贝最少
  • internal 函数调用成本极低
  • 内部调用和外部调用共享同一份逻辑

这种模式几乎没有副作用,却能避免很多隐性的 Gas 浪费。

4.6 为什么要避免 this 调用当前合约

一个非常隐蔽但代价很高的写法是:

this.update(x);

即使 update 定义在当前合约中,这种写法也会:

  • 触发一次完整的 external call
  • 进行 ABI 编码和解码
  • 走 CALL 指令路径

这意味着:

  • 更高的 Gas 成本
  • 更复杂的执行路径
  • 潜在的可重入风险

如果你发现自己需要 this.foo(),通常意味着:

  • 逻辑划分不合理
  • internal 函数抽象不充分

正确的重构方式,是将逻辑提取为 internal 函数,并在 external 函数中调用它。

4.7 一个对比示例

对比下面两种实现:

不推荐的写法:

function foo(uint256[] memory data) public {
    // 使用 data
}

function bar(uint256[] memory data) public {
    foo(data);
}

推荐的写法:

function foo(uint256[] calldata data) external {
    _foo(data);
}

function bar(uint256[] calldata data) external {
    _foo(data);
}

function _foo(uint256[] calldata data) internal {
    // 使用 data
}

第二种写法在以下方面更优:

  • 参数不被重复拷贝
  • 逻辑集中,避免重复
  • internal 调用成本更低

5. 状态布局设计:Storage Packing

在前几节中,我们讨论的优化大多发生在“如何使用状态变量”。 这一节关注一个更偏设计层面的问题:状态变量是如何被放进 storage 的

很多 Gas 浪费并不是来自频繁读写,而是来自状态布局本身不合理,导致:

  • 使用了更多的 storage slot
  • 每次读写都触发更多的 SLOAD / SSTORE
  • 合约长期运行成本被放大

5.1 Storage slot 与 32 字节对齐

从 EVM 的角度看,storage 是以 32 字节(256 bit)为一个 slot 来组织的。

Solidity 的状态变量会按照声明顺序,依次放入这些 slot 中:

  • 如果变量大小小于 32 字节,编译器会尝试将多个变量放进同一个 slot
  • 如果当前 slot 剩余空间不足,变量会被放到下一个 slot
  • 一旦一个 slot 被填满,就不会再继续向其中塞变量

这一机制被称为 storage packing

5.2 一个最基础的打包示例

考虑下面的变量声明:

uint128 a;
uint128 b;
uint256 c;

每个 uint128 占 16 字节,因此这两个变量可以共享同一个 storage slot。uint256 独占 16 字节,因此这三个变量总共使用了 2 个 slot。

但如果顺序稍有不同:

uint128 a;
uint256 c;
uint128 b;

那么:

  • a 占用 slot0 的前 16 字节
  • c 独占 slot1
  • b 由于 slot0 剩余空间不足,只能进入 slot2

现在这三个变量总共使用了 3 个 slot。仅仅因为声明顺序不同,就多消耗了一个 slot。

5.3 为什么 slot 数量直接影响 Gas

每一个额外的 storage slot,都会带来长期成本:

  • 读取更多 slot → 更多 SLOAD
  • 写入更多 slot → 更多 SSTORE
  • 结构体整体读写成本上升

尤其是在:

  • 高频调用函数
  • 需要整体复制或更新 struct 的场景中

slot 数量的差异,会直接体现在 Gas 消耗上。

5.4 小类型并不总是“越小越好”

需要注意的是:

  • 选择小于 256 bit 的类型,并不一定自动省 Gas
  • 只有在成功打包的前提下,小类型才有意义

例如:

uint128 a;
uint256 b;

即使 auint128,它依然会独占一个 slot,因为后面紧跟着一个 uint256

因此,类型选择和声明顺序应当结合考虑,而不是孤立决策。

5.5 什么时候应该关心 storage packing

并不是所有合约都需要精细打包。 storage packing 不仅适用于合约级变量,也同样适用于 struct。

storage packing 尤其适合以下场景:

  • 状态变量数量较多
  • 使用大量 struct
  • 状态会被频繁读写
  • 合约生命周期较长

而在状态极少、只部署一次、很少交互的合约中,过度调整字段顺序的收益可能有限。

6. 循环与批处理

在前几节中,我们已经看到: storage 读写本身很贵,而循环会把这种成本按次数放大。 因此,循环往往不是 Gas 的来源,但却是 Gas 的“放大器”。

不是所有循环都是问题,但不受控制的循环几乎一定会成为问题。

6.1 为什么循环容易成为 Gas 黑洞

从 EVM 的角度看,循环并不是一个特殊结构,它只是:

  • 重复执行一段指令序列
  • 每一次迭代都会完整支付指令成本

如果循环体中包含:

  • storage 读写
  • 昂贵的计算
  • 外部调用

那么 Gas 消耗就会与循环次数线性增长。

当循环次数由外部输入控制时,风险尤其明显。

6.2 缓存数组 length 与中间结果

一个非常常见、也非常基础的优化点,是缓存数组的长度。

例如:

function sum(uint256[] calldata arr) external {
    uint256 s = 0;
    for (uint256 i = 0; i < arr.length; i++) {
        s += arr[i];
    }
}

虽然 arr.length 看起来很轻量,但在每次循环判断中,都会被重新读取。

更好的写法是:

function sum(uint256[] calldata arr) external {
    uint256 s = 0;
    uint256 len = arr.length;

    for (uint256 i = 0; i < len; i++) {
        s += arr[i];
    }
}

这类优化在单次调用中节省的 Gas 不多,但在高频或大数组场景下,会逐渐显现差异。

6.3 避免在循环中直接写 storage

如前几节所强调的,在循环中写 storage 是非常昂贵的。

原则可以总结为:

循环中尽量只做内存计算,把 storage 写入放到循环之外。

6.4 在安全前提下使用 unchecked

从 Solidity 0.8 开始,整数运算默认包含溢出检查。 这对安全非常有价值,但在某些场景中,也会带来不必要的 Gas 开销。

一个典型场景是 for 循环的计数器:

for (uint256 i = 0; i < len; i++) {
    // ...
}

如果你能明确保证:

  • i 不会接近 type(uint256).max
  • 循环条件有明确上界

那么可以使用:

for (uint256 i = 0; i < len; ) {
    // ...
    unchecked {
        i++;
    }
}

这类优化的收益不如减少 storage 读写明显,但在大循环中仍然是可测量的。

6.5 避免无上限循环

在设计合约接口时,应当尽量避免:

  • 循环次数完全由用户输入决定
  • 没有任何上界或约束

例如:

function process(uint256[] calldata items) external {
    for (uint256 i = 0; i < items.length; i++) {
        // ...
    }
}

如果 items.length 没有被限制,调用者可以传入极大的数组,导致:

  • 调用失败(Out of Gas)
  • 合约在某些情况下“不可用”

常见的改进方式包括:

  • 明确限制最大长度
  • 将操作拆分为多次调用
  • 提供分页或游标式接口

6.6 批处理(Batch)设计的取舍

批处理是减少交易次数、摊薄固定成本的常见手段,但它并不是没有代价。

优点包括:

  • 减少外部调用次数
  • 摊薄函数入口和校验成本

风险包括:

  • 单笔交易 Gas 不可控
  • 更容易触发 OOG
  • 更难估算 Gas 上限

因此,批处理接口通常应当具备:

  • 明确的单次处理上限
  • 可预期的最坏情况成本
  • 清晰的失败行为

6.7 一个批处理示例

不推荐的写法:

function batchUpdate(uint256[] calldata ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        update(ids[i]);
    }
}

改进后的写法:

uint256 constant MAX_BATCH = 100;

function batchUpdate(uint256[] calldata ids) external {
    uint256 len = ids.length;
    require(len <= MAX_BATCH, "too many items");

    for (uint256 i = 0; i < len; ) {
        _update(ids[i]);
        unchecked {
            i++;
        }
    }
}

这里的关键不是“省多少 Gas”,而是:

  • 成本可控
  • 行为可预测
  • 接口对调用者友好

7. 错误处理与字节码体积优化

在讨论 Gas 优化时,很多人会把注意力集中在“成功执行路径”上,而忽略了失败路径和合约本身体积的成本。 实际上,错误处理方式不仅影响交易失败时的 Gas 消耗,也会影响:

  • 合约部署成本
  • 每次调用的基础开销
  • 字节码大小与可维护性

这一节将聚焦一个非常具体但收益稳定的优化点:如何更高效地处理错误和回滚。

7.1 revert 本身并不是“免费”的

当一笔交易 revert 时,状态会被回滚,但 Gas 并不会全部返还。 尤其是以下几类成本:

  • 已执行指令消耗的 Gas
  • 错误信息本身携带的数据
  • 与 ABI 编码相关的开销

因此,一个频繁触发的校验逻辑,其失败路径的成本,同样值得认真对待。

7.2 require(string) 的真实代价

最传统、也最常见的错误处理方式是:

require(msg.sender == owner, "Not owner");

这种写法的问题不在于功能,而在于成本:

  • 错误字符串会被编译进合约字节码
  • 每一次 revert 都需要返回这段字符串数据
  • 字符串越长,部署成本和失败成本越高

在复杂合约中,大量使用 require(string) 会显著增加 bytecode 体积。

7.3 使用 custom error 的动机

从 Solidity 0.8.4 开始,引入了 custom error

error NotOwner();

并配合:

if (msg.sender != owner) revert NotOwner();

这种写法的优势在于:

  • 不需要存储字符串
  • 错误标识以 selector 形式存在
  • revert 时返回的数据更小

从成本角度看,它同时降低了:

  • 部署 Gas
  • revert 路径的 Gas

7.4 对比示例

考虑一个最简单的权限校验。

使用 require(string)

function withdraw() external {
    require(msg.sender == owner, "Not owner");
    // ...
}

使用 custom error:

error NotOwner();

function withdraw() external {
    if (msg.sender != owner) revert NotOwner();
    // ...
}

两种写法在成功路径上的 Gas 几乎相同,但在以下方面存在差异:

  • 合约部署体积
  • revert 时的 Gas 消耗
  • 错误信息的编码方式

在高频调用或复杂合约中,这种差异会逐渐积累。

7.5 错误信息该写多“详细”

一个常见误区是: 错误信息越详细越好。

从链上执行的角度看,这并不总是成立。

更合理的分工是:

  • 链上:提供简洁、结构化的错误标识
  • 链下:通过文档或映射表解释错误含义

custom error 非常适合这种模式,因为它:

  • 本身就是结构化的
  • 可携带参数
  • 便于前端或 SDK 解码

例如:

error InsufficientBalance(uint256 available, uint256 required);

7.6 字节码体积为什么值得关注

合约字节码体积会直接影响:

  • 部署成本
  • 部署是否成功(有大小上限)
  • 每次调用的基础 Gas(代码越大,加载成本越高)

以下写法都会增加字节码体积:

  • 大量字符串常量
  • 重复的逻辑分支
  • 冗长的错误信息

因此,Gas 优化不仅是“执行时优化”,也包括部署时优化

7.7 错误处理与可读性的平衡

需要强调的是:

  • custom error 并不是为了“压缩到极限”
  • 也不意味着完全放弃可读性

合理的做法是:

  • 对外暴露的核心接口:使用清晰的 custom error
  • 内部断言或开发阶段检查:适度使用 require
  • 避免在错误信息中携带冗长文本

8. 事件与返回值设计

在智能合约中,事件(event)和函数返回值常被用于“对外提供信息”。 但如果设计不当,它们很容易成为 隐性的 Gas 消耗来源,尤其是在高频调用或数据量较大的场景中。

这一节的核心观点可以先给出:

区块链擅长做状态验证,不擅长做数据查询。

理解这一点,有助于你在事件和返回值设计上做出更经济的选择。

8.1 事件的作用边界

事件的主要用途是:

  • 供链下系统监听和索引
  • 记录重要的状态变化
  • 作为审计和分析的依据

事件不会被合约在链上读取,也不会影响后续执行逻辑。 因此,从合约执行的角度看,事件是“写一次、只给链下用”的数据。

这意味着一个设计原则:

事件应当服务于链下,而不是替代链上状态查询。

8.2 事件的 Gas 成本构成

一个事件的 Gas 成本主要由两部分组成:

  1. topics

    • 包括事件签名
    • 以及最多 3 个 indexed 参数
  2. data

    • 非 indexed 的参数
    • 按字节数计费

直觉上可以这样理解:

  • indexed 参数更利于过滤和查询
  • indexed 并不是“免费”的
  • data 部分越大,Gas 成本越高

因此,事件设计需要在可查询性成本之间取舍。

8.3 indexed 的合理使用

考虑一个转账事件:

event Transfer(address indexed from, address indexed to, uint256 amount);

这种设计是合理的,因为:

  • fromto 是最常用的查询条件
  • amount 通常不用于过滤

但如果写成:

event Transfer(
    address indexed from,
    address indexed to,
    uint256 indexed amount
);

那么:

  • 查询能力并没有显著提升
  • Gas 成本却增加了
  • 而且 indexed 参数最多只能有 3 个

一个实用原则是:

只为“经常作为过滤条件”的字段加 indexed。

8.4 避免在事件中携带大数据

一个常见但代价很高的做法,是在事件中携带大量数据:

event DataUpdated(uint256[] values);

这种设计的问题包括:

  • 数组会被完整写入日志
  • Gas 成本随数据量线性增长
  • 链上执行成本和链下存储成本都很高

更合理的替代方案是:

  • 只记录关键信息(如 ID、hash、计数)
  • 将完整数据存储在链下
  • 通过 hash 或索引进行关联

例如:

event DataUpdated(bytes32 dataHash);

8.5 避免在链上返回大数组

在 Solidity 中,函数返回数组或结构体在语法上是完全合法的,例如:

function getUsers() external view returns (User[] memory);

从接口设计的角度看,这样的函数非常直观: “调用一次,就能拿到所有用户数据。”

问题在于,这种直观并不等于便宜。

链上调用时会发生什么

如果这个函数被 另一个合约 调用,那么即使它是 view 函数,也会真实消耗 Gas。 在这种情况下,EVM 需要做的事情包括:

  • 从 storage 中逐个读取所有 User
  • 将这些数据复制到 memory
  • 按 ABI 规则编码整个数组
  • 将编码后的字节作为返回值

这些操作的成本,都会随着数组长度线性增长。

也就是说,返回的数据越多,Gas 消耗越高,而且没有上限。

这类成本往往是“没必要的”

在实际项目中,完整的数据列表通常是:

  • 给前端或后端服务用的
  • 用于展示、统计或分析
  • 不会被其他合约在链上依赖

而这些链下系统,完全可以通过 eth_call 免费读取 view 函数的返回值。

这就造成了一种常见的浪费:

  • 链上调用为返回数据付出了 Gas
  • 真正需要这些数据的是链下系统
  • 而链下系统本可以不花任何 Gas

更合理的设计思路

因此,在设计函数返回值时,应该明确区分两种使用场景:

  • 链上调用的函数 返回值应当尽量简单,甚至可以不返回任何数据

  • 链下查询用的函数 可以返回数组或结构体,但要意识到它们只适合通过 eth_call 使用

如果存在链上也需要读取部分数据的需求,那么分页或游标式接口通常是更安全的选择。

8.6 用分页与游标来替代一次性返回

如果确实需要从合约中读取大量数据,更合理的方式是分页。

例如:

function getUsers(uint256 offset, uint256 limit)
    external
    view
    returns (User[] memory)
{
    // 返回一部分数据
}

这种设计的优势是:

  • 链上调用时可以控制 Gas 上限
  • 链下系统可以逐页拉取
  • 接口行为更可预测

8.7 一个事件设计对比示例

不推荐的写法:

event OrderCreated(
    address user,
    uint256[] itemIds,
    uint256[] prices
);

改进后的写法:

event OrderCreated(
    address indexed user,
    bytes32 orderId
);

并在链下系统中:

  • 根据 orderId 关联完整订单数据
  • 使用事件作为“索引信号”,而不是数据载体

这种设计在可扩展性和成本上都更加合理。

9. 紧凑表示与低级优化(谨慎使用)

在前面的内容中,我们讨论的优化大多具备一个共同特点: 不牺牲可读性,风险可控,收益稳定。

现在我们开始讨论一些进阶技巧,这些技巧确实可以省 Gas,但同时也会带来:

  • 可读性下降
  • 实现复杂度上升
  • 更高的审计和维护成本

因此,这一节的核心不是“教你一定要用”,而是回答:

哪些低级优化在什么情况下值得用,什么时候应该果断放弃。

9.1 位运算与 bitmap

一个非常典型、也相对安全的进阶优化手段,是 bitmap(位图)

假设你需要维护一组布尔状态,例如:

  • 某个地址是否已完成某一步操作
  • 某些 ID 是否已被使用
  • 一组固定大小的开关位

最直观的写法是:

mapping(uint256 => bool) used;

这种写法清晰、易懂,但每一个 bool 实际上都会占用一个完整的 storage slot。

使用 bitmap 的思路

如果这些布尔值的 key 是:

  • 连续的
  • 范围有限的
  • 数量较多的

那么可以考虑用一个 uint256 来存储 256 个布尔值:

uint256 bitmap;
  • 第 n 位表示第 n 个状态
  • 通过位运算进行读写

例如:

function isUsed(uint256 index) internal view returns (bool) {
    return (bitmap & (1 << index)) != 0;
}

function setUsed(uint256 index) internal {
    bitmap |= (1 << index);
}

这样做的直接收益是:

  • 用 1 个 storage slot 表示 256 个状态
  • 大幅减少 storage 读写次数
  • 在高频场景下节省可观的 Gas

9.2 bitmap 的适用边界

bitmap 并不是 mapping(bool) 的“全面替代”,它适合以下场景:

  • 状态数量上限明确
  • index 可控且不来自任意用户输入
  • 逻辑相对稳定,不易变更

不适合以下场景:

  • key 是 address 或 hash
  • 状态数量不可预期
  • 逻辑频繁变动、需要高度可读性

一个实用判断是:

如果你需要在文档中专门解释“这一位代表什么”,那就说明复杂度已经上升了。

9.3 紧凑编码与“省 slot”思维

除了 bitmap,一些项目还会尝试:

  • 在一个 uint256 中打包多个小字段
  • 用位移和掩码存储多个数值
  • 手动实现类似 storage packing 的逻辑

例如:

uint256 packed;

其中:

  • 高 128 位表示余额
  • 低 128 位表示时间戳

这种写法在理论上可以减少 slot 数量,但需要注意:

  • 每一次读写都需要位运算
  • 容易引入边界错误
  • 调试和审计难度明显增加

这类优化通常只在以下情况下才值得考虑:

  • 数据结构极其稳定
  • 访问频率非常高
  • 已经确认 slot 数量是主要瓶颈

9.4 关于 assembly

Solidity 允许通过 assembly 直接编写 EVM 指令,这意味着:

  • 可以跳过部分编译器生成的冗余逻辑
  • 在极端情况下获得更低的 Gas

例如,直接使用 sloadsstorecalldataload

但需要非常谨慎:

  • assembly 不做类型检查
  • 不提供溢出保护
  • 可读性和可维护性显著下降

在大多数业务合约中,assembly 带来的收益往往小于它引入的风险

9.5 assembly 什么时候才值得用

相对合理的使用场景包括:

  • 经常被调用的“热路径”
  • 非常底层、逻辑稳定的工具函数
  • 已有充分测试覆盖
  • 有经验的开发者和审计支持

不推荐的场景包括:

  • 业务逻辑核心
  • 权限、资金相关代码
  • 仅为了节省少量 Gas

一个保守但实用的原则是:

如果不用 assembly 也能把 Gas 控制在合理范围内,那就不要用 assembly。

9.6 进阶优化的真实收益评估

需要特别强调的是,进阶优化的收益往往是:

  • 单次调用节省几十到几百 Gas
  • 只有在高频调用时才会显现价值

因此,在决定采用这些技巧之前,最好已经:

  • 完成了 storage、接口、循环等基础优化
  • 明确知道瓶颈在哪里
  • 有真实的 Gas 测试数据作为依据

否则,很容易陷入“为优化而优化”。

10. 如何验证优化是否有效

到目前为止,我们已经讨论了多种 Gas 优化手段。 但在真正的工程实践中,有一个问题始终比“怎么优化”更重要:

你怎么确定,这次优化真的有价值?

这一节的目标,是建立一种可执行的、数据驱动的优化方法,而不是依赖直觉或经验判断。

10.1 为什么不能凭感觉判断 Gas

Gas 成本并不总是和“代码复杂度”成正比:

  • 有些看起来复杂的重构,几乎不影响 Gas
  • 有些只改了几行的调整,却能节省大量成本
  • 有些优化在小规模测试中无感,在大规模使用中差异巨大

如果没有量化数据,很容易出现两种极端:

  • 低估优化价值:错过高收益改进
  • 过度优化:引入复杂性却几乎没有回报

因此,Gas 优化必须是数据驱动的工程行为

10.2 什么是“有效的 Gas 优化”

一个优化是否有效,通常需要回答三个问题:

  1. 节省了多少 Gas
  2. 发生在多高频的执行路径上
  3. 引入了多少额外复杂度或风险

只有当节省的 Gas 与复杂度之间形成合理比例时,这次优化才是值得的。

10.3 基准测试的基本思路

最简单、也最可靠的方式,是对同一逻辑进行优化前 / 优化后对比测试

基本原则包括:

  • 使用相同的输入数据
  • 只改变你关心的那一处实现
  • 关注 gas used,而不是交易费用

例如:

  • 原始版本:函数 A
  • 优化版本:函数 A′
  • 对比两者在相同调用条件下的 Gas 消耗

10.4 一个概念级的对比示例

假设你有一个累加逻辑:

function add(uint256[] calldata xs) external {
    for (uint256 i = 0; i < xs.length; i++) {
        total += xs[i];
    }
}

和一个优化版本:

function addOptimized(uint256[] calldata xs) external {
    uint256 t = total;
    uint256 len = xs.length;

    for (uint256 i = 0; i < len; ) {
        t += xs[i];
        unchecked {
            i++;
        }
    }

    total = t;
}

你关心的不是“哪一个看起来更好”,而是:

  • xs.length = 101001000
  • 两者的 Gas 消耗曲线是否明显分离

这种对比,才能真正说明问题。

10.5 关注“最坏情况”,而不仅是平均值

在智能合约中,最坏情况往往比平均情况更重要。

原因包括:

  • Gas 不够会直接导致交易失败
  • 用户更容易遇到极端输入
  • 批处理和循环的风险集中在最坏情况

因此,在测试时,应当:

  • 尝试最大允许输入
  • 覆盖边界条件
  • 关注 Gas 是否接近区块限制或函数预期上限

10.6 工具并不重要,方法才重要

不同团队可能使用不同工具:

  • Hardhat
  • Foundry
  • Truffle
  • 自定义脚本

但无论使用什么工具,核心方法都是一致的:

  • 固定输入
  • 重复测试
  • 对比 gas used
  • 用数据支撑决策

不要为了“用工具而用工具”,而是让工具服务于结论。

10.7 什么时候应该停止优化

一个容易忽视的问题是:什么时候该停下来?

可以考虑以下信号:

  • 继续优化只能节省极少量 Gas
  • 代码复杂度明显上升
  • 已经覆盖了高频和高成本路径
  • 优化收益无法抵消审计和维护成本

Gas 优化不是无止境的,而是一种平衡。

11. 一份可执行的 Gas 优化清单

到这里,我们已经从 EVM 成本模型出发,系统地讨论了 Gas 优化在设计和实现层面的主要原则。 我们现在可以把前面的内容收敛成一份可以直接使用的清单,用于日常开发和代码评审。

这份清单并不是“必须全部满足”的规则集合,而是一种优先级导向的检查顺序

11.1 设计阶段优先检查项

在写代码之前,优先思考以下问题:

  • 是否真的需要存储这个状态,还是可以通过计算或事件获得
  • 状态变量是否会被频繁读写
  • 是否存在不受限的循环或批处理接口
  • 数据结构是否有明确的规模上限
  • 是否存在“查找/去重/删除元素”需求

如果这些问题在设计阶段就能被回答,很多 Gas 问题可以被直接避免。

11.2 Storage 相关检查项(最高优先级)

  • 是否存在在同一函数中多次读取同一个 storage 变量的情况
  • 是否在循环中直接写 storage
  • 是否可以通过缓存变量减少 SLOAD / SSTORE
  • 状态变量和 struct 字段顺序是否合理,避免浪费 slot
  • 不再使用的状态是否及时 delete
  • 是否用 mapping 替代数组进行存在性判断、去重、按 key 查询,避免 O(n) 遍历
  • 若必须可枚举,是否使用 mapping + array + index(swap-and-pop)实现 O(1) 增删与枚举

这是最值得投入精力的优化区域。

11.3 函数接口与参数检查项

  • 对外接口是否优先使用 external
  • external 函数参数是否使用 calldata
  • 是否避免了不必要的 public 函数
  • 是否存在 this 调用当前合约的情况
  • 是否采用了 external 入口 + internal 实现的模式

这些优化通常风险低、收益稳定。

11.4 循环与批处理检查项

  • 是否缓存了数组 length 和中间结果
  • 循环中是否避免了 storage 读写
  • 是否避免在循环中做 O(n) 查找
  • 是否在安全前提下使用 unchecked
  • 循环是否存在明确的上限
  • 批处理接口是否限制了单次处理数量

循环是 Gas 放大器,尤其需要从“最坏情况”角度审视。

11.5 错误处理与字节码体积

  • 是否使用 custom error 替代 require(string)
  • 错误信息是否简洁、结构化
  • 是否避免在字节码中嵌入大量字符串
  • 合约体积是否接近部署限制

这些优化往往在合约复杂后才显现价值,但越早统一越好。

11.6 事件与返回值设计

  • 事件是否只记录必要信息
  • indexed 参数是否只用于高频过滤字段
  • 是否避免在事件中携带大数组或字符串
  • 是否避免链上返回大量数据
  • 是否通过分页或链下索引替代一次性查询

这里的目标是:不把链当数据库使用。

11.7 进阶优化(谨慎项)

  • 是否已经完成基础优化
  • 是否明确瓶颈来自 slot 数量或高频调用
  • 位运算或 bitmap 是否真的降低了 storage 使用
  • 是否避免在核心业务逻辑中滥用 assembly

这些优化应当是“有数据支撑的例外”,而不是常规手段。

11.8 用数据驱动最终决策

在合并任何 Gas 优化之前,建议确认:

  • 是否有明确的 Gas 对比数据
  • 优化是否发生在高频或关键路径
  • 引入的复杂度是否可被测试和审计覆盖

如果优化的收益无法清晰说明,那通常意味着它并不重要。