开场:别把合约当“无状态函数”,状态设计才是灵魂
咱们先聊点大白话。
大多数刚写智能合约的人,一开始都是这么想的:
“就几行业务逻辑嘛,写几个
#[ink(message)],能改值能转账就行了。”
结果真实世界是:
- 一开始随手往
storage里加字段,跑得挺开心; - 过几周发现:谁在哪个项目里有多少余额、哪些 NFT 属于谁、哪些订单还没结算……全塞在一个大结构里,自己都看不懂了;
- 想加个新特性,比如“分层权限、白名单、冻结资金”,发现原本的存储设计完全扛不住,要么重构一大坨,要么硬塞更多字段,让以后维护的人想打人。
你可以把合约状态想象成家里的收纳系统:
#[ink(storage)]是整间屋子;- 基本字段是客厅/卧室里固定的柜子;
Mapping是那种很多格子的收纳柜:每个 key 是一个格子标签;- “懒加载配置”“大对象”就是你只偶尔翻出来的压箱底行李箱。
今天这篇,就带你用朋友聊天的方式,过一遍 ink! 里常见的几种 Storage Patterns(存储模式),目标只有三个:
- 知道“状态应该长什么样”,不再乱塞字段;
- 手上有几套可复用的模板:余额表、权限矩阵、大对象懒加载;
- 清楚哪些操作链上根本做不到或很贵,提前避坑。
原理速写:先把几件“硬事实”认清楚
先别急着写代码,先钉几个钉子:
所有可持久化状态,都必须挂在
#[ink(storage)]下面- 你在函数里临时
let出来的变量,调用结束就没了; - 想下一次调用还能看到,只能通过 storage 存进去。
- 你在函数里临时
读写状态是要“烧 gas”的,而且写比读贵得多
- 存储越大、变更越频繁,链上负担越重;
- 把大对象拆开、按需读写,是存储设计里最重要的省钱手段。
Mapping<K, V>是链上的 key-value 表,不是普通的HashMap- 优点:可以存很多很多条记录,按 key 查很快;
- 限制:不能在链上直接“遍历所有 key”,因为 key 是逻辑概念,不是完整存储索引;
- 含义:想要“遍历所有用户”,必须自己维护一份“用户列表”,比如
StorageVec<AccountId>。
复合关系用“组合 key”解决,而不是无限套娃结构体
- 给用户余额用
Mapping<AccountId, Balance>; - 给授权额度用
Mapping<(Owner, Spender), Balance>; - 不要在
struct User里再套一个Vec<Allowance>,那样删改、查找都非常难维护。
- 给用户余额用
存储布局一旦上链,就当它“基本写死了”
- ink! 支持合约升级,但更换存储结构相当于“搬家”;
- 所以一开始想清楚分类和粒度,比什么都重要。
接下来,我们按“从简单到复杂”的顺序,带你撸几种最常见的存储模式。
实战步骤:从“一个字段”到“多维 Mapping”的完整路线
模式一:单值存储 —— 配置项与全局状态
先从最简单的开始:全局配置 & 标志位。
你可以把它当成“家里电闸总开关 + 几个统一设置”。
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod config_storage {
#[ink(storage)]
pub struct ConfigStorage {
owner: AccountId,
/// 是否暂停合约(例如紧急情况用)
paused: bool,
/// 最大供应量上限
max_supply: Balance,
}
impl ConfigStorage {
#[ink(constructor)]
pub fn new(max_supply: Balance) -> Self {
Self {
owner: Self::env().caller(),
paused: false,
max_supply,
}
}
#[ink(message)]
pub fn pause(&mut self) {
self.only_owner();
self.paused = true;
}
#[ink(message)]
pub fn unpause(&mut self) {
self.only_owner();
self.paused = false;
}
#[ink(message)]
pub fn is_paused(&self) -> bool {
self.paused
}
fn only_owner(&self) {
assert_eq!(self.env().caller(), self.owner, "not owner");
}
}
}
这类模式适合存:
- 合约所有者、管理员;
- 全局开关(暂停、维护模式);
- 单一阈值(最大供应量、手续费比例)。
读者能立刻得到什么?
你现在知道:最基础的配置就老老实实放在 storage 结构体的字段里,用 #[ink(message)] 包一层读/改接口就好,不要上来就为这点状态搞 Mapping。
模式二:一维 Mapping —— 账户余额表的标准写法
接下来是最常见的“谁有多少钱”问题。
这里我们直接借用第 3 篇事件教程里的风格,写一个最小余额表:
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod balances {
use ink::storage::Mapping;
#[ink(storage)]
pub struct Balances {
total_supply: Balance,
balances: Mapping<AccountId, Balance>,
}
impl Balances {
#[ink(constructor)]
pub fn new() -> Self {
Self {
total_supply: 0,
balances: Mapping::default(),
}
}
/// 铸币给某个账户
#[ink(message)]
pub fn mint(&mut self, to: AccountId, amount: Balance) {
let old = self.balances.get(&to).unwrap_or(0);
self.balances.insert(to, &(old.saturating_add(amount)));
self.total_supply = self.total_supply.saturating_add(amount);
}
/// 查询余额
#[ink(message)]
pub fn balance_of(&self, who: AccountId) -> Balance {
self.balances.get(&who).unwrap_or(0)
}
}
}
这里有几个容易踩的点,一次讲清:
Mapping默认值是None,所以get(&key)返回Option<V>,需要你自己unwrap_or(0);- 对余额这类数值,推荐用
saturating_add,避免意外溢出; insert的 key 可以直接用AccountId,不需要再进行哈希或 encode,一般版本的 ink! 会帮你处理。
读者能立刻得到什么?
以后你要做简单的代币、积分、计数器集合,直接用 Mapping<AccountId, Balance> 这套模板就够用,大部分业务逻辑只是在它之上加判权和事件。
模式三:二维 Mapping —— 权限矩阵、Allowance 等“谁对谁”的关系
当关系变成“谁对谁有什么权限”时,一个维度就不够了。
最典型的例子是 ERC-20/PSP22 里的 Allowance:
“A 授权 B 可以代替自己最多花掉 100 个 Token。”
在存储层面,这是一个二元关系 (owner, spender) -> amount,可以直接用组合 key 搞定:
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod allowance_storage {
use ink::storage::Mapping;
#[ink(storage)]
pub struct TokenWithAllowance {
owner: AccountId,
balances: Mapping<AccountId, Balance>,
/// (owner, spender) -> allowance
allowances: Mapping<(AccountId, AccountId), Balance>,
}
impl TokenWithAllowance {
#[ink(constructor)]
pub fn new() -> Self {
Self {
owner: Self::env().caller(),
balances: Mapping::default(),
allowances: Mapping::default(),
}
}
#[ink(message)]
pub fn approve(&mut self, spender: AccountId, amount: Balance) {
let caller = self.env().caller();
self.allowances.insert((caller, spender), &amount);
}
#[ink(message)]
pub fn allowance(&self, owner: AccountId, spender: AccountId) -> Balance {
self.allowances.get(&(owner, spender)).unwrap_or(0)
}
}
}
这里的关键点:
- 不要为了省事在
struct User里塞Vec<Allowance>,那样每次查询都得遍历; - 把关系拆成
(owner, spender)这种组合 key,就像关系数据库里的联合主键; - 要再多一维(例如
(token_id, owner, operator)),也照样可以组合成一个 tuple key。
读者能立刻得到什么?
任何“谁对谁有什么权限/额度”的关系,都可以直接套用 Mapping<(A, B), V> 这个模板,不需要设计一堆嵌套结构体。
模式四:懒加载 & 大对象 —— 不常用的配置别常驻内存
有些东西很大、用得又不频繁,比如:
- 一大坨合约配置;
- 某个游戏角色的完整属性;
- 一份“长期只读的白名单快照”。
如果你把它直接塞进 storage 结构体,每次调用都要把它 load 出来,代价很高。这时候有两个常用做法:
方案 A:拆成多个 Mapping
把大对象拆成若干小字段,各自存到 Mapping 里:
use ink::storage::Mapping;
#[ink(storage)]
pub struct PlayerStorage {
/// 玩家列表(方便遍历)
players: Mapping<AccountId, bool>,
/// 经验值
xp: Mapping<AccountId, u64>,
/// 等级
level: Mapping<AccountId, u8>,
}
优点:
- 访问哪个字段就读哪个 Mapping,粒度细;
- 缺点是字段多了之后读写稍微麻烦一点,但好控制。
方案 B:用 Option 做“懒初始化”
另一个简单粗暴的办法,是用 Option<T> 存大对象:
#[derive(scale::Encode, scale::Decode, Default)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, Debug))]
pub struct GlobalConfig {
fee_rate_bps: u16,
min_deposit: Balance,
}
#[ink(storage)]
pub struct ConfigLazy {
config: Option<GlobalConfig>,
}
impl ConfigLazy {
#[ink(constructor)]
pub fn new() -> Self {
Self { config: None }
}
#[ink(message)]
pub fn init_config(&mut self, fee_rate_bps: u16, min_deposit: Balance) {
// 只允许第一次初始化
assert!(self.config.is_none(), "config already initialized");
self.config = Some(GlobalConfig {
fee_rate_bps,
min_deposit,
});
}
#[ink(message)]
pub fn fee_rate(&self) -> u16 {
self.config.as_ref().map(|c| c.fee_rate_bps).unwrap_or(0)
}
}
这里的思路是:
- 一开始什么都不存,节省空间;
- 只有在你真的需要的时候,才初始化一次;
- 读取时可以定义“没设置时的默认值”。
如果你熟悉 ink! 内置的 Lazy<T> 类型,也可以用它来做懒加载大对象,思路类似:只在真正访问时才加载,避免每次调用都把整个结构搬出来。
读者能立刻得到什么?
看到“大而不常用”的数据结构时,脑子里有两个按钮:
- 能拆 Mapping 就拆;
- 不能拆就用
Option/Lazy一次性初始化,不要每次调用都硬搬整块数据。
模式五:枚举状态 + Mapping,别用一堆布尔炸弹
还有一类常见“味道不太对”的写法:
#[ink(storage)]
pub struct Project {
is_created: bool,
is_active: bool,
is_paused: bool,
is_finished: bool,
}
看着就头大,对吧?
更好的做法是用 枚举 + Mapping 把状态收拢成一个字段:
#[derive(scale::Encode, scale::Decode, PartialEq, Eq, Copy, Clone)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, Debug))]
pub enum ProjectStatus {
Draft,
Active,
Paused,
Finished,
}
#[ink(storage)]
pub struct Projects {
statuses: Mapping<u64, ProjectStatus>, // project_id -> status
}
impl Projects {
#[ink(message)]
pub fn set_status(&mut self, id: u64, status: ProjectStatus) {
self.statuses.insert(id, &status);
}
#[ink(message)]
pub fn status_of(&self, id: u64) -> ProjectStatus {
self.statuses.get(&id).unwrap_or(ProjectStatus::Draft)
}
}
好处:
- 逻辑上只有一个状态,不会出现“既 active 又 finished”的伪命题;
- 阅读时一眼就能看懂项目处于哪个阶段;
- 以后加新状态,只要给枚举扩展一个分支即可。
读者能立刻得到什么?
以后看到“一堆 is_xxx 布尔开关”时,条件反射地想想:能不能收拢成一个枚举状态,再用 Mapping 做一张状态表?
常见坑与对策:这些操作在链上要么贵,要么做不到
存储模式里最容易踩坑的地方,其实是“想当然地照搬 Web 开发经验”。下面列几个你以后九成会遇到的问题:
在链上遍历
Mapping- 坑点:
Mapping本身不支持“取出所有 key”,因为 key 是逻辑上的,底层用的是哈希; - 对策:
- 需要遍历时,单独维护一份
StorageVec<Key>或Mapping<u32, Key>做索引; - 读全量数据交给链下服务(索引器、后端)做。
- 需要遍历时,单独维护一份
- 坑点:
把大数组/列表直接塞进 storage 里频繁修改
- 坑点:对
Vec<T>做插入/删除时,可能触发大量数据搬迁,链上成本高; - 对策:
- 能拆 Mapping 就拆 Mapping:
id -> item; - 需要保持顺序时,用
StorageVec存 id,再用 Mapping 存具体数据。
- 能拆 Mapping 就拆 Mapping:
- 坑点:对
随意更改存储结构,指望“升级时自动兼容”
- 坑点:合约升级并不会帮你自动迁移 storage 布局,字段顺序和类型改多了,老数据直接读不出来;
- 对策:
- 一开始就按“长期演进”的视角设计结构:预留枚举分支、版本字段;
- 真要重大升级,就设计好迁移步骤,而不是直接改 struct。
把地址、ID 全都用
String存- 坑点:
String编码后占用空间大,比较慢,不必要; - 对策:
- 地址用
AccountId; - 资源 ID 用
u64、u128或Hash。
- 地址用
- 坑点:
到处散落的 Magic Number
- 坑点:到处写
10000、365这种常量,后面要改一处就得找全局; - 对策:
- 和前面“单值存储模式”结合,把这些常量提到
GlobalConfig里,通过一个统一接口去读。
- 和前面“单值存储模式”结合,把这些常量提到
- 坑点:到处写
总结与下一步:把存储当成“数据库建模”,不是随手写字段
我们收个尾,把今天的重点浓缩成几行:
- 思维方式上:把
#[ink(storage)]当成你的“链上数据库 schema”,不要随便往里塞字段; - 工具箱里:至少要熟练掌握这几种模式:
- 单值字段(配置、开关)
- 一维
Mapping<K, V>(余额表) - 组合 key 的多维 Mapping(权限矩阵)
- Mapping 拆分、Option/Lazy 懒加载(大对象)
- 枚举状态 + Mapping(状态机)
- 落地方式上:任何一个新合约,在写业务逻辑之前,先画出“有哪些实体、它们之间是什么关系”,再翻译成上面的存储模式。
下一步可以尝试:
- 把你现在的某个简单合约的 storage 重新按这几种模式设计一遍,看看能否简化;
- 给现有的余额+授权合约,补上一个“项目状态”模块,用枚举+Mapping 重构布尔开关;
- 写一份小笔记:为你的团队整理一套“ink! 存储模式速查表”,以后新合约都按这套模版走。
互动与支持(帮你我都更高效)
- 如果这篇文章有帮你把 ink! 的存储设计捋顺,欢迎点赞、收藏、转发,让更多正打算上链的朋友少踩点坑;
- 想系统地把 Rust 智能合约这一整套搞明白,可以从本系列第 1 篇的计数器开始,一篇一篇往下跑;
- 访问控制、事件/错误处理、测试也都拆成了单独章节,方便你按模块补课;
- 也欢迎关注「梦兽编程」微信公众号,我会持续更新 Rust / 区块链 / 后端实战系列,把这些“听起来高大上”的东西拆成你周末就能跑起来的小项目。
系列相关链接:
- 系列第 1 篇(计数器入门):/tutorials/smart-contracts/rust-smart-contract-ink-counter/
- 系列第 3 篇(事件与错误处理):/tutorials/smart-contracts/ink-events-error-handling/
- 系列第 4 篇(测试 ink! 合约):/tutorials/smart-contracts/ink-testing-smart-contracts/
- 系列第 5 篇(访问控制模式):/tutorials/smart-contracts/rust-smart-contract-access-control/
