想象你在一座陡峭山坡上开了家便利店,所有商品都要请快递员从山脚一趟趟背上来。快递员按趟计费,哪怕只送一瓶水也要收一次基础运费。为了让生意更赚钱,你慢慢总结出一套“山上派送哲学”:零碎的小物件先合装进同一个收纳箱再上山;容易拦截的订单先在门口做最便宜的检查;几个邻居的包裹攒一辆车一次送齐;顾客问路之类的信息贴个公告就完事,不必占货架;商品挑小巧的包装;店门口的标志一焊定死就永不更换。这套哲学放到区块链世界,就是 Gas 优化策略——“搬运费”对应以太坊交易的执行手续费,“货架”对应合约的持久化存储。接下来用六个招式,把你的智能合约武装成一座省电又省钱的半山便利店。
Gas 是智能合约的“运营成本”,优化 Gas 就是在优化协议的经济模型。学会它,你写的合约在同样功能下更便宜、更有竞争力,这是 Web3 工程师与 DeFi 协议开发者的核心基本功。
可视化演示
展示存储槽打包减少SSTORE、批量操作分摊固定Gas、各类操作Gas消耗对比及EIP-4844 Verkle树Layer2前沿优化方案,含流动波形背景动画。
本节配有交互动效(进入视口后自动加载,约 3KB)。
存储槽打包
你的便利店仓库用的是统一的 32 厘米宽标准货架,每件货按体积占位。以前进货时习惯每件商品单独占一个格子,于是小瓶水、口香糖、火柴盒都各占一格,货架很快就满了。后来你学会把零碎商品装进同一个收纳箱再上架,一个格子能塞下好几样,仓库租金立刻降了下来。
在以太坊里,合约的持久化存储正是一排 32 字节的槽位,叫作 storage slot。无论写入的是一个 uint8 还是一个 uint256,只要单独占一个槽,首次写入时都要付一次 20000 gas 的 SSTORE 费用。SSTORE 就是“把货物搬上货架”的操作码。如果把多个小字段放进同一个结构体,让它们凑满一个 32 字节的槽,就只需要付一次开槽费。
以四个 uint64 字段为例:它们加起来正好 32 字节,编译器会把它们合并到一个存储槽。而若不打包,四个 uint256 字段会占据四个槽位,初始化要花 4 × 20000 = 80000 gas;打包后只需要 20000 gas,一次省下 60000 gas。
回到便利店:把四瓶水装一个收纳箱,一个格子搞定;不打包就得开四个格子。写合约时多问一句“这几个字段能拼箱吗”,用户的 Gas 账单就会给你一个温柔的回馈。
- 字段尺寸求和把准备塞进同一槽位的各个字段字节数加起来,得到总占用字节。
- 除以槽宽用总字节数除以每个槽的容量 32,得到带小数的槽位需求。
- 向上取整只要装了 32 字节的一部分,就按整个槽收费,余数一律进位。
短路求值
你在山脚设了一个外卖登记台,订单先做最便宜的检查——“地址填了没有”。填错的单子当场退回,根本不用让快递员爬山。填对的人里再分加急件。看似不起眼的先后顺序,其实替快递员省下了大量无效路程。
Solidity 中的 && 和 || 都具有短路特性:表达式从左到右求值,一旦左侧已经能决定整个结果,右侧就完全不会执行。比如 require(user != address(0) && balances[user] > 0):如果左侧发现地址是零地址,右侧读取余额的 SLOAD(从存储槽取数据)操作压根不会发生。
因此最实用的排列原则是:把便宜、大概率拦截的判断放左侧,把昂贵、需要才触碰的存储读放右侧。这样右侧被执行的期望概率降到最低,整个函数平均花掉的 Gas 也就更少。
回到便利店:一次配送的运费由“必走的山路”加上“偶尔才走的岔路”组成。把最该拦截的检查放山脚,大多数订单压根不必走岔路——Gas 账单自然瘦了下来。
- 左侧必经代价短路中第一个表达式必定被求值,记其开销为 \(C_{left}\)。
- 右侧触发概率左侧无法单独决定结果时,右侧才以概率 \(p\) 触发。
- 期望总代价右侧代价乘以触发概率后,再与左侧代价相加,得到总体期望开销。
批量操作
你发现快递员按“趟”收基础费:跑一趟 50 元,多带一件货只加 2 元。如果每位邻居都单独下一单,快递员就得来回跑好几趟,基础费交得人心疼。改成把几单合并成一辆车一次送齐,基础费只付一份。
在智能合约中,每一笔独立交易都要支付一笔固定基础费(约 21000 gas),这和运货的“起步价”是一个道理。批量操作的思路,就是把 N 次独立调用合并成一次批量调用,让起步价只付一次,再按项目数量累加增量成本。
典型例子有 ERC1155 的批量转账、multicall 聚合工具把多个只读调用打进同一笔交易。无论业务逻辑多复杂,基础费都从 N 份缩水成 1 份。
回到便利店:攒够一车再发,比一趟趟跑省下好几份起步价。合约设计里遇到“循环里反复调外部函数”的模式,记得问问自己:能不能一趟车拉完?
- 逐个调用的总账N 次独立调用,每次都要支付一笔固定基础费。
- 批量调用的总账一次批量调用只支付一笔固定基础费。
- 两者相减总账相减后,剩余的正是批量操作省下的固定开销。
事件替代存储
仓库旁边新设了一块公告板。寄存一件货物收 20000 元寄存费,公告板上贴一张纸条只收几百元。差别在于:寄存的货随时能取回,公告板上的纸条只负责“说出去”,写上去就取不回来。你的物流信息、审计底稿,其实只需要公告板。
以太坊的存储(SSTORE)是为了让合约以后能读回状态而设计的,所以昂贵;而日志事件(LOG)只把数据写进链下可索引的历史记录,合约自身读不到它,因此便宜得多。如果业务只需要历史、通知或审计,不依赖合约在后续逻辑里读取这些数据,就应该用 event 替代存储。
比如记录“用户 A 向用户 B 转账了 100 枚代币”这种纯信息型数据,事件是天然选择。它不进任何状态槽,也就没有存储膨胀的烦恼。
回到便利店:顾客想看物流进度,你在公告板上写“编号 1024 已发货”就够了,不必为这条消息专门占一个货架。把“说给别人听的信息”从货架搬到公告板,是省钱的第四把钥匙。
- 日志基础费发出一条事件日志,平台先收 375 gas。
- 主题计数费每个用于链上检索的索引 topic 额外收 375 gas。
- 数据分段计费日志数据按 32 字节一块,每块收费 375,不足一块按整块算。
数据类型精挑
便利店货架上,最占地方的往往不是商品本身,而是千篇一律的大包装箱。同样的洗发水,换小包装能多排两排;同样一份信息,选更紧凑的类型也能省出空间。别小看包装,它直接决定了每件货的“占地面积”。
在 EVM 中,存储的最小计价单位就是 32 字节槽。类型本身不改变槽的宽度,但它决定了你能和哪些字段拼进同一个槽。此外,交易 calldata 的计价也跟字节相关:非零字节每字节 16 gas,零字节每字节 4 gas。把数值编码得更紧凑,等于让包裹更轻便。
实际开发中,能用 uint64 就别用 uint256,能用 bool 就尽量把它和其他标志位打进同一个位掩码。小类型单独占槽不会省钱,但小类型多了,拼箱的机会就大了,这才是数据精挑的奥义。
回到便利店:小包装的洗发水能在同一格里多放好几瓶,分摊出的“每瓶仓储费”自然更薄。写合约时不要惯性地用 uint256 走天下,选小包装,就是给用户省钱。
- 单槽基本开销首次向零值槽写入非零值时,SSTORE 消耗 20000 gas。
- 打包字段个数记录同一槽位中实际容纳的字段数量 k。
- 均摊到每字段用 20000 除以 k,得到每个字段承担的实际 gas 成本。
常量与不可变
便利店门口那块“营业时间 9:00-23:00”的招牌,你直接焊死在墙上,永不更换。每次上货都不必重新打字、重新贴横幅。反过来,如果每天要改营业时间,才需要反复更新价目牌。招牌焊死,省下了所有反复改装的工夫。
Solidity 的 constant 和 immutable 正是这样的“焊死招牌”。前者在编译期就确定的常量,后者在构造函数阶段赋值后不再变化——两者都会被直接内联进合约字节码,而不是放进存储槽。读取一个常量只需要一个 PUSH 指令(约 3 gas),而读取一个存储槽中的值,冷槽 SLOAD 要 2100 gas。
常见的用法包括:把部署者地址设成 immutable,把费率、版本号设成 constant。只要某个值在合约生命周期内不会变,就别让它躺在存储槽里交“租金”。
回到便利店:把招牌焊死,就永远不用为它跑腿。合约中若还留着永远不变的值在存储里,等于每个月都在为一块不换的破招牌交仓储费——现在就把它改成 constant 或 immutable 吧。
- 常量内联constant 在编译时直接嵌入字节码,运行期读取仅需 3 gas。
- 冷槽读取交易中第一次访问某个存储槽,SLOAD 收取 2100 gas。
- 温槽读取同一交易中再次读取同一槽位,费用降到 100 gas。
AI 应用场景
医疗数据共享平台
阶段:链下隐私推理 + 链上事件存证
解决的问题:医疗数据上链面临双重压力:明文存储会让患者隐私暴露,而链上存储又贵得离谱。传统方案要么把大量脱敏数据堆上链,要么留在链下无法审计。结合“事件替代存储”的 Gas 优化思路,让 AI 在链下完成推理,只把结果摘要以日志事件的方式广播出去,链上不落明文、不吃存储。
具体案例:某慢病管理平台把血糖预测模型部署在链下,预测结果通过事件批量广播给医院随访系统,并按周打包上链存证。改造后单月 Gas 支出下降约 60%,同时满足医疗审计的合规要求。
金融风控预警链
阶段:离线风控评分 + 链上短路校验
解决的问题:DeFi 借贷协议若把完整风控模型搬上链,每笔贷款都要跑巨额计算,Gas 高到用户无法承受。实践做法是把 AI 评分放在链下,链上只留一个“哨兵”合约,用短路求值思想做前置过滤:把最便宜且拦截率最高的检查放第一位,绝大多数低风险交易直接放行,极少触发后续昂贵校验。
具体案例:一个借贷协议引入离线反欺诈评分后,将“用户是否命中高危名单”作为首个短路条件,命中即拒;未命中才继续读取抵押率等存储字段。接入后高危拦截的 Gas 支出下降 77%,坏账率同步下降。
Python 演示
def storage_slots(fields): slots = 0 used = 0 for size in fields: if used + size <= 32: used += size else: slots += 1 used = size if used > 0: slots += 1 return slots def init_gas(fields): return storage_slots(fields) * 20000 def log_cost(n_topics, data_len): blocks = (data_len + 31) // 32 return 375 + 375 * n_topics + 375 * blocks def short_circuit_expected(c_left, c_right, p_right): return c_left + p_right * c_right def main(): packed = [8, 8, 8, 8] loose = [32, 32, 32, 32] for name, fields in [("松散布局", loose), ("紧凑布局", packed)]: slots = storage_slots(fields) print(name + ": " + str(slots) + " 个槽, 初始化写入 " + str(init_gas(fields)) + " gas") print("打包节省: " + str(init_gas(loose) - init_gas(packed)) + " gas") print("事件日志(3个主题, 64字节): " + str(log_cost(3, 64)) + " gas") print("短路求值期望(左100右2100, 右侧执行概率0.4): " + str(short_circuit_expected(100, 2100, 0.4)) + " gas") if __name__ == "__main__": main()
if used + size <= 32:判断当前字段能否继续装进同一个 32 字节槽位,装得下就继续拼箱。slots += 1装不下就换一个新槽,槽位计数器加一。return storage_slots(fields) * 20000每个新槽首次写入记 20000 gas,得出初始化成本。blocks = (data_len + 31) // 32事件数据长度向上取整,得到 32 字节数据块的个数。return c_left + p_right * c_right用期望公式计算短路求值的总 Gas 开销。
常见误区
误区Gas 优化里的“打包”应该就是把源代码压缩、把变量名缩短,让合约文件体积变小吧?于是我疯狂缩写变量名和函数名。
正解存储槽打包专指把多个小类型数据塞进同一个 32 字节槽位,省的是 SSTORE 的 20000 gas 初始费。变量名长短只影响源码可读性,部署后的字节码和执行成本几乎不受影响。
为何常见:传统编程中“代码精简”的概念深入人心,很容易把“打包”与“压缩”混为一谈。
误区把合约里所有 `uint256` 换成 `uint8`,Gas 一定大幅下降!
正解如果 uint8 单独占一个存储槽,它和 uint256 一样要付 20000 gas;只有和其他小字段凑满一个槽才省钱。在 calldata 中,ABI 也会把 uint8 补齐成 32 字节。关键在“拼箱”,而不在字段本身变小。
为何常见:直觉是“东西越小越便宜”,但 EVM 的最小计价单位是 32 字节槽,小类型必须抱团取暖。
误区`&&` 和 `||` 怎么写都行,编译器会自动优化顺序,不用自己操心。
正解Solidity 确实按从左到右的顺序短路求值,但编译器不会替你调整表达式顺序。必须自己把便宜且大概率拦截的检查放左侧,昂贵 SLOAD 放右侧,才能压低期望成本。
为何常见:高级语言让开发者忽略求值顺序,但链上每一笔 opcode 都明码标价。
误区事件那么便宜,干脆把所有数据都 `emit` 出去,合约里什么都不存。
正解事件日志无法被合约读取,只能由链下索引。如果业务逻辑后续需要读取或修改状态,只能老老实实用存储。事件适合广播、审计、历史记录,不适合当“当前状态”来用。
为何常见:初学 Solidity 时只看到日志便宜,忽略了它“只出不进”的单向性。
面试题
Q1. EVM 中一个存储槽位是多少字节?
- A.16 字节
- B.32 字节← 正确答案
- C.64 字节
- D.8 字节
答案:B。EVM 的字长是 256 位,即 32 字节,所以每个存储槽固定 32 字节。这也是为什么小类型必须拼箱才能省槽位。
Q2. 首次向零值存储槽写入非零值时,SSTORE 的基础花费是多少?
- A.100 gas
- B.5000 gas
- C.20000 gas← 正确答案
- D.21000 gas
答案:C。SSTORE 从零到非零的首次写入收取 20000 gas,这是“开新货架”的成本;同一槽非零改非零只需 5000 gas。
Q3. 短路求值中,最推荐的表达式排列原则是什么?
- A.昂贵检查放最左边
- B.便宜检查放最左边← 正确答案
- C.昂贵检查放最右边
- D.便宜检查放最右边
答案:B。左侧一定执行、右侧可能跳过,因此把便宜且大概率拦截的检查放左边,让昂贵读取放在右侧减少触发概率,是性价比最高的排列。
Q4. uint8 与 uint256 各自单独占一个存储槽时,首次写入成本分别是多少?
- A.两者都花 20000 gas← 正确答案
- B.小类型花 20000 gas
- C.大类型花 5000 gas
- D.小类型花 100 gas
答案:A。存储槽按 32 字节计价,只要单独占槽,无论类型多小,首次写入都是 20000 gas。小类型的优势只有在拼箱时才能体现。
Q5. 事件日志可以被合约内部读取吗?
- A.合约可直接读取
- B.合约无法读取日志← 正确答案
- C.创建者合约可读
- D.仅管理员可读
答案:B。事件日志只面向链下,合约自身无法读取历史事件。因此状态必须留有存储,而纯广播型信息才适合用事件。
Q6. 批量操作节省 Gas 的主要来源是什么?
- A.每项都能打折
- B.固定费只付一次← 正确答案
- C.编译器自动优化
- D.存储完全不变
答案:B。N 次独立调用需要 N 份基础费,而一次批量调用只付一份基础费,省下的正是那 N-1 份固定成本。
Q7. 以下哪些场景适合用事件替代存储?
- A.合约后续要读取的状态
- B.链下审计的历史记录← 正确答案
- C.跨合约依赖的当前值
- D.链下监控的告警推送← 正确答案
答案:BD。合约要读取或跨合约依赖的现值必须存存储;而链下审计和告警推送属于单向广播,用事件既便宜又符合场景。
Q8. 事件日志的数据段按多大块计费?
- A.16 字节
- B.32 字节← 正确答案
- C.64 字节
- D.8 字节
答案:B。日志数据按 32 字节一块计费,每块 375 gas,不足 32 字节也按一块算。因此写事件前要尽量压缩数据大小。
Q9. 关于 constant 与 immutable,下列说法正确的是?
- A.immutable 构造函数定值← 正确答案
- B.constant 编译期内联← 正确答案
- C.两者都会写入存储
- D.两者都免存储读取← 正确答案
答案:ABD。constant 在编译期内联,immutable 在构造时定值后写入字节码;两者都不占用存储槽,也就省去了昂贵的 SLOAD 读取。
Q10. 以下哪些做法能同时降低 calldata 与存储的 Gas 开销?
- A.小字段打包进一槽← 正确答案
- B.批量合并多次调用← 正确答案
- C.压缩合约源码注释
- D.事件替代历史存储
答案:AB。小字段打包减少存储槽写次数、也减少 calldata 体积;批量合并把多次调用的重复数据载荷拼成一份。压缩注释不影响链上字节码,事件替代存储只降存储不降 calldata。