关于 stable-memory
面向 Internet Computer 上 Motoko 和 Rust canister 开发者的稳定内存与升级指南,讲解如何让数据跨升级持久保存。内容涵盖 Motoko 持久化 actor、Rust 稳定结构及兼容性检查。
主要功能
- 01Motoko actor 自动持久化规则
- 02Rust 稳定结构存储方案
- 03MemoryManager 划分虚拟内存
- 04升级钩子与数据迁移注意事项
- 05升级前稳定性兼容检查
适用场景
- 01确保用户数据跨 canister 升级保留
- 02为 Rust canister 选择稳定存储结构
- 03将多个数据结构分区存入稳定内存
- 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 的本地功能。
会导致构建失败的错误
-
使用
thread_local! { RefCell<T> }存储用户数据(Rust)——这是堆内存,每次 Canister 升级时都会被清除。以这种方式存储的所有用户数据、余额和设置都会在icp deploy后消失。请改用StableBTreeMap。 -
忘记定义
#[post_upgrade]处理函数(Rust)——如果没有post_upgrade函数,Canister 在升级后可能会静默重置状态或出现异常行为。务必同时定义#[init]和#[post_upgrade]。 -
在持久化 Actor 中使用
stable关键字(Motoko)——在 mo:corepersistent actor中,所有let和var声明都会自动保持稳定。写入stable let会产生警告 M0218,而stable var则是多余的。直接使用let和var即可。 -
混淆堆内存限制与稳定内存限制(Rust)——堆(Wasm 线性)内存上限为 wasm32 的 4GB 和 wasm64 的 6GB。稳定内存则可增长到数百 GB(子网存储上限)。真正的风险在于:如果使用
pre_upgrade/post_upgrade钩子将堆数据序列化到稳定内存,再反序列化回来,就会同时受堆内存大小和升级钩子指令限制的约束。大型数据集会在升级时触发 trap,导致 Canister 无法运行。解决方案是使用稳定结构(StableBTreeMap、StableCell等),直接读写稳定内存,完全绕过堆。使用MemoryManager将稳定内存划分为虚拟内存,让多个结构可以共存而不会相互覆盖。 -
将升级被拒绝误认为数据丢失(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。 -
在 pre_upgrade 中序列化大型数据(Rust) --
pre_upgrade有固定的指令数限制。如果在 pre_upgrade 中将大型 HashMap 序列化到稳定内存,就会超出限制并触发 trap,导致 canister 无法使用。请使用StableBTreeMap,它会直接写入稳定内存,无需序列化步骤。 -
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,说明你的数据不在稳定内存中。请检查存储声明。