Solana 运行时与账户模型

从代码与数据彻底分离的账户模型出发,系统拆解 Solana 运行时:PDA 种子推导与 bump 搜索、CPI 跨程序调用的签名权限传递、Proof of History 与 Sealevel 并行调度、计算单元预算与租金豁免、账户生命周期与关闭,并给出 Anchor 程序与客户端调用的完整代码示例、常见报错定位与性能优化建议。

Solana 与以太坊最根本的分歧不在共识算法,而在状态组织方式。以太坊把代码和数据都塞进同一个合约账户,靠 EVM 的存储树按合约地址隔离;Solana 则把「可执行代码」与「可变数据」拆成两类完全不同的账户,程序本身是无状态的纯逻辑,所有状态散落在独立账户中并由交易显式传入。

理解这一条,就能解释 Solana 上几乎所有反直觉的设计:为什么一次转账要在指令里传一串账户列表、为什么会有 PDA 这种「没有私钥的地址」、为什么跨程序调用需要重新声明权限、为什么并行执行在架构上是可能的。本文从账户模型出发,逐层拆到运行时调度与费用模型,最后给出可运行的 Anchor 程序与客户端代码。

账户模型:代码与数据彻底分离

Solana 的账户是运行时的唯一状态单元,每个账户有固定字段结构。它不像以太坊那样区分 EOA 与合约账户,而是统一为一种容器:

字段类型含义
lamportsu64余额,1 SOL = 10^9 lamports
dataVec<u8>账户数据,上限 10 MiB
ownerPubkey拥有该账户的程序,只有它能改写 data
executablebool是否为可执行程序
rent_epochu64租金相关字段,新代码基本忽略

两条硬约束决定了整个开发范式:

  1. 只有 owner 程序能修改账户的 data 与扣减 lamports。System Program 拥有所有普通钱包账户,所以钱包余额变更必须走 System Program 指令,第三方程序无法凭空扣别人的钱。
  2. 程序账户不可写且无状态。可执行程序的 data 里放的是 BPF 字节码,executable = true,它自身不保存任何业务状态。

这与以太坊合约把 storage 挂在合约地址下的模型形成鲜明对比,详见 以太坊 EVM 状态与存储布局 。在 Solana 里,一个「代币」不是合约内部的一张 mapping,而是一个独立的 mint 账户加每个持有人各自的 token account。

一次转账指令的实际形态因此变得很长,账户列表必须显式列出所有会被读写的账户:

// 一笔 SPL Token 转账的指令构成
let ix = spl_token::instruction::transfer(
    &spl_token::id(),          // 程序 id
    &source_token_account,     // 读写的来源账户
    &destination_token_account,// 读写的目标账户
    &owner_authority,          // 签名者
    &[],                       // 多签额外签名者
    amount,
)?;

为什么必须显式传账户:运行时需要在执行前就知道这笔交易会碰哪些账户,才能判断它和并行的其他交易是否冲突。如果允许程序在运行时动态发现账户,并行调度就无从谈起。

程序派生地址 PDA 与种子推导

程序无状态带来一个难题:程序怎么「拥有」数据?答案是用 PDA(Program Derived Address)。PDA 是一组种子加程序 id 经哈希后,故意偏移到 Ed25519 曲线之外的地址,因此它没有对应私钥,只能由派生它的程序通过 invoke_signed 代表它签名。

推导公式可以理解为:

hash(seeds..., program_id, bump_seed) 落在曲线外

bump_seed 是一个从 255 递减到 0 的字节,用来寻找第一个落在曲线外的结果。开发者通常把它存进账户数据,避免每次重新搜索:

// 派生一个全局配置账户
let seeds = &[b"config".as_ref()];
let (config_pda, bump) = Pubkey::find_program_address(seeds, program_id);
// bump 必须持久化,否则每次验证都要重算

Anchor 里用 seeds 约束自动校验 PDA,写错种子或 bump 会直接报 ConstraintSeeds:

#[derive(Accounts)]
pub struct UpdateConfig<'info> {
    #[account(
        mut,
        seeds = [b"config"],
        bump = config.bump,     // 从账户数据里读取已存的 bump
    )]
    pub config: Account<'info, Config>,
    #[account(mut)]
    pub authority: Signer<'info>,
}

常见坑:

  • 用 find_program_address 的返回 bump 去初始化,但后续校验时硬编码了 bump = 255,导致账户不匹配。
  • 种子长度超过 32 字节会被截断,若两个不同字符串前缀相同,可能派生出相同 PDA。务必用长度前缀或固定长度种子。
  • PDA 只是地址,不是账户。find_program_address 不会创建账户,创建仍需 System Program 的 create_account 并支付租金。

跨程序调用 CPI 与权限传递

CPI(Cross Program Invocation)是 Solana 的组合原语,等价于以太坊的 call,但权限模型严格得多。调用方必须把被调用程序需要的全部账户重新组装成一个 AccountInfo 列表传进去,权限不会自动继承。

两种签名场景:

// 场景一:普通 CPI,签名者由外层交易提供
invoke(
    &token_instruction,
    &[source.clone(), mint.clone(), authority.clone(), token_program.clone()],
)?;

// 场景二:程序代表自己的 PDA 签名
let seeds = &[b"vault".as_ref(), &[vault_bump]];
invoke_signed(
    &transfer_ix,
    &[vault_token.clone(), dest_token.clone(), vault_pda.clone(), token_program.clone()],
    &[seeds],   // 用种子「冒充」PDA 的签名
)?;

关键点:

  • invoke_signed 只能为本程序派生的 PDA 签名。传别人的 PDA 种子不会生效,运行时会在签名校验阶段拒绝。
  • CPI 深度上限是 4 层,递归调用要留余量。
  • CPI 会消耗计算单元,一次 SPL Token 转账大约几千 CU,深链组合容易触顶。

一个典型陷阱是账户别名攻击:如果程序信任传入的账户而没校验 owner,攻击者可以传入伪造账户。Anchor 的 Account<'info, T> 会自动校验 owner 与 discriminator,但 UncheckedAccount 不会,用它时必须手写 require! 校验。

Proof of History 与 Sealevel 并行执行

PoH 不是共识算法,而是一种可验证延迟函数式的本地时钟。验证者用 SHA-256 反复对自己上一次的输出做哈希,形成一个可事后验证的序列,每条记录里插入事件哈希。这样就能给交易打上全局可验证的时间戳,而不需要节点间反复通信对齐时间。

Sealevel 则是运行时的并行执行引擎。调度器把交易按账户读写集分组:

  • 两笔交易若访问的账户集合不相交,可以并行执行。
  • 只要有一方写、另一方读或写同一个账户,就必须串行。
  • 账户列表在交易里是显式声明的,所以冲突检测在执行前就能完成。

这带来一条重要优化原则:把热点账户拆开。例如把所有用户余额放进一个全局 counter 账户,会让所有交易串行化,吞吐骤降。正确做法是每个用户一个独立 PDA。

相比之下,以太坊的并行方案(如 Block-STM 的思路)需要在执行后做冲突回滚,而 Solana 因为账户前置声明,冲突判断更早也更便宜。共识层的取舍可对照 区块链共识机制深潜 与 Layer1 的 P2P 与内存池同步 。

交易结构与版本化交易

一笔 Solana 交易由签名列表、消息头和指令数组组成。指令里的每个账户引用都是 1 字节的索引,指向消息头部的账户表,所以账户越多,交易体积越大,而交易体积受限于 1232 字节的 MTU 上限(要留出 IP/UDP 头,实际可用约 1232 字节)。

老版本交易把所有账户内联在消息里,一次最多塞 35 个左右账户。VersionedTransaction 配合**地址查找表(Address Lookup Table,ALT)**可以把常用地址搬到链上的一张表里,指令里只引用表的索引,从而把单笔交易的账户上限提到 256 个:

// 创建并扩展一张查找表
let slot = connection.get_slot()?;
let (create_ix, table_address) = AddressLookupTableProgram::create_lookup_table(
    authority.pubkey(),
    authority.pubkey(),
    slot,
);
let extend_ix = AddressLookupTableProgram::extend_lookup_table(
    table_address,
    authority.pubkey(),
    vec![authority.pubkey()],
    &[token_program, system_program, associated_token_program],
);

// 组装版本化交易
let message = v0::Message::try_compile(
    &payer.pubkey(),
    &instructions,
    &[lookup_table_account],
    recent_blockhash,
)?;
let tx = VersionedTransaction::try_new(VersionedMessage::V0(message), &[payer])?;

使用 ALT 的注意事项:

  • 查找表有激活延迟,新建的表要等一个 slot 才能被引用。
  • 表可以被冻结(freeze),冻结后不可再扩展,适合上线后的稳定程序集。
  • 地址必须真的在表里,否则交易模拟会报 AddressLookupTableAccountNotFound。
  • ALT 只能省体积,不能省权限:账户仍然要按读写标志正确分组。

另一条与体积相关的规则是签名者数量。每个签名占 64 字节,多签交易极易超限。若确实需要多签,考虑改用 PDA 代表合约持有资产,把多签逻辑放到程序内部而不是链上多签账户,这与以太坊上账户抽象要解决的问题本质相同。

计算单元、租金与账户生命周期

Solana 用**计算单元(CU)**而非 gas 计量资源,默认上限 200,000 CU,可通过 ComputeBudgetProgram 提升到 1,400,000:

let modify_ix = ComputeBudgetInstruction::set_compute_unit_limit(400_000);
let price_ix = ComputeBudgetInstruction::set_compute_unit_price(10_000); // 微 lamports/CU
tx.add(modify_ix).add(price_ix);

费用 = 基础签名费(每签名 5000 lamports)+ CU 数 × CU 单价。注意 CU 单价是竞价,网络拥堵时它直接决定优先级,这点与以太坊的 EIP-1559 拍卖在动机上相似但机制不同。

租金方面,新代码基本只需关注租金豁免:账户余额必须 ≥ 两年租金,否则会被垃圾回收。免租金额取决于账户数据大小:

solana rent 165        # 一个 SPL Token 账户所需的最低 lamports
# 输出示例:0.00203928 SOL

关闭账户用 close 约束,把 lamports 退回给指定账户:

#[account(mut, close = authority)]
pub temp_account: Account<'info, TempData>,

踩坑记录:

  • 忘记 close 会导致账户永久占用租金,测试网无所谓,主网是真金白银。
  • realloc 扩容账户时要同步补足租金,否则下次交易会因余额不足失败。
  • 超过 10 KiB 的账户需要分片,单个账户上限 10 MiB 但大账户会显著增加交易体积与租金。

Anchor 程序与客户端调用完整示例

下面是一个最小可用的「计数器 + 归属校验」程序,覆盖初始化、递增与关闭三个指令。

use anchor_lang::prelude::*;

declare_id!("Counter111111111111111111111111111111111111");

#[program]
pub mod counter {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.authority = ctx.accounts.authority.key();
        counter.value = 0;
        counter.bump = ctx.bumps.counter;
        Ok(())
    }

    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        counter.value = counter.value.checked_add(1).unwrap();
        msg!("counter now {}", counter.value);
        Ok(())
    }

    pub fn close_counter(_ctx: Context<CloseCounter>) -> Result<()> {
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + 32 + 8 + 1,
        seeds = [b"counter", authority.key().as_ref()],
        bump,
    )]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut, has_one = authority)]
    pub counter: Account<'info, Counter>,
    pub authority: Signer<'info>,
}

#[derive(Accounts)]
pub struct CloseCounter<'info> {
    #[account(mut, has_one = authority, close = authority)]
    pub counter: Account<'info, Counter>,
    pub authority: Signer<'info>,
}

#[account]
pub struct Counter {
    pub authority: Pubkey,  // 32
    pub value: u64,         // 8
    pub bump: u8,           // 1
}

space = 8 + 32 + 8 + 1 里的 8 是 Anchor 的 discriminator 前缀,用来区分账户类型;漏掉它会得到 AccountDidNotSerialize。

客户端调用:

import * as anchor from "@coral-xyz/anchor";

const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Counter as anchor.Program<Counter>;

const [counterPda] = anchor.web3.PublicKey.findProgramAddressSync(
  [Buffer.from("counter"), provider.wallet.publicKey.toBuffer()],
  program.programId
);

await program.methods
  .initialize()
  .accounts({ authority: provider.wallet.publicKey })
  .rpc();

await program.methods
  .increment()
  .accounts({ authority: provider.wallet.publicKey })
  .rpc();

const acct = await program.account.counter.fetch(counterPda);
console.log("value =", acct.value.toString());

若要做无感签名或代付手续费的体验,可以配合 账户抽象与 UserOperation 的思路做对比设计。

常见报错与排错手册

报错根因处理
ConstraintSeedsPDA 种子或 bump 不匹配打印 findProgramAddressSync 结果与链上地址比对
AccountOwnedByWrongProgram传入了非本程序 owner 的账户检查是否误传了伪造账户
AccountNotInitialized账户尚未创建或 discriminator 不符先执行 init 指令
ComputeBudgetExceededCU 超限提升预算或减少 CPI 层数
Transaction simulation failed: Blockhash not foundblockhash 过期重新获取 blockhash 并重签
insufficient lamports未满足租金豁免增加初始 lamports

调试建议:

  1. 用 solana logs 实时看 msg! 输出,比看交易模拟错误直观得多。
  2. solana confirm -v <signature> 会打印完整的日志与账户列表。
  3. 本地用 solana-test-validator 加 --reset 复现,避免主网 blockhash 波动干扰。
  4. 单元测试用 anchor test 配合 banksClient 模拟,速度快且可断言错误码。

小结

Solana 的设计可以浓缩成一句话:用显式账户声明换取并行执行与可预测的资源计量。代价是开发者要手动管理账户列表、租金与 PDA 种子;收益是极高的吞吐上限与低到可忽略的手续费。掌握账户分离、PDA 派生、CPI 权限这三件事,剩下的都是工程细节。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「blockchain」更多文章

  1. DePIN 去中心化物理基础设施网络
  2. DeFi 衍生品:期权、永续合约与合成资产
  3. 智能合约形式化验证:Certora 与 K 框架