EVM vs SVM:Solana为何能够并行执行

1. 概述

以太坊虚拟机(EVM)按顺序处理交易,每个区块都是一系列连续操作的队列。Solana虚拟机(SVM)则相反:每秒在多个CPU核心上并行运行数万个交易。本文解释了为什么会有这样的差异——不是因为SVM以某种魔法方式“更快”,而是因为它的账户模型让运行时提前知道谁会访问谁。阅读后,你将理解Sealevel机制,能够用Java模拟并行调度器,并知道这个模型何时会失败。

2. EVM的顺序执行模型

EVM将整个状态存储在所有智能合约共享的一个Merkle Patricia Trie树中。调用contract A的increment()函数的一个交易不会提前声明它会访问哪个存储槽——这只有在运行时才能知道,因为EVM是一个可编程的状态机(图灵完备),分支(if/else)可以根据输入数据决定写入哪个槽位。

contract Counter {
    uint256 public count;

    function increment() public {
        count += 1; // 唯一的存储槽,没有提前向任何人声明
    }
}

count是一个固定的存储槽,但EVM运行时没有办法提前知道——在执行字节码之前——这个函数会读取/写入那个确切的槽位而不会尝试其他的。因为对于可编程的字节码来说“提前知道”是不可能的,以太坊客户端(geth、Nethermind等)被迫在一个区块内按顺序执行交易,确保后续交易总是能看到先前交易已更新的状态。

2.1. 为什么无法预测冲突

没有足够便宜的“静态分析”机制来分析EVM字节码的每条路径,并推断运行前会受到影响的存储槽集合。一些项目如Monad或Block-STM(Aptos)通过乐观并发来解决:只是并行运行,如果检测到冲突就回滚并重新运行。这是“先执行、后检查”的解决方案——与SVM的做法截然不同,SVM是“先声明、后调度”。

EVM区块(默认主网):
tx1 -> tx2 -> tx3 -> tx4   (顺序执行,一个接一个)

3. SVM的显式账户模型(Sealevel)

Solana强制每个交易显式声明它将读取或写入的账户列表,就在交易消息中,在发送到网络之前。运行时不需要试运行来了解——客户端已经明确说明。

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let counter_account = next_account_info(accounts_iter)?;

    let mut data = counter_account.try_borrow_mut_data()?;
    let mut count = u64::from_le_bytes(data[0..8].try_into().unwrap());
    count += 1;
    data[0..8].copy_from_slice(&count.to_le_bytes());
    Ok(())
}

accounts是从外部传入的切片——客户端必须明确指定counter_account是哪个账户、是可写还是只读,就在构建交易时。程序不能在途中自己“发现”其他账户。正是这个约束——尽管看起来很严格——才使得Sealevel能够进行并行调度。

3.1. 在交易中立即声明可写/只读

交易消息(简化版):
  instruction: increment_counter
  accounts:
    - counter_pda      (writable)
    - fee_payer        (writable, signer)
    - system_program    (readonly)

运行时只需读取这个元数据——不需要执行字节码——就能知道这个交易会写入counter_pda和fee_payer,读取system_program。这是相比EVM的核心区别。

4. 冲突检测和并行批处理机制

一旦运行时有了每个交易的读集/写集,并行调度问题就变成了众所周知的图着色问题:如果两个交易访问的账户集合在写写或写读意义上不相交,它们可以同时运行。

public record BlockTransaction(String id, Set<String> writes, Set<String> reads) {}

writes和reads分别对应交易会修改和只读的账户。两个交易冲突当:

public final class SealevelScheduler {

    public static boolean conflicts(BlockTransaction a, BlockTransaction b) {
        return !Collections.disjoint(a.writes(), b.writes())
            || !Collections.disjoint(a.writes(), b.reads())
            || !Collections.disjoint(a.reads(), b.writes());
    }
}

逐条解释:

  • writes ∩ writes 非空 → 双方都想写同一账户 → 必须顺序执行。
  • writes ∩ reads 非空 → 一方读、一方写同一账户 → 必须顺序执行。
  • reads ∩ reads 非空 → 没关系,同时读不需要独占锁。

4.1. 用Java模拟:分组批次然后并行执行

public final class ParallelExecutor {

    private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

    public List<BlockTransaction> nextNonConflictingBatch(
            Deque<BlockTransaction> queue, Set<String> lockedAccounts) {

        List<BlockTransaction> batch = new ArrayList<>();
        for (BlockTransaction tx : queue) {
            boolean overlaps = Stream.concat(tx.writes().stream(), tx.reads().stream())
                    .anyMatch(lockedAccounts::contains);
            if (!overlaps) {
                batch.add(tx);
                lockedAccounts.addAll(tx.writes());
            }
        }
        queue.removeAll(batch);
        return batch;
    }

    public void runBatch(List<BlockTransaction> batch, Consumer<BlockTransaction> execute) {
        List<CompletableFuture<Void>> futures = batch.stream()
                .map(tx -> CompletableFuture.runAsync(() -> execute.accept(tx), pool))
                .toList();
        futures.forEach(CompletableFuture::join);
    }
}

nextNonConflictingBatch扫描交易队列,将不触及当前lockedAccounts的交易纳入批次,然后锁定它写入的账户。runBatch使用虚拟线程(Java 21)同时运行整个批次,join等待整个批次完成后才构建下一个批次——正是Sealevel调度器按“波次”分组交易的方式。

注意: 这是为了阐明想法的简化模拟。真实的Sealevel使用更复杂的算法(按账户的依赖图而不仅仅是按顺序批次)来最大化并行度。

4.2. 用JUnit 5验证冲突逻辑

class SealevelSchedulerTest {

    @Test
    void 不访问同一账户则无冲突() {
        var tx1 = new BlockTransaction("tx1", Set.of("walletA"), Set.of());
        var tx2 = new BlockTransaction("tx2", Set.of("walletB"), Set.of());

        assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
    }

    @Test
    void 共同写入同一账户则冲突() {
        var tx1 = new BlockTransaction("tx1", Set.of("poolAccount"), Set.of());
        var tx2 = new BlockTransaction("tx2", Set.of("poolAccount"), Set.of());

        assertThat(SealevelScheduler.conflicts(tx1, tx2)).isTrue();
    }

    @Test
    void 共同读取则无冲突() {
        var tx1 = new BlockTransaction("tx1", Set.of(), Set.of("priceOracle"));
        var tx2 = new BlockTransaction("tx2", Set.of(), Set.of("priceOracle"));

        assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
    }
}

这三个测试精确描述了读读不锁、写写锁、写读锁的原理——整个SVM并行问题的基础。

5. EVM vs SVM 比较

标准 EVM SVM
状态模型 一个共享的trie,通过字节码隐式访问 离散账户,在交易中显式声明
何时知道冲突 仅在运行后才知道(或试运行后) 运行前从交易元数据就知道
执行方式 按区块内的顺序执行 按不相交的批次并行执行
跨合约调用 在运行时任意调用其他合约 跨程序调用(CPI)仍需提前声明账户
开发者职责 不需要考虑账户集合 必须声明正确、完整的账户——声明不足会报错,多声明会失去并行性
并行改进方向 乐观并发(Block-STM、Monad) 优化调度依赖图(Sealevel、Firedancer)

6. 权衡和Solana程序开发中的常见错误

显式账户声明是并行的根源,但也是两个常见错误的根源:

账户声明不足。 如果程序内的逻辑需要写入一个客户端没有在交易中列出的账户,程序会立即失败,错误类似“账户未在账户列表中找到”——因为SVM不允许像EVM那样在途中“发现”账户。

账户声明过多或创建热点。 如果太多交易都写入同一个账户(例如唯一的流动性池、全局计数器),它们被迫顺序化——正好是EVM的情况,只是你自己制造了它。这是习惯于EVM的开发者的常见思维错误:将“一个全局变量”的习惯带到Solana而不按分片拆分账户。

错误做法:所有用户交易都写入 `global_stats` -> 失去并行性
正确做法:每个用户有自己的账户(按用户生成的PDA),只定期汇总

7. 并行化无法帮助的情况

注意: 如果你的应用本质上是真正的顺序队列(例如根据提交顺序计价的拍卖),运行时层的并行化无法帮助——你仍然必须自己序列化业务逻辑,SVM只能节省那些彼此独立的交易。

类似地,如果你正在设计一个集中式系统(不是区块链)但想“学习”Sealevel模型来提高吞吐量,它只在大部分任务有明确分离的读/写集时才值得应用。如果每个任务都触及一个共享的表/记录,管理依赖图的成本反而比简单的顺序锁更高。

8. 结论

EVM顺序执行是因为它的字节码不能让运行时提前知道谁会访问谁——任何冲突推断只能通过试运行或回滚来完成。SVM并行执行是因为它强制每个交易从一开始就声明读/写账户,允许Sealevel构建依赖图并分组不相交的批次以在多核上运行。代价是Solana开发者必须自己设计分离的账户,避免创建热点——否则,你无意中把SVM变成了一个缓慢的顺序EVM。

svmevmsolanablockchainparallel-computing