如何存储结构体到链上?

解读

国内区块链岗位面试里,“链上”特指链上世界状态(World State),即节点共识后写入区块、可被后续交易读取的数据。
与链下数据库不同,链上存储成本极高(Gas/手续费按字节计费),且一旦写入即不可修改,因此面试官想确认三件事:

  1. 你是否理解链上存储的代价模型
  2. 能否把 Rust 结构体无损、紧凑、可验证地序列化
  3. 是否掌握SCALE、Borsh、Protobuf等在国内主流公链(如 Polkadot、Solana、蚂蚁链、长安链)中实际落地的编码方案。

回答时务必先讲**“为什么”(成本、不可变、共识),再讲“怎么做”(选型、实现、验证),最后给出“可编译、可审计”**的代码片段。

知识点

  1. 链上存储代价模型:以太坊 1 字节约 20 000 Gas,Polkadot 每字节约 0.1 μDOT;国内 BSN 开放联盟链按“千字节·日”阶梯计价。
  2. Rust 常见序列化框架对比
    • SCALE(Parity 官方):无版本号、无字段名,极致紧凑,国内 Polkadot 生态必用
    • Borsh:Solana、Near 官方推荐,确定性高,Rust 与 TypeScript 双向映射零成本
    • Protobuf + prost:长安链、蚂蚁链企业版采用,IDL 先过审计,再生成 Rust 代码,方便多语言 SDK。
  3. 确定性(Determinism):同一结构体在不同机器、不同时间必须得到完全一致的字节数组,否则共识失败。
  4. Schema 演进:链上数据永不删除,新增字段必须用 #[codec(index = …)]#[borsh(skip)] 做向后兼容。
  5. 存储键设计:国内联盟链多用 “命名空间 + TwoX128(模块名) + TwoX128(存储名) + Blake256(账户)” 做分层前缀,避免重名冲突。

答案

下面以国内最常被问到的 Polkadot 平行链为例,展示如何把 Rust 结构体安全、紧凑、可验证地存到链上。代码基于 Substrate 21 稳定版,no_std 环境可直接编译进 WASM 运行时。

#![cfg_attr(not(feature = "std"), no_std)]

use codec::{Encode, Decode, MaxEncodedLen};
use scale_info::TypeInfo;
use frame_support::{pallet_prelude::*, storage::types::{StorageMap, StorageValue}};
use sp_runtime::RuntimeDebug;

/// 1. 定义结构体:必须 Encode + Decode + MaxEncodedLen + TypeInfo
///    MaxEncodedLen 用于链上提前计算最大占用,防止 DoS
#[derive(Clone, RuntimeDebug, PartialEq, Eq, Encode, Decode, MaxEncodedLen, TypeInfo)]
pub struct LoanOrder<BlockNumber, Balance> {
    pub borrower: AccountId,
    pub amount: Balance,
    pub expire_at: BlockNumber,
    /// 新增字段必须给 index,保证向后兼容
    #[codec(index = 4)]
    pub interest_rate: u32,
}

/// 2. 计算最大字节数,用于链上收费
///    国内面试常问:为什么不用 std::mem::size_of?
///    答:size_of 包含填充字节,而链上按 Encode 后实际字节收费
const MAX_LOAN_ORDER_LEN: u32 = LoanOrder::<u32, u128>::max_encoded_len() as u32;

/// 3. 存储项:用 StorageMap 存,键用 Blake2_128Concat 防碰撞
#[pallet::storage]
#[pallet::getter(fn loan_of)]
pub type LoanOf<T> = StorageMap<
    _,
    Blake2_128Concat,
    AccountId,              // key
    LoanOrder<BlockNumberFor<T>, BalanceOf<T>>,
>;

/// 4. 业务逻辑:写入链上
#[pallet::call]
impl<T: Config> Pallet<T> {
    #[pallet::weight(10_000 + MAX_LOAN_ORDER_LEN)]
    pub fn create_loan_order(
        origin: OriginFor<T>,
        amount: BalanceOf<T>,
        expire_at: BlockNumberFor<T>,
        interest_rate: u32,
    ) -> DispatchResult {
        let who = ensure_signed(origin)?;
        let order = LoanOrder {
            borrower: who.clone(),
            amount,
            expire_at,
            interest_rate,
        };
        // 写入前先做确定性校验:金额非零、过期块高大于当前
        ensure!(!amount.is_zero(), Error::<T>::ZeroAmount);
        ensure!(expire_at > frame_system::Pallet::<T>::block_number(), Error::<T>::ExpireInPast);
        // 真正写链上,节点共识后持久化
        <LoanOf<T>>::insert(&who, order);
        Self::deposit_event(Event::LoanCreated(who, amount));
        Ok(())
    }
}

关键点回顾

  • Encode/Decode 由 SCALE 保证确定性,MaxEncodedLen 提前告知链上最大体积,TypeInfo 让前端 SDK 自动生成 Typescript 定义。
  • Blake2_128Concat 是国内联盟链审计要求的哈希前缀,防止键冲突。
  • weight 公式里显式加上 MAX_LOAN_ORDER_LEN,让手续费可预测,符合国内监管“明码标价”要求

拓展思考

  1. 升级场景:半年后业务方要在 LoanOrder 里加字段 collateral: Balance
    • 若用 SCALE,必须给新字段加 #[codec(index = 5)],并在 runtime 升级时写 migration 把旧数据读出来再写回去;
    • 若用 Borsh,可在结构体尾端加 #[borsh(skip)] 做可选字段,但国内审计要求“字段必须显式可验证”,因此多数联盟链仍强制重新迁移。
  2. 存储租赁:国内 BSN 开放联盟链引入“状态租赁”,数据写入后 N 天未访问即自动清理。Rust 侧需实现 OnRuntimeUpgrade 钩子,在块高到达前主动续租,否则业务方需额外付费。
  3. 零知识证明:若结构体含敏感字段(如用户征信分),可在链上只存 PoseidonHash(order),原始数据用 IPFS + AES-GCM 加密后放链下,Rust 侧通过 zk-SNARK 证明“加密数据与链上哈希对应”,满足国内《个人信息保护法》“最小可用”原则。