news 2026/9/9 15:03:37

从零实现Rollup:以太坊Layer2扩容原理与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现Rollup:以太坊Layer2扩容原理与工程实践

如果你在2021年那轮行情里用以太坊做过转账,一定记得一次简单Transfer动辄几十美元Gas费的酸爽。我后来做链上数据分析工具时,发现不少项目已经开始把业务从主网迁移到Layer 2,而Layer 2赛道里最被主流认可的方案就是Rollup。这篇文章不打算重复白皮书里的大道理,而是从一个开发者的角度,把Rollup扩容方案的原理、选型、代码实现和踩坑经历完整过一遍。适合对以太坊有一定了解、想深入Layer 2开发,或者正在考虑把Rollup技术拿来做实际业务的读者。

我写这篇的动机很直接:网上讲Rollup的文章,理论派居多,真正把L1合约、排序器、状态根、批处理串成一整套Demo的很少。很多朋友看完概念还是一头雾水,不知道从哪里下手写代码。我就用自己做过的一个最小化Rollup原型来拆解,所有代码都能在本地跑起来。先声明一句,下面所有代码是一个教学性质的最小实现,和生产级的Arbitrum、zkSync有差距,但核心架构是一脉相承的,能帮你把抽象概念落地成可运行的东西。

1. 为什么必须关注Rollup:以太坊扩容困境与Layer 2的崛起

1.1 拥堵的L1与Gas费上涨

以太坊主网(L1)的本质是一台全球共享的状态机,所有节点都要执行同一批交易,维护同一份状态。为了保证去中心化和安全性,以太坊刻意限制了区块大小与Gas上限,这导致整个网络在高峰期能处理的交易量非常有限。我印象很深刻,2021年某个NFT项目发售当天,普通转账的Gas费一度冲到0.02 ETH,那时候做链上业务,最头疼的不是合约Bug,而是用户根本付不起手续费。

在这种背景下,整个行业开始围绕“能不能把交易搬到链下执行,再把结果锚定回链上”做文章,这就是Layer 2的基本思路。Layer 2不是一个新的独立区块链,而是借用以太坊的安全性和数据可用性,在链下做计算,在链上做结算的一整套方案。用户从L2发起提款,最终还是要回到L1拿到资产,所以L2的安全上限完全由L1来兜底。

有人会问,为什么不等以太坊自己把性能提上去?这里有个很现实的问题:L1的每次升级都要考虑到全球分布的成千上万个节点,任何一条规则改动都会牵一发而动全身。与其等待一个不确定的扩容方案,不如在L1之上叠加一层处理逻辑。这也是为什么Rollup能在短时间内跑出来,因为它不改变L1的共识规则,只是把L1当成一个“数据可用层+仲裁法院”来使用。

1.2 为什么最终是Rollup而不是状态通道或Plasma

其实在Rollup之前,市场上就已经有状态通道、Plasma、侧链等方案,它们各有各的优势,但都存在明显短板。

状态通道适合高频小额的固定双边支付,比如两个人之间反复转账,通道打开期间不需要在链上记录每一笔交易。但它的致命问题是需要双方都在线,而且资金锁定太死,一旦通道关闭流程没走对,资金可能被没收。Plasma的思路是把交易放在子链上,定期向L1提交Merkle根,安全性理论上继承L1,但退出机制极其复杂,数据可用性是个大坑,普通用户根本玩不转。侧链的方案更直接,就是另起炉灶独立运行一条链,但独立链的安全性完全靠自己维护,这和直接用一条新公链没有本质区别,谈不上“继承以太坊安全性”。

Rollup之所以能在竞争里胜出,关键就两点。第一,交易数据会实实在在上传到L1,哪怕Rollup的交易执行结果有问题,挑战者也能基于L1上公开的数据发起质疑。第二,资产托管在L1合约里,L2状态无论如何变化,最终都不能绕过L1合约取走用户资产。这两条保证了Rollup的安全模型非常清晰:计算可以链下做,数据必须链上留痕,状态根最终由L1来确认。

2. Rollup核心原理拆解:两种范式的对比与取舍

2.1 Rollup的共识模型:计算下沉、数据上链

先给一个最直观的理解:Rollup就是把以太坊当成一个记事本,业务方在链下维护一个高速执行的账本,每隔一段时间把账本的最新快照和这段时间内的交易摘要提交到链上。以太坊上的合约只负责两件事,一是校验提交人的资格,二是记录状态转换的承诺。

这里有个必须讲清楚的概念,状态根。在一个Rollup系统里,账户余额、合约存储、随机数这些信息组合在一起,经过哈希运算会产生一个固定长度的摘要,一般叫stateRoot。链下执行器每处理完一批交易,就计算出一个新的stateRoot,然后把旧根、新根、交易摘要一起提交到L1。L1合约不需要重新执行每一笔交易,它只需要相信这个状态转换在挑战期内可被验证。

“计算下沉”的意思是,所有繁重的执行工作,比如运行EVM字节码、更新存储、计算哈希,全部挪到排序器或验证者节点上去做,L1只做轻量级的校验与仲裁。而“数据上链”保证的是,任何时候挑战者都能拿到完整交易数据,重新执行一遍,验证提交的stateRoot对不对。如果数据不上链,状态根就成了无源之水,用户无法自证清白,这是Rollup设计里最重要的一根红线。

2.2 Optimistic Rollup与欺诈证明

Optimistic Rollup的代表是Arbitrum和Optimism。它的核心假设是“默认所有提交都是正确的”,所以提交批次不需要附带证明,直接就能上链。但为了防止恶意排序器提交错误状态,系统设置了一个挑战窗口,一般是7天左右。在窗口期内,任意验证者如果发现状态根与交易数据对不上,可以发起欺诈证明挑战。

欺诈证明的玩法不止一种,简单型是把整批交易在链上重新执行,发现结果不一致就处罚提交者;高级型是二分查找,先在链上定位到出错的那一笔交易,再对这一笔做精确验证,这样能把验证成本降到很低。我实际体验过这种设计,它在安全性和效率之间做了很好的平衡。真正动手做的时候,关键在于L1合约要把“验证逻辑”和“挑战逻辑”分开,避免挑战者在验证过程中被恶意合约卡住。

Optimistic Rollup最大的优势是EVM兼容性好,Solidity合约几乎不需要改动就能直接迁移上去。因为它的执行环境和L1一致,不需要专用编译器。代价就是出金确认时间长,用户从L2把资产提回L1,得等挑战期结束才能拿到钱,虽然市场上有一些快速退出服务商愿意垫资,但这是有风险的。

2.3 ZK Rollup与有效性证明

ZK Rollup这条路线,代表项目是zkSync、StarkNet、Scroll。它的逻辑更有“数学味”,每次提交批次时,提交者必须附带一个零知识证明,证明这批交易执行后的状态转换是正确的。L1合约只负责验证证明,验证通过就直接确认,不设置争议窗口,所以提款速度远比Optimistic Rollup快。

零知识证明听起来神秘,其实在工程上可以当成一个“压缩版执行见证”。链下执行10000笔交易后,生成一个大概几十KB的证明文件,L1合约不需要知道每笔交易怎么执行,只需要运行一个固定的验证电路就能确信结果是正确的。当前效率比较高的方案,比如PLONK、STARK,各有侧重。STARK证明尺寸大但不需要可信设置,PLONK/R1CS证明小但通常依赖可信设置。

ZK Rollup的问题在于EVM兼容性。Solidity合约跑在ZK虚拟机里,需要把字节码翻译成电路约束,这个翻译过程的工程难度非常大。早期很多项目只能支持有限的转账操作,后来虽然出了很多兼容方案,但复杂度依然摆在那里。另一个痛点是证明生成时间太长,一笔批量证明可能要几分钟甚至更久,在交易激增时容易导致批次延迟。

2.4 名词“Rollup”为什么总是被误用

我这个月在好几个技术群里看到有人问“SQL Server里的ROLLUP怎么用”,也看到有人把前端构建工具rollup.js和区块链Rollup混为一谈。这不是笑话,而是不同领域的重名现象。

数据库里的ROLLUP是GROUP BY的扩展,用来生成小计和总计,本质是一个聚合函数层级。前端构建工具rollup.js是一个模块打包器,把多个JavaScript模块打包成单文件。它们和区块链Rollup没有任何从属关系,只是恰好都叫同一个名字。

区块链Rollup的英文原意是“卷起、累聚”,指的是把一批交易聚拢起来处理,再压缩成一个状态根提交到L1。如果你准备搜“Rollup代码实现”,建议先确认自己想知道的是数据库聚合、前端构建,还是L2扩容。方向不对,学的内容就完全对不上。

3. 从零写一个极简Rollup:合约、模拟器与联调

3.1 环境准备

动手写代码之前,先把开发环境准备好。我这里用的技术栈是Hardhat加Solidity 0.8.19,离线模拟器用Python 3.9,交互脚本用ethers.js。Hardhat是目前最顺手的以太坊本地开发框架,自带本地节点、测试账户、合约编译部署一套流程,比用Remix写完整应用舒服太多。

安装依赖就几条命令,先把Node.js 16以上版本装好,然后在一个空目录里执行:

npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox npx hardhat

选择“Create a JavaScript project”,Hardhat会自动生成contracts、scripts、test目录。再装一个Python的hashlib库,这个不用装,标准库自带。我的架构分三块:L1合约负责资产托管和批次承诺,Python模拟器负责维护L2状态、执行交易、计算状态根,最后的联调脚本负责把模拟器生成的批次提交到链上。

3.2 L1结算合约代码

L1合约是整个Rollup系统的裁判,它不需要执行每一笔交易,只需要记录排序器提交的状态根,并在挑战期结束后确认。下面这个SimpleRollup合约是我在实际Demo里用的版本,已经去掉了很多业务逻辑,只保留核心骨架:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract SimpleRollup { struct Batch { uint256 committedAt; // 提交时间 bytes32 stateRoot; // L2状态根 bytes32 transactionsRoot; // 交易批次哈希 bool finalized; // 是否已确认 } mapping(uint256 => Batch) public batches; uint256 public batchCount; address public sequencer; uint256 public immutable challengeWindow = 7 days; bytes32 public currentStateRoot; uint256 public latestFinalizedBatch; event BatchCommitted(uint256 indexed batchIndex, bytes32 stateRoot, bytes32 transactionsRoot); event BatchFinalized(uint256 indexed batchIndex); event Deposit(address indexed token, uint256 amount, bytes32 account); constructor(address _sequencer) { sequencer = _sequencer; } modifier onlySequencer() { require(msg.sender == sequencer, "not sequencer"); _; } // 用户往L1托管里充值,并注册到L2账户 function deposit(address token, uint256 amount, bytes32 account) external { // 实际项目中需要 IERC20(token).transferFrom(msg.sender, address(this), amount) // 这里为了跑通流程,只记录事件,不真实转移资产 emit Deposit(token, amount, account); } // 排序器提交一个批次 function commitBatch(bytes32 stateRoot, bytes32 transactionsRoot) external onlySequencer { batches[batchCount] = Batch(block.timestamp, stateRoot, transactionsRoot, false); emit BatchCommitted(batchCount, stateRoot, transactionsRoot); batchCount += 1; } // 挑战期结束后,任意调用者都可以确认批次 function finalizeBatch(uint256 batchIndex) external { Batch storage b = batches[batchIndex]; require(block.timestamp >= b.committedAt + challengeWindow, "still in challenge window"); require(!b.finalized, "already finalized"); b.finalized = true; currentStateRoot = b.stateRoot; latestFinalizedBatch = batchIndex; emit BatchFinalized(batchIndex); } }

这里有几个设计点需要解释。为什么用mapping存批次而不是数组?因为生产环境里批次号可能会被挑战、回滚、重排,mapping天然支持稀疏索引。为什么finalize要设置成任何人都能调用?因为确认批次没有经济利益,只有维护义务,所以必须鼓励任意节点补位执行,一旦排序器宕机,其他节点也能推进系统。

3.3 离线执行器模拟

L2侧的离线执行器我用了Python来写,因为要演示状态转换逻辑,Python的可读性比Solidity高一个量级。核心就是维护一份账户余额表,按顺序执行交易,最后计算状态根:

import hashlib class RollupState: def __init__(self): self.balances = {} def apply_transfer(self, from_addr, to_addr, amount): if self.balances.get(from_addr, 0) < amount: raise ValueError(f"{from_addr} balance insufficient") self.balances[from_addr] = self.balances.get(from_addr, 0) - amount self.balances[to_addr] = self.balances.get(to_addr, 0) + amount def state_root(self): # 教学简化:全局排序后拼接哈希 # 生产环境用 Merkle Patricia Trie content = "".join( f"{addr}:{self.balances.get(addr, 0)};" for addr in sorted(self.balances.keys()) ) return hashlib.sha256(content.encode()).hexdigest() def simulate_batch(transactions, initial_state): state = RollupState() state.balances = dict(initial_state) for tx in transactions: state.apply_transfer(tx["from"], tx["to"], tx["amount"]) return state if __name__ == "__main__": initial = { "0x1111111111111111111111111111111111111111": 1000, "0x2222222222222222222222222222222222222222": 500, } txs = [ {"from": "0x1111111111111111111111111111111111111111", "to": "0x2222222222222222222222222222222222222222", "amount": 30}, {"from": "0x2222222222222222222222222222222222222222", "to": "0x1111111111111111111111111111111111111111", "amount": 10}, ] new_state = simulate_batch(txs, initial) print("new state root:", new_state.state_root())

这个模拟器有两个细节值得注意。第一,在apply_transfer里我先检查余额再扣款,交易如果失败,整个批次应该回滚到未执行状态,而不是跳过继续。真实Rollup的区块构建器同样遵循这个原则,状态转换是原子的。第二,state_root的计算方式我用了最简单的全局拼接哈希,这只能用来理解原理。真实项目必须用Merkle Patricia Trie,这样即使账户有上亿个,也能在O(logN)时间内生成和验证某个账户的状态证明。

3.4 端到端调用

写完合约和模拟器,下一步是把它们串起来。我的做法是先本地起Hardhat节点,部署SimpleRollup合约,然后用Python模拟器生成一批交易并算出新的stateRoot,最后用ethers.js把批次提交到链上。下面是部署和提交的脚本:

const { ethers } = require("hardhat"); async function main() { const [deployer] = await ethers.getSigners(); const SimpleRollup = await ethers.getContractFactory("SimpleRollup"); const contract = await SimpleRollup.deploy(deployer.address); await contract.deployed(); console.log("SimpleRollup deployed to:", contract.address); // 这里填Python模拟器算出来的stateRoot和transactionsRoot const stateRoot = "0x" + "11".repeat(32); const transactionsRoot = "0x" + "22".repeat(32); const tx = await contract.commitBatch(stateRoot, transactionsRoot); const receipt = await tx.wait(); console.log("batch committed, tx hash:", receipt.transactionHash); } main().catch(console.error);

实际联调的时候,我会写一个Python脚本把transactions生成成JSON文件,再用Node脚本读取并提交,这样可以避免手工复制哈希。整个链条跑通之后,你会发现Rollup的核心工作流其实很朴素:L2业务逻辑在链下跑,成熟一批就向L1交一次作业,L1只做登记和裁决。

4. 我在联调过程中踩过的坑

4.1 nonce与事件顺序

第一次联调时我犯了个低级错误,用Python的dict保存状态,结果Python 3.7之后的dict虽然保留插入顺序,但真实业务里交易来源是多线程的,可能乱序到达。如果在模拟器里不按nonce排序就执行交易,状态根就会算错,而且这种错误很难排查,因为单笔交易本身都合法。

解决办法是在交易结构体里加nonce字段,每次执行前按账户和nonce排序,遇到nonce空洞就把交易放回待处理队列。还有一个更隐蔽的问题:事件顺序。Solidity里面emit事件不会自动排序,如果你依赖事件日志来恢复L2状态,一定要在合约里记录一个序号字段,比如batchIndex,这样链下服务才能按序消费日志,否则遇到区块重组就会乱套。

我建议所有做L2索引服务的朋友,不要把区块高度当唯一顺序依据,区块高度加交易index加logIndex,三层组合才能唯一定位一条事件。

4.2 calldata压缩与Gas优化

Rollup的成本大头在L1的数据发布,而不是L2的计算。以太坊上calldata的写入成本是非零字节16 Gas,零字节4 Gas,这个数字看着不高,但当你每天发布几千个批次时,压缩率会直接决定商业模式能不能成立。

我在Demo里刚开始用的是原始JSON格式传输,一个批次500笔交易,calldata大概2MB,Gas费用高到离谱。后来改成RLP编码,再把地址做哈希索引映射成短ID,交易类型用一个bit位表示,同样的数据压缩到不到200KB,Gas成本降了大概七八成。生产级Rollup还会用到一些更激进的压缩方案,比如把地址替换成索引、金额用变长编码、批量交易共享签名上下文。

这里有个经验想分享:做Rollup优化,先压数据,再调证明,顺序别反了。很多团队一开始就纠结零知识证明的电路优化,结果发现瓶颈根本不在证明生成,而在上链价格。

4.3 状态根同步问题

另一个让我印象很深的坑是状态根的同步时延。在测试网联调时,我让排序器每10秒提交一个批次,但链下数据服务读取的是本地数据库的L2状态,没有和“链上已确认状态”严格区分。结果用户发起提款时,合约里保存的currentStateRoot还是旧的,导致提款证明验证失败。

正确的做法很明确,L2执行层需要维护两套状态:一套是pending state,排序器正在执行但还没上链;一套是finalized state,已经过了挑战期并确认的状态。用户提款时,只认finalized state对应的Merkle证明。这个设计在真实项目里就是“提款最终性”和“快速提款市场”存在的根本原因,理解了这两套状态,很多L2术语就都好懂了。

5. 生产落地不可忽视的安全与架构细节

5.1 数据可用性是生死线

我在第2章讲了数据上链的重要性,这里再展开说。Rollup如果想继承L1的安全性,就必须保证任何人在挑战期内都能拿到交易数据。如果排序器提交了stateRoot,但把原始交易藏在链外,用户无法重新执行验证,系统就退化成了“半中心化侧链”,用户的资产安全完全依赖排序器的诚实。

现在行业里解决数据可用性有两种主流思路。一种是把交易calldata完整发布到L1,像Arbitrum、Optimism初期就是这么做的,简单可靠,缺点是贵。另一种是采用数据可用性采样,把数据分片后随机抽样验证,比如Celestia那套思路,成本低但引入了新的信任假设。从安全和合规的角度看,做资产类业务必须选择最保守的方案,把数据完整放L1,不要为了省钱牺牲可用性。

开发者在设计L1合约时也要预判到数据可恢复的场景,最好内置一个逃生舱机制。比如当排序器长时间不提交批次时,用户可以强制在L1发起退出,把资产从托管合约里赎回来。这不是一个额外功能,而是Rollup的“宪法性权利”。

5.2 排序器去中心化

排序器负责接收用户交易、构建批次、提交L1,这个角色在早期几乎都是项目方自己运行。它是Rollup生态里最中心化的组件,因为排序器能看到所有用户交易,可以决定交易打包顺序,甚至在极限情况下审查某些地址的请求。这引发了一个关键问题:如何防止排序器作恶?

用户的保护机制是强制交易和提款通道。哪怕排序器拒绝打包某笔交易,用户仍然可以直接向L1合约提交请求,触发强制包含。在建Demo时我没有实现这部分,但生产系统里这是必不可少的逃生窗口。另一条路是把排序权拍卖或者用一组去中心化排序器轮换出块,结合共识协议形成共享排序器网络,这已经是目前L2领域最前沿的课题之一。

如果你只是自己做一个小型Rollup内部试用,单人排序器问题不大。如果面对真实用户,就必须认真对待这个单点,否则一次停机会让所有用户被卡住,一次恶意排序会造成系统的信任崩塌。

5.3 安全审计清单

最后整理一份我在代码审查时反复对照的清单,虽然不能替代专业审计,但能帮你提前排除绝大多数低级问题。

审计点风险说明缓解措施
签名重放同一笔L2交易被恶意重复纳入多个批次交易字段带nonce,每个账户递增,批次内去重
排序器越权非授权地址调用commitBatch只有sequencer角色可提交,多重签名加固
状态冲突同一高度提交了不同stateRoot挑战期内拒绝冲突状态,挑战成功则惩罚提交者
重入攻击deposit回调恶意token合约多次扣款先转资产再更新状态,或者用防重入锁
提款证明不完整用户伪造Merkle分支合约必须验证Merkle根和分支匹配,不允许跳过
参数溢出金额相加导致uint256溢出使用SafeMath或Solidity 0.8自带溢出检查
挑战窗口太短验证者来不及发现恶意批次按系统规模设置足够窗口,并考虑动态调整机制

我每次做Rollup合约审计都有一个习惯:拿转移资产的路径逐笔走一遍,从L1存款到L2入账,再到L2出款回L1,任何一环断裂都是致命的。建议你也画一张资金流向图,每段流程对照合约函数,逐个检查权限、时间锁、证明验证三个维度。

按照我这个Demo跑一遍,你对Rollup的整个数据流会有非常清晰的体感。我个人实际开发中的体会是,这类系统里最难的不是某个算法,而是把所有组件的时序关系对齐。最后再分享一个小经验:做L2开发一定养成先定义清楚“谁能做什么、什么时候能做”的习惯,把权限和时序画在先,再动手写合约,比回头重构省太多时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 15:02:57

光储充换电站优化调度:用户负荷与分时电价互动建模及Matlab实现

光储充换电站的优化调度&#xff0c;我去年下半年花了挺长时间在折腾这个方向。最近有位读者给我发来一个复现需求&#xff0c;标题很长&#xff1a;"考虑用户充电负荷与最优分时电价互动的光储充换电站优化模型研究"&#xff0c;要求用Matlab代码实现。拆开看其实就…

作者头像 李华
网站建设 2026/9/9 15:02:47

JMeter数据服务性能基准测试实战:从脚本编写到结果分析

JMeter这个东西&#xff0c;我在数据服务性能测试里用了好多年了。说实话&#xff0c;提起性能基准测试&#xff0c;很多人第一反应是上LoadRunner&#xff0c;或者直接写脚本用wrk、ab去压。但如果你测的是数据服务——不管是内部REST API、微服务网关&#xff0c;还是某种数据…

作者头像 李华
网站建设 2026/9/9 15:01:38

LabVIEW集成OCR实现文字识别:从选型到落地全攻略

1. 测试现场的真实痛点&#xff1a;LabVIEW凭什么要"会认字"1.1 一个产线追溯场景的具体画像我接过一个不算复杂但很典型的项目&#xff1a;一条组装线上的工位需要把产品侧面的序列号拍下来&#xff0c;和MES系统里的订单号做比对&#xff0c;对上就放行&#xff0c…

作者头像 李华
网站建设 2026/9/9 15:01:25

LLM 推理基础设施规划:GPU 选型、容量设计与成本优化

这里写自定义目录标题欢迎使用Markdown编辑器一、为什么推理基础设施规划如此困难二、第一步&#xff1a;明确你的工作负载类型三、第二步&#xff1a;用六个维度量化需求四、第三步&#xff1a;GPU 选型与容量计算五、第四步&#xff1a;本地与云端的容量组合六、第五步&#…

作者头像 李华
网站建设 2026/9/9 15:01:17

如何 5 分钟用 Docker 部署 Hermes WebUI:三种容器模式完整指南

如何 5 分钟用 Docker 部署 Hermes WebUI&#xff1a;三种容器模式完整指南 【免费下载链接】hermes-webui Hermes WebUI: The best way to use Hermes Agent from the web or from your phone! 项目地址: https://gitcode.com/GitHub_Trending/he/hermes-webui 想把 He…

作者头像 李华
网站建设 2026/9/9 15:00:16

Sqoop离线数据采集工具安装与实战:MySQL到HDFS/Hive完整指南

1. 标题拆解&#xff1a;为什么最终落点是离线采集工具 Sqoop看到这个标题&#xff0c;我猜有一半人是冲着 Gemini 永久会员来的&#xff0c;另一半是真想找 Sqoop 离线数据采集工具的安装教程。Gemini 相关的“永久会员”这类说法&#xff0c;基本可以默认不太靠谱。正规服务很…

作者头像 李华