ARTICLE DETAIL

资讯详情

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

Substrate实战:从选型到pallet开发的无分叉升级之路

Substrate实战:从选型到pallet开发的无分叉升级之路 1. 我为什么最终选了Substrate而不是其他链开发框架先交代一下背景。前两年我所在团队接到一个需求帮一家供应链金融公司搭建一条联盟链要求是能自定义业务逻辑、支持多方节点参与、后续还要能平滑升级。最开始我没直接选Substrate而是先评估了当时主流的几条路径——基于以太坊改一条链、用Fabric搭联盟链、或者直接拿Cosmos SDK改。先说结论这三条路都走了一段才回的Substrate踩过的坑让我后来跟别人推荐时都会先讲清楚为什么选它。第一个淘汰的是Fabric原因很现实Fabric是面向企业级联盟链的业务网络设计它的链码Chaincode是部署在背书节点上运行的和区块链账本本身是分开的。我们业务里有比较强的Token流转需求需要原生级的资产处理能力Fabric里做这事要在链码里自己实现一套完整的账本逻辑而且它的共识机制PBFT家族在节点扩容和动态加入这一块体验真不算好。第二个淘汰的是以太坊改链OpenEthereum那个代码库我去读了一圈庞杂得吓人改一条业务链要动的部分比想象中多得多——共识、区块结构、状态树、预编译合约每一个都是大工程改完还要维护一整条分叉上线之后升级基本都得硬分叉解决这对需要长期演进的业务链来说是致命伤。最后评估Cosmos SDK的时候其实已经比较接近了但注意到它的模块模块化体系和ABCI边界设计让我觉得有点半成品的味道业务逻辑跑在SDK之上但实际执行还是要通过ABCI和Tendermint状态机交互调试链路长出错时很难定位到底是业务层还是共识层的问题。然后才认真看的Substrate。它给我的第一印象是框架感特别重——不是让你去改一条现有的链而是把区块链的通用组件拆成积木共识、网络、存储、Runtime执行环境你要做的就是组合积木 写自己的业务逻辑。这一点在经过两轮其他方案试错之后感受特别强烈。Substrate做对的一件关键事情是Runtime运行时既是业务逻辑的载体也是区块链状态机的一部分这意味着你写的业务pallet和链本身的区块执行天然无缝结合不会有链是链、业务是业务的割裂感。2. 从零搭一个开发节点工具链与环境准备2.1 版本选择的一个实际建议很多人刚开始会直接拉Substrate仓库最新的主分支来编译我劝你别这么干。Substrate的迭代速度非常快主分支的API可能每周都在变你今天写好的pallet过两周拉新代码可能编译不过。正确做法是选择一个稳定版Node的tag来开发。我建议新手直接使用官方维护的substrate-node-template并且固定版本号。我在做供应链项目时用的就是某个特定版本的template所有依赖Substrate、FRAME、pallet相关crate都锁定在那个版本上后期再统一升级这是最省心的路径。依赖版本锁定这件事看起来不起眼实际救命。曾经有一次我把Substrate相关crate从4.0升到4.1结果一个pallet里的#[pallet::storage]宏生成的代码和新的frame_support不兼容排查了两个小时才发现是版本问题。从那之后我养成了习惯Cargo.toml里所有依赖尽量精确到版本号或者至少用major.minor层级锁死绝不用latest。2.2 编译环境和时间成本如果机器配置一般第一次编译Substrate节点会非常耗时全量编译三十分钟到一小时是很正常的。这里有几个可以大幅提速的技巧建议配置至少16G内存、4核以上CPUSSD硬盘。内存不够的话编译过程中会出现SIGKILL因为Rust编译器对内存要求不低。在~/.cargo/config.toml里配置Rust编译的增量缓存用sccache做编译缓存多次编译时提升非常明显。把开发环境依赖build-dependencies和运行时依赖分开避免不必要的重编译。编译前先cargo check而不是直接cargo build --release语法检查阶段快得多。2.3 开发节点的启动与前端联动等编译通过你会得到一个substrate-node-template二进制。启动节点前需要有一个默认的链配置chain spec模板里自带了一个dev模式的spec直接跑./target/release/substrate-node-template --dev这个命令会启动一个单节点的开发链默认监听端口9944WebSocket和30333P2P。--dev模式有几个特性值得注意节点启动后账本为空、自动出块非常快默认大概几秒一个块、并且会自动预置一个Alice的开发账号。配合Polkadot.js Apps界面浏览器打开apps.substrate.io然后切换节点到ws://localhost:9944就能直观地看到区块在出、转账在跑。我第一次在Polkadot.js里看到自己链上的区块增长时确实有点小兴奋。3. FRAME与pallet体系理解Substrate的模块化设计3.1 模块化到底是怎么实现的Substrate的区块链运行时本质上是一堆pallet的集合。每个pallet是一个Rust模块封装了一组相关的业务功能、存储项、事件和错误。所有pallet被编译进一个巨大的Runtime结构体这个结构体实现了frame_support::construct_runtime!宏定义的所有trait。打一个比方你把Substrate运行时想象成一台机器pallet就是机器里的功能模块——有的模块负责记账Balances有的负责用户身份Identity有的负责治理Democracy、Collective。你要做一条自己的链不需要自己造这些模块直接用现成的然后往机器里塞一个你自己的业务模块就行。我见过很多初学者看FRAME代码时被一堆宏吓到其实核心概念并不多#[pallet::storage]定义存储#[pallet::event]定义事件#[pallet::origin]定义调用者来源#[pallet::call]定义可被外部调用的交易函数。理解这几个宏你的业务pallet就写了七八成了。3.2 存储设计的隐藏规则pallet的存储设计是我觉得整个Substrate开发里最容易入门简单深入挖坑的环节。存储项在链上会被写入状态State而这个状态是有存储成本的——每条链的存储增加都会导致链的状态膨胀影响全节点同步和验证性能。Substrate提供了三种存储类型存储类型适用场景底层实现注意点StorageValue存单个值一个key映射最简单用于存配置型数据StorageMap存key-value映射哈希后的key映射最常用但注意遍历不是原生支持的StorageDoubleMap存两层key的映射双key哈希适合按维度分组的场景比如(账户, 资产类型) - 余额这里有个容易被忽略的坑在链上存储设计里遍历是一个非常昂贵的操作。很多从传统数据库思维转过来的开发者会天然地想我存一个列表然后遍历它统计个数。在Substrate里这基本是反模式。你需要为按某个维度查询单独设计存储结构用嵌套Map或双Map去支撑查询路径而不是靠遍历。比如统计某类资产的持有人数我会专门维护一个StorageValue(u32, VecAccountId)或者用CountedStorageMap来避免遍历所有用户。3.3 事件与错误的正确姿势pallet中#[pallet::event]声明的每个事件在交易成功后会被记录到区块中用户客户端通过监听事件来感知业务状态变化。这个设计和以太坊的Event Log很像但Substrate事件更结构化。我的习惯是每个业务操作最少emit一个事件让客户端不查状态就能知道发生了什么同时事件字段尽量用结构化类型方便前端直接解析。错误则走#[pallet::error]。这里有个很有用的细节Substrate的错误在交易失败时会被当作DispatchError返回同时它也记录到交易结果中但只有事件是会被包含在区块里的错误本身不会作为日志持久化。所以如果业务上需要审计某个失败操作你应该显式emit一个失败事件而不是只返回Err。这是我在做金融链时特别强调的一个设计原则——审计追踪需要完整的链路包括失败的尝试。4. 手写第一个业务pallet从需求到上线的完整过程4.1 需求定义与接口设计拿我们供应链项目的第一个业务pallet举例需求是企业可以在链上发行应收账款凭证我们内部叫BillToken并且可以转让给其他企业。这是个非常典型的资产代币化场景。第一步不是写代码而是把存储结构和可调用函数画出来。pallet需要的数据结构凭证信息BillToken { id, issuer, amount, holder, status, create_block }全局凭证数量计数用于生成自增ID按持有方查询的凭证列表映射holder - VecBillTokenId可调用函数#[pallet::call]issue_bill(issuer, recipient, amount): 发行一张新凭证transfer_bill(token_id, from, to): 转让持有权可选redeem_bill(token_id): 到期赎回/核销4.2 一个pallet的骨架代码下面是简化后的pallet结构你可以直接作为模板往自己的runtime里塞#[pallet::config] pub trait Config: frame_system::Config { // 事件类型 type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; // 币种类型用Balance type Balance: Parameter Member AtLeast32BitUnsigned Default Copy; } #[pallet::storage] #[pallet::getter(fn bills)] pub type BillsT: Config StorageMap _, Blake2_128Concat, T::BillId, BillTokenT, ; #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn issue_bill( origin: OriginForT, recipient: T::AccountId, amount: T::Balance, ) - DispatchResult { let issuer ensure_signed(origin)?; let bill_id ...; // 自增逻辑 let bill BillToken { id: bill_id, issuer: issuer.clone(), holder: recipient.clone(), amount, status: BillStatus::Active, create_block: frame_system::Pallet::T::block_number(), }; BillsT::insert(bill_id, bill); Self::deposit_event(Event::BillIssued { bill_id, issuer, recipient, amount }); Ok(()) } }这里说明几个规范每个#[pallet::call]函数必须标注#[pallet::weight(...)]它表示这个调用的计算开销直接影响交易的费用计算。ensure_signed(origin)用来验证调用者是否一个合法签名账户返回该账户ID。存储操作完成后用deposit_event记录事件便于前端和索引器捕获。4.3 从pallet到Runtime的接入pallet写完并不能直接用还需要把它接进Runtime结构体。典型流程是在runtime/Cargo.toml的dependencies里加入pallet-bill-token或你的pallet名。在runtime/src/lib.rs里construct_runtime!宏中加入该pallet。给Runtime实现pallet的Configtrait绑定到使用的类型比如把Balance绑定到Balances pallet的Balance。这一步出错率很高尤其是trait关联类型绑定时类型匹配的问题。例如type RuntimeEvent必须实现统一的RuntimeEvent枚举type Balance必须满足trait约束编译时的错误信息往往又长又绕第一次遇到会非常崩溃。但跑通一次之后模式化的接入流程就固定了。4.4 前端交互与测试链改好之后前端调用也有一套固定流程用api.tx.billToken.issueBillpallet名称的小驼峰 方法名的小驼峰提交交易用api.query.billToken.bills查询存储。如果你用的是Polkadot.js建议用api.tx的TS类型增强或者干脆用polkadot/api的decorate去自动生成类型避免手写接口签名错误。上线测试时我们走的是节点本地的curl方式先验证确认区块包含正确的Extrinsic和Event之后再让前端接入。这个顺序能帮你把前端问题和链本身的问题隔离出来不然两边同时出bug时会非常难排查。5. 无分叉升级Runtime升级机制是怎么帮我省钱的5.1 为什么这能省大钱传统区块链比如以太坊一旦上线升级通常要走硬分叉即需要所有节点在同一高度切换到新规则。这在联盟链/私有链上虽然没那么棘手但依然要协调各方节点、安排停机窗口、备份状态每次升级都是一场运维手术。Substrate的一项核心能力是无分叉升级Runtime代码本身被存储在链上状态中可以通过一个特殊的调用set_code或runtime_upgrade的治理流程来替换整个Runtime而无需重新部署节点、无需节点停机。这个机制的原理并不复杂Substrate节点的核心部分是固定的共识、网络、状态管理而业务运行时被编译成一个Wasm blob存放在链上。节点执行区块时会加载链上存储的Wasm执行Runtime。你只要提交一个runtime::set_code交易把新的Wasm blob写进状态下一个区块就可以开始用新逻辑处理交易了。对于联盟链场景这一步是配置变更级别不用重启任何机器。5.2 实操中必须注意的迁移风险虽然无分叉升级很方便但它不等于随便升级。真正的风险在数据迁移尤其是存储结构变更时。Substrate提供了#[pallet::migration]旧版叫OnRuntimeUpgradetrait机制让你在升级时写一段迁移代码把旧存储结构的数据读出来转换后写入新存储。举个例子我做过一次版本迭代需要把按凭证ID存储改成按持有人分组的嵌套Map存储。如果不做迁移旧的存储项会直接丢失所有历史凭证全部消失——这在金融场景是不可接受的。因此我在升级代码里实现了OnRuntimeUpgrade遍历旧StorageMap把每条凭证按持有方插入新结构并且在迁移完成之后emit一个事件、更新版本号。我最想强调的一点是迁移代码上线前必须用旧版本节点生成的状态做一次完整演练。我们的做法是先把链导出到固定区块高度--export-state然后用升级后的二进制在该状态上跑一轮检查迁移前后数据一致性。测试环境跑通才敢在生产链上执行升级。5.3 治理在你的链里怎么落地Substrate的治理机制Democracy、Council、Technical Committee不是必须的你完全可以做一个治理者就是超级管理员的链。但如果希望体系化管理建议把这些治理pallet集成进运行时。我开始做供应链链时觉得治理是花架子可后来业务方提出某个节点账号要踢出、参数要动态调整才发现没有治理机制的时候每改一个参数都要重新走代码发布非常僵化。推荐的做法是初期直接把参数比如凭证手续费比例做成StorageValue存储在链上然后通过一个带Root权限的调用如set_fee_rate去修改这样类似配置走链上、代码走升级的两层模式灵活性和稳定性都能兼顾。6. 测试、调试和踩坑实录6.1 用Mock Runtime写单元测试Substrate pallet支持用Mock Runtime做单元测试这是我强烈推荐每个开发者都必须掌握的。方法并不复杂在src/mock.rs里构造一个最小化的Runtime只包含System和你的pallet然后调用trait实现相关方法直接跑Rust测试即可。frame_support::construct_runtime!( pub enum Test for Runtime { System: frame_system, BillToken: crate, } ); #[test] fn issue_bill_works() { new_test_ext().execute_with(|| { let issuer 1u64; let recipient 2u64; assert_ok!(BillToken::issue_bill(RuntimeOrigin::signed(issuer), recipient, 100)); let bill_id 1u64; let bill BillToken::bills(bill_id).unwrap(); assert_eq!(bill.holder, recipient); assert_eq!(bill.amount, 100); }); }测试中我最常遇到的问题是忘了初始化System的账号信息导致ensure_signed失败。解决办法是new_test_ext()里要executive with system的block_number等配置或者更简单——mock里用frame_system::Pallet::Test::inc_providers等初始化账户余额以便签名账户正常。6.2 子链开发中改代码不生效的坑这个问题真的会反复折磨人。--dev模式启动的节点如果你改了代码重新编译并重启节点默认情况下链上状态会被清空因为dev模式使用全新的状态目录但有些人会遇到改了代码但行为没变的情况——大概率是节点没有真的重启成功或者你的前端Websocket连接的是旧节点的端口。我的排查流程是先确认节点日志里出现New block输出的最新块高再用Polkadot.js里的chain_getRuntimeVersion接口看看运行时版本号是否变化。如果版本号没变说明新代码根本没上链检查编译是否成功、节点进程是否真的重启了。这个经验在多人协作的团队尤其重要因为经常有人忘了重新编译就重启节点。6.3 权重计算的隐藏陷阱#[pallet::weight(...)]不是装饰品它决定交易费用和区块可容纳交易上限。很多人图省事直接写10_000这样的固定值但这在生产链上是危险的权重过低的pallet调用可能导致区块交易费用失真恶意用户可以用极低成本刷交易耗尽区块算力。最好的做法是用#[pallet::weight(dispatchable_weights::some_function)]做基准测试生成权重或者至少用#[pallet::weight(T::DbWeight::get().reads_writes(2,1))]这种基于存储读写次数的估算。在金融链上我们花了整整一天时间做全pallet的基准测试用pallet_benchmarking的宏生成权重文件。辛苦是辛苦但这直接关系到链的TPS数据和交易定价的合理性属于后端上线前的必经环节。6.4 一个让我失眠的Bug事件丢失之谜有一次上线后发现部分交易在链上执行成功了但前端怎么都收不到事件。一开始以为是前端订阅逻辑问题排查好久没结果。后来直查区块原文发现这些交易的事件确实没有出现在Events存储里。根因让我哭笑不得我在pallet中调用另一个pallet的方法时那个pallet内部使用Self::deposit_event但我在事件监听时只订阅了system.Events没注意到这个event的topics和模块路径问题。更准确说我在一个交易里调用了Balances的转账和我的pallet的业务逻辑Balances的事件正常发出但我的业务pallet的事件因为一个分支判断逻辑错误根本没有走到deposit_event那行。这不是Substrate的问题是我代码逻辑的问题。这个坑给我一个启示事件是否需要发出必须作为业务逻辑的一部分来对待而不是可有可无的点缀。在写pallet的每个可调用函数时我的习惯是先列出成功路径下应该有哪些事件、每个事件应该带哪些字段再开始写代码。7. 生产环境部署的几个深入细节7.1 区块链节点的数据目录与备份多数人开发时对数据目录不敏感但在生产部署中这是关键决策。Substrate节点的数据目录包含链数据默认在$HOME/.local/share/substrate包括块数据、状态数据库RocksDB、Wasm缓存等。备份共识关键的状态数据比如每个块的状态快照比备份节点本身重要得多——丢状态等于丢链。我们的做法是构建一个独立的归档节点archive node专门做全量数据备份和状态导出验证节点则跑裁剪模式pruning mode删掉历史状态减少磁盘占用。这样即使主节点崩了也能从归档节点快速同步恢复。7.2 节点身份与P2P网络生产环境里节点启动时一定要设置--name、--node-key固定的节点私钥否则每次重启都会生成新的P2P身份会导致其他节点把它当成一个新节点连接和信任关系都要重新建立。同时要做端口暴露规划——P2P端口30333必须开放RPC端口9944或9933建议只在内网开放或用TLS代理保护避免RPC接口被任意调用。我对联盟链部署有一个建议让每个成员的节点用--bootnodes指定固定的种子节点列表不要把整个P2P网络暴露在公网上而是每一个企业内部通过内部网络访问。这样做的边界清晰链的拓扑结构也受控。7.3 链上治理的真实落地场景前面提到治理集成最后补一个具体例子。我们链上有一个凭证模板参数哪些凭证类型允许发行、每种类型的手续费率是多少。这个参数一开始写死在了代码里每次变更都得走代码升级。后来我把参数挪到链上存储用治理pallet来控制修改权限只有治理提案通过才能改参数。这个改动让业务方真正自己管理链上参数不再每次都要找开发团队发版本。这也是Substrate最打动客户的一点——链不是一次性的它是可治理、可演进的基础设施。8. 给新手的最后几条建议本文快结尾了按惯例给想入门Substrate的朋友一些不是教科书会告诉你的经验第一不要把Polkadot平行链和Substrate框架混为一谈。Substrate是框架Polkadot是建立在Substrate之上的一个具体网络。你用Substrate完全可以做一条独立的链不需要跟Polkadot有任何关系。很多教程一开始就讲平行链插槽那是另一回事容易把人绕晕。第二Rust语言是你绕不开的门槛但千万别被吓到。Substrate的宏和泛型让初看代码的人觉得很吓人但这就像骑自行车——你需要的是大量模仿和练习。我推荐的学习路径先跑通substrate-node-template再写一个最简单的HelloWorld pallet然后逐步加存储、事件、调用。这个路径会比一开始就去啃construct_runtime!宏生成代码有效得多。第三一定要掌握读取链上状态的调试方法。用Polkadot.js的api.query接口能直接查存储能看历史区块。相比起打日志直接查链上状态往往更快定位问题。遇到异常先查存储、再查Event、最后才看日志——这是我个人总结的三段式排错法。第四对运维成本要有心理准备。Substrate的学习曲线不只是开发还包括部署运维。Wasm编译体积、跨节点同步、链数据备份、权重校准这些都不是前端业务能帮你兜底的。如果团队里没有一个人能扛起链运维这面旗建议先跑PoC再冲锋。最后说句心里话从那次供应链项目结项到后来带着团队做技术选型Substrate在我的工具箱里始终留着一个位置。它的设计理念——运行时可升级、模块化pallet、状态即真相——对业务链这类项目几乎是量身定做。但它的陡峭学习和大量抽象概念也意味着你需要在框架带来的长期灵活性和短期开发成本之间做好心理预期。好在一旦把pallet体系玩熟后面每一条新业务链的开发速度会越来越快这笔前期投资是值得的。
返回列表