3. 循环优化误区:无界循环与Gas耗尽攻击

说到循环优化,我得先坦白一件事。

我刚入行写Solidity那会儿,特别喜欢用循环。为什么呢?因为循环看起来太优雅了。一个for循环搞定所有数据,代码简洁,逻辑清晰。我当时觉得,这简直是程序员的浪漫。

直到有一天,我在测试网上部署了一个带循环的合约,调用时直接把我钱包里的测试ETH烧光了。嗯,那笔交易失败了,但Gas费一分没退。从那以后,我对循环的态度就变了——循环在EVM里,不是你想用就能用的

为什么循环在Solidity里这么危险?

说白了,EVM的Gas机制和循环天然冲突。

你想想看,一个循环要跑多少次,完全取决于输入数据。如果用户传入一个长度为1000的数组,循环就跑1000次。如果传入10000呢?那就跑10000次。每次循环都要消耗Gas,总Gas = 单次循环Gas × 循环次数。

问题来了:Gas上限是固定的。以太坊每个区块有Gas limit,单笔交易也有Gas limit(通常是3000万左右)。如果你的循环次数太多,总Gas超过上限,交易就会失败。更惨的是,失败的交易已经消耗的Gas不会退还。

⚠️ 核心陷阱: 无界循环(unbounded loop)是指循环次数由外部输入决定,且没有上限检查。这是Gas耗尽攻击的温床。

一个真实的Gas耗尽攻击案例

我记得有个项目叫GovernMental,是个庞氏骗局合约。攻击者利用了一个无界循环的漏洞,让合约陷入死循环,最终耗尽了合约里的所有资金。具体怎么做的呢?

合约里有个函数需要遍历所有参与者列表,而攻击者通过某种方式让列表无限增长。每次调用这个函数,Gas消耗都爆炸式增长,最后没人能成功调用——合约就"冻住"了。

这种攻击说白了就是:让循环次数失控,导致Gas耗尽,合约功能瘫痪

常见的循环优化误区

很多开发者(包括以前的我)会犯以下几个错误:

  • 误区一:觉得循环次数少就安全
    我见过有人写循环时只检查了当前输入,没考虑未来状态变化。比如一个数组初始只有10个元素,但合约允许用户不断添加元素。几个月后数组变成10000个,循环就炸了。
  • 误区二:用循环遍历动态数组
    动态数组的长度是变量,由用户控制。如果你在函数里遍历整个动态数组,就等于把Gas控制权交给了用户。这是非常危险的。
  • 误区三:循环内做复杂计算
    有些开发者为了"优化Gas",把大量计算塞进循环体。结果单次循环Gas很高,总Gas直接爆表。这其实是反向优化。

如何安全地使用循环?

我个人习惯遵循几个原则,分享给你:

  1. 固定循环次数
    循环次数必须是常量,或者由合约内部状态决定,不能由用户输入控制。
  2. 设置硬上限
    如果实在需要用户输入,那就加一个上限检查。比如:require(ids.length <= 100, "too many items");
  3. 用映射代替数组遍历
    很多时候,你根本不需要遍历。用mapping加计数器,或者用链表结构,可以避免循环。
  4. 分页处理
    如果数据量确实很大,可以分批次处理。每次只处理一小部分,用户分多次调用。

代码示例:安全 vs 危险

先看一个危险的写法:

// ❌ 危险:无界循环
function batchTransfer(address[] memory recipients, uint256 amount) public {
    for (uint256 i = 0; i < recipients.length; i++) {
        payable(recipients[i]).transfer(amount);
    }
}

这个函数的问题很明显:recipients.length完全由调用者控制。如果有人传入一个长度为10000的数组,单次transfer消耗约2300 Gas,总Gas就是2300万。再加上循环本身的开销,很容易超过Gas limit。

再看安全的写法:

// ✅ 安全:固定上限 + 分页
uint256 public constant MAX_BATCH = 50;

function batchTransfer(address[] memory recipients, uint256 amount) public {
    require(recipients.length <= MAX_BATCH, "too many recipients");
    for (uint256 i = 0; i < recipients.length; i++) {
        payable(recipients[i]).transfer(amount);
    }
}

加了上限检查后,单次调用最多处理50个人,Gas消耗可控。如果用户有1000个人要转账,那就分20次调用。

更高级的替代方案:避免循环

我曾经在一个DeFi项目里遇到过类似问题。合约需要统计所有用户的质押量,最开始用的是遍历数组。后来数据量大了,Gas消耗高得离谱。

最后怎么解决的?用了一个累加器变量:

// ✅ 推荐:用累加器避免遍历
mapping(address => uint256) public balances;
uint256 public totalStaked;

function stake(uint256 amount) public {
    balances[msg.sender] += amount;
    totalStaked += amount;  // 每次操作时更新总量
}

function unstake(uint256 amount) public {
    require(balances[msg.sender] >= amount, "insufficient balance");
    balances[msg.sender] -= amount;
    totalStaked -= amount;
}

这样就不需要遍历所有用户了。每次质押或解押时,顺手更新总量。查询时直接读totalStaked,零Gas消耗。

💡 技巧: 能用映射解决的问题,绝对不要用数组。能用累加器解决的问题,绝对不要用循环。这是Solidity开发的黄金法则。

循环优化的知识体系

我把循环相关的知识点整理成了一张图,方便你理解:

循环优化与Gas安全知识体系 循环在EVM中的风险 问题:无界循环 循环次数由外部输入控制 Gas消耗不可预测 攻击:Gas耗尽 / 合约冻结 解决方案:限制循环 设置硬上限(如MAX_BATCH) 分页处理 / 分批调用 安全:Gas可控,功能正常 替代方案:避免循环 用mapping代替数组遍历 累加器变量实时更新 最优:零遍历,零Gas风险

避坑指南

我曾经犯过的错,你别再犯了:

  • 写循环前先问自己:这个循环次数是固定的吗?如果不是,立刻加上限。
  • 别相信用户传入的数据。用户可能传一个超长数组,也可能传一个空数组。两种情况都要处理。
  • 循环体越轻量越好。复杂的数学计算、多次SLOAD操作,都别放循环里。
  • 测试时一定要模拟极端情况。比如数组长度10000,看看Gas消耗是否在合理范围内。

总结一下

循环优化不是让你把循环写得更快,而是让你避免写出危险的循环。在Solidity里,安全永远比Gas优化更重要。一个Gas耗尽攻击就能让你的合约废掉,省那几百Gas有什么用?

我个人现在写合约的原则是:能用映射绝不用数组,能用累加器绝不用循环。如果实在要用循环,那就加死上限,分页处理。这样虽然代码看起来没那么"优雅",但至少不会在线上翻车。

嗯,这就是我今天想分享的内容。希望你能从我的踩坑经历中学到点什么。


专注资料整理