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不会退还。
一个真实的Gas耗尽攻击案例
我记得有个项目叫GovernMental,是个庞氏骗局合约。攻击者利用了一个无界循环的漏洞,让合约陷入死循环,最终耗尽了合约里的所有资金。具体怎么做的呢?
合约里有个函数需要遍历所有参与者列表,而攻击者通过某种方式让列表无限增长。每次调用这个函数,Gas消耗都爆炸式增长,最后没人能成功调用——合约就"冻住"了。
这种攻击说白了就是:让循环次数失控,导致Gas耗尽,合约功能瘫痪。
常见的循环优化误区
很多开发者(包括以前的我)会犯以下几个错误:
- 误区一:觉得循环次数少就安全
我见过有人写循环时只检查了当前输入,没考虑未来状态变化。比如一个数组初始只有10个元素,但合约允许用户不断添加元素。几个月后数组变成10000个,循环就炸了。 - 误区二:用循环遍历动态数组
动态数组的长度是变量,由用户控制。如果你在函数里遍历整个动态数组,就等于把Gas控制权交给了用户。这是非常危险的。 - 误区三:循环内做复杂计算
有些开发者为了"优化Gas",把大量计算塞进循环体。结果单次循环Gas很高,总Gas直接爆表。这其实是反向优化。
如何安全地使用循环?
我个人习惯遵循几个原则,分享给你:
- 固定循环次数
循环次数必须是常量,或者由合约内部状态决定,不能由用户输入控制。 - 设置硬上限
如果实在需要用户输入,那就加一个上限检查。比如:require(ids.length <= 100, "too many items"); - 用映射代替数组遍历
很多时候,你根本不需要遍历。用mapping加计数器,或者用链表结构,可以避免循环。 - 分页处理
如果数据量确实很大,可以分批次处理。每次只处理一小部分,用户分多次调用。
代码示例:安全 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消耗。
循环优化的知识体系
我把循环相关的知识点整理成了一张图,方便你理解:
避坑指南
我曾经犯过的错,你别再犯了:
- 写循环前先问自己:这个循环次数是固定的吗?如果不是,立刻加上限。
- 别相信用户传入的数据。用户可能传一个超长数组,也可能传一个空数组。两种情况都要处理。
- 循环体越轻量越好。复杂的数学计算、多次SLOAD操作,都别放循环里。
- 测试时一定要模拟极端情况。比如数组长度10000,看看Gas消耗是否在合理范围内。
总结一下
循环优化不是让你把循环写得更快,而是让你避免写出危险的循环。在Solidity里,安全永远比Gas优化更重要。一个Gas耗尽攻击就能让你的合约废掉,省那几百Gas有什么用?
我个人现在写合约的原则是:能用映射绝不用数组,能用累加器绝不用循环。如果实在要用循环,那就加死上限,分页处理。这样虽然代码看起来没那么"优雅",但至少不会在线上翻车。
嗯,这就是我今天想分享的内容。希望你能从我的踩坑经历中学到点什么。