mcpField
S
stable-memory

stable-memory

github.com/dfinity开发39314 次安装今天更新
代码仓库

关于 stable-memory

面向 Internet Computer 上 Motoko 和 Rust canister 开发者的稳定内存与升级指南,讲解如何让数据跨升级持久保存。内容涵盖 Motoko 持久化 actor、Rust 稳定结构及兼容性检查。

主要功能

  1. 01Motoko actor 自动持久化规则
  2. 02Rust 稳定结构存储方案
  3. 03MemoryManager 划分虚拟内存
  4. 04升级钩子与数据迁移注意事项
  5. 05升级前稳定性兼容检查

适用场景

  1. 01确保用户数据跨 canister 升级保留
  2. 02为 Rust canister 选择稳定存储结构
  3. 03将多个数据结构分区存入稳定内存
  4. 04在部署前发现不兼容的状态变更

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dfinity/icskills --skill stable-memory

安装后在 Claude Code 中输入/stable-memory确认已加载。

详细介绍

稳定内存与 Canister 升级

这是什么

稳定内存是 Internet Computer 上的持久化存储,可在 Canister 升级后保留。普通变量能否保留取决于语言:

  • Rust——堆数据(thread_local! { RefCell<T> }、普通 static)会在每次升级时被清除。需要保留的数据必须存放在稳定内存中,可以使用 ic-stable-structures,也可以在 pre_upgrade/post_upgrade 中自行序列化。
  • Motoko——默认启用增强型正交持久化,因此 persistent actor 中的 let 和 var 无需 stable 关键字或升级钩子就能在升级后保留。只有 transient 声明会被清除。

前提条件

  • 对于 Motoko:在 mops.toml 中配置 core = "2.0.0",并使用 moc >= 1.7.0(Map/List 的点表示法需要此版本)
  • 对于 Rust:在 Cargo.toml 中配置 ic-stable-structures = "0.7"

Canister ID

不依赖外部 Canister。稳定内存是 Canister 的本地功能。

会导致构建失败的错误

  1. 使用 thread_local! { RefCell<T> } 存储用户数据(Rust)——这是堆内存,每次 Canister 升级时都会被清除。以这种方式存储的所有用户数据、余额和设置都会在 icp deploy 后消失。请改用 StableBTreeMap。

  2. 忘记定义 #[post_upgrade] 处理函数(Rust)——如果没有 post_upgrade 函数,Canister 在升级后可能会静默重置状态或出现异常行为。务必同时定义 #[init] 和 #[post_upgrade]。

  3. 在持久化 Actor 中使用 stable 关键字(Motoko)——在 mo:core persistent actor 中,所有 let 和 var 声明都会自动保持稳定。写入 stable let 会产生警告 M0218,而 stable var 则是多余的。直接使用 let 和 var 即可。

  4. 混淆堆内存限制与稳定内存限制(Rust)——堆(Wasm 线性)内存上限为 wasm32 的 4GB 和 wasm64 的 6GB。稳定内存则可增长到数百 GB(子网存储上限)。真正的风险在于:如果使用 pre_upgrade/post_upgrade 钩子将堆数据序列化到稳定内存,再反序列化回来,就会同时受堆内存大小和升级钩子指令限制的约束。大型数据集会在升级时触发 trap,导致 Canister 无法运行。解决方案是使用稳定结构(StableBTreeMap、StableCell 等),直接读写稳定内存,完全绕过堆。使用 MemoryManager 将稳定内存划分为虚拟内存,让多个结构可以共存而不会相互覆盖。

  5. 将升级被拒绝误认为数据丢失(Motoko)——增强型正交持久化会检查新程序与现有状态是否兼容,并在升级生效前接受或拒绝升级。升级被拒绝不代表数据丢失:Canister 会继续运行旧版本,且状态保持不变,因此只需修复代码并重新部署。

    哪些架构变更需要迁移,以及如何编写迁移,由 migrating-motoko-actors(由 mops 管理的迁移链)负责;请加载该内容以了解规则和语法。这里需要了解的一个常见意外情况是:更改现有持久化变量所持有的记录类型属于不兼容变更,需要迁移,即使只是添加一个可选字段也一样,而且无论记录位于何处(顶层 var、Map 值等)。添加新稳定字段本身是否需要迁移取决于项目设置——在增强型迁移链中,稳定字段仅声明类型,因此新增字段需要迁移;而普通的 persistent actor 中,新字段带有初始值时则不需要迁移——这正是相关规则归属该技能而非在此列成表格的原因。不兼容的升级会在运行时被拒绝,并报错 RTS error: Memory-incompatible program upgrade(旧状态保持不变,不会丢失数据)。 不要等到部署时才发现此问题。在 mops.toml 中配置 [canisters.<name>.check-stable],让 mops check 使用上一个版本的 .most 执行稳定性兼容性检查(加载 mops-cli):这样你可以在检查时了解更改是否兼容,或是否需要迁移,而不是等到升级调用时才出现 RTS error。

  6. 在 pre_upgrade 中序列化大型数据(Rust) -- pre_upgrade 有固定的指令数限制。如果在 pre_upgrade 中将大型 HashMap 序列化到稳定内存,就会超出限制并触发 trap,导致 canister 无法使用。请使用 StableBTreeMap,它会直接写入稳定内存,无需序列化步骤。

  7. actor 形式与 --default-persistent-actors 标志不匹配(Motoko) -- 普通 actor 和 persistent actor 都是正确的;具体哪一种能编译,取决于该编译器标志。推荐启用此标志,因为这样可以让代码更简洁:

    # mops.toml — recommended; see `mops-cli`
    [moc]
    args = ["--default-persistent-actors"]
    

    启用此标志后,所有 actor 都是 persistent,因此应编写普通的 actor { };persistent 关键字仍可编译,但多余(警告 M0217)。未启用此标志时,必须使用 persistent actor { },而普通的 actor 会报错 [M0220] this actor or actor class should be declared persistent。添加任何注解都无法解决此问题——stable、transient 和 pre_upgrade/post_upgrade 钩子仍然都会失败,因为错误出在 actor 本身:

    // without --default-persistent-actors
    actor {
      stable var users = Map.empty<Nat, Text>();   // M0220 — `stable` does not help
    };
    
    persistent actor {
      let users = Map.empty<Nat, Text>();          // compiles; persisted automatically
    };
    

    下面的示例使用 persistent actor,两种情况下都能编译。

    标志关闭时有一个陷阱:如果 actor 主体中有未加注解的 let/var,moc 会在这些行报告 [M0219] this declaration is currently implicitly transient, please declare it explicitly transient,而 M0220 不会出现。不要照此建议操作——transient 会在升级时丢弃数据,而且 actor 仍会触发 M0220。解决方法是在 actor 上使用 persistent,或启用该标志。

实现

Motoko

使用 mo:core 2.0 时,persistent actor 可让稳定存储变得简单。actor 主体中的所有 let 和 var 声明都会在升级期间自动持久化。

import Map "mo:core/Map";
import List "mo:core/List";
import Nat "mo:core/Nat";  // required: the compiler derives Map's implicit `compare` from this module
import Text "mo:core/Text";
import Time "mo:core/Time";

persistent actor {

  // Types: in the actor body (as here) or in an imported module -- but never
  // between the imports and the actor, which is M0141. See the `writing-motoko` skill.
  type User = {
    id : Nat;
    name : Text;
    created : Int;
  };

  // These survive upgrades automatically -- no "stable" keyword needed
  let users = Map.empty<Nat, User>();
  var userCounter : Nat = 0;
  let tags = List.empty<Text>();

  // Transient data -- reset to initial value on every upgrade
  transient var requestCount : Nat = 0;

  public func addUser(name : Text) : async Nat {
    let id = userCounter;
    users.add(id, {
      id;
      name;
      created = Time.now();
    });
    userCounter += 1;
    requestCount += 1;
    id
  };

  public query func getUser(id : Nat) : async ?User {
    users.get(id)
  };

  public query func getUserCount() : async Nat {
    users.size()
  };

  // requestCount resets to 0 after every upgrade
  public query func getRequestCount() : async Nat {
    requestCount
  };
}

Motoko persistent actor 的关键规则:

  • let 用于 Map、List、Set、Queue——自动持久化,无需序列化
  • var 用于简单值(Nat、Text、Bool)——自动持久化
  • transient var 用于缓存、以及升级时应重置的计数器
  • 无需 pre_upgrade / post_upgrade——运行时会自动处理
  • 无需 stable 关键字——它是多余的,还会产生警告
mops.toml
[package]
name = "my-project"
version = "0.1.0"

[dependencies]
core = "2.0.0"

# Required. Without a [toolchain] moc pin, mops falls back to the dfx cache and
# the build dies with `dfx: not found` (see `mops-cli`).
[toolchain]
moc = "1.9.0"
Rust

Rust canister 使用 ic-stable-structures 实现持久化存储。MemoryManager 会将稳定内存(最多可达数百 GB,受子网存储限制)划分为虚拟内存,每块虚拟内存为不同的数据结构提供支持。

Cargo.toml
[package]
name = "stable_memory_backend"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ic-cdk = "0.19"
ic-stable-structures = "0.7"
candid = "0.10"
serde = { version = "1", features = ["derive"] }
ciborium = "0.2"
单个稳定结构(简单情况)
use ic_stable_structures::{
    memory_manager::{MemoryId, MemoryManager, VirtualMemory},
    storable::{Bound, Storable},
    DefaultMemoryImpl, StableBTreeMap,
};
use ic_cdk::{init, post_upgrade, query, update};
use candid::CandidType;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

// -- Implement Storable for custom types --
// StableBTreeMap keys need Storable + Ord, values need Storable.
// Storable defines how a type is serialized to/from bytes in stable memory.
// Use CBOR (via ciborium) for serialization -- compact binary format, faster than candid.

#[derive(CandidType, Serialize, Deserialize, Clone)]
struct User {
    id: u64,
    name: String,
    created: u64,
}

impl Storable for User {
    // Recommended: prefer Unbounded to avoid backwards compatibility issues when adding new fields.
    // Bounded requires a fixed max_size -- adding a field that increases the size will break existing data.
    const BOUND: Bound = Bound::Unbounded;

    fn to_bytes(&self) -> Cow<'_, [u8]> {
        let mut buf = vec![];
        ciborium::into_writer(self, &mut buf).expect("Failed to encode User");
        Cow::Owned(buf)
    }

    fn into_bytes(self) -> Vec<u8> {
        let mut buf = vec![];
        ciborium::into_writer(&self, &mut buf).expect("Failed to encode User");
        buf
    }

    fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
        ciborium::from_reader(bytes.as_ref()).expect("Failed to decode User")
    }
}
// Bound::Bounded { max_size, is_fixed_size: true } exists for fixed-size types but is NOT
// recommended -- adding a new field later will exceed max_size and break deserialization.

// Stable storage -- survives upgrades
thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<u64, User, Memory>> =
        RefCell::new(StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0)))
        ));

    // Counter stored in stable memory via StableCell
    static COUNTER: RefCell<ic_stable_structures::StableCell<u64, Memory>> =
        RefCell::new(ic_stable_structures::StableCell::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))),
            0u64,
        ));
}

#[init]
fn init() {
    // Any one-time initialization
}

#[post_upgrade]
fn post_upgrade() {
    // Stable structures auto-restore -- no deserialization needed
    // Re-init timers or other transient state here
}

#[update]
fn add_user(name: String) -> u64 {
    let id = COUNTER.with(|c| {
        let mut cell = c.borrow_mut();
        let current = *cell.get();
        cell.set(current + 1);
        current
    });

    let user = User {
        id,
        name,
        created: ic_cdk::api::time(),
    };

    USERS.with(|users| {
        users.borrow_mut().insert(id, user);
    });

    id
}

#[query]
fn get_user(id: u64) -> Option<User> {
    USERS.with(|users| users.borrow().get(&id))
}

#[query]
fn get_user_count() -> u64 {
    USERS.with(|users| users.borrow().len())
}

ic_cdk::export_candid!();
使用 MemoryManager 管理多个稳定结构
use ic_stable_structures::{
    memory_manager::{MemoryId, MemoryManager, VirtualMemory},
    DefaultMemoryImpl, StableBTreeMap, StableCell, StableLog,
};
use std::cell::RefCell;

type Memory = VirtualMemory<DefaultMemoryImpl>;

// Each structure gets its own MemoryId -- NEVER reuse IDs
const USERS_MEM_ID: MemoryId = MemoryId::new(0);
const POSTS_MEM_ID: MemoryId = MemoryId::new(1);
const COUNTER_MEM_ID: MemoryId = MemoryId::new(2);
const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(3);
const LOG_DATA_MEM_ID: MemoryId = MemoryId::new(4);

thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<u64, Vec<u8>, Memory>> =
        RefCell::new(StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(USERS_MEM_ID))
        ));

    static POSTS: RefCell<StableBTreeMap<u64, Vec<u8>, Memory>> =
        RefCell::new(StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(POSTS_MEM_ID))
        ));

    static COUNTER: RefCell<StableCell<u64, Memory>> =
        RefCell::new(StableCell::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)),
            0u64,
        ));

    static AUDIT_LOG: RefCell<StableLog<Vec<u8>, Memory, Memory>> =
        RefCell::new(StableLog::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(LOG_INDEX_MEM_ID)),
            MEMORY_MANAGER.with(|m| m.borrow().get(LOG_DATA_MEM_ID)),
        ));
}

Rust 稳定结构的关键规则:

  • MemoryManager 会划分稳定内存——每个结构都会获得唯一的 MemoryId
  • 切勿让两个不同的结构共用同一个 MemoryId——它们会相互破坏数据
  • StableBTreeMap 的键必须实现 Storable 和 Ord,值必须实现 Storable
  • 为自定义类型实现 Storable:定义 BOUND、to_bytes、into_bytes 和 from_bytes。使用 ciborium::into_writer/ciborium::from_reader 进行 CBOR 序列化(紧凑、快速)。优先使用 Bound::Unbounded——添加新字段时,它可以避免破坏向后兼容性。虽然存在 Bound::Bounded,但不建议使用,因为模式变更后超出 max_size 会导致反序列化失败
  • 基本类型(u64、bool、f64 等)、String、Vec<u8> 和 Principal 已实现 Storable——无需手动实现
  • 单个值(计数器、配置)使用 StableCell
  • 仅追加的日志使用 StableLog(需要两个内存区域:索引和数据)
  • thread_local! { RefCell<StableBTreeMap<...>> } 是正确模式——RefCell 包装的是稳定结构,而不是堆上的 HashMap
  • 无需进行 pre_upgrade/post_upgrade 序列化——数据已存储在稳定内存中

部署与测试

Motoko:验证升级后的持久性
# Start local replica
icp network start -d

# Deploy
icp deploy backend

# Add data
icp canister call backend addUser '("Alice")'
# Expected: (0 : nat)

icp canister call backend addUser '("Bob")'
# Expected: (1 : nat)

# Verify data exists
icp canister call backend getUserCount '()'
# Expected: (2 : nat)

icp canister call backend getUser '(0)'
# Expected: (opt record { id = 0 : nat; name = "Alice"; created = ... })

# Now upgrade the canister (simulates code change + redeploy)
icp deploy backend

# Verify data survived the upgrade
icp canister call backend getUserCount '()'
# Expected: (2 : nat) -- STILL 2, not 0

icp canister call backend getUser '(1)'
# Expected: (opt record { id = 1 : nat; name = "Bob"; created = ... })
Rust:验证升级后的持久性
icp network start -d

icp deploy backend

icp canister call backend add_user '("Alice")'
# Expected: (0 : nat64)

icp canister call backend get_user_count '()'
# Expected: (1 : nat64)

# Upgrade
icp deploy backend

# Verify persistence
icp canister call backend get_user_count '()'
# Expected: (1 : nat64) -- data survived

icp canister call backend get_user '(0)'
# Expected: (opt record { id = 0 : nat64; name = "Alice"; created = ... })

验证是否正常工作

验证稳定内存的权威测试:数据在升级后仍然存在。

# 1. Deploy and add data
icp deploy backend
icp canister call backend addUser '("TestUser")'

# 2. Record the count
icp canister call backend getUserCount '()'
# Note the number

# 3. Upgrade (redeploy)
icp deploy backend

# 4. Check count again -- must be identical
icp canister call backend getUserCount '()'
# Must match step 2

# 5. Verify transient data DID reset
icp canister call backend getRequestCount '()'
# Expected: (0 : nat) -- transient var resets on upgrade

如果第 3 步后计数变为 0,说明你的数据不在稳定内存中。请检查存储声明。

评论

支持 Markdown