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 优化并不是追求“极致便宜”,而是服务于三个更现实的目标:
-
降低长期使用成本 高频调用的函数,哪怕节省几百 Gas,长期也会累积显著差异。
-
提高交易成功率 Gas 消耗越可控,越不容易在复杂路径中触发 Out of Gas。
-
提升成本的可预测性 让调用者更容易估算所需 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;
这并不是一次简单的“加一”,而是一个完整的读–改–写过程:
- SLOAD:读取原始值
- 执行加法
- 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 优化的顺序应当是:
- 先看是否能减少 storage 读写
- 再考虑循环、参数、位运算等次级优化
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独占 slot1b由于 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;
即使 a 是 uint128,它依然会独占一个 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 成本主要由两部分组成:
-
topics
- 包括事件签名
- 以及最多 3 个
indexed参数
-
data
- 非 indexed 的参数
- 按字节数计费
直觉上可以这样理解:
indexed参数更利于过滤和查询- 但
indexed并不是“免费”的 - data 部分越大,Gas 成本越高
因此,事件设计需要在可查询性和成本之间取舍。
8.3 indexed 的合理使用
考虑一个转账事件:
event Transfer(address indexed from, address indexed to, uint256 amount);
这种设计是合理的,因为:
from和to是最常用的查询条件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
例如,直接使用 sload、sstore、calldataload。
但需要非常谨慎:
- 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 优化”
一个优化是否有效,通常需要回答三个问题:
- 节省了多少 Gas
- 发生在多高频的执行路径上
- 引入了多少额外复杂度或风险
只有当节省的 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 = 10、100、1000时 - 两者的 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 对比数据
- 优化是否发生在高频或关键路径
- 引入的复杂度是否可被测试和审计覆盖
如果优化的收益无法清晰说明,那通常意味着它并不重要。