开场:别把合约当“无状态函数”,状态设计才是灵魂

咱们先聊点大白话。

大多数刚写智能合约的人,一开始都是这么想的:

“就几行业务逻辑嘛,写几个 #[ink(message)],能改值能转账就行了。”

结果真实世界是:

  • 一开始随手往 storage 里加字段,跑得挺开心;
  • 过几周发现:谁在哪个项目里有多少余额、哪些 NFT 属于谁、哪些订单还没结算……全塞在一个大结构里,自己都看不懂了;
  • 想加个新特性,比如“分层权限、白名单、冻结资金”,发现原本的存储设计完全扛不住,要么重构一大坨,要么硬塞更多字段,让以后维护的人想打人。

你可以把合约状态想象成家里的收纳系统

  • #[ink(storage)] 是整间屋子;
  • 基本字段是客厅/卧室里固定的柜子;
  • Mapping 是那种很多格子的收纳柜:每个 key 是一个格子标签;
  • “懒加载配置”“大对象”就是你只偶尔翻出来的压箱底行李箱。

今天这篇,就带你用朋友聊天的方式,过一遍 ink! 里常见的几种 Storage Patterns(存储模式),目标只有三个:

  1. 知道“状态应该长什么样”,不再乱塞字段;
  2. 手上有几套可复用的模板:余额表、权限矩阵、大对象懒加载;
  3. 清楚哪些操作链上根本做不到或很贵,提前避坑。

原理速写:先把几件“硬事实”认清楚

先别急着写代码,先钉几个钉子:

  1. 所有可持久化状态,都必须挂在 #[ink(storage)] 下面

    • 你在函数里临时 let 出来的变量,调用结束就没了;
    • 想下一次调用还能看到,只能通过 storage 存进去。
  2. 读写状态是要“烧 gas”的,而且写比读贵得多

    • 存储越大、变更越频繁,链上负担越重;
    • 把大对象拆开、按需读写,是存储设计里最重要的省钱手段。
  3. Mapping<K, V> 是链上的 key-value 表,不是普通的 HashMap

    • 优点:可以存很多很多条记录,按 key 查很快;
    • 限制:不能在链上直接“遍历所有 key”,因为 key 是逻辑概念,不是完整存储索引;
    • 含义:想要“遍历所有用户”,必须自己维护一份“用户列表”,比如 StorageVec<AccountId>
  4. 复合关系用“组合 key”解决,而不是无限套娃结构体

    • 给用户余额用 Mapping<AccountId, Balance>
    • 给授权额度用 Mapping<(Owner, Spender), Balance>
    • 不要在 struct User 里再套一个 Vec<Allowance>,那样删改、查找都非常难维护。
  5. 存储布局一旦上链,就当它“基本写死了”

    • 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 存具体数据。
  • 随意更改存储结构,指望“升级时自动兼容”

    • 坑点:合约升级并不会帮你自动迁移 storage 布局,字段顺序和类型改多了,老数据直接读不出来;
    • 对策:
      • 一开始就按“长期演进”的视角设计结构:预留枚举分支、版本字段;
      • 真要重大升级,就设计好迁移步骤,而不是直接改 struct。
  • 把地址、ID 全都用 String

    • 坑点:String 编码后占用空间大,比较慢,不必要;
    • 对策:
      • 地址用 AccountId
      • 资源 ID 用 u64u128Hash
  • 到处散落的 Magic Number

    • 坑点:到处写 10000365 这种常量,后面要改一处就得找全局;
    • 对策:
      • 和前面“单值存储模式”结合,把这些常量提到 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/