迈向语义区块分块——分片

文章探讨了以太坊语义区块分块(Semantic Block Chunking)的架构方案,提议将区块重构为多个独立可验证的语义单元。通过引入独立的区块访问列表(CAL)来传输状态差异,该方案能够实现流式验证和并行执行,旨在应对区块 Gas 限制提升带来的传播延迟和验证压力。

随着区块 Gas 限制(Block gas limits)的增加,以太坊区块的大小不可避免地会增长。我们不能依赖被压缩、传播然后再执行的单体区块(Monolithic blocks)。在某些时刻,我们需要引入区块分块(Block chunking),以减少延迟、平滑传播,并在关键路径上减轻验证者(Validators)的负担。

之前的一篇文章中,我描述了语义化 Payload 分块及其背后的动力。现在,我想进一步深化这个想法:真正的进步来自于将语义分块(Semantic chunks)视为一等对象(First-class objects),并更进一步,将分块访问列表(Chunk Access Lists, CALs)从分块本身中分离出来。

分类法回顾

关于区块分块,有几种截然不同的思考方式:

(1) 在基础层面,可以简单地将 RLP 序列化的区块视为字节流,并将其切割成固定大小的切片。这些碎片易于传播,且非常适合传输协议,但它们没有任何意义:在整个区块重新组装完成之前,验证者无法对其进行任何操作。这种非语义化分块 (non-semantic chunking) 有助于带宽平滑和数据可用性,但并不能改善执行延迟。

non_semantic_chunking

(2) 结构上的进步是沿其自然模式边界拆分区块。在这里,Header、交易列表、提款(Withdrawals)、收据(Receipts)和执行输出都将作为独立对象进行传播。这些部分对应于协议定义的字段,并且允许(例如)在获取完整主体之前部分获取 Header,或优先处理证明(Attestations)。然而,这些组件通常只有在一起时才有意义:执行仍然需要完整的 Payload,因此对验证者延迟的改善仍然有限。

naive_semantic_chunking

(3) 更具雄心的版本是重新定义区块本身,将其视为一系列自包含的 分块 (chunks)。每个分块都携带自己的 Header、交易和后状态根(Post-state root),并且作为一个对象是可以独立验证的。通过将一个分块的后状态连接到下一个分块的前状态(Pre-state),分块链构成了规范区块。这超越了分解,变成了组合式语义分块 (compositional semantic chunking):分块不仅仅是碎片,而是协议的新语义单元。验证者可以在第一个分块到达时立即开始执行,而无需等待完整区块到达。这开启了真正的流式验证(Streaming validation),并为每分块证明(Per-chunk proofs)和并行证明铺平了道路。

semantic_chunking

值得注意的是,这些方法并不一定相互排斥:我们可以采用 (3),同时将交易、提款、执行输出等从分块中分离出来,并将它们与 (2) 的语义分块方法相结合。此外,分块也可以在序列化之后、压缩之前进行拆分,如 (1) 所述。

作为新语义单元的分块

我们不再将区块视为单一的执行 Payload,而是将其重构为多个分块。我们今天所熟知的区块在概念上被重新构建,现在仅由一个 Header 和若干分块组成。Header 承诺了一个 Slot 中所有分块的执行,包括最后一个分块的后状态根。

分块架构

在 CL 上,每个 ExecutionChunk 包含:

class ExecutionChunk(Container):
    chunk_header: ExecutionChunkHeader  # 带有执行承诺的 Header
    transactions: List[Transaction, MAX_TRANSACTIONS_PER_PAYLOAD]
    withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]

ExecutionChunkHeader 包含了通常在区块 Header 中预期的字段:

class ExecutionChunkHeader(Container):
    index: int
    txs_root: Root
    post_state_root: Root # 可以移除以减少哈希开销
    receipts_root: Root
    logs_bloom: Bloom
    gas_used: uint64
    withdrawals_root: Root

正如 proto这份关于 Flash BALs 的文档中所描述的,后状态根可以从分块 Header 中移除,以减少哈希开销。这是一个有趣的想法,它使设计更接近于区块现在的样子(即,没有中间状态根)。

技术约束

协议实施了严格的边界以确保可预测的执行:

  • Gas 限制:每个分块必须遵守 CHUNK_GAS_LIMIT = 2^24 (16,777,216) Gas 的硬性限制。
  • 最大分块数:每个区块最多 MAX_CHUNKS_PER_BLOCK = 16 个分块。
  • 最低填充要求:非末尾分块必须至少填充 50%(≥ 2**23 Gas),以防止效率低下的碎片化。
  • 提款位置:提款仅包含在最后一个分块中,以保持一致性。
  • 状态连续性:分块 i 的后状态成为分块 i+1 的前状态。分块边界充当中间状态根,但如果额外的哈希增加了过多的开销,这些中间状态根可以被移除。
  • 区块原子性:通过按顺序排列分块并仅考虑最后一个分块的执行输出,我们近似于当前定义的区块。

chunk_networking

这使分块从一种传播技巧转变为区块结构的语义重新定义。验证者可以通过流式验证过程在分块到达时独立执行它们,但只有在序列中的每个分块都成功执行时,区块才有效。

CAL 架构:状态差异作为独立对象

假设我们将区块拆分为分块,那么我们应该将区块级访问列表(Block-level Access List, BAL)放在哪里?

区块级访问列表 (EIP-7928) 是一个向区块添加状态位置(地址、存储键)+ 交易级状态差异(State Diffs)的提案,旨在实现独立于交易依赖关系的并行执行。

对于 Payload 分块,分块访问列表 (CALs) 作为 Sidecar 与分块分开传播,其中 CALs 包含其对应分块的状态差异

chunk_cal_networking

执行依赖关系

解决分块之间的依赖关系:

  • 每个 CAL 包含其对应分块执行后的状态差异(账户更改、存储修改)。
  • 每个分块 N 需要直到 N-1 为止所有前序分块的 CAL 并集才能被独立执行。这是因为 CALs 包含了重建所需前状态所需的状态更改:

apply_cals(parent_state, [cal_0, cal_1]) -> pre_state_for_chunk_2

  • 要执行分块 N,验证者必须拥有:
    • chunk[N] 本身
    • CAL[0], CAL[1], ..., CAL[N-1](包含累积的状态差异)

这创建了一个累积依赖链,其中早期 CAL 的延迟到达会延迟所有后续分块的执行。然而,由于 CALs 通常很小,它们在网络中的传播速度很快。

设计考虑: 每一个 CAL 都可以携带一个相对于前状态(Chunk 0 之前)的差异,而不是编码相对于前状态(分块 0)或前一个分块(分块 1 到 n)的状态差异。这将允许验证者在不等待 CALs #0–2 的情况下执行(例如)分块 #3,仅需要 CAL #2。权衡之处在于 CAL #2 会变得更大,因为它必须包含自身的状态差异以及从早期分块累积的差异。

网络架构

CALs(包含状态差异)与分块的分离创造了截然不同的传播路径:

Gossip 主题

  • 分块(交易 + 提款)在 execution_chunk_sidecar_{subnet_id} 上传输,其中 subnet_id = chunk_index % 16
  • CALs(状态差异 + 访问信息)在 chunk_access_list_sidecar_{subnet_id} 上传输,其中 subnet_id = cal_index % 16

chunk_networking

分叉选择和两阶段验证

分叉选择仔细区分了分块可用性和区块有效性:

修改后的 Store

class Store:
    # ... 现有字段 ...
    chunks: Dict[Tuple[Root, ChunkIndex], ExecutionChunk]
    chunk_access_lists: Dict[Tuple[Root, ChunkIndex], ChunkAccessList]
    chunk_validation_status: Dict[Tuple[Root, ChunkIndex], bool]  # 第一阶段
    payload_chunk_availability: Dict[Root, bool]  # 所有分块都存在
    block_state_valid: Dict[Root, bool]  # 第二阶段 - 最终状态与 Header 匹配

关键区别:分块可以被独立验证(Validated),但只有在验证了完整的状态转换后,区块才是有效的(Valid)

执行引擎接口

两阶段验证反映在执行引擎 API 中:

## 第一阶段:独立分块验证
def notify_new_chunk(execution_engine, block_root, chunk, parent_root):
    # 内部验证分块 - 交易、Gas、本地状态转换
    return execution_engine.validate_chunk(block_root, chunk, parent_root)

def notify_new_chunk_access_list(execution_engine, block_root, index, cal):
    # 提供带有状态差异的 CAL 以进行独立执行
    execution_engine.new_chunk_access_list(block_root, index, cal)

## 第二阶段:完整的状态转换验证
def finalize_chunked_payload(execution_engine, block_root, expected_chunks, header):
    # 验证:chunks[-1].post_state_root == header.state_root
    # 这是确定区块有效性的地方
    return execution_engine.verify_state_transition(block_root, expected_chunks, header)

区块有效性检查

协议在证明(Attestation)之前强制执行这两个阶段:

def is_block_valid(store: Store, block_root: Root) -> bool:
    block = store.blocks[block_root]

    # 第一阶段:所有分块均已单独验证
    for i in range(len(block.body.chunk_roots)):
        if not store.chunk_validation_status[(block_root, i)]:
            return False  # 分块验证失败
        # 验证分块是否与承诺的根匹配
        if hash_tree_root(store.chunks[(block_root, i)]) != block.body.chunk_roots[i]:
            return False

    # 第二阶段:完整状态转换已验证
    if not store.block_state_valid[block_root]:
        return False  # 最终状态与 Header 不匹配

    # 验证最后一个分块的后状态是否与 Header 匹配
    final_chunk = store.chunks[(block_root, len(block.body.chunk_roots) - 1)]
    if final_chunk.post_state_root != block.header.state_root:
        return False  # 状态转换无效

    return True

验证者绝不能is_block_valid() 返回 true 之前进行证明,以确保:

  1. 所有分块均独立验证(第一阶段)。
  2. 最终状态转换与区块 Header 匹配(第二阶段)。

执行层架构

执行层实现流式验证,以便在分块到达时对其进行处理:

核心数据结构

在 EL 上,分块是自包含的执行单元:

class ExecutionChunk:
    chunk_header: ExecutionChunkHeader  # 在区块中的位置
    transactions: Tuple[Transaction]    # 完整的交易(未拆分)
    withdrawals: Tuple[Withdrawal]      # 仅在最后一个分块中

流式验证组件

EL 采用关键组件进行流式验证:

  1. StreamingChunkReceiver:管理每个区块的分块接收和验证。
    • 跟踪分块的到达(可能是无序的)。
    • 一旦满足依赖关系就立即验证。
    • 维护每个分块的验证上下文。
  2. Chunk Processor:编排验证流水线。
    • 顺序模式:使用前一个分块的后状态。
    • 独立模式:将累积的 CALs 应用于父状态(Parent State)。

关键见解在于 CALs 包含了分块执行中的状态差异,从而能够实现独立验证而无需顺序依赖。

执行模式

根据可用数据,分块可以以两种模式执行:

def validate_chunk(chunk_index, chunk, dependencies):
    if chunk_index == 0:
        # 第一个分块直接使用父状态
        pre_state = parent_state
    elif previous_chunk_validated:
        # 使用前一个后状态进行顺序执行
        pre_state = chunks[chunk_index - 1].post_state
    elif all_prior_CALs_available:
        # 通过应用 CALs(包含状态差异)进行独立执行
        # CALs 包含其分块的所有状态更改
        pre_state = apply_CALs(parent_state, CALs[0:chunk_index])

    # 执行分块交易
    post_state = execute(pre_state, chunk.transactions)
    assert post_state.root == chunk.post_state_root

两阶段验证:独立分块,有效状态转换

该架构将分块验证与区块验证分开:

第一阶段:独立分块验证(流式)

当每个分块到达时,验证者立即核实:

  • 内部一致性:交易有效,遵守 Gas 限制。
  • 本地状态转换:使用前一个分块的后状态,或将 CALs 应用于父状态。
  • 分块承诺:执行分块交易后 post_state_root 匹配。
  • 不需要区块上下文:每个分块在隔离状态下进行验证。

这发生在区块 Header 到达之前,并且不知道最终状态根。可以通过验证提议者在分块 Header 上的签名来验证分块和 CALs。

第二阶段:区块状态转换验证

一旦所有分块都通过验证:

  1. 验证连续性:分块 N-1 的后状态必须等于分块 N 的前状态。
  2. 聚合输出:来自所有分块的总 Gas、收据、日志。
  3. 根据 Header 验证:最终状态根必须匹配 block.header.state_root
  4. 原子决策:当且仅当所有分块都通过验证且最终状态匹配时,区块才有效。
## 第一阶段:每个分块独立验证
for chunk in arriving_chunks:
    validate_chunk(chunk)  # 无需完整区块即可快速失败

## 第二阶段:验证完整的状态转换
if all_chunks_valid:
    final_state = chunks[-1].post_state_root
    assert final_state == block.header.state_root  # 区块有效性

这种分离使分块能够在流式处理期间快速失败,但保留了关键属性,即区块有效性取决于与承诺 Header 匹配的完整状态转换

包含证明

每个分块和 CAL Sidecar 都包含一个 Merkle 包含证明(Inclusion Proof):

class ExecutionChunkSidecar(Container):
    chunk: ExecutionChunk
    chunk_signature: SignedChunkHeader
    chunk_root_inclusion_proof: Vector[Bytes32, CHUNK_INCLUSION_PROOF_DEPTH]

这确保了即使分块无序到达或来自不同来源,验证者也可以在处理之前验证它们属于承诺的区块。

构建者(Builder)与本地区块生产

协议支持两种截然不同的分块生产流程:

构建者流程

  1. 构建者创建一个遵守所有约束的分块 Payload。
  2. 构建者发送包含 chunk_rootschunk_access_list_rootsSignedExecutionPayloadBid
  3. 提议者(Proposer)将这些承诺包含在信标区块(Beacon Block)中。
  4. 构建者作为 Sidecar 发布实际的分块和 CALs。如果构建者未能发布,则区块变为无效。

本地流程

  1. 提议者向本地执行引擎请求一个分块 Payload。
  2. EL 返回构建好的分块,同时遵守 Gas 限制和填充要求。
  3. 提议者在分块返回时开始发布它们。
  4. 提议者计算根:chunk_roots = [hash_tree_root(chunk) for chunk in chunks],分块访问列表同理。
  5. 提议者发布一个包含这些承诺的信标区块。

与将区块生产外包给试图尽可能晚提交的实体(即提议者时间博弈)相比,在本地构建区块的提议者将从流式处理中获益更多。

其他生态系统中的相关方法

其他生态系统以不同的方式探索了并行化和“分块”。

FuelSolana 都依赖于预先声明其完整读/写集的交易:Fuel 对 UTXO 样式的资源强制执行显式访问列表,以将交易划分为并行的无冲突集合;而 Solana 的 Sealevel 运行时并发调度非重叠的账户写入,其 Turbine 协议纯粹为了传播而将区块数据分块为 Shreds。

AptosStarknet 使用 Block-STM,它投机性地并行执行所有交易,并回滚冲突,直到达到确定的顺序状态;而 Sui 应用了一种变体,其中非冲突的以对象为中心的交易可以完全绕过共识。

相比之下,NEARPolkadot 追求状态分片:NEAR 在分片之间拆分账户,并将每个分块聚合到一条链中;而 Polkadot 运行异构平行链,其有效性证明由中继链(Relay Chain)检查并使其可用。

Base 的 Flashblocks 引入了时间分块 (temporal chunking) 而不是分片或状态分区:在每个 2 秒的区块窗口内,排序器以预确认的形式流式传输十个“Flashblocks”(间隔约 200 毫秒),并带有受限的 Gas 预算。这些微区块通过公开子区块排序降低了延迟并改善了用户体验,但所有节点仍然处理一个全局状态,并且 Flashblocks 会合并到最终区块中,没有跨分片的复杂性。

与这些模型相比,语义化 Payload 分块保持了以太坊单一全局状态机的特性,但将分块提升为具有可验证交易后状态差异(CALs)的一等协议对象,从而实现了流式验证和每分块证明,而无需投机执行或跨分片异步。此外,得益于 CALs,我们不必止步于乐观并行化:交易可以实现完美的并行化,且独立于状态依赖关系。

为什么语义化分块很重要

非语义化分块有助于传输,但不会改变区块的本质。相比之下,所描述的语义分块方法:

  • 重新定义了区块结构:每个分块都是具有自身验证规则和执行输出的一等协议对象。
  • 支持流式验证:验证者并行化下载和执行,分块通过 notify_new_chunk() 立即处理。
  • 提高了模块化:CALs 将状态差异与交易数据分开封装,从而实现独立执行。
  • 支持未来证明:可以为每个分块生成 ZK 或乐观证明,且证明时间有界(最大 16.7M Gas)。
  • 保留了原子性:区块保持原子性——所有分块必须验证通过,区块才有效。

在 EL 上,我们可以期待具体的性能提升:

  • 有界的内存和计算:每个分块最多消耗 CHUNK_GAS_LIMIT 的资源。
  • 快速拒绝:无效分块会快速失败,而不需要处理所有分块。
  • 并行验证:可以使用 CALs(包含状态差异)并发验证多个分块。

共识层的语义化分块与执行层的流式验证相结合,将区块处理从单体操作转变为可组合、可流式传输的流水线,该流水线随着网络容量的增长而扩展,而不是被最大的原子单元所阻塞。

  • 原文链接: ethresear.ch/t/toward-se...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
以太坊中文
以太坊中文
以太坊中文, 用中文传播以太坊的最新进展