
最近有个做供应链溯源的朋友来问我说想自己发一条链和几个合作伙伴一起用。我问他为什么要自己发他答不上来只说“不想把数据存在别人的链上”。我说那正好今天这篇就从这里开场——Substrate 是我折腾了大半年以后觉得最适合这种“半公开但独立”场景的造链框架。先说结论Substrate 不是一条链而是一个“造链的框架”。它来自波卡生态但本身并不依赖波卡你想拿它做一条完全独立的链完全没问题。常见的理解误区是把它当成普通区块链项目其实它更像给你一套预制好的钢筋水泥和图纸让你按自己的需求盖一栋楼而不是像传统那样只能在一栋现成的楼里刷墙。如果你正在犹豫“要不要自己搞一条链”或者已经决定搞但不知道从哪里动手这篇基于我实际折腾 Substrate 的笔记应该能帮你省下不少时间。我按我的真实路径来写从为什么选它到怎么跑通第一个节点再到设计模块、调试测试最后聊一聊上线前那些容易忽略的抉择。整个过程没那么多高大上的概念更多是一步一步踩出来的经验。1. 从“发币”到“发链”我为什么最终选了 Substrate1.1 起初心目中的“一条链”其实是个伪需求最初我接到的需求特别简单给企业做一套积分系统积分要能流通要能对账要防篡改。第一反应当然是直接用合约发一个 ERC20上一条现成链又快又省事。但需求往下挖就变味了——对方希望数据不落在别的链上因为积分体系涉及多个合作方他们不想让第三方平台的共识规则影响自己的业务同时他们又希望未来可以自己修改业务规则而不需要请求别人升级合约。这时候就出现一个尴尬局面普通合约能解决“防篡改”但解决不了“独立”和“可升级”。我试着去分叉一条现成链改起来才发现链的共识、账户、交易、治理全都是绑定在一起的我想动的只是业务层但牵一发动全身。后来朋友提醒我你要不要看看 Substrate它天生就是把“业务层”和“基础设施层”拆开的。1.2 三条技术路线的直接对比我给团队列过一个对比表至今觉得挺直观方案适合场景前期成本长期维护最大的坑直接发 ERC20/合约只需要代币、不需要自定义规则很低低依赖目标链数据完全受目标链约束升级要兼容旧合约分叉现成链想要独立链但业务逻辑简单中等高代码合并困难共识、治理、账户系统全部绑定修改困难基于 Substrate 自建独立链 自定义业务模块 可持续升级高一点中等但升级路径成熟需要 Rust 基础概念多上手有门槛我最后选择 Substrate更多是被它的模块化设计吸引。因为你不需要动共识层不需要重新发明账户系统只需要写自己的业务模块框架会帮你把交易、共识、存储这些脏活累活接好。1.3 真正打动我的三个设计点跳过那些官方文档里的定义我用大白话讲一下它的核心逻辑共识是可插拔的。默认你用 Aura、GRANDPA或者测试用的 Instant Seal都没问题。你不需要理解 BLS 签名怎么实现只需要在配置里声明“我要用什么共识”。运行时是可以升级的。链上跑的业务代码是一段单独的 wasm节点程序只负责执行它。你甚至可以做到旧链、新链无缝切换不需要硬分叉。业务以 pallet模块为单位。每个业务功能就是一个模块模块之间还能互相调用。这太适合我的场景了积分系统是一个 pallet权限管理是一个 pallet审计记录又是一个 pallet各自独立又能协同。这个设计思路直接影响了我后面所有模块的写法所以文章后面会有很大篇幅在讲怎么拆 pallet。2. 环境搭建与节点启动跑起来容易跑明白难2.1 Rust 环境与版本锁定Substrate 是 Rust 写的所以第一步当然是准备 Rust 环境。官方的标准动作是rustup update nightly rustup target add wasm32-unknown-unknown --toolchain nightly这里有个很典型的坑千万不要图省事直接拉最新的 nightly不同版本的 Substrate 对 Rust 版本有要求你大概率会遇到某些 crate 编译失败。我的习惯是查看模板项目里的rust-toolchain.toml它会锁定一个具体的 nightly 版本然后按那个版本装环境至少能少掉一半兼容性问题。我当时用的模板是substrate-node-template直接基于 Git 拉取git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template然后是第一次编译。第一次编译会下载几百个依赖编 Rust 逻辑再编 wasm总共可能花一两个小时。这里我强烈建议不要在中途强制关机或者频繁清理缓存你就让它慢慢跑。编译完成之后你会发现target/release/node-template这个文件就是你的第一个“链节点”。2.2 第一次启动节点日志里藏着大量信息启动开发模式非常简单./target/release/node-template --dev --tmp--dev是使用开发模式的默认配置--tmp表示不持久化数据重启即清零非常适合本地测试。第一次启动时你会在日志里看到一堆关键词️ Building block表示正在构建区块✨ Imported #1表示第 1 个区块已导入 Idle表示节点在空闲状态等待出块 Role: AUTHORITY说明当前节点参与出块。很多人会忽略日志但我建议你仔细看。如果你看到no authority字样说明你的链缺少出块者配置区块高度会一直卡在 0如果看到wasm execution failed说明你的运行时 wasm 没编对这比任何前端报错都更根本。2.3 和链交互从 RPC 到 Polkadot-JS Apps节点跑起来之后你可以用任何 WebSocket 客户端访问它。默认端口是ws://127.0.0.1:9944。最常用的工具是 Polkadot-JS Apps官方提供了一个在线版你把端点改成前面这个地址就能连上。第一次连上时你会看到“当前链”名字和创世块信息这些都是由你的运行时定义出来的。如果你看到的链名不是自己的说明你刚刚启动的还是模板默认链不要惊讶下一步你会在运行时配置里改掉它。这里要特别提醒Substrate 生态的工具链迭代很快你已经不需要从零手写全部 RPC但至少要明白chain_spec是什么。它定义了你的链最初的状态比如初始账户、初始余额、共识初始配置有没有配置好这些直接决定你后面能不能优雅地“开张”。3. pallet 设计把你的业务逻辑拆成模块而不是写进一个巨型 runtime_impl3.1 一个最小 pallet 的构成Substrate 里的业务逻辑几乎都写在 pallet 里pallet 可以理解成一个微服务。我看过很多新手把一堆函数直接塞进runtime_impl里结果模块边界完全混乱代码根本没法维护。实际上官方推荐的模式非常清晰一个最基础的 pallet 长这样pub use pallet::*; #[frame_support::pallet] pub mod pallet { use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; type Currency: CurrencySelf::AccountId; } #[pallet::pallet] pub struct PalletT(_); #[pallet::storage] #[pallet::getter(fn something)] pub type SomethingT StorageValue_, u32, ValueQuery; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { SomethingStored { who: T::AccountId, something: u32 }, } #[pallet::error] pub enum ErrorT { NoneValue, StorageOverflow, } #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn do_something(origin: OriginForT, something: u32) - DispatchResult { let who ensure_signed(origin)?; Something::T::set(something); Self::deposit_event(Event::SomethingStored { who, something }); Ok(()) } } }这段代码看着有点吓人但拆开就很简单Config是 pallet 与外部世界的接口声明这个模块依赖什么类型Pallet结构体是逻辑容器所有函数都挂在它上面storage定义链上状态event定义链上通知error定义可返回的错误类型call才是外层真正能触发的交易接口。我的经验是刚开始不要追求各种高级特性先掌握这个最基本骨架后面加权限、加计费、加迁移都不会太慌。3.2 我把业务抽象成三种 extrinsic在设计积分系统时我并没有在一个模块里塞所有函数。我用了一个很简单的分类法写入类操作、查询类操作、管理类操作。查询类我一般不会做成 extrinsic因为查询不需要产生交易直接用链上存储的 getter 就够了免费且不用签名。写入类才需要 extrinsic比如“增加积分”“扣除积分”“转移积分”。管理类操作则单独开一个 pallet比如“修改积分上限”“冻结账户”。这样分类之后我在代码里能把函数命名、参数设计、权重标注都理得很清楚。一个常见错误是试图把“写数据”和“校验权限”混在同一个函数里最后测试贼难写。Substrate 真的不鼓励你去写那种“万能函数”它鼓励你按模块和意图拆分。3.3 模块间通信不要急着拥抱 OuterCall如果一个 pallet 需要调用另一个 pallet很多人第一反应是去查OuterCall这让一个模块直接嵌入另一个模块的调用。我的建议是尽量通过Config里的 trait 来定义依赖关系而不是直接调用对方的具体实现。比如我有个point_ledger模块需要给用户增加余额但它不应该知道钱在哪个模块里存着。我会配置一个type PointCurrency: MutateBalanceSelf::AccountId然后由统一账户模块去实现它。这样做的好处是测试时可以用模拟实现替代真实模块依赖关系非常干净。模块通信还有一个反直觉的点在 chain runtime 中任何跨模块调用都可能失败。比如 A 模块给 B 模块转账B 模块返回错误A 模块如果不做回滚整个状态就会不一致。所以我在写跨模块调用时几乎都会把调用结果包裹在一个分批事务里保证原子性。这一点和普通后端开发中的“事务”概念很像但区块链上下文里你感知到的会更强烈。4. 存储设计、事件与错误处理链上数据的“砖瓦”怎么放4.1 存储项决定数据契约在设计链上数据结构时我吃过一次亏直接把所有积分流水都存到一个VecStruct里结果每次写操作都要重排整个数组区块塞满不说查询也越来越慢。后来重新改成StorageMapAccountId, Balance存余额再配合索引存最近流水性能立刻好起来。Substrate 存储的几个基础类型我列个快速参考类型用途典型例子StorageValue存单个值总积分发行量、版本号StorageMap键值映射账户余额映射、地址到身份的映射StorageDoubleMap双重键映射按“用户 资产”存储余额NMap / CountedMap更复杂键的映射多维权限表这里最核心的原则是链上存储很贵务必只存业务必须的最终状态而不要存中间过程。比如历史流水可以靠事件还原没必要在链上存一份完整的关系型数据库。我见过很多新手把自己后端的那套表结构原封不动搬到链上结果一个区块只能塞几条记录测试的时候直接卡死。4.2 事件是链上的“API 接口”千万别省很多人写 pallet 只关心存储和函数事件能省就省这是大忌。事件有两个重要作用一是给外部客户端提供可靠的变更通知二是可以在不读取存储的情况下确认操作结果。我在实际项目中几乎每种写入型 extrinsic 都配套一个事件。比如#[pallet::event] pub enum EventT: Config { PointIssued { who: T::AccountId, amount: u64, reason: Vecu8 }, PointBurned { who: T::AccountId, amount: u64 }, }外部服务只需要订阅PointIssued就能更新自己的数据库而不需要反复查询链上状态。事件字段我建议尽量精简不够用的再通过存储查询补上。事件本身不便宜每条事件都有字节开销但比起存储来说还是划算得多所以能用事件表达的信息就不要再写一份存储。4.3 错误处理别让业务异常变成“无响应”Rust 的函数可以返回DispatchResult也就是Result(), DispatchError。但这里有个细节很容易被忽略如果你直接panic!节点不会把这个当作一个友好的业务错误而是整个交易执行直接失败外部客户端看到的往往是笼统的“执行错误”而不是你定义好的Error::InsufficientBalance。正确的做法是定义#[pallet::error]然后用ensure!ensure!(balance amount, Error::T::InsufficientBalance);错误枚举里的每个成员最终都会映射成一个可读的错误码外部 SDK 能识别。还有一个小技巧是在比较高级的错误时你可以使用#[pallet::error]枚举带字段虽然支持有限但调试时非常有用。我踩过一个坑错误定义好了但函数里忘了把?加上结果ok分支画蛇添足错误分支又继续往下执行导致数据被污染而且事件没发出来。后来我养成一个习惯写完函数先数一遍确保每个可能失败的ensure都有?或者显式map_err。5. 调试、测试与基准测试我一共踩过的五个典型坑5.1 坑一存储 Key 路径对不上Substrate 的存储项在链上有一个前缀它由模块名和存储名组合计算。如果你在 pallet 里改过存储名但没做迁移那旧的链上数据会直接“消失”——不是没了而是查询路径对不上了读出来的全是默认值。我当时就遇到一个情况模块升级后用户积分突然全部归零。后来通过链上原始查询接口对比发现 key 的确变了。记住升级你的存储结构不是简单改 Rust 结构体必须额外写迁移代码或者至少把旧数据搬到新键路径下。5.2 坑二测试环境忘统一版本Substrate 测试通常要建一个 mock runtime然后在 mock 里去实现各种 pallet 的 Config。有一阵子我依赖了一个外部 pallet但自己 mock config 时用了和主 runtime 不同的参数结果测试里全通过、一跑真链就崩。所以我的经验是mock runtime 尽量按主 runtime 的同样配置来写不要图省事只实现最少的模块。比如主 runtime 里有 Balances 模块mock 里也必须把它的类型、函数都配好否则涉及账户余额的操作会有永远偏离的行为。5.3 坑三权重忘写交易永远“卡在池里”Substrate 每个 extrinsic 都要标注权重权重反映了计算量。如果你给的权重太少或者根本没写 annotation出块者可能不会接受这个交易或者它会被放入交易池但不进区块。表现就是“交易一直 pending永远不确认”。有一次我只写了#[pallet::weight(0)]本地测试没事但一旦构建出真的块那个交易就被反复打包重算最后导致中继链日志全是警告。权重不是“随便填一个数字”你要借助框架自带的基准测试工具测量真实执行时间再由开发模式下的配置加上一个系数。5.4 坑四Batch 交易中断没有回滚意识Substrate 有一种批量交易机制可以把多个调用组合到一个事务里。但如果你在写的模块里自己会调用其他模块然后某个调用失败你又不打算让它回滚那么前面写入的状态就已经发生了这是一个特别隐蔽的坑。我现在的处理方式很简单要么所有跨模块调用都确保在整个事务范围内执行要么显式地检查每个调用是否成功并且返回错误。千万不要因为省事把一个大函数写成“执行到一半就return”那半途数据会一直留在链上外人根本看不出来。5.5 坑五升级逻辑没写在“升级前的链”里做过一次链上运行时升级的准备时我直接在最新代码里写了迁移逻辑结果测试通过但真正升级时发现旧链根本没有这个迁移入口代码一递归就崩。Substrate 官方提供了存储迁移的 hook但并不是说你在新代码里写一个初始化函数旧链就会把它当迁移执行。你必须在上一个运行时版本里预留升级入口或者通过外部事件触发迁移流程。这个坑我在第二次升级时狠狠踩了几次最后只能回滚链上数据重放创世区块。所以后续每一次升级我都要先在测试网模拟“旧链数据 新代码”的组合而不是只测试最新代码。6. 主网前的抉择共识、升级与权重别急着发币6.1 共识选型开发、测试、主网各自用哪套很多人一上来就问“用什么共识”我的回答是在没有足够大的验证人网络之前先别纠结复杂的共识算法。如果你还在本地开发直接用 Instant Seal它每个交易到了就立刻出块非常适合调试测试阶段可以切到 Aura GRANDPA 的组合一个负责出块一个负责最终确认主网如果验证人可控Aura GRANDPA 也是够用的。这里我给的对比场景推荐共识原因本地调试Instant Seal即时打包块高增长快小型测试网Aura GRANDPA出块稳定最终性概念完整多验证人业务链Aura GRANDPA实现简单参数好调整需要去中心化共识Babe GRANDPA 或 NPoS更接近波卡但复杂度高值得注意的是共识选择会直接影响出块时间和最终性。你不能拍脑袋把一个 1 秒出块的配置搬到生产环境节点带宽、同步压力、验证人数量都得考虑。我见过一条测试链因为出块时间设太短共识消息堆积直接把网络搞崩。6.2 升级不是“重启”而是链上运行时替换Substrate 最大的卖点是无分叉升级但这里有个前提你是在运行时层面做替换而不是直接替换节点二进制。大致流程是把新代码编译成*.compact.wasm然后发起一个set_code调用节点们接受新 wasm 并把执行环境切换。过程中必须保证 node 程序本身兼容新旧运行时否则可能出现“新代码执行但是旧节点状态机读不懂”的尴尬。在我实际项目中我通常会先写一个sudo collectives或者system的专用调用用一个专门的管理员密钥发起升级。前期测试网升级时可以直接用--force参数强制更新但主网一定要走完整的权限控制流程并且强烈建议做一次从旧块高度同步的快照备份。6.3 发币不可怕可怕的是经济模型没有链上逻辑很多链一上来就想要“发币”但发币只是最简单的一步。真正坑人的是总供应量怎么定、转移时要不要扣税、质押规则怎么设计、团队份额怎么锁定。Substrate 里账户余额默认由 pallet_balances 管理这没问题但一旦你把经济规则写进代码后面想改就必然面临升级和迁移。我强烈建议哪怕只是先跑通一个积分系统也要把经济模型的核心约束放到测试里。我给自己定了一个规矩任何经济参数比如“每日新增上限”“单个账户最大余额”都要有专项测试覆盖否则不参与主网部署。我在最后还要加一句Substrate 给了你无限的自由但真正的风险从来不是“框架能不能撑住”而是你自己设计的业务逻辑有没有边界。如果你也打算从零搭一条链最好的方式不是看一百篇文档而是先跑起一个最小节点写一个最简单的 pallet然后让它在开发模式下转一天。你会发现很多焦虑都会变成很具体的待办事项。