如何存储结构体到链上?
解读
国内区块链岗位面试里,“链上”特指链上世界状态(World State),即节点共识后写入区块、可被后续交易读取的数据。
与链下数据库不同,链上存储成本极高(Gas/手续费按字节计费),且一旦写入即不可修改,因此面试官想确认三件事:
- 你是否理解链上存储的代价模型;
- 能否把 Rust 结构体无损、紧凑、可验证地序列化;
- 是否掌握SCALE、Borsh、Protobuf等在国内主流公链(如 Polkadot、Solana、蚂蚁链、长安链)中实际落地的编码方案。
回答时务必先讲**“为什么”(成本、不可变、共识),再讲“怎么做”(选型、实现、验证),最后给出“可编译、可审计”**的代码片段。
知识点
- 链上存储代价模型:以太坊 1 字节约 20 000 Gas,Polkadot 每字节约 0.1 μDOT;国内 BSN 开放联盟链按“千字节·日”阶梯计价。
- Rust 常见序列化框架对比
- SCALE(Parity 官方):无版本号、无字段名,极致紧凑,国内 Polkadot 生态必用。
- Borsh:Solana、Near 官方推荐,确定性高,Rust 与 TypeScript 双向映射零成本。
- Protobuf + prost:长安链、蚂蚁链企业版采用,IDL 先过审计,再生成 Rust 代码,方便多语言 SDK。
- 确定性(Determinism):同一结构体在不同机器、不同时间必须得到完全一致的字节数组,否则共识失败。
- Schema 演进:链上数据永不删除,新增字段必须用 #[codec(index = …)] 或 #[borsh(skip)] 做向后兼容。
- 存储键设计:国内联盟链多用 “命名空间 + 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,让手续费可预测,符合国内监管“明码标价”要求。
拓展思考
- 升级场景:半年后业务方要在
LoanOrder里加字段collateral: Balance。- 若用 SCALE,必须给新字段加
#[codec(index = 5)],并在 runtime 升级时写 migration 把旧数据读出来再写回去; - 若用 Borsh,可在结构体尾端加
#[borsh(skip)]做可选字段,但国内审计要求“字段必须显式可验证”,因此多数联盟链仍强制重新迁移。
- 若用 SCALE,必须给新字段加
- 存储租赁:国内 BSN 开放联盟链引入“状态租赁”,数据写入后 N 天未访问即自动清理。Rust 侧需实现
OnRuntimeUpgrade钩子,在块高到达前主动续租,否则业务方需额外付费。 - 零知识证明:若结构体含敏感字段(如用户征信分),可在链上只存 PoseidonHash(order),原始数据用 IPFS + AES-GCM 加密后放链下,Rust 侧通过
zk-SNARK证明“加密数据与链上哈希对应”,满足国内《个人信息保护法》“最小可用”原则。