ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Substrate区块链开发实战:Runtime、Pallet与无分叉升级

Substrate区块链开发实战:Runtime、Pallet与无分叉升级 看到 substrate 这个词圈内人通常脑补出两种东西一种是实验室里培养微生物用的培养基另一种是 Parity 开源的那套区块链开发框架。这篇不聊培养基聊后者。如果你正在准备自研一条链或者已经把 Solidity 玩得挺熟、想搞明白链本身怎么定制Substrate 是绕不开的一个名字。它不是什么三两天的玩具框架而是把整条链拆成“外部节点 内部 runtime”两大块让你能替换共识、改链上逻辑、做无分叉升级。我也见过不少朋友先啃框架再写业务结果被 Rust 和宏劝退这篇就把我实际搭链时的取舍、编译血泪和调试路径从头到尾捋一遍希望能帮你少走几段弯路。1. Substrate 到底是什么不重复造链轮子的底层框架1.1 一条链不是从零写出来的是组合出来的很多人第一次听到“用 Substrate 开发”时第一反应是“我要写一个区块链”。但严格来说Substrate 不是一个现成的区块链它是一套积木底盘。Parity 用了大量工程实践把一条链通常会遇到的共性模块全部做成了可替换的组件比如网络层用 libp2p、存储层用自己的状态数据库、共识层支持 Aura、BABE、Grandpa、Sassafras 等方案然后让你把全部精力放在一件事上链上状态转换逻辑也就是 runtime。为什么这么设计因为真实业务里除了币转账这种通用能力几乎所有链都需要自己的业务状态和规则。比如一条存证链核心逻辑是“谁能在什么条件下写入数据”一条游戏链核心逻辑是“道具怎么发放、怎么转移”。这些规则如果写死在客户端里每次改逻辑都要所有节点升级客户端运维成本直接爆炸。Substrate 把业务规则编译成一个 Wasm blob 存在链上节点客户端只需要按这个 blob 执行状态转换业务逻辑和节点代码就彻底解耦了。这带来的一个直接好处是运行时升级不需要分叉。传统链上一旦规则写死想改就得硬分叉社区撕扯、节点升级、数据迁移每一步都是坑。Substrate 的 runtime 本身就是链上的一个可更新组件只要治理机制允许就能通过一次交易替换掉旧 Wasm节点不用重启链的历史数据也不用推倒重来。我自己做联盟链时这个特性省掉了至少三次“要不要硬分叉”的争论。1.2 为什么选择 Rust 和 Wasm升级能力从哪来Substrate 的底层实现是 Rust这也劝退了一部分只想写业务的人。但我建议你先别急着跑想清楚为什么它偏要用 Rust。区块链节点对安全性和性能的要求很苛刻状态转换要快内存管理要可控并发要安全。Rust 的所有权模型在编译期就能挡掉大量内存错误这比运行时发现崩溃要强得多。虽然 Rust 的学习曲线确实陡但一旦过了借用检查这一关后续开发反而稳定很多。更关键的是 Wasm 这个设计。runtime 编译成 Wasm 后链上保存的是字节码任何节点只要支持 Wasm 虚拟机就能执行这条链的逻辑。这听起来像“前端浏览器跑字节码”一样自然但在链的世界里它意味着链的规则可以在不依赖特定硬件、操作系统、客户端语言的情况下被验证。哪怕未来节点端换成 C 或者 Go 实现只要它能正确解释 Wasm链就能继续跑。这种“逻辑一次编译到处执行”的特性让 Substrate 天然具备了跨客户端能力也把升级的影响面收敛到了链内而不是扩散到所有下游用户。当然Wasm 带来的也不是只有好处。编译复杂度、运行时开销、调试工具不成熟这些都是实际工程里要付出的代价。可如果你对比过其他框架会发现这点代价换来的升级自由度在长期运维中非常值。1.3 开发 Substrate 链和写智能合约的思维差别很多从以太坊转过来的开发者一开始会用写合约的思维写 runtime这是最大的认知陷阱。智能合约跑在别人的链上你只需要关心业务函数gas 由 EVM 统一处理存储也基本不用考虑“我要不要为每个用户单独开一张表”。但在 Substrate 里你就是链本身的主宰所有存储、手续费、共识、治理规则都要自己定义这既是自由也是责任。举个例子在 Solidity 里你写一个mapping(address uint256)就完事了成本模型由以太坊定死。在 Substrate 的 FRAME 里你要决定用StorageValue还是StorageMap用ValueQuery还是OptionQuery要不要加 getter前缀怎么起因为这些直接决定读写的存储密钥布局和实际执行成本。这种粒度更细的控制适合做业务定制但要求你对链的底层存储模型有起码的概念。所以我一般建议团队在决定用 Substrate 之前先问自己三个问题业务规则是否足够复杂、是否高频变化、是否依赖多条链之间的交互。如果三者都是Substrate 大概率是合适的如果只是发一个普通代币那直接用智能合约平台反而更轻量。2. 选型与比较为什么值得用 Substrate 而不是自研2.1 四类方案的优劣势对比网上讨论链选型常常陷入“XX 框架是全世界最好的”这种偏执。我自己的态度是拿一张表对比关键维度比信仰重要。你可以把自己心里的几个方案按下面的维度过一遍。对比维度从零自研分叉 EVM 链Cosmos SDKSubstrate开发工作量极高中中中业务可定制性最高低受限于 EVM中高高升级灵活性看实现低基本靠分叉链升级较复杂原生支持无分叉升级共识可替换性看实现低中Tendermint 为主高多种可插拔生态资产/现成模块无多较多较多pallet 丰富维护压力很高中中中高需理解框架逻辑从这张表能看出从零自研是理想主义者的选择适合科研实验或极强定制需求但工程落地非常痛苦。分叉 EVM 链适合想快速兼容以太坊生态的项目但代价是业务逻辑被 EVM 限制得很死状态转换很难突破 gas 模型的框架。Cosmos SDK 和 Substrate 属于同一量级的技术路线核心差异在于架构哲学前者的链间通信被设计成原生能力后者的 runtime 升级和自由状态机设计更突出。我见过有的团队因为“我们是做链的不能用人家的框架”这种心态一头扎进自研结果两年过去了连稳定出块都没做到。搞出 PoC 和搞出生产级系统是两码事自研链的真正成本不在共识算法而在周边生态密钥管理、浏览器、钱包、SDK、监控、索引服务这些全要自己造工作量是几何级数的。2.2 共识可插拔不必被单一共识绑死共识几乎是每条链最敏感的组件。自研链最大的恐惧就是“如果有人把我选的共识算法攻击了怎么办”而 Substrate 把共识层做成可替换的抽象层意思是你不用为了换共识而重写整条链。开发模式可以用 Aura 快速出块生产环境可以切到 BABE Grandpa甚至未来可以尝试更强调公平性的 Sassafras。可插拔听起来很美但实际使用中要注意共识和 runtime 之间是通过接口通信的换共识往往意味着调整出块时间、最终性规则、交易池校验逻辑等参数。如果业务对最终性有硬性要求比如支付结算类应用你就不能单纯依赖出块要设计好 Grandpa finality 的确认流程。这里没有“万能共识”选哪种取决于你对安全性和出块速度的取舍。2.3 什么时候别用 Substrate说实话Substrate 也不是银弹。如果你的项目只是需要一个标准 ERC20 或者 NFT 市场用现成的合约平台可能更快运维也更简单。Substrate 适合需要掌控链级规则、要做应用链、或者要和其他链做复杂交互的团队但这也意味着你的团队里至少要有两三个人能理解和修改 Rust 代码否则把链搭起来之后的维护期会非常痛苦。另外Substrate 的学习资料绝对值不低官方文档虽然完善但没有一定 Rust 基础的人刚开始会非常吃力。你要是团队全是前端工程师硬上 Substrate 的后果可能是前三个月几乎没有产出。与其这样不如先评估一下业务是不是真的需要一条专属链。能用一个合约解决的问题就不要先想着自建链。3. 最小可执行的链工程从模板到自定义 Pallet3.1 环境准备与节点模板初始化工欲善其事必先利其器。开发 Substrate 链我的建议是先准备一台至少 16G 内存的机器最好有 32G。别迷信“我编译的是小项目”Substrate 依赖的 crate 非常多全量编译时并行度拉满内存不够直接 OOM。如果你手里只有 8G 的笔记本我劝你先配好 swap 再开工不然编译到一半没了心态容易崩。环境准备其实不复杂。先装好 Rust 工具链官方推荐用rustup管理。Substrate 通常要求 stable 工具链但部分组件需要 nightly所以我一般直接用一个固定的 nightly 版本省得老是切换。可以用下面的命令快速安装并锁定版本rustup toolchain install nightly-2023-01-15 rustup target add wasm32-unknown-unknown --toolchain nightly-2023-01-15装好之后拉取官方模板。模板是最快的起点不建议从零手写 runtime 文件结构因为 runtime 的lib.rs里有大量工程性的框架代码自己照抄文档容易漏细节。官方模板分为substrate-node-template、substrate-front-end-template和substrate-parachain-template几类做独立链用node-template就够了。git clone https://github.com/substrate-developer-hub/substrate-node-template.git cd substrate-node-template cargo build --release第一次编译会非常久喝杯咖啡、看两集剧都算正常。build 成功后./target/release/node-template --dev就能启动一条单节点开发链默认监听127.0.0.1:9944。此时你打开浏览器接上 Polkadot.js Apps把 endpoint 设为本地端口就能看到区块在一点点长高。很多人第一次看到自己链的浏览器界面时都会有点激动但后面的坑才是真正的考验。3.2 写一个自己的 Pallet从存储和 Call 开始模板跑起来后真正的业务开发集中在 pallet 里。所谓 pallet其实就是 runtime 里的一个模块类似于“智能合约 链级权限”的集合体。下面我用一个最简的“会员登记”例子带你看一眼 FRAME 的代码长什么样。先建目录pallets/member/src在lib.rs里声明 pallet 主体。注意 Substrate 的 FRAME 宏体系非常庞大第一次看会有点懵但核心结构其实就几块#![cfg_attr(not(feature std), no_std)] 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; } #[pallet::pallet] pub struct PalletT(_); #[pallet::storage] #[pallet::getter(fn members)] pub type MembersT: Config StorageMap_, Blake2_128Concat, T::AccountId, bool, ValueQuery; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { MemberAdded(T::AccountId), } #[pallet::error] pub enum ErrorT { AlreadyMember, NotMember, } #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn add_member(origin: OriginForT) - DispatchResult { let who ensure_signed(origin)?; ensure!(!Members::T::contains_key(who), Error::T::AlreadyMember); Members::T::insert(who, true); Self::deposit_event(Event::MemberAdded(who)); Ok(()) } #[pallet::weight(10_000)] pub fn remove_member(origin: OriginForT) - DispatchResult { let who ensure_signed(origin)?; ensure!(Members::T::contains_key(who), Error::T::NotMember); Members::T::remove(who); Ok(()) } } }这段代码看起来不长含义却不小。StorageMap声明了一个以账户地址为键、布尔值为值的存储表底层的默认查询方式是ValueQuery也就是读不存在的键时返回默认值false。#[pallet::call]里的函数就是链上可调用的交易入口每个函数都必须标注#[pallet::weight]这是手续费计算的基础。ensure_signed负责校验调用者身份ensure!是运行时条件检查失败时返回错误并终止执行。这里有个特别容易犯的错想用String当存储值。Substrate runtime 运行在 Wasm 环境里标准库的String不可用也没有堆分配的概念所以一切字符串都必须序列化成字节数组。保存一句话请用Vecu8或者用bhello.to_vec()这种写法。刚开始写 pallet 的人十个有九个在这里被编译器教育记住这个原则能帮你省一堆时间。3.3 把 Pallet 装进 Runtime 并跑通节点写好了 pallet还要把它注册进 runtime否则链根本不知道有这个模块。打开runtime/src/lib.rs做三件事第一在mod区域引入 pallet 的 crate第二在construct_runtime!宏里加上模块名第三如果 pallet 需要配置实现它的Configtrait。以这个memberpallet 为例先声明pub use pallet_member; impl pallet_member::Config for Runtime { type RuntimeEvent RuntimeEvent; }然后修改construct_runtime!construct_runtime!( pub enum Runtime where Block Block, NodeBlock opaque::Block, UncheckedExtrinsic UncheckedExtrinsic, { System: frame_system, Timestamp: pallet_timestamp, Balances: pallet_balances, Member: pallet_member, Sudo: pallet_sudo, } );这里容易踩的一个坑是顺序会影响 pallet 的存储前缀因此增删 pallet 时链上已有的存储布局会变导致读取旧数据时找不到对应密钥。开发阶段无所谓但如果已经上线增删 pallet 或改 pallet 名字都会影响存储迁移这个后面升级时会单独说到。重新编译运行cargo build --release ./target/release/node-template --dev然后用 Polkadot.js Apps 的 Sudo 页面调用member.addMember交易如果返回MemberAdded事件说明这个 pallet 已经成功跑起来了。我第一次跑通这个流程时最大的感受是Substrate 的开发实际上没有太多“魔法”它只是把很多分布式系统的复杂度整理成了可复用组件而你只要把注意力放在自己的业务 pallet 上。4. 上线交付前的几个核心关注点链能出块和链能上线是两码事。很多团队做 demo 时觉得 Substrate 太顺利了但真正交付前要考虑的存储、手续费和升级流程才是决定项目生死的关键。4.1 存储设计读多写多怎么权衡链上的存储是全局共享的任何状态的读写都要消耗系统资源和区块空间。在 Substrate 里内存型的数据可以直接放在 runtime storage但大量高频访问的数据不能无脑往链上塞。你可以根据访问频率把数据分成几类需要链上共识的放链上只用于索引或查询的放到链下服务里中间态可以考虑用 off-chain worker 处理。另外要注意StorageMap的 key 编码方式会影响查找效率。模板里常见的Blake2_128Concat是一种带前缀的哈希能避免存储碰撞但如果你要用存储迭代器遍历大量条目就要特别注意执行成本。开发时可能只有几条数据感觉不到问题一旦上主网后存储膨胀到几十万条那些在测试网看起来正常的操作可能直接让手续费暴涨甚至把区块堵死。4.2 权重系统与手续费不要把链做成免费狂欢Substrate 用权重Weight来衡量一条交易的计算成本。每个 call 函数都要返回一个 weight 值区块生产者会根据 weight 决定是否打包这笔交易。如果你把所有函数的 weight 都写成一个象征性的10_000等于告诉节点“这笔交易很便宜”一旦有人恶意高频调用复杂逻辑链上性能就会被拖垮。真实的 weight 设计要考虑数据库读写次数和 CPU 计算量。FRAME 提供了frame_support::weights工具帮你估算但更稳的方式是先按业务逻辑的复杂度手动设定一个偏保守的初始值再配合基准测试去校准。上线后如果发现手续费不合理也可以手动调 runtime 参数但每次调整都要走一次升级流程。所以上线前把重点交易的 weight 测试做扎实能省掉后续很多运维麻烦。4.3 链上治理与无分叉升级的运维准备无分叉升级是 Substrate 的招牌能力但“能不能升级”和“怎么安全升级”是两回事。开发模式直接用pallet_sudo强制升级很简单但在生产环境里一般会配置理事会和技术委员会等多签治理流程保证升级不是一个人拍脑袋就能触发的事。每做一次 runtime 升级都要准备新 Wasm、评估存储迁移脚本、检查事件和错误定义是否变更、测试节点能否平滑同步旧区块。我个人的习惯是所有升级先在本地开一条临时链用命令行工具把当前链的区块高度和数据快照导入确认节点能够处理新旧状态交替再走正式提案。这个流程虽然繁琐但能避免“升级后所有节点回滚”这种灾难级事故。还有一点存储迁移代码要提前写好别在 upgrade 当天临时写因为迁移脚本本身也要经过充分测试它影响的是整条链的可用性。5. 常见问题与排查实录我踩过的坑5.1 Runtime 里不能随便用 String 和普通集合有一阵子我特别想在业务里保存一段用户备注很自然就写了StorageValue_, String, ValueQuery结果编译器直接把错误拍到脸上。原因很简单runtime 默认运行在 no_std 环境String这种依赖堆分配的标准库类型不被支持。Substrate 提供的方案是Vecu8也就是只存字节不保证它是合法 UTF-8。真要展示给前端就让前端自己解析编码。如果你要存结构化数据比如一个对象数组也不建议直接把 Rust 的VecCustomStruct塞进存储。虽然技术上可行但每次读取都要做完整的 SCALE 解码读取成本很高而且存储迭代时非常容易写出性能炸弹。我的做法是将数据拆成多张StorageMap用业务键关联读取时只拿需要的那一条不要整表拉取。5.2 编译时间太长搞到怀疑人生Substrate 编译慢是个绕不开的话题。第一次 clone 模板后编译我足足等了半小时当时我还以为是自己电脑坏了。后来总结出来几条经验一是尽量用 release 模式编译debug 模式虽然快一些但节点性能和 Wasm 执行效率差很多开发链体验很差二是把编译任务挂后台给电脑留足内存和 swap三是不要频繁cargo clean增量编译其实没那么慢很多人是被“第一次全量编译”吓到了。如果内存实在不够可以限制并行编译任务数。比如cargo build --release -j 4-j指定并行度虽然编译时间会变长但能避免内存溢出导致编译中断。此外确认 nightly 工具链和wasm32-unknown-unknowntarget 都装了。很多人卡在“编译时报 wasm target 缺失”就是这个没装好。5.3 Runtime 升级后旧状态读不到了这个问题是最隐蔽的。明明升级流程很顺利节点也正常出块但前端发现某些账号的余额或业务数据变成了默认值。绝大多数情况不是数据丢了而是存储布局变了。比如你在旧版本有个StorageMap_, Blake2_128Concat, AccountId, Balance新版本把 key 从AccountId换成了某个复合结构SCALE 编码后的存储前缀就变了旧数据自然就读不到了。解决思路不是“尽量不改存储”而是建立一套存储迁移机制。FRAME 提供了OnRuntimeUpgradetrait你可以在升级时新增一个迁移 pallet显式地读取旧存储、转换格式、写入新存储。关键是迁移代码要在升级生效前部署并且严肃测试否则一旦链上运行起来再想补救性价比就很低。5.4 本地节点连不上浏览器一直转圈开发链最常见的问题就是“起服务容易连不上浏览器”。优先检查三件事第一确认节点进程真的在跑控制台有没有报 panic第二确认端口没被防火墙拦Polkadot.js Apps 的 endpoint 要选127.0.0.1:9944或你自定义的 RPC 端口第三确认节点处于可接受连接的状态--dev模式默认只对本机开放如果你要远程调试需要显式指定--rpc-corsall和--rpc-external但为了安全不要在生产环境这么干。另外如果同一台机器之前跑过其他链注意 WebSocket 端口冲突。Substrate 节点启动时如果端口被占用会默认向后递增一个端口而你浏览器里还盯着 9944自然就卡死了。最简单的排查方式就是看节点启动日志里的Running JSON-RPC server上面写着实际监听端口照着填就行。6. 最后几句实在话我现在开发新链时已经习惯先把“升级方案”和“存储迁移”画在设计文档的第一页业务功能反而放在后面。Substrate 给你自由的同时也要求你承担运维责任这是所有“灵活框架”的共性。不要被“无分叉升级”几个字冲昏头脑以为上线后随便改升级的前置测试、回滚预案、存储迁移每一件都要提前规划好。如果你刚开始接触 Substrate建议不要把第一个项目定得太复杂。先用模板把一个业务 pallet 跑通理解清楚存储和 call 的基本流程再考虑接共识、治理、跨链模块。链开发不是堆功能而是把状态转换这件事想得足够透彻。我自己也是从踩坑里一路过来的希望这篇能帮你把该避的坑提前避掉把真正的精力放在业务逻辑上。
返回列表