一、区块链数据为什么越跑越重
很多朋友第一次接触 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。业务方说历史交易可以查,但三个月前的交易基本没人看,希望你能优化存储。你决定做三件事:
- 归档三个月(约 90 天)前的区块文件。
- 压缩 CouchDB 数据库并清理视图索引。
- 执行链码清理,把已经完成订单的临时状态删掉。
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、清理无用视图。两者配合起来,才能让联盟链节点在运行几年后依然保持轻盈。
最后提醒一句:不要为了省存储而走极端。区块链本身的“历史不可篡改”特性是有价值的,清得太多可能会把有价值的审计线索弄丢。建议先和业务方确认数据保留周期,再制定清理策略。存储优化是一个平衡艺术,懂得保留什么,也知道丢弃什么,才是真正的高手。
评论
围绕“Hyperledger Fabric存储优化:联盟链节点数据存储的压缩与清理方案设计与实践”参与讨论