
在兼容 EVM 的链上编写智能合约,不仅关乎正确性和安全性,还关乎效率。链上执行的每条指令都会消耗 gas,而每单位 gas 都对应着真实成本。优化不佳的合约可能导致用户交易费用过高、用户体验下降,甚至在网络高峰期加剧网络拥堵。
本文将深入探讨 gas 优化技巧。在研究具体模式和技巧之前,我们先统一术语:
Gas:衡量以太坊上计算工作量的单位。每个操作码(opcode)、存储读写和内存操作都会消耗预定义数量的 gas。
Gas 价格(gwei):愿意为每单位 gas 支付的费用,以 gwei 为单位(1 gwei = 10⁹ wei)。
小费(优先费):一笔额外费用(EIP-1559 中的 maxPriorityFeePerGas),用于激励矿工更快打包交易,在基础费之上支付。
交易费用:因交易类型(Legacy、EIP-2930、EIP-1559)而异,但本质上取决于 gas 使用量和有效 gas 价格。在所有情况下,减少 gas 使用量都会降低总成本。
了解了所有交易类型并编写了几个智能合约后,我们来看看如何优化合约的 gas 使用,以及为什么这一点很重要。
本文将介绍:
- EVM gas 成本基础
- 如何测量 gas 使用量
- 常见优化模式
- 高级技巧
掌握这些基础知识后,我们就能找出合约中 gas 消耗最多的地方,并让每个操作更经济。
EVM gas 成本基础
在进行优化之前,必须了解操作码和数据结构层面的 gas 消耗来源。从宏观上看,gas 成本主要来自四个方面:
存储(SSTORE / SLOAD)
以太坊 gas 费用按操作码和数据操作计算。以下是大部分 gas 消耗的详细分类,数据基于 EIP-2200 和 [EIP-2929]:
合约存储中的持久化键值槽位。最昂贵的操作包括:
SSTORE(写入):使用 EIP-2200 中定义的动态计量方式:
- 零 → 非零(original_value == 0):
SSTORE_SET_GAS = 20000gas - 非零 → 不同非零(original_value != 0):
SSTORE_RESET_GAS = 5000gas - 值 == 当前值:冷访问消耗
800gas,热访问消耗100gas(无状态变更)(柏林硬分叉引入了 EIP-2929,如之前的文章所述,热访问可享受折扣) - 退款(非零 → 零):
SSTORE_CLEARS_SCHEDULE = 15000gas 返还
SLOAD(读取):冷访问消耗 800 gas,热访问消耗 100 gas(EIP-2929)
注意:存储写入是目前最昂贵的操作。复用存储位置或批量写入可节省每笔交易数万个 gas。
gas 成本示例:基于 EIP-2200 官方摘要,转换为伪代码
SLOAD_GAS = 800
SSTORE_SET_GAS = 20000
SSTORE_RESET_GAS = 5000
SSTORE_CLEARS_SCHEDULE = 15000
GAS_STIPEND = 2300
function performSSTORE(originalValue, currentValue, newValue):
if gasleft() ≤ GAS_STIPEND:
revert("out of gas")
if currentValue == newValue:
deductGas(SLOAD_GAS)
return
if originalValue == currentValue:
if originalValue == 0:
deductGas(SSTORE_SET_GAS)
else:
deductGas(SSTORE_RESET_GAS)
if newValue == 0:
refund(SSTORE_CLEARS_SCHEDULE)
else:
deductGas(SLOAD_GAS)
if originalValue ≠ 0:
if currentValue == 0:
removeRefund(SSTORE_CLEARS_SCHEDULE)
if newValue == 0:
refund(SSTORE_CLEARS_SCHEDULE)
if originalValue == newValue:
if originalValue == 0:
refund(SSTORE_SET_GAS - SLOAD_GAS)
else:
refund(SSTORE_RESET_GAS - SLOAD_GAS)
注意:
original_value是交易开始时存储槽位的值。current_value是执行此SSTORE前槽位的值,反映同一交易中之前的写入操作。
内存(Memory)
- 扩展成本:每 32 字节字 3 gas,加上二次项
⌊a² ÷ 512⌋,在访问之前未使用的内存时触发(在以太坊黄皮书中规定)。
解释:这会增加一个小的超线性惩罚,因此当分配更多内存时,边际成本会缓慢增加(例如,64 个字的情况下,⌊64²/512⌋ = ⌊4096/512⌋ = 额外 8 gas)。
C_mem(a) = G_memory·a + ⌊a² ÷ 512⌋
- 读写:扩展后,访问内存成本较低(每字 3 gas)。
注意:通过预先设定数组大小或对大型输入使用 calldata,可最小化内存扩展。
调用数据(Calldata)
- 成本:访问函数输入数据时,每字节 3 gas(不可变且不可扩展,在以太坊黄皮书中规定)。
- 影响:对于包含大型数组或结构体的函数,使用 calldata 而非 memory 可避免内存扩展成本。
栈和其他操作码
- 算术运算(ADD、MUL 等):3 gas
- 环境操作(CALLER、BALANCE):根据操作码不同,2–400 gas
- 加密操作(ECRECOVER):约 3000 gas
- 日志(LOGn):基础 375 gas + 主题/数据成本(每字节 8 gas)
通过理解这些类别和具体的 gas 成本,你可以识别合约中的热点,特别是存储写入和内存扩展,并进行有针对性的优化以降低交易费用。
如何测量 gas 使用量
分析 gas 使用量是实现有效优化的第一步:没有准确的数据,你可能会把精力放在次要成本上,而忽略主要的 gas 消耗点。在本节中,我们将介绍多种链下和链上测量技术,它们共同构成了一套全面的工具,用于建立 gas 使用基准。
使用 Foundry 进行链下测量(构建智能合约时)
以下是在测试套件中进行链下测量的方法:
本例中,我们将使用 Foundry 和一个智能合约(/src/Storage.sol),该合约来自之前的一篇博客文章。
pragma solidity ^0.8.12;
contract Storage {
struct my_storage_struct {
uint256 number;
string owner;
}
my_storage_struct my_storage;
function store(my_storage_struct calldata new_storage) public {
if (new_storage.number > 100) {
revert("Number too large");
}
my_storage = new_storage;
}
function retrieve() public view returns (my_storage_struct memory){
return my_storage;
}
}
以及来自(/test/Storage.t.sol)的测试代码:
pragma solidity ^0.8.12;
import "forge-std/Test.sol";
import "../src/Storage.sol";
contract StorageTest is Test {
Storage public storageContract;
function setUp() public {
storageContract = new Storage();
}
function testStoreStruct() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 25,
owner: "bob"
});
storageContract.store(input);
}
function testStoreStructReverts() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 101,
owner: "too much"
});
vm.expectRevert("Number too large");
storageContract.store(input);
}
}
有了这个智能合约及其测试,我们可以检查执行 store 方法将消耗多少 gas。为此,我们运行:
forge test --gas-report
我们应该会看到类似如下的输出:
(点击或按 Enter 查看完整尺寸图片)

我们看到的内容:
- 运行
store()函数的测试套件在不同条件下被调用了两次。(一次使用 SSTORE,另一次在store检查处失败) - 最小值(22535 gas):成本较低的情况(函数调用在
if检查处失败)。(记住在第一篇博客中提到,基础交易成本为 21000 gas,这里额外消耗了约 1.5k gas 用于读取和检查值) - 最大值(67908 gas):成本较高的情况(函数成功执行并写入内存),需支付 20000 gas 的 SSTORE 全额成本,加上冷访问附加费和函数自身的操作码开销。
- 平均值/中位数(45221 gas):两次运行的中点,可作为典型调用的大致预期。
注意:可以通过运行以下命令来运行特定方法:
forge test --match-test testStoreStruct --gas-report
执行前测量
当合约已部署,且我们想检查特定输入的 gas 使用量时,可以使用此方法。
本例中,我们使用之前的智能合约 /src/Storage.sol 并部署它。
我们使用 Anvil(来自 Foundry)来分叉区块链:
anvil
然后部署智能合约:
forge create src/Storage.sol:Storage \
注意:从
anvil的输出中选择一个私钥
输出将类似于:
[⠊] Compiling...
No files changed, compilation skipped
Deployer: <your-address>
Deployed to: <deployed-contract-address>
Transaction hash: <tx-hash>
现在我们来估算交易的 gas 成本:
export SIG=$(cast sig "store((uint256,string))")
export ARGS=$(cast abi-encode "store((uint256,string))" "(25,\"bob\")")
export CALLDATA="${SIG}${ARGS#0x}"
cast rpc eth_estimateGas \
'{"from":"<your-address>","to":"contract-address","value":"0x0","data":"'"$CALLDATA"'"}' \
'latest' \
--rpc-url http://127.0.0.1:8545
注意:这里我们使用了 Foundry 的 CLI 工具,但同样的方法也适用于在任何 EVM 链上对已部署合约的调用。
执行后测量
发送签名交易后,我们可以在链上查看该交易实际消耗的 gas 量。
我们使用之前的示例,通过 Foundry CLI 工具发布交易(或按照之前文章中的方法创建实际区块链交易):
cast send <your-deployed-contract-address>\
"store((uint256,string))" \
'(25,"bob")' \
--rpc-url http:
--private-key <your-key>
在输出中,你应该会看到类似如下的内容:
...
transactionHash 0xe69ac7819787dc5ca8e5e5b0420936cdde171fce8760931bce5e0fc03e68cbac
...
然后我们通过交易收据查看实际使用的 gas 量:
cast rpc eth_getTransactionReceipt \
'"<your-transaction-hash>"' \
--rpc-url http://127.0.0.1:8545
"gasUsed":"0x10944"
常见优化模式
在本节中,我们将介绍降低 Solidity 代码 gas 消耗的实用技巧。每种模式都针对前面提到的主要成本类别之一。
最小化存储写入
- 变量打包:将多个
uintX或bool组合到单个 32 字节槽位中。例如:
struct Packed {
uint128 a;
uint64 b;
uint64 c;
}
- 条件写入:仅在新值与旧值不同时更新状态:
if (x != newX) {
x = newX;
}
缓存存储读取
- 先读取到内存变量,然后在本地复用:
uint256 localVar = storageVar;
对外部数组使用 Calldata
- 将大型外部输入标记为
calldata而非memory,以避免内存扩展成本:
function batchTransfer(address[] calldata recipients) external {
}
循环中的 unchecked 算术
- 在安全的情况下跳过溢出检查,例如简单的递增循环:
for (uint256 i = 0; i < n; ) {
unchecked { i++; }
}
短路与逻辑简化
- 使用提前返回和组合条件,减少不必要的操作:
function withdraw(uint256 amount) external {
if (amount == 0) return;
require(balances[msg.sender] >= amount, "Insufficient");
}
链下预计算常量
- 硬编码不变的值,而非在链上计算:
uint256 constant RATE_MULTIPLIER = 1e18;
利用 Immutable 变量
- 对于在构造函数中设置一次的值,使用
immutable以节省存储读取成本:
address public immutable owner;
constructor() {
owner = msg.sender;
}
优化事件与存储的使用
- 使用事件进行临时日志记录,而非将数据写入合约存储:
event TransferLogged(address from, address to, uint256 amount); // 发送 TransferLogged() 事件而非存储到数组
在热点路径使用汇编
- 内联汇编可减少函数调用开销并实现自定义优化,但应谨慎使用:
assembly {
}
注意:还有更多可探索的优化技巧,但这些是编写合约时需要牢记的基础。此外,尽可能避免对动态数组使用循环,输入过大时可能会导致 out of gas 错误。
高级技巧
除了常见模式,这些高级技术也有助于进一步节省 gas(此处不详细展开,仅做简要说明供探索):
- 访问列表(Access List):我们在这篇博客文章中已经了解过。
- 最小代理(EIP-1167):部署小型代理合约,通过委托调用(delegate call)指向共享实现,与完整合约相比,大幅降低部署 gas 成本。
- SSTORE2:将大型静态字节数组直接存储在合约字节码中,通过
extcodecopy读取,避免为只读数据进行昂贵的存储写入。 - Gas 退款优化:调整状态变更顺序以最大化退款资格。例如,仅在最终使用后清除存储槽位,避免将槽位重置回非零值。
- 位运算标志打包:使用位掩码和移位将多个布尔值或小型枚举组合到单个
uint256中,以最小化使用的存储槽位数。 - 构造函数预计算:将大量计算移至链下,将预计算值传入构造函数,减少部署期间的链上算术和存储成本。
- 批量处理与多调用聚合:使用
multicall模式(我们将在后续博客中介绍)将多个逻辑操作聚合到一笔交易中,分摊每次调用的开销并降低总 gas 消耗。 - 高级汇编模式:在热点循环、自定义哈希或位运算中使用内联汇编,可绕过 Solidity 的安全检查并节省 gas,但会降低可读性。
总结
你现在已经实际了解了 EVM 链上 gas 成本的构成——从主导交易费用的昂贵存储写入,到内存、调用数据和常见操作码的隐性成本。你还知道如何可靠地测量 gas 使用量,包括通过链下 RPC 估算、链上收据和测试中的报告工具。有了这些数据,你可以应用经过验证的优化模式——最小化存储写入、利用 calldata、使用 unchecked 循环等——立即降低 gas 消耗。对于需要更精细处理的场景,访问列表、最小代理和代码内数据策略等高级技术可提供进一步的节省。
你现在可以做的事:
- 使用 Foundry 或 RPC 工具分析你的合约,识别 gas 热点。
- 将 gas 报告集成到测试套件中,尽早发现性能退化。
- 对最昂贵的函数应用优化模式。
- 每次更改后测量影响,验证节省效果并指导进一步改进。
通过这种方法,你将确保你的智能合约在任何兼容 EVM 的网络上都能实现更快的执行、更低的费用和更好的用户体验。
💪欢迎加入 OpenBuild 开发者交流群,和更多开发者一起探讨技术、交流进步!

作者:Andrey Obruchkov
原文:https://medium.com/@andrey_obruchkov/gas-matters-how-to-reduc…
编者注:本文内容仅代表原作者观点,旨在分享技术知识。由于编者水平有限,不保证翻译的完全精确性、完整性和时效性。内容仅供参考,不构成任何建议。
1076






