news 2026/9/28 17:12:50

从零理解 Substrate:架构分层、Pallet 开发与无分叉升级实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零理解 Substrate:架构分层、Pallet 开发与无分叉升级实战

如果你第一次在网上搜 "substrate" 这个词,大概率不是被它"中文译名叫基底"搞糊涂,就是被一堆看起来互相矛盾的介绍弄懵。我当初从"想发一条自己的链"这个想法出发,翻了不少资料才搞清楚:Substrate 是一整套用 Rust 写成的区块链开发框架,Polkadot 上的中继链、平行链,以及大量独立网络,底层跑的都是这套框架。这篇文章会先把 Substrate 最容易被误解的架构分层讲清楚,再带你从零跑通一个自定义模块,最后分享几个我在 pallet 开发和测试过程中真实踩过的坑。适合那种"已经能跑通模板,但还想真正理解并动手改链"的开发者。

1. substrate 到底是"一个东西"还是"一堆东西"

很多人刚接触 Substrate 时最大的困惑是:它到底是一个软件、一个 SDK,还是一套协议?我的理解其实很简单,它是一个"把区块链拆成标准零件,然后允许你自由组装"的框架。这也是它和以太坊这类"整链交付"方案最大的区别。

要理解 Substrate,先把它拆成三个不同层次的组件:外部节点、运行时、以及 FRAME 这套 pallet 开发库。三者各自解决不同的问题,但最终组合成一条完整的链。

1.1 客户端:你平时"跑节点"跑的是这一层

客户端是编译后直接跑在你机器上的二进制程序,也就是平时敲./target/release/node-template --dev启动的那个东西。它负责所有"链底层"但不涉及业务逻辑的工作,包括:

  • 网络层:基于 libp2p 实现节点发现、区块同步、交易广播;
  • 共识层:比如生产区块用的 BABE 或 Aura,最终性用的 GRANDPA;
  • 存储层:区块、状态、索引都落在 RocksDB 或 ParityDB 里;
  • RPC 层:对外提供 JSON-RPC 接口,前端应用通过它读写链。

这部分代码对于绝大多数业务开发者来说是"黑盒"。你不必搞懂 GRANDPA 的投票协议到底怎么在节点间协调,因为框架已经把它封装成稳定的共识引擎。这就像你开车不需要重新发明变速箱,踩油门就能走。

1.2 运行时:真正被存到链上的业务逻辑

真正有意思的是运行时。区块链的业务逻辑——比如余额怎么转、治理投票怎么算、自定义的业务模块怎么运行——全部在运行时里定义。Substrate 的做法很特别:运行时不是直接编译成机器码,而是编译成 Wasm,然后以"链上状态"的形式存在链上。

这带来的直接后果是:链上的业务逻辑可以随区块一起被网络共识验证,而且可以通过一次特殊的交易替换掉旧的 Wasm。这就是常说的"无分叉升级",后面我会专门讲。

理解这个分层之后,再去读 Substrate 文档,你会有一种豁然开朗的感觉:原来"链上代码"和"链上数据"是两个可以独立更新的东西。数据的存储结构在链上,代码以 Wasm 形式也在链上,两者绑定在一起,但又各自独立演进。

1.3 FRAME:被反复复用的 pallet 工具箱

第三层是 FRAME,它是 Substrate 官方提供的一套 runtime 开发库。FRAME 的核心单元叫 pallet,你可以把它理解成"链上的一个功能模块"。账户余额是一个 pallet,交易费用是一个 pallet,链上治理是一组 pallet,你完全可以在上面添加自己业务逻辑的 pallet。

层次主要职责你平时需要关注的程度
外部节点客户端网络、共识、存储、RPC基本不用改
Runtime业务逻辑的 Wasm 代码核心开发区域
FRAME / pallet可复用的模块库复用 + 自定义

我早期犯的一个错误,是试图"读懂每一行代码再动手"。实际完全没必要。Substrate 的工程哲学就是"分层屏蔽复杂度":你在 pallet 里写业务逻辑时,不需要关心区块是怎么广播出去的;反过来,你调整共识参数时,也尽量不要碰业务代码。先接受这种分层,再逐步深入,比一开始死抠底层高效得多。

2. 坦白说,我也纠结过要不要自己从零搓一条链

在做技术选型时,我有个很轴的念头:既然框架是别人的,会不会有黑盒问题?万一自定义需求很怪,框架反而束缚手脚怎么办?于是那段时间我真去调研了"从零写一条链"需要做的事,结果很快就清醒了。

2.1 自己写链要面对的问题清单

如果完全从零开始,你至少得解决以下问题:

  • P2P 网络:节点之间怎么发现彼此、怎么同步区块、怎么广播交易;
  • 状态存储:账户余额、业务数据怎么持久化,怎么保证可验证、可回溯;
  • 共识算法:区块由谁生产、怎么确认、怎么防止分叉和双花;
  • 交易执行环境:怎么把一段交易代码在节点里安全执行,并且验证执行结果一致;
  • RPC 接口:前端钱包、浏览器通过什么方式查询链上数据;
  • 运行时升级:业务规则变了之后,现有的链怎么升级,节点是否要硬分叉。

这不是一年半载能做完的工程量。就算不考虑工程质量,光是把这些模块正确组合并保证安全,对专业团队都是很大的挑战。我意识到:如果你最关心的是业务本身,而不是重复发明区块链底层技术,那么站在框架肩膀上走,几乎是唯一务实的选择。

开发方式需要自己写的部分工作量和风险
从零写链P2P、共识、存储、RPC、业务逻辑、升级机制极高,周期以年计
Substrate + FRAME只用 pallet 组合 + 自定义业务逻辑集中在 runtime 层,可控

2.2 为什么不直接拿智能合约框架做

也有人说:那我直接用智能合约平台不就行了?确实,如果你要做的逻辑复杂度不高,合约是更快的路径。但 Substrate 和智能合约最大的区别在于:合约是被链上固定规则包裹的"沙盒程序",而 Substrate 的 runtime 可以修改链本身的规则。

举个例子。在智能合约里,转账手续费的结构、共识参数、治理规则等等,都在链层被固定,合约不能越界修改。而在 Substrate 里,你可以写一个 pallet 直接改掉交易费用的计算方式,或者设计一套新的区块生产者激励机制。这种"协议层可编程"能力,是普通合约平台给不了的。

所以我的判断标准很简单:如果你要做的是一条应用链,或者你希望业务规则能深度定制到链层面,选 Substrate;如果业务逻辑可以完全落在现有合约平台的能力范围内,那没有必要自建一条链的成本。

2.3 我的选择:什么项目适合 substrate

具体来说,我在下面几种场景里会优先考虑 Substrate:

  • 对交易费用、出块节奏、共识参数有特殊要求;
  • 业务数据模型很复杂,合约存储难以优雅表达;
  • 希望链上规则可以平滑升级,不想硬分叉;
  • 未来要接入跨链生态,比如作为平行链接入中继链。

反过来,如果只是做一个标准的代币、一个 NFT 市场,用成熟的合约平台可能更快。技术选型最忌"为了框架而框架",把需求摆出来,答案往往很明显。

3. 第一次跑通 node-template:环境、编译,以及别在错误版本上浪费时间

Substrate 开发的第一步通常是拉一个 node-template 项目下来。这个模板已经配置好了一条链最基础的零件,包括账户系统、余额模块、sudo 模块和一个示例 pallet。你的工作是在这个骨架上做加法,而不是从中抽出部件。

3.1 环境准备与模板下载

Substrate 目前依赖 Rust 的 nightly 工具链,并需要 Wasm 编译目标。如果你机器上没装过 Rust,先去 rustup 官网装,然后执行:

rustup update nightly rustup target add wasm32-unknown-unknown --toolchain nightly

然后拉模板:

git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template

这一步之后千万别急着删库里的旧分支。Substrate 的版本迭代非常快,模板的当前版本如果和网上老教程对不上,你会在编译阶段被一堆莫名其妙的宏错误轰炸。建议把模板 repository 一直保留在本地,作为版本对照的参照物。

3.2 编译和启动

进入项目后,直接编译 release 版本:

cargo build --release

第一次编译需要下载并编译几百个依赖 crate,耗时因机器而异,二十分钟到一个小时都正常。这时候别盯着终端干着急,可以去读读 runtime 目录下的代码结构,或者提前把 polkadot-js/apps 前端工具准备好。

编译完成后启动开发链:

./target/release/node-template --dev

--dev参数会让节点以单人开发者模式启动,自动清空之前的链上状态,每次启动都是一条新链。如果你加了--tmp,临时数据用完后自动删,调试阶段我强烈建议两者连用:./target/release/node-template --dev --tmp。

3.3 连接前端

节点默认监听ws://127.0.0.1:9944。把 polkadot-js/apps 打开,切换网络到 Custom,填上本地 WebSocket 地址,就能看到你这条链的区块在滚动。前端完全通过链的元数据来识别链上有哪些模块、哪些可调用函数、哪些存储项,所以即使你还没写任何前端代码,只要改动 runtime,前端界面会自动跟着变。

3.4 新手最容易翻车的三个点

第一,cargo build和cargo b --release混用。Substrate 的 runtime 编译依赖WASM_BUILD环境变量,release 和非 release 的构建产物不一致容易导致你改了代码却没有生效。固定用一个命令,按项目文档来。

第二,编译报错先看版本。假如你从网上找了个自定义 pallet 代码,粘贴之后疯狂报错,八成不是逻辑问题,而是它基于老版本 Substrate 写的。特别注意decl_module!这种老宏,新版本里已经被 attribute 宏方案替代,见到直接放弃,别试着兼容。

第三,链刚启动完马上发交易测试,偶尔会遇到交易不打包的情况。先等几个区块稳定,再连上前端操作。

4. 手写一个 pallet:存储、调用与事件的三件套

模板跑通之后,真正的工程才刚刚开始。接下来我会带你在 runtime 里加一个自己的 pallet,实现一个简单的"链上凭证记录"模块:用户可以把一段数据的哈希登记到链上,作为存在性证明,之后还能把这个凭证转给其他人。

4.1 用 pallet 组织业务逻辑

pallet 是 Substrate runtime 的基本模块单元。以太坊合约里你写一个contract,在 Substrate 里你写一个 pallet。pallet 可以访问系统级功能,比如读取调用者身份、访问链上时间、操作存储,并且天然支持事件和错误处理。

模板自带一个pallet-template,最简单的做法是复制它的目录,改掉 crate 名字,然后在 runtime 里挂载。但我建议你也看看它的结构,因为一个标准 pallet 通常由四大部分组成:

  • Configtrait:声明这个 pallet 依赖哪些外部类型;
  • Storage:声明链上持久化数据;
  • Call:可被交易调用的业务函数,也叫 dispatchable call;
  • Event和Error:描述链上发生的事件和可预期的失败原因。

4.2 一个链上凭证记录模块

这个模块的存储结构不复杂,就是一个"账号 -> 凭证记录"的映射。凭证记录包含所有者、内容哈希、登记区块号。

#[derive(Clone, Encode, Decode, PartialEq, RuntimeDebug, scale_info::TypeInfo)] pub struct Record<AccountId, BlockNumber> { pub owner: AccountId, pub hash: Vec<u8>, pub created_at: BlockNumber, }

存储部分用StorageMap:

#[pallet::storage] #[pallet::getter(fn records)] pub type Records<T: Config> = StorageMap< _, Blake2_128Concat, T::AccountId, Record<T::AccountId, T::BlockNumber>, OptionQuery, >;

先别急着跳进函数,注意T::AccountId和T::BlockNumber这种写法。pallet 本身不关心账号具体是哪种类型,它通过Config: frame_system::Config拿到 runtime 给它的类型。这套"类型参数化"机制,让你的 pallet 可以在不同 runtime 里复用。

登记的调用长这样:

#[pallet::call] impl<T: Config> Pallet<T> { #[pallet::weight(10_000)] pub fn create_record( origin: OriginFor<T>, hash: Vec<u8>, ) -> DispatchResult { let who = ensure_signed(origin)?; ensure!( !Records::<T>::contains_key(&who), Error::<T>::AlreadyExists ); let record = Record { owner: who.clone(), hash, created_at: frame_system::Pallet::<T>::block_number(), }; Records::<T>::insert(&who, record); Self::deposit_event(Event::<T>::RecordCreated(who)); Ok(()) } #[pallet::weight(10_000)] pub fn transfer_record( origin: OriginFor<T>, to: T::AccountId, ) -> DispatchResult { let who = ensure_signed(origin)?; let mut record = Records::<T>::get(&who).ok_or(Error::<T>::NotFound)?; ensure!(record.owner == who, Error::<T>::NotOwner); Records::<T>::remove(&who); record.owner = to.clone(); Records::<T>::insert(&to, record); Self::deposit_event(Event::<T>::RecordTransferred(who, to)); Ok(()) } }

这里出现了一个很关键的宏:#[pallet::weight]。Substrate 要求每个可调用函数必须估算自己的计算成本,它决定了这条交易在整个区块里占多大的"资源额度"。开发阶段图省事可以写固定值,但上线前建议用官方的 benchmark 工具做真实测速,不然区块生产者很容易被设计超重或过轻的交易影响。

4.3 深入理解 weight 与 origin

weight和origin是 Substrate pallet 开发里绕不开的两个概念。

origin是调用来源的认证信息。ensure_signed表示"调用者必须是一个真实签名的账号",如果调用来自 root(链上治理)或者未认证来源,这里就会返回错误。这套访问控制是每条 call 的第一道门槛。

weight除了关系到区块资源,还牵扯到交易费计算:权重越高,费用通常越高。一个设计良好的 pallet,每个 call 都应该给出合理权重。别以为权重只是数字,它直接影响链的吞吐能力。

5. 测试 pallet 不能靠 println:mock runtime 与几个绕不开的坑

pallet 写完之后,你面临一个现实问题:它跑在哪?pallet 不能像普通 Rust 二进制一样有 main 函数直接运行,它必须嵌入 runtime 才能工作。如果每次都启动节点来测试,效率太低。Substrate 的标准做法是构建一个 mock runtime。

5.1 为什么必须 mock 一个 runtime

mock runtime 是一个"最小可运行"的 runtime 环境,它只包含测试需要的 pallet,比如frame_system加上你的自定义 pallet。它跑在普通 Rust 测试进程里,不需要网络、不需要共识、不需要真实交易池,所以测试可以飞快执行,还能精确断言链上存储和事件。

5.2 mock 代码骨架

在 pallet 的src/mock.rs里,你通常需要做两件事:实现 runtime 配置、构建TestExternalities。

construct_runtime!( pub enum Test { System: frame_system, TemplateModule: pallet_template, } ); impl frame_system::Config for Test { // 模板里有完整版本,这里省略大部分 type 绑定 type AccountId = u64; type BaseCallFilter = frame_support::traits::Everything; // ... } pub fn new_test_ext() -> sp_io::TestExternalities { let storage = frame_system::GenesisConfig::default() .build_storage::<Test>() .unwrap(); storage.into() }

这段代码里最不起眼但最容易出错的是依赖版本一致性。sp-io、sp-core、sp-runtime这些 crate 的版本必须和 runtime 使用的完全一致,差一个 minor 版本都会编译出一堆类型不匹配的报错。

5.3 典型测试用例

写完 mock 之后,测试就变得很直观了。以之前的凭证模块为例:

#[test] fn test_create_record() { new_test_ext().execute_with(|| { assert_ok!(TemplateModule::create_record( RuntimeOrigin::signed(1u64), vec![1u8, 2u8, 3u8], )); assert!(TemplateModule::records(1u64).is_some()); }); } #[test] fn test_create_record_duplicate_fails() { new_test_ext().execute_with(|| { assert_ok!(TemplateModule::create_record( RuntimeOrigin::signed(1u64), vec![1u8, 2u8, 3u8], )); assert_noop!( TemplateModule::create_record( RuntimeOrigin::signed(1u64), vec![4u8, 5u8, 6u8], ), Error::<Test>::AlreadyExists ); }); }

execute_with里的闭包相当于替你搭好了"链上状态沙盒",每次测试进来都是干净状态,修改存储不会污染其他用例。assert_ok!和assert_noop!是框架提供的测试宏,前者断言调用成功,后者断言失败并检查具体错误值。

5.4 我踩过的几个坑

第一个坑是"事件没实现,错误提示却指向宏"。如果你在 pallet 里用了事件,但没有正确声明RuntimeEvent类型,编译错误经常出现在construct_runtime!附近,一时很难联想到是 pallet 内的事件定义不完整。解决方法是先检查#[pallet::event]块是否存在,再检查 runtime 的construct_runtime!里是否已经加入你的 pallet。

第二个坑是"存储键顺序不能乱调"。编译器不会帮你检查construct_runtime!里 pallet 的顺序,但存储编码会受顺序影响。你在开发中发现存储数据错乱、读不到旧数据,大概率是调整了 pallet 顺序而没有执行存储迁移。开发阶段还好,正式链上这属于高危操作。

第三个坑是"测试全绿但主链报错"。mock runtime 和真实 runtime 的配置存在差异,比如 mock 里AccountId = u64,真实 runtime 却是AccountId32。测试能通过的逻辑,主链上因哈希前缀等细节不同仍然可能出错。所以 mock 测试只是第一道防线,合入前一定要在--dev节点上手动走一遍关键流程。

6. forkless 升级:把链上代码和链上数据解耦

Substrate 最有魅力、也最反直觉的特性,是链上升级。传统区块链改成逻辑,基本等于硬分叉:节点必须全员换新二进制,共识网络在某个高度分道扬镳。Substrate 提供了一条完全不同的路。

6.1 硬分叉与无分叉升级

普通链的规则被写进节点客户端的二进制里,所以改规则必须改节点、动共识。而 Substrate 把 runtime 编译成 Wasm 并存在链上,节点执行的其实是链上这份 Wasm。修改代码时,你只需要编译一份新的 Wasm,再通过一个特殊调用把它写到链上,之后全链节点会自动执行新逻辑,不需要替换二进制。

我把这个过程理解为"换发动机不换车架":数据、账号、历史区块都在原地,只有链上代码被原子地换掉了。

6.2 实操一次升级

升级流程不复杂。修改局部代码后,先编译:

cargo build -p node-template-runtime --release

构建产物会生成新的 Wasm 文件。然后在 polkadot-js 前端用 sudo 模块提交setCode调用,把新的 Wasm 上传上去。几秒后,链上代码就换成了新版本。

如果只是新增了一个 call,不会影响现有数据。但如果你的存储结构变了,例如从StorageMap改成StorageDoubleMap,那么旧键值不能被自动读取,你需要写一个存储迁移逻辑,在升级后把旧数据搬移到新键下。这个迁移逻辑往往比业务代码本身更考验人,因为它要兼容"链上可能跑了很久、积累了大量数据"的现实情况。

6.3 升级的风险与备份习惯

开发期随便升级没关系,但正式链上,任何一次升级都必须先备份。我现在的习惯是:先在--dev节点完整走一遍升级流程,再对着测试网做一次带真实数据的演练,最后才考虑生产链。升级过程中如果出现问题,优先用快照回滚,而不是慌张地继续发交易。

另外,升级后立刻检查前端元数据是否刷新。因为前端依赖元数据分析链上接口,如果缓存没有清除,你会看到一堆"接口不存在"的报错,实际链上却一切正常。这个小问题在开发期往往比复杂的共识问题更浪费时间。

最后再分享一个我很个人的体会:Substrate 的学习曲线之所以陡,不是因为它比别的框架复杂太多,而是因为它给了你太多可以用"自己的方式"去做的事情。开发链和写普通后端的思维方式不太一样——你不仅关心功能对不对,还要关心链上状态怎么演进、升级怎么平滑、数据怎么迁移。如果一开始被宏和类型绕晕了,别着急,那都是必经之路。多用 mock 测试兜底、多跑 dev 节点、把版本这个锚点牢牢锁住,后面你会慢慢发现,这套框架真正值钱的地方,不是那些花哨的功能,而是它把"一条链要跨越长期运行这道坎"这件事,实实在在地拉近了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 17:12:38

叶片病害目标检测:VOC标注转YOLO全流程与训练陷阱详解

简介&#xff1a;面向需要构建农业病虫害检测训练集的算法工程师与科研人员&#xff0c;这份大型植物叶片病害缺陷检测数据集覆盖29类常见叶片病害&#xff0c;包含葡萄叶黑腐病、番茄叶菌斑、苹果锈叶病、马铃薯晚疫病等典型类别&#xff0c;并按VOC格式组织训练集与验证集。训…

作者头像 李华
网站建设 2026/9/28 17:12:38

YOLO训练番茄叶病害7类数据集:从数据检查到调参避坑全指引

简介&#xff1a;面向番茄叶部病害的智能识别与目标检测场景&#xff0c;该YOLO数据集收录约700张实拍叶片图像&#xff0c;覆盖细菌性斑点、黑点、早期枯萎病等7个常见类别&#xff0c;图像兼顾不同光照、角度与生长期&#xff0c;能够为农业视觉模型提供较丰富的训练样本。其…

作者头像 李华
网站建设 2026/9/28 17:12:31

CAN FD BRS详解:从协议原理到IG模块配置与调试

我们群里天天有人问“BRS怎么配”、“IG模块打开全是灰的”&#xff0c;其实多数时候不是软件有问题&#xff0c;是没搞明白CAN FD的BRS到底在做什么。这个位说简单也简单——就是允许你在报文中间切换波特率&#xff0c;但真上手配的时候&#xff0c;涉及采样点、数据段长度、…

作者头像 李华
网站建设 2026/9/28 17:11:56

穿越古代商帮搞DDD:用限界上下文拯救烂账本

人这一辈子&#xff0c;总要碰上几次"系统崩了"的时刻。我脑子里的最后记忆&#xff0c;是凌晨三点盯着监控大屏上飙升的错误率&#xff0c;然后眼前一黑。再睁开眼时&#xff0c;面前没有服务器日志&#xff0c;只有一摞墨迹未干的账本。外头有人扯着嗓子喊&#xf…

作者头像 李华
网站建设 2026/9/28 17:11:41

CNN遥感地物分类:Landsat影像深度学习实战指南

简介&#xff1a;面向遥感与深度学习交叉方向学习者的CNN地物分类实战资源&#xff0c;对应Landsat影像的像素级分类任务&#xff0c;适合计算机、地信、人工智能等专业学生用于课程设计或毕业设计。压缩包共10个文件&#xff0c;14.89MB&#xff0c;包含Python源码、训练好的模…

作者头像 李华
网站建设 2026/9/28 17:11:31

Superpowers:让Codex从代码生成器变成工程助手

如果你用过Codex这类AI编程助手&#xff0c;多少会有一种“它能写代码&#xff0c;但写不出我要的工程”的憋屈感。代码片段倒是一套一套的&#xff0c;可真放进项目里&#xff0c;命名规范不统一、缺少异常处理、不考虑历史包袱&#xff0c;改起来比手写还累。这就像发动机给你…

作者头像 李华