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。