1. 从“substrate”这个词说起:它到底是什么,为什么值得单独聊
第一次看到“substrate”这个词,很多人会愣一下。它在不同圈子里指向完全不同的东西:做区块链的人第一反应是 Parity 那套区块链开发框架,做材料或者生物的人想到的是“基底、基质”,做电子工程的人想到的是芯片衬底,做印刷或者涂装的人想到的是承印物或者底材。这个词本身的意思是“底层、基底、被承载的那一层”,所以它天然就是一个跨领域的词。
我这次想聊的,是把“substrate”当作一个通用概念来看:任何系统里那个“承载上层功能、决定整体性质”的底层结构。你把它放到区块链里,它就是那条决定共识、存储、运行时逻辑的底层框架;你把它放到材料学里,它就是决定涂层附着力、晶体生长质量的那层基底;你放到软件架构里,它就是决定上层业务能跑多稳、扩多快的那层基础设施。核心逻辑是一样的:底层选错,上层全废;底层选对,上层省一半力气。
这篇文章适合谁看?如果你是刚接触区块链开发、想搞清楚 Substrate 框架到底解决什么问题的人,这篇能帮你把概念和实操路径理清楚;如果你是从其他领域过来、想理解“基底思维”怎么迁移到自己工作里的人,这篇也能给你一套可参考的分析框架。我不会只讲概念,会把选型逻辑、关键参数、实操步骤、踩坑经验都摊开讲,让你看完能直接上手或者直接套用到自己的项目里。
关键词“substrate”在整篇里会反复出现,但我会尽量让它落在具体场景里,而不是空喊名词。下面从整体设计思路开始拆。
2. 整体设计与思路拆解:为什么“底层”值得单独设计
2.1 底层决定上限:substrate 思维的核心逻辑
任何一个系统,上层功能再花哨,最终都要落到某个底层结构上。这个底层结构就是 substrate。它的第一个特点是不可轻易替换。你可以在上层加功能、改界面、换业务逻辑,但底层一旦定下来,后面所有东西都要围着它转。所以设计 substrate 的时候,不能只看“现在能不能跑”,要看“三年后还能不能扩”。
第二个特点是它定义规则,而不是执行规则。以区块链为例,Substrate 框架提供的是共识机制、存储结构、运行时环境、治理模块这些“规则层”的东西,具体你的链是做什么业务的、代币怎么发、治理怎么投票,那是上层 runtime 的事。这种分层的好处是:底层稳定,上层灵活。坏处是:底层一旦有设计缺陷,上层再怎么补都很难绕过去。
第三个特点是它往往被忽略,直到出问题。平时大家关注的是功能、界面、性能数字,没人会天天盯着基底看。但涂层脱落的时候,你才会去查底材处理;链分叉的时候,你才会去查共识参数;系统崩了的时候,你才会去看基础设施配置。所以 substrate 的设计质量,平时看不出来,关键时刻见真章。
我个人的经验是:在 substrate 上多花一天时间想清楚,能在上层省掉一周的返工。这个投入产出比,在大多数项目里都是成立的。
2.2 方案选型:自己造底层还是用现成框架
这是所有项目都会遇到的第一个岔路口。自己造 substrate,好处是完全可控、没有多余依赖、可以针对特定场景做极致优化;坏处是周期长、坑多、需要的人力和经验门槛高。用现成框架,好处是起步快、社区支持、经过大量项目验证;坏处是受框架约束、需要学习成本、有时候要为了框架改自己的设计。
以区块链场景为例,如果你要做一条应用链,自己从零写共识、写 P2P 网络、写存储、写 runtime 执行环境,没有几十人年的投入基本不现实。Substrate 这类框架的价值就在于:它把那些通用但复杂的底层能力封装好了,你只需要写 runtime 里的业务逻辑。这就是典型的“用现成 substrate 换时间”。
但选现成框架也有代价。你得接受它的编程模型、它的存储抽象、它的升级机制。如果你的业务逻辑和框架假设冲突,改起来会很痛苦。所以选型的时候,我一般会问三个问题:第一,我的核心需求是不是框架已经覆盖了百分之八十;第二,剩下百分之二十我能不能在框架内解决;第三,如果框架未来不维护了,我的迁移成本有多大。这三个问题想清楚,选型基本不会出大错。
2.3 分层设计:把“变”和“不变”分开
Substrate 思维里最值钱的一条,就是分层。把系统拆成“稳定的底层”和“多变的上层”,底层尽量少动,上层随便折腾。区块链里,共识、网络、存储这些是底层,业务逻辑、治理规则、经济模型这些是上层。材料里,基底处理是底层,涂层配方是上层。软件里,操作系统和运行时是底层,应用代码是上层。
分层的好处是隔离变化。底层稳定,上层就可以快速迭代,不用担心每次改业务都要动地基。坏处是层与层之间的接口要设计好,接口一旦定错,上层再灵活也发挥不出来。所以分层设计的核心工作量,其实在接口定义上。
我见过很多项目,底层和上层混在一起写,短期看开发快,长期看维护成本极高。改一个业务逻辑,要动底层代码;换一个底层组件,上层全得跟着改。这就是没有分层的代价。Substrate 框架之所以强调 runtime 和 node 的分离,本质上就是在强制你做分层。
3. 核心细节解析与实操要点:substrate 落地的关键环节
3.1 环境准备:从零搭建 substrate 开发环境
如果你要走区块链 Substrate 这条路,第一步是搭环境。官方推荐的方式是用 Rust 工具链加 Substrate 的节点模板。具体步骤我按实际操作的顺序列一下,顺便把每个步骤的意图说清楚。
先装 Rust。Substrate 是 Rust 写的,所以 Rust 工具链是基础。用 rustup 安装,然后确认版本。这里有个细节:Substrate 对 Rust 版本有要求,太新或者太旧都可能编译报错。我一般会看官方文档里当前推荐的版本,然后固定下来。不要盲目追最新版,稳定比新功能重要。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.cargo/env rustup update rustup target add wasm32-unknown-unknown最后那行wasm32-unknown-unknown是关键。Substrate 的 runtime 会编译成 WebAssembly,所以必须加这个 target。很多人第一次编译失败,就是因为漏了这一步。
然后装依赖。不同系统要装的包不一样,Linux 下一般需要 build-essential、clang、cmake、openssl 这些。这些是编译底层库用的,缺一个都可能卡住。装完之后,拉节点模板,编译,跑起来。
git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template cargo build --release ./target/release/node-template --dev--dev是开发模式,会用临时数据库、单节点出块,适合本地调试。看到区块开始出,环境就算通了。这个过程第一次编译可能要十几分钟甚至更久,取决于机器性能,耐心等。
注意:编译 Substrate 节点对内存要求比较高,建议至少 8GB,16GB 更稳。内存不够的时候,编译会卡死或者报链接错误,不是代码问题,是资源问题。
3.2 Runtime 开发:业务逻辑到底写在哪
Substrate 里最核心的概念是 runtime。你可以把它理解成“链的业务逻辑层”。共识、网络、存储这些底层能力由框架提供,runtime 决定这条链具体做什么。Runtime 是用 Rust 写的,编译成 Wasm,然后被节点加载执行。
写 runtime 的基本单位是 pallet。一个 pallet 就是一组相关功能的集合,比如资产 pallet、治理 pallet、身份 pallet。你可以用官方提供的 pallet,也可以自己写。自己写 pallet 的时候,一般会用到几个宏:#[pallet::config]定义配置,#[pallet::storage]定义存储,#[pallet::call]定义可调用函数,#[pallet::event]定义事件。
我拿一个最简单的计数器 pallet 举例,说明结构:
#[pallet::pallet] pub struct Pallet<T>(_); #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>; } #[pallet::storage] pub type CounterValue<T> = StorageValue<_, u32, ValueQuery>; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event<T: Config> { CounterIncremented(u32), } #[pallet::call] impl<T: Config> Pallet<T> { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn increment(origin: OriginFor<T>) -> DispatchResult { let _who = ensure_signed(origin)?; let current = CounterValue::<T>::get(); let new_value = current.saturating_add(1); CounterValue::<T>::put(new_value); Self::deposit_event(Event::CounterIncremented(new_value)); Ok(()) } }这段代码里,StorageValue定义了链上存储,increment是外部可以调用的函数,调用后会更新存储并发出事件。ensure_signed确保调用者是签名用户,不是 root。weight是这笔调用消耗的计算资源估算,后面会细说。
写 pallet 的时候,有几个点特别容易出错。第一是存储类型选错。StorageValue适合单值,StorageMap适合键值对,StorageDoubleMap适合双键。选错了,查询效率会差很多。第二是 weight 估算不准。估少了,链可能被恶意调用拖垮;估多了,正常用户成本高。第三是事件设计。事件是链下系统感知链上状态变化的主要途径,设计不好,链下索引会很难做。
3.3 存储设计:链上数据怎么放才合理
Substrate 的存储是链上状态的核心。所有需要共识的数据都放在存储里,所有节点都要维护一份。所以存储设计的第一原则是:能不放链上就不放链上。链上存储贵,读写都要消耗资源,而且会增大状态体积。图片、大文本、日志这些,放链下,链上只存哈希或者引用。
第二原则是结构要可预测。Substrate 的存储是键值对,键的设计直接影响查询效率。如果你经常按用户查数据,就用StorageMap,键是用户 ID。如果你经常按“用户加资产”查,就用StorageDoubleMap。不要把所有东西塞进一个大的StorageValue里,那样每次读写都要序列化整个结构,效率极低。
第三原则是考虑迁移。链上存储一旦上线,改结构就要做存储迁移。迁移逻辑写不好,可能导致状态不一致甚至链停。所以设计存储的时候,要预留升级空间,比如用版本号、用可扩展的结构,而不是写死字段。
我踩过的一个坑是:早期为了省事,把配置数据直接硬编码在 runtime 里。后来要改配置,只能升级 runtime,而 runtime 升级又要走治理流程,非常慢。后来改成把配置放存储里,通过治理调用更新,灵活多了。这个教训就是:凡是可能变的东西,都不要硬编码。
3.4 Weight 与费用:链上资源的定价逻辑
Substrate 里用 weight 来衡量一笔操作消耗的计算资源。每个 extrinsic 都要声明自己的 weight,区块也有 weight 上限。这样做的目的是防止单个操作占用过多资源,保证区块能按时出。
Weight 分两部分:ref_time和proof_size。前者是计算时间,后者是状态证明大小。写 pallet 的时候,你要为每个可调用函数估算 weight。估算方法一般是用 benchmark 工具跑实际代码,得到基准值,再加上数据库读写开销。
#[pallet::weight(T::WeightInfo::increment())] pub fn increment(origin: OriginFor<T>) -> DispatchResult { ... }上面这种写法,weight 来自WeightInfotrait,这个 trait 通常由 benchmark 自动生成。Benchmark 的写法是定义一组测试用例,跑不同参数下的操作,然后生成 weight 公式。这个过程有点繁琐,但必须做,不然 weight 不准。
费用方面,Substrate 支持多种费用模型。最基础的是按 weight 收费,也可以加长度费、小费、优先级。费用最终会进入某个账户,可以是销毁,也可以是给区块作者。具体怎么设计,取决于经济模型。
实操心得:weight 估算宁大勿小。估大了,用户多花一点费用;估小了,链可能被攻击。早期项目经常为了“用户体验”把 weight 调低,结果被刷交易,区块堵死。这个亏我见过不止一次。
4. 实操过程与核心环节实现:从模板到可运行链
4.1 从节点模板到自定义链的完整流程
有了环境之后,下一步是把模板改成自己的链。流程大致是:改链名和标识、加自己的 pallet、配置创世状态、编译运行、测试。我按顺序说。
改链名和标识,是在 node 的 chain spec 里改。链名、链 ID、协议 ID 这些要唯一,不然会和测试网冲突。创世状态里可以配置初始账户、初始余额、初始参数。这些配置在 chain spec 的 JSON 文件里,改完重新生成即可。
加自己的 pallet,是在 runtime 的lib.rs里注册。先加依赖,然后在construct_runtime!宏里加一行,最后在impl块里配置 pallet 的参数。这一步容易漏的是Config的实现,漏了会编译报错,报错信息有时候不太直观,要耐心看。
编译运行之后,用 Polkadot.js 或者 Substrate 的前端模板连上去,就能看到自己的链在出块,也能调用 pallet 的函数。测试的时候,先测正常路径,再测异常路径,比如权限不足、参数越界、重复调用。异常路径往往比正常路径更容易出问题。
4.2 参数计算:以出块时间和区块容量为例
出块时间和区块容量是两个核心参数,直接决定链的性能和体验。出块时间太短,节点压力大,网络容易分叉;太长,交易确认慢,用户体验差。区块容量太小,吞吐低;太大,状态膨胀快,节点门槛高。
以出块时间为例,假设目标 TPS 是 100,每笔交易平均 weight 是 100_000,那么每秒需要的 weight 是 10_000_000。如果出块时间是 6 秒,每个区块的 weight 上限至少要 60_000_000。这个计算是粗略的,实际还要考虑网络延迟、节点性能、状态读写开销。
区块容量方面,除了 weight 上限,还有区块大小上限。区块太大,传播慢,孤块率高。一般建议区块大小控制在几百 KB 到几 MB 之间,具体看网络条件。如果是公链,节点分布广,网络差异大,区块要保守一点;如果是联盟链,节点可控,可以激进一点。
我一般会先定一个保守值,跑压力测试,看实际表现,再逐步调整。不要一上来就追求高 TPS,稳定比数字重要。
4.3 升级机制:链上治理与 runtime 升级
Substrate 的一大特色是支持无分叉升级。Runtime 编译成 Wasm,升级的时候只需要把新的 Wasm 通过治理提交,链上投票通过后自动替换。整个过程不需要节点停机,也不需要硬分叉。
升级流程一般是:本地改代码、编译出新 Wasm、计算 Wasm 哈希、提交治理提案、投票、执行。执行的时候,链会用新的 Wasm 替换旧的,同时可以附带存储迁移逻辑。存储迁移要特别小心,写错了可能导致状态损坏。
// 存储迁移示例:把旧存储的值搬到新存储 pub fn migrate<T: Config>() -> Weight { let mut count = 0; for (key, value) in OldStorage::<T>::drain() { NewStorage::<T>::insert(key, value); count += 1; } T::DbWeight::get().reads_writes(count, count) }这段代码把旧存储里的数据搬到新存储,然后返回消耗的 weight。实际迁移里,还要考虑数据量、分批处理、失败回滚。数据量大的时候,一次迁移可能超过区块 weight 上限,所以要分批,每次迁一部分,多次完成。
注意:存储迁移一定要在测试网充分测试,最好用真实数据的快照跑一遍。我见过迁移逻辑写错导致链停的例子,恢复起来非常麻烦。
5. 常见问题与排查技巧实录:substrate 实操中的坑
5.1 编译与运行问题速查
Substrate 开发里,编译和运行问题占了很大一部分时间。我整理了一个速查表,覆盖最常见的几类。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 编译报链接错误 | 内存不足或依赖缺失 | 检查内存,装齐 build-essential、clang、cmake |
| Wasm 编译失败 | 缺 wasm32 target | 运行 rustup target add wasm32-unknown-unknown |
| 节点启动后不出块 | 共识配置错误或创世状态问题 | 看日志,检查 chain spec 和共识参数 |
| 调用 extrinsic 失败 | 权限、weight、参数问题 | 看错误码,对照 pallet 的 Error 定义 |
| 存储查询为空 | 键设计错误或未初始化 | 检查存储类型和键,确认创世状态 |
| 升级后链停 | 存储迁移逻辑错误 | 回滚到旧 Wasm,修迁移逻辑,重新测试 |
这个表里的每一行,我基本都实际遇到过。最麻烦的是升级后链停,因为涉及状态,恢复成本高。所以升级前的测试再怎么强调都不过分。
5.2 性能与状态膨胀的排查思路
链跑一段时间后,常见的问题是性能下降和状态膨胀。性能下降可能来自存储读写变慢、weight 估算不准、节点资源不足。状态膨胀则是链上数据越来越多,节点存储和同步时间不断增加。
排查性能问题,我一般先看监控:出块时间是否稳定、区块 weight 使用率、交易池积压情况、节点 CPU 和内存。如果出块时间波动大,可能是 weight 估算问题或者节点性能问题。如果交易池积压,可能是区块容量不够或者费用太低。
状态膨胀的排查,要看存储增长曲线。如果某个 pallet 的存储增长特别快,就要检查它的存储设计。常见问题是用了无界集合,比如StorageMap的键没有清理机制,数据只增不减。解决办法是加清理逻辑,或者用有界集合,或者把历史数据移到链下。
我个人的经验是:状态膨胀要早发现早处理,等到节点同步要几个小时的时候,再改就晚了。上线前就要设计好数据生命周期,哪些数据永久保留,哪些可以清理,哪些放链下。
5.3 安全与权限的常见误区
Substrate 的权限模型基于 origin。ensure_signed要求签名用户,ensure_root要求 root 权限,ensure_none允许无签名调用。用错 origin 检查,可能导致未授权访问。
常见误区有几个。第一是忘了检查 origin,函数直接执行,任何人都能调用。第二是用了ensure_signed但没检查调用者身份,任何签名用户都能操作别人的数据。第三是 root 权限滥用,把太多功能放在 root 下,治理效率低且风险集中。
正确的做法是:每个可调用函数都明确自己的权限要求,该签名就签名,该 root 就 root,该无签名就无签名。涉及用户资产的,一定要检查调用者是不是资产所有者。涉及系统参数的,走治理流程,不要硬编码 root 调用。
实操心得:写 pallet 的时候,先把每个函数的权限要求列出来,再写代码。这样不容易漏。我见过因为漏了一个 origin 检查,导致任何人都能改系统参数的例子,后果很严重。
6. 从 substrate 到“基底思维”:跨领域的迁移价值
聊了这么多 Substrate 框架的实操,最后我想把视角拉高一点。Substrate 这个词背后的“基底思维”,其实可以迁移到很多领域。
做材料的,基底处理决定涂层附着力。基底没处理好,涂层再贵也白搭。做软件的,基础设施决定应用稳定性。基础设施没搭好,应用写得再漂亮也跑不稳。做内容的,底层知识结构决定输出质量。底层不扎实,技巧再多也是花架子。
所以当你看到“substrate”这个词的时候,不管它在哪个领域出现,都可以问自己几个问题:这个系统的基底是什么?它决定了什么?它能不能换?换的成本有多大?我有没有在基底上偷懒?这几个问题问下来,很多问题的根源就清楚了。
我在实际项目里的体会是:越是底层的东西,越值得花时间。上层的东西改起来快,底层的东西改起来慢。把时间花在底层,短期看慢,长期看快。这个账,做久了自然算得过来。
最后分享一个小技巧:如果你不确定某个底层设计对不对,就假设它三年后要改,然后问自己“改起来要动多少东西”。如果答案是“几乎全部”,那这个设计大概率有问题,值得重新想。如果答案是“只动一小部分”,那说明分层做对了,可以放心往下走。这个自检方法,我在很多项目里都用过,挺管用。