一、区块链数据为什么越跑越重

很多朋友第一次接触 Hyperledger Fabric 时都会被它“联盟链”的定位吸引,觉得它比公链更适合企业场景。但用一段时间后大家都会遇到同一个烦恼:节点磁盘占用越来越吓人,从几个GB一路涨到几百GB,再这么下去服务器就要报警了。

为什么会这样?我们可以把 Fabric 的账本想象成一本流水账本。每来一笔交易,负责记账的节点就要在本子上新写一页。这个本子平时不能撕页,也不能涂改,所有写过的内容都得留着。与此同时,为了让查询更快,系统还会另外做一份“最新目录”,也就是状态数据库,它记录每个业务对象当前的值。所以,你的磁盘其实同时承担了两份存储:一份是完整的交易历史(区块文件),一份是当前状态(状态库)。前一套数据只增不减,后一套数据虽然可以更新,但频繁更新也会让数据库内部产生大量碎片和旧版本数据,体积照样越来越大。

打个比方:你买了一堆纸质发票,每张都不能扔;同时你还要用电脑表格记录每张发票的最新报销状态,表格不断修改保存,文件也越来越大。这就是 Fabric 存储膨胀的本质。

二、存储膨胀带来的实际麻烦

存储变大最直接的影响就是钱包遭殃。云服务器磁盘按容量收费,1TB 和 2TB 的价格差不少。为了省成本,你可能要研究各种压缩方案。

除了钱的问题,性能也会下滑。节点启动时要加载状态数据库,账本文件过多还会让 peer 的启动时间变长,甚至出现“长时间卡住”的情况。备份也变得非常痛苦,本来每天备份一次数据,现在因为数据量太大,备份可能要跑好几个小时,还容易把磁盘带宽占满。更麻烦的是,如果某个节点需要重新同步账本,从其他节点拉取历史区块时,数据传输量巨大,网络再一抖,同步就失败重来。

所以说,存储优化不是可做可不做的加分项,而是联盟链长期运行后必须面对的日常课题。

三、压缩与清理的整体思路

既然存储膨胀来自数据太多,那思路自然就是两条:压缩和清理。

压缩是“让单个数据变小”。比如 CouchDB 支持数据库压缩,可以回收文件内部的空闲空间;再比如我们可以把链码里保存的大段文字换成哈希值,体积一下子从几 KB 变成几十字节。

清理是“把不需要的数据扔掉”。比如把几个月前的区块文件归档到冷存储,或者从状态数据库里删除那些业务上已经失效的键值对。需要注意的是,Fabric 的账本设计得非常严谨,并不是随便删个文件就能完事。你必须在保证节点数据一致性的前提下,找到安全的时机,用合适的工具去操作。

所以,一套完整的存储优化方案,通常包含三部分:第一,优化链码的数据设计,从源头少存;第二,配置好数据库的压缩机制;第三,定期执行清理任务,把老数据搬走。

四、针对区块文件的压缩与清理

4.1 区块文件是什么

Fabric 每生成一个区块,peer 节点就会把区块追加写入本地的文件里。这些文件叫 blockfile,默认放在节点容器中的 /var/hyperledger/production/ledgersData/chains/chains/<通道名>/ 目录下。文件命名类似 blockfile_000000、blockfile_000001。随着交易增多,文件数量会越来越多,单个文件也会越来越大。

区块文件是账本的根本,所有交易的历史都在里面。如果某天你需要查一笔交易的历史,或者给新加入的节点做同步,都离不开这些文件。所以“清理”并不是直接删除,而是“归档”,把旧文件挪到更便宜的地方,需要的时候还能找回来。

4.2 先看看状态数据库是怎么配的

动手优化之前,你要先搞清楚自己的节点用的是哪种状态数据库。Fabric 支持两种:Goleveldb(也叫 LevelDB)和 CouchDB。LevelDB 轻量,CouchDB 支持富查询,但 CouchDB 是独立的数据库服务,文件膨胀问题更明显。

在节点的 core.yaml 里,有类似下面的配置(这里用 JSON 格式示意):

{
  "ledger": {
    "state": {
      "stateDatabase": "CouchDB",
      "couchDBConfig": {
        "couchDBAddress": "couchdb:5984",
        "username": "admin",
        "password": "adminpwd"
      }
    }
  }
}

如果你的配置里写的是 goleveldb,那么状态库就是 LevelDB;如果写的是 CouchDB,那么你就得另外关注 CouchDB 的数据目录和压缩情况。

4.3 区块文件能直接删吗

不能。网上有些文章说删掉 blockfile 可以减负,但直接删会让节点无法响应历史查询,甚至导致 peer 启动报错。因为 peer 启动时会做账本校验,发现文件缺失就会拒绝启动。另外,如果这个节点需要给其他节点提供区块同步源,删了文件基本等于断了粮。

那怎么清理?业界比较稳妥的做法是:把旧区块文件复制到独立备份目录,然后把原文件从运行目录里移走,再建立一个软链接。这样 peer 以为文件还在,但实际上数据已经被搬到了另一个磁盘分区或低成本存储上。需要注意,这个操作必须在节点停止或至少没有新交易写入的维护窗口里做,防止文件被写一半。

4.4 用 Node.js 脚本实现旧区块文件归档

为了让大家更好地理解,我用 Node.js 写一个归档脚本。这段脚本会扫描账本目录,把所有超过 30 天没有修改的 blockfile 文件复制到备份目录,删除原文件,然后创建一个软链接指向备份文件。

// 技术栈:Node.js
const fs = require('fs');
const path = require('path');

// 配置需要归档的区块文件目录
const ledgerDir = '/var/hyperledger/production/ledgersData/chains/chains/mychannel';
// 备份目录,建议放在另一个挂载盘上
const backupDir = '/mnt/cold-storage/blockfile-backup';
// 超过多少天的文件就归档
const maxAgeDays = 30;

// 创建备份目录(如果不存在)
fs.mkdirSync(backupDir, { recursive: true });

// 读取所有 blockfile 文件
const files = fs.readdirSync(ledgerDir)
  .filter((f) => f.startsWith('blockfile_'))
  .sort();

const now = Date.now();

for (const file of files) {
  const fullPath = path.join(ledgerDir, file);
  const stat = fs.statSync(fullPath);

  // 将文件的最后修改时间转换成“天数”
  const ageMs = now - stat.mtimeMs;
  const ageDays = ageMs / (1000 * 60 * 60 * 24);

  // 只处理超过保留期限的文件
  if (ageDays > maxAgeDays) {
    const backupPath = path.join(backupDir, file);
    console.log(`归档 ${file} -> ${backupPath}`);

    // 第一步:复制文件到备份目录
    fs.copyFileSync(fullPath, backupPath);

    // 第二步:删除原文件
    fs.unlinkSync(fullPath);

    // 第三步:创建软链接,让 peer 仍然能找到“原路径”
    fs.symlinkSync(backupPath, fullPath);

    console.log(`已完成:${file}`);
  }
}

console.log('归档任务结束,请检查日志');

这个脚本很简单,但很实用。需要注意,peer 在运行过程中可能会持有文件描述符,如果你在 peer 运行时就删了原文件,即使有软链接,某些版本也可能报“文件不存在”。所以最安全的方式是:先停掉 peer,再执行脚本,再启动 peer。

五、状态数据库的压缩与清理

5.1 状态库膨胀的原因

状态数据库保存的是通道里所有业务键的最新值,类似于数据库的“主表”。当链码频繁调用 putState 更新同一个键时,CouchDB 内部会保留多个版本的数据,再加上为富查询建立的索引,数据库文件就会不断膨胀。LevelDB 也有类似机制,它会做 compaction(压缩),但压缩后的文件也不一定马上变小,因为旧数据可能还被快照引用。

生活化的理解:你每天都在手机上修改同一篇文档并保存,系统会不断生成临时文件。如果 App 不清理,这些临时文件会让你的手机存储越来越满。

5.2 CouchDB 压缩

CouchDB 提供了一个压缩接口,可以对指定数据库进行压缩,将文件末尾的空闲空间回收掉。我们可以用 Node.js 的 https 模块直接调用它,这样就不需要额外安装命令行工具。

// 技术栈:Node.js
const https = require('https');

// CouchDB 连接信息
const couchHost = 'couchdb.example.com';
const couchPort = 6984; // 如果使用 HTTPS,通常为 6984
const dbName = 'mychannel';
const username = 'admin';
const password = 'secret';

// 构造 POST /{db}/_compact 请求,触发压缩
const options = {
  hostname: couchHost,
  port: couchPort,
  path: `/${dbName}/_compact`,
  method: 'POST',
  auth: `${username}:${password}`,
  rejectUnauthorized: false,
  headers: {
    'Content-Type': 'application/json'
  }
};

const req = https.request(options, (res) => {
  console.log(`压缩请求已发送,HTTP 状态码: ${res.statusCode}`);
  res.setEncoding('utf8');
  res.on('data', () => {});
  res.on('end', () => {
    console.log('压缩请求完成');
  });
});

req.on('error', (err) => {
  console.error('请求失败:', err.message);
});
req.end();

压缩操作是异步的,CouchDB 会在后台慢慢执行。如果你想确认压缩状态,可以再发一个 GET /{db}/_compact 来查看。另外,视图索引也会占用不少空间,可以调用 _view_cleanup 清理不再使用的索引文件:

// 技术栈:Node.js
const https = require('https');

const dbName = 'mychannel';
const options = {
  hostname: couchHost,
  port: couchPort,
  path: `/${dbName}/_view_cleanup`,
  method: 'POST',
  auth: `${username}:${password}`,
  rejectUnauthorized: false
};

const req = https.request(options, (res) => {
  console.log('视图清理状态码:', res.statusCode);
  res.resume();
});
req.end();

执行完这两个接口后,CouchDB 的数据文件会明显变小。建议把这步操作加入定期维护计划,比如每周执行一次。

5.3 LevelDB 压缩

LevelDB 本身自带压缩机制,它会自动将多层级的 SST 文件合并。但问题是,LevelDB 的压缩只回收“被标记删除”的老数据,如果业务只是更新键值,没有删除操作,文件还是会只增不减。社区里有些人会建议直接删除 stateleveldb 目录,让 peer 从区块文件重新生成状态库。这个方法听起来很“干净”,但实际非常危险:如果区块文件也不完整,直接重建失败;而且重建过程可能耗时几个小时,期间节点无法服务。所以我不推荐在生产环境使用这种方法。

如果你真的想用 LevelDB 做清理,唯一的思路是:把状态库导出来,重新初始化一个干净的环境,再把数据导入。但这个过程和链码业务绑得很紧,没有通用工具。所以更明智的做法是,从一开始就选择 CouchDB,并做好定期的 CouchDB 压缩。

六、通过链码设计从源头减少存储

6.1 大文件不上链,只存哈希和 URL

很多时候,我们并不是必须把整个文件存到链上。比如要存一份产品质检报告,PDF 可能有 10MB,如果直接把二进制内容写进状态库,存储压力可想而知。更好的做法是:把文件放到对象存储或 IPFS 上,只把文件的哈希值和访问地址记录在区块链里。这样链上数据量非常小,查询也快,而且哈希值可以校验文件有没有被篡改。

下面是一个 Node.js 写的链码示例,演示如何保存文件信息。

// 技术栈:Node.js (fabric-contract-api)
'use strict';
const { Contract } = require('fabric-contract-api');

class FileInfoContract extends Contract {
  
  // 保存文件信息:只记录哈希、URL 和大小
  async saveFileInfo(ctx, fileId, hash, url, size) {
    // 组装一个轻量级信息对象
    const fileInfo = {
      fileId: fileId,
      hash: hash,            // 文件的 SHA-256 哈希,用于防篡改
      url: url,              // 文件实际存放的地址
      size: size,            // 文件大小,单位字节
      updatedAt: new Date().toISOString()
    };

    // 写入世界状态
    await ctx.stub.putState(fileId, Buffer.from(JSON.stringify(fileInfo)));

    // 返回可读的 JSON 字符串
    return JSON.stringify(fileInfo);
  }

  // 查询文件信息
  async readFileInfo(ctx, fileId) {
    const data = await ctx.stub.getState(fileId);
    if (!data || data.length === 0) {
      throw new Error(`文件 ${fileId} 不存在`);
    }
    return data.toString();
  }
}

module.exports = FileInfoContract;

这个链码让每个键值对从“几 MB”变成“几百字节”,效果立竿见影。而且因为 URL 是普通字符串,查询时也不会慢。

6.2 使用私有数据减少账本存储

Hyperledger Fabric 有个很棒的特性叫“私有数据”。它允许部分组织直接共享敏感数据,而不必把数据写入公共账本。私有数据实际上存储在节点侧边的私有数据库里,链上只保留一个哈希值,用来做防篡改校验。这样,公众(其他组织)看不到原始数据,存储也大大减少。

在链码中,你可以通过 ctx.stub.putPrivateData(collection, key, value) 写入私有数据。集合要在通道配置里提前定义。下面是一个集合配置文件的示例:

{
  "name": "privateDetails",
  "policy": "OR('Org1MSP.member', 'Org2MSP.member')",
  "requiredPeerCount": 2,
  "maxPeerCount": 3,
  "blockToLive": 0,
  "memberOnlyRead": true
}

blockToLive 可以设置私有数据存活多少个区块,超过后自动清理,这个参数就是专门用来控制私有数据存储增长的。比如设为 100,那么私有数据在链上保留 100 个区块后就会被清除,只保留哈希。这个特性非常适合“临时凭证”之类的场景。

6.3 定期删除不必要的历史状态

链码里同样可以删除状态。比如订单完成三个月后,中间计算用的缓存数据就不需要再放在状态库里了。你可以写一个“清理历史状态”的链码函数,调用 deleteState 删除键。

// 技术栈:Node.js (fabric-contract-api)
'use strict';
const { Contract } = require('fabric-contract-api');

class CleanupContract extends Contract {
  
  // 删除指定键的状态
  async deleteState(ctx, key) {
    const existed = await ctx.stub.getState(key);
    if (!existed || existed.length === 0) {
      return '状态不存在,无需删除';
    }

    // 调用 deleteState 移除键值
    await ctx.stub.deleteState(key);

    return `键 ${key} 已删除`;
  }
}

module.exports = CleanupContract;

注意,删除状态只会影响状态数据库里的当前值,区块链历史区块中依然保留着这条数据被创建、更新、删除的记录。所以“删除”是为了让查询不再返回旧值,也为了释放状态库的容量,并不影响账本本身的完整性。换句话说,你修改了未来,但历史依然客观存在。

七、实操案例:一个完整的清理流程

7.1 场景描述

假设你是某个联盟链的运维负责人,节点已经运行了一年,磁盘用了 800GB。业务方说历史交易可以查,但三个月前的交易基本没人看,希望你能优化存储。你决定做三件事:

  1. 归档三个月(约 90 天)前的区块文件。
  2. 压缩 CouchDB 数据库并清理视图索引。
  3. 执行链码清理,把已经完成订单的临时状态删掉。

7.2 用 Node.js 脚本整合清理流程

为了方便,我把归档和压缩整合到一个 Node.js 脚本里。先停掉 peer,在大维护窗口里运行脚本,然后再启动 peer。

// 技术栈:Node.js
const fs = require('fs');
const path = require('path');
const https = require('https');

// ====== 配置 ======
const config = {
  ledgerDir: '/var/hyperledger/production/ledgersData/chains/chains/mychannel',
  backupDir: '/mnt/cold-storage/blockfile-backup',
  maxAgeDays: 90,
  couchHost: 'couchdb.example.com',
  couchPort: 6984,
  couchUser: 'admin',
  couchPass: 'secret',
  dbName: 'mychannel'
};

// ====== 1. 归档旧区块文件 ======
function archiveBlockFiles() {
  console.log('开始归档旧区块文件...');
  fs.mkdirSync(config.backupDir, { recursive: true });

  const files = fs.readdirSync(config.ledgerDir)
    .filter((f) => f.startsWith('blockfile_'));

  const now = Date.now();
  let archived = 0;

  for (const file of files) {
    const fullPath = path.join(config.ledgerDir, file);
    const stat = fs.statSync(fullPath);
    const ageDays = (now - stat.mtimeMs) / (1000 * 60 * 60 * 24);

    if (ageDays > config.maxAgeDays) {
      const backupPath = path.join(config.backupDir, file);
      fs.copyFileSync(fullPath, backupPath);
      fs.unlinkSync(fullPath);
      fs.symlinkSync(backupPath, fullPath);
      console.log(`已归档:${file}`);
      archived++;
    }
  }
  console.log(`归档完成,共处理 ${archived} 个文件。`);
}

// ====== 2. 压缩 CouchDB ======
function compactCouchDB() {
  return new Promise((resolve, reject) => {
    console.log('开始压缩 CouchDB...');

    const options = {
      hostname: config.couchHost,
      port: config.couchPort,
      path: `/${config.dbName}/_compact`,
      method: 'POST',
      auth: `${config.couchUser}:${config.couchPass}`,
      rejectUnauthorized: false
    };

    const req = https.request(options, (res) => {
      console.log('压缩接口状态码:', res.statusCode);
      res.resume();
      resolve();
    });

    req.on('error', reject);
    req.end();
  });
}

// ====== 3. 清理 CouchDB 视图 ======
function viewCleanup() {
  return new Promise((resolve, reject) => {
    console.log('开始清理视图索引...');

    const options = {
      hostname: config.couchHost,
      port: config.couchPort,
      path: `/${config.dbName}/_view_cleanup`,
      method: 'POST',
      auth: `${config.couchUser}:${config.couchPass}`,
      rejectUnauthorized: false
    };

    const req = https.request(options, (res) => {
      console.log('视图清理状态码:', res.statusCode);
      res.resume();
      resolve();
    });

    req.on('error', reject);
    req.end();
  });
}

// ====== 主流程 ======
async function main() {
  try {
    await archiveBlockFiles();
    await compactCouchDB();
    await viewCleanup();
    console.log('存储清理全部完成!请启动 peer 节点。');
  } catch (err) {
    console.error('清理失败:', err.message);
    process.exit(1);
  }
}

// 执行
main();

7.3 验证结果

运行完脚本后,可以先用 df -h 查看磁盘占用,再用 du -sh 分别统计账本目录和 CouchDB 数据目录。正常情况下,你会看到账本目录因为软链接多出来一些“小文件”,但实际占用变成了备份目录那边的数据;CouchDB 的 shards 目录明显变小。此外,如果链码执行了删除,状态数据库里的“数据量”也会下降,可以通过链码查询确认对应键已经不存在。

八、应用场景与优缺点

8.1 适用场景

这种压缩与清理方案最适合下面这几类场景:

  • 业务上只需要保存最近一段时间的完整交易记录,更早的仅保留摘要或哈希。
  • 联盟链运行时间较长,磁盘成本成为运营大头。
  • 状态库中存在大量临时数据,比如订单中间态、一次性凭证、批量计算缓存。
  • 对历史查询要求非常低,甚至从不查询三个月前的数据。

8.2 优点

  • 磁盘占用大幅下降,节约硬件成本。
  • 节点启动速度提升,因为状态库扫描的文件更少。
  • 备份耗时缩短,降低运维压力。
  • 查询性能更稳定,不会因为文件碎片过多而变慢。

8.3 缺点与代价

  • 需要额外的归档存储,虽然便宜,但不是零成本。
  • 操作过程有风险,一旦脚本有 Bug,可能影响节点启动。
  • 如果业务真的需要查很久以前的原始交易,清理后就要去冷存储恢复,过程会比较慢。
  • 需要各组织和运维人员提前约定好清理策略,避免某些节点没清理、某些节点清理了,导致数据不一致的误解。

九、注意事项

第一,不要在节点运行的时候直接删除 blockfile。即使建立了软链接,也可能遇到文件锁问题。最好停掉 peer,再执行归档,最后启动。

第二,归档文件必须放到一个长期可靠的位置。你可以用云上的对象存储,也可以用另一个磁盘阵列。别把备份和原文件放在同一个挂载盘,否则磁盘满了大家一起遭殃。

第三,CouchDB 压缩不是实时的,接口返回后可能还要几个小时才能完成。你别急着看结果,可以第二天再观察。

第四,私有数据和公共账本不一样。私有数据的清理由 blockToLive 或者链码逻辑控制,一定要仔细阅读链码文档,确认数据不再需要后才删除。

第五,所有操作都要先在测试环境演练。Fabric 的版本差异会影响路径和接口,比如 CouchDB 的端口可能是 5984 或 6984,HTTPS 或 HTTP,一定要根据实际环境调整脚本。

第六,软链接可能会在 peer 重启后失效,因为 peer 进程可能用 filepath.Walk 等逻辑遍历目录。如果你发现重启后 peer 找不到区块文件,那就需要检查软链接的权限和路径是否仍然正确。

十、总结

Hyperledger Fabric 的存储优化不是一次性工作,而是一套需要长期维护的机制。我们既要关注链码层面,避免把大文件存进状态库,也要关注运维层面,定期归档旧区块、压缩 CouchDB、清理无用视图。两者配合起来,才能让联盟链节点在运行几年后依然保持轻盈。

最后提醒一句:不要为了省存储而走极端。区块链本身的“历史不可篡改”特性是有价值的,清得太多可能会把有价值的审计线索弄丢。建议先和业务方确认数据保留周期,再制定清理策略。存储优化是一个平衡艺术,懂得保留什么,也知道丢弃什么,才是真正的高手。