在 EVM 的世界里,每一行代码都要付费。Gas 不仅是交易成本,更是用户留存与产品竞争力。一个精心优化的合约可能比粗暴实现便宜 30%-70%。本文从 EIP-1559 计费机制出发,深入存储布局、数据位置、变量打包与最小代理模式,最后以 Uniswap 为案例给出可执行的优化清单。
一、Gas 机制与 EIP-1559
交易费用构成
自 EIP-1559(伦敦升级,2021-08)后,以太坊费用模型为:
totalFee = (baseFeePerGas + priorityFeePerGas) × gasUsed
├─ baseFeePerGas :动态调节,按区块利用率调整,被销毁
└─ priorityFeePerGas:小费,给验证者,用于激励打包
maxFeePerGas = 用户愿意支付的单位费用上限
maxPriorityFeePerGas = 小费上限
Base Fee 的调节规则
若上一个区块 gasUsed > 目标 15M → baseFee 上升(最多 +12.5%)
若 gasUsed < 15M → baseFee 下降
| 场景 | baseFee 表现 | 对开发者的意义 |
|---|---|---|
| 网络拥堵 | 指数上升 | 合约调用成本波动大 |
| 空闲期 | 逐步下降 | 批处理写入成本降低 |
| EIP-4844(blob) | L2 数据费显著降低 | zk/op Rollup 性价比提升 |
一句话:EIP-1559 把"油价"动态化,开发者无法控制 baseFee,只能减少
gasUsed——这正是本文的全部主题。
二、存储读写成本:最贵的操作
EVM 三类数据位置的 Gas 差异
| 操作 | Gas | 说明 |
|---|---|---|
| SSTORE(0 → 非 0,冷槽位) | 22,100 | 首次写入 + 冷访问附加费 |
| SSTORE(非 0 → 非 0,冷槽位) | 7,100 | 改写 + 冷访问附加费 |
| SSTORE(热槽位,0→非0) | 20,000 | 同交易内已访问过 |
| SSTORE(热槽位,非0→非0) | 5,000 | 同交易内已访问过 |
| SSTORE(值不变/脏写) | 100 | 写相同值最便宜 |
| SLOAD(冷) | 2,100 | EIP-2929 引入冷/热区分 |
| SLOAD(热) | 100 | 同交易内已访问 |
| 清空槽位(非0→0) | 5,000 + 4,800 退款 | 退款上限为本次交易 gas 的 1/5 |
⚠️ EIP-3529 把清空槽位退款从 15,000 降到 4,800,并限制退款最多为消耗 gas 的 1/5——“写垃圾再清空"的套利模式已失效。
一次写入的真实成本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract StorageCost {
// 三个 uint256 占用 3 个槽位(slots 0,1,2)
uint256 public a;
uint256 public b;
uint256 public c;
// 冷访问:3 次 SLOAD = 3 × 2100 = 6300 gas
function readAll() external view returns (uint256, uint256, uint256) {
return (a, b, c);
}
// 热访问:a 在上一行已读,本次读 b、c 各 100 gas
function readTwice() external view returns (uint256) {
uint256 x = a; // 冷 SLOAD 2100
return x + b; // 热 SLOAD 100
}
}
一句话:一次冷 SLOAD(2100)相当于约 700 次基础算术运算——把数据"读一次、算多次、写一次"是优化的第一原则。
三、数据位置:storage / memory / calldata
三者的本质差异
| 维度 | storage | memory | calldata |
|---|---|---|---|
| 位置 | 链上持久化 | 交易内临时 | 交易的输入参数 |
| 成本 | 最贵(读写都贵) | 中(扩展呈二次方) | 读最便宜(CALLDATALOAD=3) |
| 可变性 | 可读写 | 可读写 | 只读 |
| 生命周期 | 永久 | 函数调用内 | 本次调用内 |
| 拷贝成本 | 读 storage 到 memory 需 COPY 费用 | — | 复制 calldata 到 memory 也花钱 |
选择策略
- 入参优先 calldata:
external函数的string/bytes/array用calldata,避免拷贝 - 计算中间态用 memory:不要频繁读写 storage
- 存储指针直接操作:
StorageStruct storage s = arr[i]不产生拷贝费用 - 避免 storage → memory → storage 往返:直接在 storage 上累计
// ❌ 差:把 calldata 拷贝进 memory,再写入 storage
function bad(string memory name) external {
names.push(name);
}
// ✅ 好:calldata 只读,直接写入 storage
function good(string calldata name) external {
names.push(name);
}
memory 扩展的二次方成本
使用 0-64 字节约 3 gas/字
之后按 内存大小 的二次方公式计费:
memoryCost(n) = 3 × n + n² / 512 (n 为 32 字节字数量)
⚠️ 避免在循环中无界地增长 memory 数组——成本会非线性上涨。
四、状态变量打包与结构体优化
打包规则
EVM 状态以 32 字节(1 字) 为槽位。Solidity 按声明顺序连续存放,能塞进同一槽的变量共用槽:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// ❌ 差:3 个变量各占一个完整槽位 = 3 个槽位
contract Bad {
uint256 a; // slot 0(完整 32B)
uint8 b; // slot 1(只用 1B,其余 31B 浪费)
uint256 c; // slot 2
}
// ✅ 好:打包到 1 个槽位 = 1 个槽位
contract Good {
uint128 a; // slot 0 前 16B
uint64 b; // slot 0 中 8B
uint64 c; // slot 0 后 8B
}
结构体打包
// ❌ 差:address(20B)+uint96(12B) 可打包却被隔开
struct BadUser {
uint256 id; // slot 0
address addr; // slot 1(20B,浪费 12B)
uint96 balance; // slot 2
}
// ✅ 好:同类小类型紧邻,塞进一个槽
struct GoodUser {
address addr; // slot 0 前 20B
uint96 balance; // slot 0 后 12B
uint256 id; // slot 1
}
打包要点
- 把
uint8/uint16/uint32/uint64/uint128/address/bool等小类型聚拢声明 - 变量顺序不可随意乱排——重排可能增槽位
bool只占 1 字节,可与uint8、address打包- 数组/映射的元素不受打包影响(各自独立槽位)
一句话:减少"槽位数量"就是减少 SLOAD/SSTORE 次数——每省一个槽位,读可省 2100、写可省 22,100。
五、EIP-1167 最小代理(Minimal Proxy)
痛点
工厂批量部署合约时,每次完整部署都昂贵。EIP-1167 只部署一个 45 字节的代理,把逻辑委托给已部署的实现合约:
部署成本对比:
完整合约: ~2,000,000+ gas(依逻辑复杂度)
最小代理: ~100 gas + 存储 ← 省 99%+
代理的运行时字节码
EIP-1167 运行时字节码(45 字节):
363d3d373d3d3d363d73 <20字节目标地址> 5af43d82803e903d91602b57fd5bf3
语义:复制 calldata → delegatecall 到目标地址 → 返回结果
用 OpenZeppelin Clones 部署
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract TokenFactory {
address public immutable implementation;
address[] public allTokens;
event TokenDeployed(address token, address owner);
constructor() {
// 先部署一次完整实现(逻辑)
implementation = address(new ERC20Impl());
}
function createToken(string calldata name, string calldata symbol)
external returns (address)
{
address clone = Clones.clone(implementation); // 45 字节代理
ERC20Impl(clone).initialize(name, symbol, msg.sender);
allTokens.push(clone);
emit TokenDeployed(clone, msg.sender);
return clone;
}
}
// 用 initialize 代替 constructor(代理无法运行构造函数)
contract ERC20Impl is ERC20 {
function initialize(string memory name, string memory symbol, address owner) external {
// 一次性初始化逻辑...
}
}
代理模式的注意事项
| 风险 | 说明 | 对策 |
|---|---|---|
| 构造函数不可用 | 逻辑在代理中不执行 | 使用 initialize + 初始化锁 |
| 实现合约升级风险 | 升级影响所有代理 | 实现合约"永久锁定"或走 UUPS |
| 存储冲突 | 代理与实现共享存储布局 | 布局升级需谨慎 |
| delegatecall 安全 | 实现可被 delegatecall 滥用 | 实现内禁止 selfdestruct |
一句话:EIP-1167 用"45 字节 + delegatecall"把批量部署成本压缩一个数量级,代价是把构造逻辑换成可重入保护的 initialize。
六、Uniswap 式优化案例
v2 Pair:极致打包 + Library
Uniswap v2 把三种状态打包进一个槽位:
// Uniswap V2 Pair 的经典打包
struct Reserves {
uint112 reserve0; // 8+4=12 字节
uint112 reserve1; // 12 字节
uint32 blockTimestampLast; // 4 字节
} // 合计 32 字节 = 1 槽位
配合全库化逻辑(library Pair,全部函数 internal),每次 swap 的存储访问被降到最低。
现代优化的其他手段
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 自定义错误:比 require("message") 省 ~50 gas + 部署更省
error InsufficientOutput(uint256 have, uint256 want);
error Expired(uint256 deadline, uint256 blockTimestamp);
contract SwapRouter {
address public immutable weth; // immutable 内联,免 SLOAD
uint256 internal constant BPS = 10000; // constant 编译期内联
function swapExactETHForTokens(
uint256 minOut,
uint256 deadline,
address[] calldata path
) external payable returns (uint256 amountOut) {
if (block.timestamp > deadline) revert Expired(deadline, block.timestamp);
// 核心 swap 逻辑...
if (amountOut < minOut) revert InsufficientOutput(amountOut, minOut);
return amountOut;
}
}
优化手段清单
| 手段 | 收益 | 说明 |
|---|---|---|
| 自定义错误 | revert 省 ~50 gas + 部署省数千 | Solidity ≥ 0.8.4 |
| immutable/constant | 免 SLOAD,内联 | 仅用于部署后不变的值 |
| library(internal) | 代码复用,避免 delegatecall | 注意 internal 是内联 |
| CREATE2 | 确定性地址,省 1 次 SLOAD | 适合工厂与路由 |
| uint256 单变量 | 少算数指令 | 不用小类型装大值 |
| 缓存到 memory | 循环内避免重复 SLOAD | 循环外读一次 |
| 批量转移 | 每笔省固定开销 | 合并多笔小额转账 |
七、Gas 基准对比
以下为 Foundry forge test --gas-report 风格的典型对比数据(示意量级):
| 场景 | 朴素实现 | 优化实现 | 节省 |
|---|---|---|---|
| 单次状态写入(0→非0) | 22,100 | 20,000(预触热) | 10% |
| 批量部署 10 个 Token | ~20M gas | ~1M gas(EIP-1167) | 95% |
| 读 3 个打包变量 | 6,300(3×SLOAD冷) | 100(1×SLOAD热+复用) | 98% |
| revert 带消息 | 基线 | 基线 -50 gas/次 | 少量 |
| 循环读数组元素 | N×SLOAD | 1×SLOAD + 内存拷贝 | 视 N 而定 |
| 转帐结算(含手续费) | 2 次转账 | 1 次净额转账 | ~21,000 |
⚠️ 注意:gas 报告必须用真实测试数据,本文数字为教学量级。用
forge snapshot记录每个测试的 gas,作为优化前后的基准。
# Foundry 生成 gas 快照,跟踪优化效果
forge snapshot --diff .gas-snapshot
八、最佳实践清单
检查表
- 外部函数入参用 calldata:string/bytes/array 立即生效
- 读一次写一次:函数内把 storage 读到 memory 缓存
- 变量打包:小类型聚拢,结构体重排
- 用 immutable 存常量地址:Token 地址、Router 地址
- 自定义错误替代 require 字符串
- 批量操作合并:mint/burn/transfer 批量化
- 最小代理:工厂场景优先 EIP-1167
- 避免无界循环:既贵又易触发 gas 上限
- 减少事件成本:事件仅记录必要字段(索引字段最多 3 个)
- 部署前先 benchmark:
forge snapshot作为基线
反模式警示
❌ 循环内写 storage 累计变量
❌ 大数组整体拷贝到 memory 再操作
❌ 多次 require(msg.sender == owner)(用 modifier 缓存?不——用地址存 immutable)
❌ 用 string 存结构化数据(应编码或用 bytes32)
❌ 忽略脏写优化(写相同值最便宜,但不要依赖)
总结
| 维度 | 核心要点 |
|---|---|
| 计费机制 | EIP-1559:baseFee 动态销毁 + priority 小费,只能减 gasUsed |
| 存储成本 | SSTORE 最高 22,100、SLOAD 冷 2,100,读写是最贵的操作 |
| 数据位置 | 入参 calldata、中间态 memory、直接操作 storage 指针 |
| 打包 | 小类型聚拢,一槽省一次 SLOAD/SSTORE |
| 最小代理 | EIP-1167:45 字节、部署省 95%+、需 initialize 模式 |
| Uniswap 案例 | 112+112+32 打包、library 内联、自定义错误、immutable |
| 工程方法 | forge snapshot 基准 + 真实测试数据驱动优化 |
Gas 优化不是玄学,而是对 EVM 计费模型的工程化运用:能少写 storage 就少写,能打包就打包,能缓存就缓存,能内联就内联。每一个决定都有明确的气体成本数字支撑。建议每次代码评审都附带 forge snapshot 对比,让优化从"经验"变成"可量化指标”——这既是用户友好,也是与竞争对手拉开成本差距的关键竞争力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。