小B要从地球搬家到“链上星球”,搬家师傅递来一张报价单:扛一个箱子收一段路费,走楼梯加收,大件家具占用车厢另算,想要长期存放还得租仓库——每一项都是明码标价的“力气费”。链上世界的Gas正是这样的“力气费”:智能合约执行的每一个操作,小到一次加法、大到写入一段永久数据,都被标上了价格。最贵的是“永久仓库”——把数据存进区块链的存储区,要烧掉一大笔Gas;而临时在手上过一过的东西,却几乎不花钱。 聪明的搬家团队从不靠蛮力,而是靠会打包:把零碎物品塞进标准箱、能随身带的绝不进仓库、常取用的放在开放式货架。Gas优化的本质正是如此——在功能不变的前提下,把合约的“搬运量”压缩到最小,让每一笔交易的账单既快又省。这篇文章将跟着搬家师傅的每一步,从报价单、仓库租金、合并装箱、临时工作台到选箱工具,看看如何给智能合约“瘦身”,把Gas账单打到最低。
掌握Gas优化,意味着你能在DeFi、NFT等高频交易场景里为团队省下真金白银,是智能合约工程师从入门到进阶的核心必修课。
目录
可视化演示
展示EVM指令Gas成本对比、存储打包优化与Bitmap位图压缩技术,切换步骤查看不同Gas优化手段。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Gas成本模型 SSTORE新建:20000 Gas(最贵) ADD/MEM:仅3 Gas(最便宜) SLOAD热访问100 Gas vs 冷访问2100 Gas 存储写入是Gas消耗的主要来源,优先优化SSTORE
Gas机制基础:搬家公司的报价单
搬家公司的师傅不会凭感觉收费,而是掏出一张打印好的报价单:搬一个标准箱收几步路的钱,走一截楼梯加收多少,大件家具占用车厢空间另算。报价单上的每一项,都对应着一次真实的气力支出。
以太坊的EVM(以太坊虚拟机)也一样,对每条指令都有精确的Gas定价:普通算术运算只要3~5 Gas,读取内存3 Gas,但写入永久存储要几万Gas。Gas不是一种币,而是“节点执行一个单位的测量尺”。用户能自行设定Gas单价(gwei),但总共要烧多少Gas,取决于合约代码写得“累不累”。
EIP-1559引入了“基础费+优先费”双轨制。基础费根据上一个区块的拥堵程度自动上下浮动——区块塞得越满,下一块的基础费越高;使用率低则回落,目标是把平均使用率维持在50%左右。用户最终支付的总费用 = 用气量 × (基础费 + 优先费)。
报价单会随油价波动:货车多的时候,运费自然水涨船高——基础费就是链上的“油价”。看懂报价结构后,精明的用户知道何时出手更划算;但对开发者而言,更关键的是把包裹本身变轻。这就引出了最贵的“仓库租金”问题。
- 用气量合约执行所有操作消耗的Gas总和,反映代码的“工作量”。
- 基础费网络根据上一个区块的实际使用量、向50%目标回归的动态价格。
- 优先费用户额外支付的“小费”,激励验证者优先打包自己的交易。
EVM存储成本:仓库租金为何最贵
搬家师傅给小B算了笔账:随身工具袋里的螺丝刀,用一次就放回袋里,几乎不花钱;但要把旧沙发搬进永久仓库,每占一格都要按年付租金。链上的存储(Storage)就是那个永久仓库——合约的每一个状态变量都被锁在256位的格子里,放进去、改动它、哪怕只想看一眼,都要交钱。
存储读写之所以贵,是因为每个节点都要把这条数据永久记在自己的账本上并保持一致。EVM为此给出了严格的定价:读取存储(SLOAD)约100~2100 Gas,写入存储(SSTORE)则是几千到两万Gas起步。对比算术运算的3~5 Gas,差距是千倍级别的。
SSTORE的定价还分几种情况:向一个从没写过的空槽写入非零值(0→非零),要烧约20000 Gas,相当于“新租一格仓库”;把已有非零值改成另一个非零值,约5000 Gas,相当于“续约换物品”;把非零值改回零,虽然仍要付约2900 Gas,但协议会立即返还约4800 Gas,鼓励大家“退租”腾出空间。
仓库租金贵,自然要少租。有经验的师傅会把能合并的东西合成一个大箱,一次放一件和一次放四件,付的格子费却一样——这正是下一节“状态变量打包”要解决的事。
- 新增槽 0 → 非零首次向空槽写入数据,要修改整棵存储树,约20000 Gas。
- 修改槽 非零 → 非零覆盖已有数据,约5000 Gas。
- 清空槽 非零 → 0执行付费约2900 Gas,紧接着协议返还约4800 Gas。
状态变量打包:把零碎物品合并装箱
师傅指着仓库的货架说:一个标准格层刚好放下四个小行李箱。如果你今天搬一个箱子,明天又搬一个,每次都占一个新格层,等于多付三倍租金。把四个小箱子叠进同一格层,租金立刻省下一大截——存储优化的第一课,就是学会“合并装箱”。
Solidity的存储模型正是“格子层”:每个存储槽(Storage Slot)固定256位(32字节)。编译器会把连续声明的、大小不足256位的状态变量(如bool、uint8、uint128)自动“塞”进同一个槽。塞得越满,占用的槽位越少,后续的SSTORE和SLOAD成本就越低。
例如一个玩家信息结构体包含 uint8 level; uint8 hp; uint16 bagSlots; uint8 mapId;——总位宽只有8+8+16+8=40位,编译器可以把它压缩进1个槽,读取四个字段时也只需要访问一次存储树节点。但若在中间插入一个256位的 uint256 score;,Score 需要独占一个整槽,其余成员就必须另占一个槽——总槽数从1变成2,Gas账单立刻翻倍。
师傅的装箱口诀是:“小件挨着小件放,大件单独占一层,顺序排好再搬车。”打包顺序看似只是代码排版,实际上每一格被浪费的仓库空间,都在为Gas账单默默充值。
- 计算总位宽把所有可打包的小类型成员位宽相加。
- 除以256并向上取整判断需要几个标准槽。
- 插入大变量的变化
uint256占用一个整槽,其余成员再占一槽,总数变2。
计算与内存:临时工作台 vs 随身行李
搬家师傅手上提着两个工具袋:一个是随身的“临时口袋”(memory),里面的螺丝刀、扳手用过就丢,不额外收费;另一个是永久仓库(storage),放一件收一件的钱。能放在临时口袋里处理的,绝不往仓库搬。
函数内部声明的局部变量默认就是这样的“临时口袋”:它们在函数执行期间存在,结束后自动释放,读写只需3 Gas。相比之下,从存储中读一个变量(SLOAD)要花100~2100 Gas,是内存读取的几十到几百倍。于是最经典的优化手段出现了:把循环外要反复读的存储值,先拷进memory里缓存一份,循环内只碰内存。
更进阶的用法还有:函数参数声明为 calldata,表示“只读的货单”,直接引用交易原始数据,比copy到memory再读又省一笔;在确定不会溢出的场景(如只做加法且值有上界)里用 unchecked 块跳过安全校验,也能省下大量算术指令的Gas。
搬家师傅的经验是:能随手做的准备,别等到仓库门口才做。提前把要放的物品放进口袋,到了仓库一次性拿出来摆好,省下的每一步,都是实打实的Gas。
- 识别循环中的存储读取循环体内每次引用
storage变量都是一次SLOAD。 - 循环外缓存到内存用局部变量(memory)先保存一份,循环内读它。
- 加入unchecked的收益跳过溢出检查后,算术指令成本进一步下降。
数据容器选择:选箱子还是货架
搬家公司仓库里有两种存放方式:开放式货架(mapping)和带编号的箱子阵列(array)。货架上的每个位置都有固定标签,找东西按标签一步到位;箱子阵列则要一件件翻找,货越多找得越慢。
mapping是键值对存储,定位一个键只需一次哈希运算(O(1));数组是连续存储,要查找某个下标必须线性遍历(O(n)),而且长度变化会触发扩容或收缩的额外成本。因此在“按用户ID查余额”“按tokenId查所有者”这类场景里,mapping几乎总是更优选择。
但容器选择不止“货架vs箱子”一个维度。有些数据压根不该进仓库:比如只用来记录历史、合约逻辑不需要读取的日志型信息,应该用事件(Event)而非状态变量。事件只写进链下可检索的日志区,不占存储槽,成本只有日志费,比SSTORE便宜一两个数量级。删除用不上的数据也能拿到返还Gas——把不用的箱子退租,账本上省出一笔钱。
师傅挑容器的口诀是:“常用工具摆开放货架,搬完就完事的清单写进运单就行。”选择正确的容器,让每一次存取都花在刀刃上。
- mapping定位开销无论键多少,一次keccak256哈希计算即可定位。
- 数组遍历开销数组元素线性排列,最坏情况要读完整个数组。
- Event vs SSTORE日志记录不进入存储树,成本远低于一次存储写入。
实战优化工具:用尺子量着搬
搬家公司不会凭感觉收钱,而是用电子秤、卷尺和报价软件算出每件物品的费用。合约开发同样离不开一整套“测量工具”——Gas排查不能靠拍脑袋,要用工具把消耗摸得清清楚楚。
开发中最常见的三件套:Remix IDE内置的Gas估算器,可以单步执行并看到每个操作码的Gas消耗;Hardhat生态的hardhat-gas-reporter插件,会在跑测试时自动输出每个函数的平均Gas报告;Foundry的forge test --gas-report则生成结构化对比表,适合测试驱动下的优化迭代。
优化迭代的标准流程是:先跑一遍测试拿到基线Gas报告,定位消耗最高的函数,针对存储读写、循环、变量布局逐一优化,再重跑同一套测试,用节省率量化成果,确认没有引入回归。
师傅收工前会复称一遍所有箱子,确认报价无误。合约上线前同样必须用工具复核每一笔关键交易的Gas——优化不是感觉,而是用数据说话的工程素养。
- 基线测量优化前先记录每个函数的关键路径Gas。
- 优化后复测应用技巧后跑同一测试用例,得到新Gas值。
- 计算节省率两者之差占优化前的百分比。
AI 应用场景
DeFi去中心化交易所的Swap合约调优
阶段:智能合约开发与部署迭代
解决的问题:用户每做一次代币兑换(Swap)都要付一次Gas。Gas越高,小额交易越无利可图,用户会大量流失到Layer2。传统方案只关注功能正确,上线后才发现交易费用高得无人问津。
具体案例:某DeFi协议参考Uniswap V2的做法,在Swap核心函数中把多次存储状态更新合并到最后一次写入,中间结果全部缓存在memory里;同时将交易对合约的工厂部署采用单例模式,把可复用的storage布局固化,使单次Swap的Gas稳定在行业均值附近。
NFT批量铸造的Gas瘦身
阶段:合约上线前的Gas调优
解决的问题:批量铸造NFT时,如果每个token都触发一次 ownerOf 映射的SSTORE,Gas会随数量线性爆炸。高Gas让“批量Mint”名存实亡,也抬高了小散户的参与门槛。
具体案例:一个链上艺术平台把元数据(图片URL、描述)放进事件里广播,链上只存储“已铸造数量”计数器;在 mintBatch 中通过单次存储写入配合位运算记录连续ID段,将单件铸造的边际Gas降低了40%以上。
Python 演示
class StorageLayout: SSTORE_NEW_SLOT = 20000 def __init__(self, name): self.name = name self.slots = [] self._var_count = 0 def add_variable(self, bit_width): if not self.slots: self.slots.append(0) if self.slots[-1] + bit_width > 256: self.slots.append(0) self.slots[-1] += bit_width self._var_count += 1 def packed_gas(self): return self.SSTORE_NEW_SLOT * len(self.slots) def separate_gas(self): return self.SSTORE_NEW_SLOT * self._var_count def make_layout(widths): layout = StorageLayout("demo") for w in widths: layout.add_variable(w) return layout def report(widths): packed = make_layout(widths) sg = packed.separate_gas() pg = packed.packed_gas() save = sg - pg print(f"变量位宽列表: {widths}") print(f" 打包后槽位: {len(packed.slots)} 个, Gas: {pg:,}") print(f" 独立占位槽: {packed._var_count} 个, Gas: {sg:,}") print(f" 节省: {save:,} Gas ({save / sg * 100:.1f}%)\n") def main(): print("=== 存储打包 Gas 模拟 ===\n") report([64, 64, 64, 64]) report([128, 256, 128]) report([8, 8, 16, 8]) report([8, 256, 8]) if __name__ == "__main__": main()
class StorageLayout模拟一个存储布局容器,slots列表表示最终占用的存储槽。add_variable(bit_width)把变量按位宽放进当前槽,放不下就开新槽,实现“顺序打包”。packed_gas()打包方案的成本 = 槽位数 × 每次SSTORE的2万Gas。separate_gas()不做打包时的成本 = 变量个数 × 2万Gas。report([64, 64, 64, 64])4个uint64打包进1个槽,成本从8万降到2万,节省75%。report([128, 256, 128])中间插入uint256,本可以2个槽放下的数据变成3个槽,浪费立刻显现。
常见误区
误区初学者常以为类型越小越省Gas,于是把合约里能改成uint8的全部改成uint8,结果Gas报告反而没有明显下降。
正解Gas省钱的关键不是单个变量的大小,而是“存储槽的利用率”。Solidity按32字节的槽来收费,一个uint8独自占一个槽时,跟uint256占一个槽交的SSTORE费用完全一样。小类型只有在“能和其他小类型共享一个槽”时才真正省钱。把类型改小却不调整声明顺序,等于换了个更小的箱子却仍然独占一格货架。
为何常见:因为“越小越省”符合日常直觉,而存储槽的计费模式藏在EVM底层,不亲手查Gas报告很难意识到。
误区很多人用“行数”衡量Gas消耗,觉得删几行代码就能降Gas,甚至通过把多个表达式挤在一行来“优化”。
正解Gas消耗由实际执行的操作码决定,与源码行数没有直接关系。压缩代码可能会触发完全相同的操作码序列,账单一分不减;而某些“变长”的技巧——比如循环展开(把循环体复制几份)——反而能减少循环控制指令,省下一笔笔小额Gas。正确的测量方式是跑Gas报告,而不是用眼睛数行数。
为何常见:多数人的性能直觉来自普通软件开发中的“代码越短执行越快”,但EVM按操作码计费的模型里,行数是无效指标。
误区学会 `unchecked` 省Gas后,开发者在所有算术运算上都套上它,觉得溢出检查是多余的。
正解unchecked 省掉的Gas正是Solidity自动插入的安全校验。一旦运算真实发生溢出,结果会静默回绕,轻则逻辑错误,重则资金被盗——历史上多起著名的攻击事件都源于整数溢出。正确用法是只在“能由外部约束证明不会溢出”的场景使用,比如时间戳差值、有明确上限的计数器。
为何常见:因为溢出在测试环境里难复现,而Gas节省是立竿见影的,安全收益被短期利益掩盖了。
误区事件这么便宜,把游戏战绩、订单状态全部写成Event,链上查不到了再去事件日志里找。
正解事件日志是“链下检索”的数据,不能直接被合约逻辑读取。如果合约需要判断某个状态、根据历史做计算(例如余额校验),就必须用存储变量。把必须可读的状态放进事件,等于把货物运到了无法取回的仓库暗格——数据在,但合约够不着。事件适合存“给外部看的公告”,存储适合存“合约自己要用的账本”。
为何常见:因为事件Gas确实比SSTORE低很多,初学者容易被成本差吸引,忽略了“谁在读数据”这个根本问题。
面试题
Q1. 以太坊智能合约执行时,以下哪类操作消耗Gas最高?
- A.内存读写MLOAD
- B.存储写入SSTORE← 正确答案
- C.算术运算ADD
- D.栈操作PUSH
答案:B。存储写入涉及全网节点的账本同步,SSTORE可达数万Gas;而内存读写、算术运算都只要个位数Gas。可以把存储想象成“永久仓库租金”,其他操作只是“临时工工时费”。
Q2. EIP-1559中“基础费”被销毁而非给矿工,主要目的是什么?
- A.增加矿工直接收益
- B.让Gas价格可预测并减少通胀← 正确答案
- C.防止重入攻击发生
- D.直接提高区块容量上限
答案:B。基础费按区块拥堵度动态调整并销毁,让用户对下一块费用有更稳定的预期,同时ETH总量趋向通缩;矿工只拿优先费。这与安全性或区块容量无关,是经济模型设计。
Q3. 以下哪种状态变量排布能放入同一个存储槽?
- A.四个uint8连续声明← 正确答案
- B.bool加上uint256变量
- C.uint256加上两个uint8
- D.两个uint128加uint8
答案:A。一个存储槽256位。四个uint8共32位可共存一槽;B、C、D的总位宽都超过256(分别约328位、272位、273位),必然分槽。打包就像往32字节的格层里叠小箱子,装不下就得另开一格。
Q4. 在EVM中,以下哪个操作会触发Gas返还(refund)?
- A.SLOAD读取热存储槽
- B.SSTORE将非零值改为零← 正确答案
- C.MLOAD读取内存区域
- D.CALL调用外部合同
答案:B。把存储槽从非零改为零相当于“退租仓库”,协议对释放存储空间的行为给予约4800 Gas的返还;读取操作、调用操作和内存读写都没有“释放空间”的语义,不会返还。
Q5. 以下哪些做法能有效降低合约Gas消耗?
- A.用mapping替代频繁遍历的数组← 正确答案
- B.在循环里重复读取存储槽
- C.将结构体成员紧凑排序← 正确答案
- D.把历史数据写入事件← 正确答案
答案:ACD。mapping查找O(1)天然比遍历数组省;结构体紧凑排序能提高槽位利用率;事件不占存储槽、成本远低于SSTORE。B恰恰相反,循环内重复SLOAD是典型的Gas浪费,应先把存储值缓存到memory。
Q6. 状态变量声明顺序影响Gas的关键原因是什么?
- A.Solidity编译器自动打包连续小类型← 正确答案
- B.变量名的字符长度设置
- C.编译器优化开关状态
- D.合约注释数量的多少
答案:A。编译器会把连续声明且总大小不超过256位的小类型变量打包进同一存储槽。声明顺序决定了哪些变量能“住进同一格”,从而减少槽位总数和后续SSTORE/SLOAD成本。
Q7. 下列关于SSTORE成本的说法正确的有哪些?
- A.首次写入新空槽约消耗20000 Gas← 正确答案
- B.修改已有槽比新增槽便宜← 正确答案
- C.清空存储槽可以获得Gas返还← 正确答案
- D.存储读取永远完全免费
答案:ABC。0→非零是“新租仓库”最贵;非零→非零是“换货”略便宜;非零→零是“退租”有返费。D错误:SLOAD按冷热状态收100~2100 Gas,绝非免费。
Q8. 使用 unchecked 块省Gas的原理是什么?
- A.跳过安全的算术校验← 正确答案
- B.完全关闭存储功能
- C.开启并行多线程处理
- D.直接压缩运行代码体积
答案:A。Solidity默认在算术指令后插入溢出检查,unchecked 跳过这些检查从而减少执行的操作码。代价是溢出不再报错、结果可能回绕,必须在能证明不会溢出的场合使用。
Q9. 循环中需要遍历一个storage数组时,最省Gas的做法是?
- A.循环内每次读写存储槽
- B.拷贝到内存后再遍历← 正确答案
- C.循环中用哈希重新定位
- D.用多个局部变量暂存
答案:B。数组在storage中,循环内按下标访问,每次都是一次SLOAD;先把整个数组拷贝到memory,循环内只做3 Gas的MLOAD,可省下n×(SLOAD−MLOAD)的巨大开销。哈希定位不会减少读取,多个局部变量也无法覆盖任意长度的遍历。
Q10. Gas优化中的“平衡之道”主要包括哪些维度?
- A.优化与代码可读性平衡← 正确答案
- B.优化与合约安全性平衡← 正确答案
- C.用最少Gas保留所需功能← 正确答案
- D.合约内绝不使用任何存储
答案:ABC。过度优化会让代码晦涩难懂、难以审计;unchecked 等技巧还会放大安全风险,所以最优解法是在保证功能与安全的前提下尽量省Gas。D是极端化误区——完全不使用存储,等于放弃合约的核心状态能力。