Solana 状态压缩技术详解:账户数据 Merkle 化与并发写入的冲突解决方案

📅 2026/7/15 21:40:33
Solana 状态压缩技术详解:账户数据 Merkle 化与并发写入的冲突解决方案
Solana 状态压缩技术详解账户数据 Merkle 化与并发写入的冲突解决方案一、Solana 的状态爆炸问题当账户租金遭遇海量数据Solana 的账户模型和以太坊有本质差异。在以太坊上你可以在合约中用一个mapping存储任意数量的键值对成本随存储量线性增长。在 Solana 上每一个独立的状态单元都是一个账户而每个账户都需要支付租金rent来维持存在——如果余额不足以支付租金账户会被回收。这个模型在处理百万级用户数据时直接爆炸。以一个 NFT 项目为例10 万个持有者需要 10 万个 Token Account每个账户至少需要 0.00203928 SOL 的免租金余额。10 万个账户 约 204 SOL 的锁定资金——这只是为了维持数据存在。当项目方要做一个百万用户级的应用时租金成本变得不可接受。状态压缩State Compression是 Solana 针对这一问题的官方解决方案。核心思想是将大量账户数据通过 Merkle 树合并为一个压缩状态只在 Merkle 根上链实际数据存储在链下的 RPC 节点中。验证数据有效性时通过 Merkle Proof 即可证明某条数据存在于压缩状态中无需将所有数据都保存在链上账户里。flowchart TB subgraph 传统账户模型 A1[用户1 Accountbr/租金: 0.002 SOL] A2[用户2 Accountbr/租金: 0.002 SOL] A3[用户3 Accountbr/租金: 0.002 SOL] A4[用户N Accountbr/租金: 0.002 SOL] end subgraph 状态压缩模型 CONC[Merkle树根br/ConcurrentMerkleTree账户br/单账户租金] CONC -- L1[叶子1: 用户1数据] CONC -- L2[叶子2: 用户2数据] CONC -- L3[叶子3: 用户3数据] CONC -- L4[叶子N: 用户N数据] end TRAD_COST[传统模型成本: N × 0.002 SOLbr/10万用户 ≈ 204 SOL] -.- COMP_COST[压缩模型成本: 1 × 0.002 SOLbr/10万用户 ≈ 0.002 SOL] COMP_COST -.- TRADEOFF[代价: 写入时需要生成Merkle Proofbr/读取时需要RPC节点返回证明路径]这张图展示了状态压缩的核心思想用计算生成和验证 Merkle Proof换取存储减少链上账户数量。对于读多写少的场景如 NFT 元数据、用户积分余额这种权衡是极为划算的。二、Concurrent Merkle Tree 的并发写入机制2.1 传统 Merkle Tree 的并发瓶颈标准的 Merkle 树在并发写入时有一个根本问题多个写入者同时修改树节点会导致冲突。例如用户 A 修改叶子 L1用户 B 修改叶子 L2——虽然叶子不同但它们共享同一条从根到各自叶子的路径上的部分内部节点。当 A 提交改动时更新了这些内部节点B 的 Proof 就失效了因为 B 的 Proof 包含旧的内部节点哈希。传统解决方案是加锁互斥但这在去中心化系统中等于引入了一个中心化瓶颈——所有写入者必须排队等待上一个写入交易的确认。2.2 Solana 的 Concurrent Merkle Tree 解决方案Solana 的Concurrent Merkle TreeCMT通过一个巧妙的设计绕过了这个问题Change Log变更日志机制。CMT 维护一个环形缓冲区的变更日志记录每次叶子变更时被修改的所有节点的旧值和新值。当节点 B 的 Proof 因为节点 A 的并发写入而失效时B 可以扫描变更日志找到被 A 修改的那个内部节点的旧值——用它替换掉 B 的 Proof 中对应的旧值Proof 就重新变得有效了。这是一种乐观并发策略不阻止并发写入而是允许写入后快速修复 Proof 的失效问题。2.3 数据结构约束CMT 的关键参数maxDepth树的深度。深度 20 2^20 ≈ 100 万个叶子maxBufferSize叶子缓冲区的最大大小一批写入的最大叶子数canopyDepthcanopy 深度。将树的顶部 N 层节点预先存储在链上账户中减少链下 RPC 需要返回的 Proof 长度canopy 是一个关键的优化参数。没有 canopy 时验证一个深度 20 的 Proof 需要 20 个节点的哈希值约 640 字节。设置 canopyDepth10 后顶部 10 层节点直接在链上账户中Proof 只需要 10 个节点320 字节验证成本减半。三、代码实现CMT 的创建与数据读写/** * Solana 状态压缩数据读写 * * 设计决策 * - 使用 solana/spl-account-compression SDK 操作 CMT * - 读取时优先使用 RPC 节点的索引数据通过 getAsset/getAssetsByOwner * - 写入时生成 Merkle Proof 并附带 Change Log 处理逻辑 * - 并发写入通过 retry proof 修复机制处理冲突 */ import { ConcurrentMerkleTreeAccount, createAllocTreeIx, SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, ValidDepthSizePair, } from solana/spl-account-compression; import { Keypair, Connection, PublicKey, Transaction, sendAndConfirmTransaction, } from solana/web3.js; import { keccak256 } from js-sha256; // CMT 树创建 interface CMTCreateParams { maxDepth: number; // 树深度如 20 表示 2^20 个叶子 maxBufferSize: number; // 叶子缓冲区大小 canopyDepth?: number; // canopy 深度链上存储的顶层节点数 } class ConcurrentMerkleTreeManager { private connection: Connection; private treeAccount?: PublicKey; constructor(connection: Connection) { this.connection connection; } /** * 创建 CMT 账户并初始化树 * * 设计决策 * - maxDepth 根据预期用户数选择10万用户 → depth 17100万 → depth 20 * - canopyDepth 影响链上存储成本和验证成本 * 没有 canopyProof 大但链上租金低 * 有 canopyProof 小但链上租金更高需要存储更多节点 * 推荐 canopyDepth maxDepth - 8存储顶部 8 层约 256 个节点 */ async createTree( payer: Keypair, params: CMTCreateParams ): PromisePublicKey { const { maxDepth, maxBufferSize, canopyDepth 0 } params; // 验证深度对的有效性SDK 限制的有效组合 const depthPair: ValidDepthSizePair { maxDepth, maxBufferSize } as ValidDepthSizePair; // 生成树账户 Keypair const treeKeypair Keypair.generate(); this.treeAccount treeKeypair.publicKey; // 计算所需空间canopy 节点 树的内部节点 变更日志 // 公式实现由 SDK 的 getConcurrentMerkleTreeAccountSize 提供 const allocTreeIx createAllocTreeIx( treeKeypair.publicKey, payer.publicKey, depthPair, canopyDepth ); // 创建并发送交易 const tx new Transaction().add(allocTreeIx); const signature await sendAndConfirmTransaction( this.connection, tx, [payer, treeKeypair], { commitment: confirmed } ); console.log(Tree created: ${this.treeAccount.toBase58()}); console.log(Tx: ${signature}); return this.treeAccount; } /** * 加载已有的 CMT 账户 */ async loadTree(treeAddress: PublicKey): PromiseConcurrentMerkleTreeAccount { this.treeAccount treeAddress; const account await this.connection.getAccountInfo(treeAddress); if (!account) throw new Error(Tree account not found); return ConcurrentMerkleTreeAccount.fromBuffer(account.data); } } // 叶子数据的读写操作 interface CompressedLeafData { owner: PublicKey; amount: bigint; // 业务数据使用任意长度的字节数组 // 设计决策数据哈希存储于树的叶子节点 // 原始数据通过 RPC 的索引层或 IPFS 获取 data: Buffer; } class CompressedStateManager { private connection: Connection; private treeManager: ConcurrentMerkleTreeManager; constructor(connection: Connection) { this.connection connection; this.treeManager new ConcurrentMerkleTreeManager(connection); } /** * 哈希用户数据生成叶子值 * 设计决策使用 keccak256 而非 SHA256 * 因为 Solana 的 CMT 程序使用 keccak256 作为默认哈希 */ private hashLeaf(data: CompressedLeafData): Buffer { const serialized Buffer.concat([ data.owner.toBuffer(), Buffer.from(data.amount.toString(16).padStart(32, 0), hex), data.data ]); return Buffer.from(keccak256.arrayBuffer(serialized)); } /** * 将叶子数据追加到压缩状态 * * 设计决策 * - 使用 append 操作仅在 CMT 中有空叶子时可用 * - append 不需要替代已有叶子只需生成从空叶子到新值的 Proof * - 并发写入场景下使用 optimistic retry 策略 */ async appendLeaf( payer: Keypair, treeAddress: PublicKey, leafData: CompressedLeafData ): Promisestring { const leafHash this.hashLeaf(leafData); // 构造 append 指令 const appendIx this.buildAppendInstruction( treeAddress, payer.publicKey, leafHash ); const tx new Transaction().add(appendIx); // 带重试的发送处理并发冲突 return this.sendWithRetry(tx, payer, 3); } /** * 更新已有叶子Replace 操作 * * 设计决策 * - Replace 需要提供完整的 Merkle Proof叶子 → 根 * - 如果需要 canopy 但 Proof 中包含 canopy 节点验证会失败 * - 使用并发安全策略检查 Change Log 修复失效 Proof */ async updateLeaf( payer: Keypair, treeAddress: PublicKey, leafIndex: number, oldLeafHash: Buffer, newLeafData: CompressedLeafData ): Promisestring { const newLeafHash this.hashLeaf(newLeafData); // 1. 从 RPC 获取当前 Merkle Proof const proof await this.getMerkleProof(treeAddress, leafIndex); // 2. 检查 Change Log 并修复可能失效的 Proof const fixedProof await this.fixProofViaChangeLog( treeAddress, leafIndex, proof ); // 3. 构造 replace 指令 const replaceIx this.buildReplaceInstruction( treeAddress, payer.publicKey, leafIndex, oldLeafHash, newLeafHash, fixedProof ); const tx new Transaction().add(replaceIx); return this.sendWithRetry(tx, payer, 3); } /** * 从 RPC 获取指定叶子的 Merkle Proof * * 设计决策Proof 路径从叶子到根 * 每个 Proof 节点附带 sibling 的位置标记左/右 */ private async getMerkleProof( treeAddress: PublicKey, leafIndex: number ): PromiseBuffer[] { // 使用 Helius/DAS API 或 Triton RPC 的 getAssetProof 方法 const response await fetch( https://rpc.helius.xyz/?api-keyYOUR_KEY, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ jsonrpc: 2.0, id: cmt-proof, method: getAssetProof, params: { id: ${treeAddress.toBase58()}:${leafIndex} } }) } ); const data await response.json(); return (data.result?.proof ?? []).map( (p: string) Buffer.from(p, base64) ); } /** * 通过 Change Log 修复因并发写入而失效的 Proof * * 设计决策 * - 遍历 Change Log 中的变更记录 * - 如果变更记录中的节点在 Proof 路径上用旧值替换新值 * - 修复后的 Proof 对应变更发生前的树状态 * - 注意如果树的根已经改变需要获取变更后的 Proof 而非修复旧的 */ private async fixProofViaChangeLog( treeAddress: PublicKey, leafIndex: number, proof: Buffer[] ): PromiseBuffer[] { // 获取树的 Change Log const tree await this.treeManager.loadTree(treeAddress); const changeLog tree.getChangeLogs(); if (changeLog.length 0) return proof; // 计算 Proof 路径上每个节点的索引 const pathIndices this.computePathIndices(leafIndex, Math.log2(proof.length 1)); // 遍历 Change Log检查是否有影响 Proof 路径的变更 const fixedProof [...proof]; for (const log of changeLog) { for (let depth 0; depth pathIndices.length; depth) { const pathNodeIndex pathIndices[depth]; // Change Log 中的节点是否在 Proof 路径上 for (const change of log.changes) { if (change.index pathNodeIndex) { // 用旧值替换 Proof 中的对应节点 // 这样 Proof 就回到了变更发生前的有效状态 fixedProof[depth] change.oldValue; } } } } return fixedProof; } /** * 计算叶子的所有祖先节点索引 */ private computePathIndices(leafIndex: number, depth: number): number[] { const indices: number[] []; let current leafIndex; for (let d 0; d depth; d) { current Math.floor(current / 2); indices.push(current); } return indices; } /** * 带重试的事务发送处理并发冲突 * * 设计决策 * - 冲突检测捕获特定错误码如 Proof 失效 * - 最大重试 3 次每次重试前重新获取最新的 Proof * - 指数退避重试间隔 500ms × 2^retryCount */ private async sendWithRetry( tx: Transaction, payer: Keypair, maxRetries: number ): Promisestring { let lastError: Error | null null; for (let attempt 0; attempt maxRetries; attempt) { try { const signature await sendAndConfirmTransaction( this.connection, tx, [payer], { commitment: confirmed, skipPreflight: false // 开启预检查尽早发现 Proof 失效 } ); return signature; } catch (err: any) { lastError err; // 判断是否是冲突导致的失败 if (this.isConflictError(err)) { console.warn( Concurrency conflict at attempt ${attempt 1}, retrying... ); // 指数退避 await new Promise(resolve setTimeout(resolve, 500 * Math.pow(2, attempt)) ); continue; } // 非冲突错误直接抛出 throw err; } } throw new Error( Max retries (${maxRetries}) exceeded. Last error: ${lastError?.message} ); } private isConflictError(err: any): boolean { // Solana 事务冲突的错误码判定 return ( err?.message?.includes(0x1) || // Account in use err?.message?.includes(invalid proof) || err?.message?.includes(concurrent modification) ); } private buildAppendInstruction( treeAddress: PublicKey, authority: PublicKey, leafHash: Buffer ): any { // 实际实现使用 solana/spl-account-compression 的 createAppendIx // 此处简化为伪代码 throw new Error(Use createAppendIx from solana/spl-account-compression); } private buildReplaceInstruction( treeAddress: PublicKey, authority: PublicKey, leafIndex: number, oldLeafHash: Buffer, newLeafHash: Buffer, proof: Buffer[] ): any { throw new Error(Use createReplaceIx from solana/spl-account-compression); } } // 批量查询优化 /** * 批量读取压缩状态高效查询多个用户的资产 * * 设计决策 * - 使用 DAS APIDigital Asset Standard的 getAssetsByOwner 批量查询 * - 单次查询可获取一个地址的所有压缩资产 * - 相比逐个查询每个需要一次 RPC 调用批量查询减少网络开销 */ async function batchGetCompressedAssets( ownerAddress: PublicKey, connection: Connection ) { const response await fetch( https://rpc.helius.xyz/?api-keyYOUR_KEY, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ jsonrpc: 2.0, id: batch-query, method: getAssetsByOwner, params: { ownerAddress: ownerAddress.toBase58(), page: 1, limit: 1000 } }) } ); const data await response.json(); return (data.result?.items ?? []).map((asset: any) ({ id: asset.id, // 资产数据存储在 content 中 name: asset.content?.metadata?.name, symbol: asset.content?.metadata?.symbol, // 压缩相关的元数据 compression: asset.compression, // 所有权信息 ownership: asset.ownership, // 叶子在树中的位置 leafIndex: asset.compression?.leaf_id })); }四、边界分析CMT 的限制与退化场景限制一树的容量上限CMT 创建后其深度maxDepth是固定的不能动态扩展。如果项目初期估算 10 万用户depth17但实际增长到 200 万用户时需要部署一棵新树并迁移数据。迁移方案创建 depth21 的新树200 万叶子将旧树中所有有效叶子逐一 append 到新树更新应用层索引映射旧 leafIndex → 新 leafIndex。限制二Change Log 的有限窗口CMT 的 Change Log 是环形缓冲只会保留最近 N 条变更。如果某个用户的交易因网络延迟在队列中等待了比 Change Log 窗口更长的时间他的 Proof 可能无法通过 Change Log 修复。此时需要回退到重新获取完整 Proof的降级策略——直接向 RPC 请求最新的 Proof 路径。限制三Canopy 的维护成本canopy 节点存储在链上账户中每次根变更时都需要更新。具体的 Gas 成本取决于 canopy 深度——深度越大需要更新的节点越多。对于高频写入的应用如每分钟 1000 次叶子更新canopy 深度过大会导致每次写入的 Gas 成本显著上升。推荐在部署前用solana-test-validator做成本测试。限制四RPC 供应商的索引延迟压缩状态的读取依赖 RPC 节点的索引层通常是 DAS API 的底层索引。不同 RPC 供应商Helius、Triton、QuickNode的索引延迟不同——从叶子更新到可通过 API 查询延迟在 200ms 到 5s 之间波动。如果你的应用需要在写入后立即读取如 NFT 铸造后立即展示需要接受这个延迟或者在客户端本地维护一个pending 写入的乐观状态。限制五跨程序调用中的 Proof 传递如果你的 Solana 程序需要在 CPI跨程序调用中验证一个压缩状态的 ProofProof 数据必须作为指令参数传入。Proof 的大小 (maxDepth - canopyDepth) × 32 字节对于 depth20, canopyDepth10 的情况Proof 约为 320 字节。这比直接读取链上账户数据大得多会增加指令数据的序列化成本。五、总结Solana 状态压缩用一棵 Merkle 树 几 KB 的链上存储替代了数十万个独立账户。对于 NFT、积分、社交图谱等数据量巨大但单条数据价值低的场景这是目前最经济的链上存储方案。关键决策点CMT 深度选择预估 2-3 年内的用户量上限选择对应的深度。10 万 → depth 17100 万 → depth 20Canopy 深度权衡canopy 深度每增加 1Proof 减少 32 字节但链上更新成本增加。推荐 canopy depth - 8并发处理依赖 Change Log 的乐观并发 指数退避重试而非悲观锁RPC 选择使用支持 DAS API 的 RPC 供应商它们封裝了 CMT 的 Proof 管理和变更日志处理状态压缩不是银弹——它解决的是大批量低价值数据的存储成本问题而不是高频更新或强一致性的并发问题。选择合适的存储方案取决于你的数据到底是需要独立账户的资产还是可以合并到一棵树里的记录。