首页
/ Bitcoin Core 交易索引(txindex)新存储格式:磁盘占用减半的升级原理与重建指南

Bitcoin Core 交易索引(txindex)新存储格式:磁盘占用减半的升级原理与重建指南

2026-09-06 13:33:24作者:戚魁泉Nursing

本文基于 Bitcoin Core 发布说明 doc/release-notes-35531.md(对应 PR #35531)展开,讲清 -txindex 交易索引在磁盘存储格式上的新优化:为什么重建后的索引占用空间不到原来的一半、新旧格式如何共存与回退,以及存量节点如何通过删除 indexes/txindex 目录安全重建以回收磁盘空间,并借助 getindexinfo RPC 监控重建进度。

一、背景:-txindex 的作用、默认值与运行约束

-txindex 用于让节点维护一份“全交易索引”,使任意历史交易都能按 txid 被快速检索。该参数在 src/init.cpp 中注册:

argsman.AddArg("-txindex", strprintf("Maintain a full transaction index, used by the getrawtransaction rpc call (default: %u)", DEFAULT_TXINDEX), ...);

从源码结构看,有三个值得注意的事实:

  1. 默认关闭src/index/txindex.hinline constexpr bool DEFAULT_TXINDEX{false};,即不开启时不会建立交易索引,getrawtransaction 等依赖索引的 RPC 也不可用。
  2. 与剪枝互斥src/init.cpp 中,-prune-txindex 同时启用会直接报 InitError("Prune mode is incompatible with -txindex."),因为按 txid 查历史交易必须随时能读回对应区块文件。
  3. 索引数据库路径固定src/index/txindex.cpp 中:
    static fs::path TxIndexDBPath() { return gArgs.GetDataDirNet() / "indexes" / "txindex"; }
    
    即索引存放在 <datadir>/indexes/txindex(LevelDB 数据库)。这正是发布说明中要求手动删除的目录。
  4. 缓存分配。索引的 LevelDB 缓存占总数据库缓存的 10%,上限 1 GiB,见 src/node/caches.cpp
    index_sizes.tx_index = std::min(total_cache * 10 / 100, args.GetBoolArg("-txindex", DEFAULT_TXINDEX) ? MAX_TX_INDEX_CACHE : 0);
    
    注释解释了理由:txindex 主要服务 getrawtransaction 这类“全链范围、低重复”的点查,因此分配比例高于 filter 索引。

二、#35531 变更内容:重建后索引占用不足一半

发布说明的核心结论是:

交易索引(-txindex)现在在磁盘上存储的数据更少;一个完全重建的索引占用空间不到原来的一半。索引保持向后兼容,因此现有用户除非重建索引,否则不会看到空间节省。

操作层面的完整说明如下(这是存量节点回收空间的标准流程):

  1. 停止节点
  2. 删除 <datadir>/indexes/txindex 目录(注意:只删这个目录,不要动 blockschainstate 等);
  3. 重新启动节点,txindex 会从已有区块数据中重建。根据硬件不同,重建可能需要数小时;
  4. getindexinfo RPC 监控进度。该 RPC 定义于 src/rpc/node.cpp,可查询全部索引或按名称过滤,例如:
    bitcoin-cli getindexinfo
    bitcoin-cli getindexinfo txindex
    
    返回结果中包含索引的当前同步高度与 best_block_hash,据此可以判断重建是否追平。

兼容性要点同样重要:

  • 新格式索引向后兼容:旧版本仍可运行并继续写入旧格式条目;
  • 重建后的索引无法被更早版本读取,回退(downgrade)会迫使旧版本按老格式再重建一遍;
  • 因此若打算永久回退到旧版本,请先删除 <datadir>/indexes/txindex 目录,因为旧版本不会回收新格式条目占用的空间。

节点层面也提供了自动提示:当检测到数据库内混有旧格式(legacy)条目时,启动日志会输出(见 src/index/txindex.cpp):

txindex contains entries in the legacy format, which uses excessive disk space.
To reclaim disk space, stop the node, delete <datadir>/indexes/txindex and restart to rebuild the index.

这为运维人员提供了明确信号:看到这条日志,即可按上面流程重建。

三、旧格式为什么大:完整 txid 作为键 + CDiskTxPos 值

旧(legacy)格式下,每一笔交易在 LevelDB 中占一条记录:

  • 't' 前缀(0x74)+ 完整 32 字节 txid,共 33 字节,定义见 src/index/txindex_key.hLegacyTxKey
  • CDiskTxPos,结构为区块文件号 nFile(varint)+ 区块在文件中的位置 nPos(varint)+ 交易在区块内的偏移 nTxOffset(varint),定义见 src/index/disktxpos.h
struct CDiskTxPos : public FlatFilePos
{
    uint32_t nTxOffset{0}; // after header

    SERIALIZE_METHODS(CDiskTxPos, obj)
    {
        READWRITE(AsBase<FlatFilePos>(obj), VARINT(obj.nTxOffset));
    }
    ...
};

也就是说,每条交易记录 = 33 字节键 + 约 3~5 字节以上(三个 varint 各占 1~5 字节)的值。全链约 8 亿笔交易时,仅 txid 键就贡献约 26 GB 的键空间,这是旧索引体积大的主因。

四、新格式:SipHash 5 字节前缀键 + 位置编码进键内

#35531 引入的新数据库布局完整定义在 src/index/txindex_key.h 的注释中:

Database layout:

  ['x', hash prefix, block seq, tx offset] -> (empty)
  ['s', block seq]                         -> block hash
  ['h', block hash]                        -> block seq
  ["next_block_seq"]                       -> next block seq to assign
  ["txid_hash_salt"]                       -> txid hasher salt
  ["best_block_v2"]                        -> current sync locator
  ['t', txid]                              -> legacy CDiskTxPos
  ['B']                                    -> legacy sync locator

各条目的含义:

用途
'x' + 5 字节前缀 + 区块序号 + 3 字节偏移 每笔交易一条,位置直接编码在键内
's' + block_seq 区块哈希 由序号反查区块哈希
'h' + 区块哈希 block_seq 由哈希查序号(去重用)
next_block_seq 下一个可分配的序号 单调分配,区块重连后保持不变
txid_hash_salt 128 位随机盐 初始化时随机生成,用于 SipHash1-3 派生前缀
best_block_v2 同步定位点(locator) 记录索引已同步到的位置
't' + txid(legacy) CDiskTxPos 兼容旧数据

新交易键如何变小。前缀由对 txid 做 SipHash1-3 再取高 5 字节得到(盐持久化在 txid_hash_salt,见 src/index/txindex.cppReadOrCreateTxidHasher):

constexpr int HASH_PREFIX_SIZE{5};
using TxHashKeyPrefix = uint64_t;

inline TxHashKeyPrefix CreateKeyPrefix(const SipHasher13UJ& hasher, const Txid& txid)
{
    return hasher.Hash(txid.ToUint256()) >> (8 * (sizeof(TxHashKeyPrefix) - HASH_PREFIX_SIZE));
}

(见 src/index/txindex_key.h

位置部分 BlockTxPosition 由变长整数 block_seq + 3 字节大端 tx_offset_in_block 组成(见 src/index/txindex_key.h):

//! tx_offset is encoded in 3-byte big-endian integer.
//! This can hold up to 16,777,216, which is >4x the maximum 4 million block weight position
static constexpr uint32_t TX_OFFSET_SIZE{3};
static_assert(MAX_BLOCK_SERIALIZED_SIZE <= BigEndianFormatter<TX_OFFSET_SIZE>::MAX);

固定 3 字节编码偏移的原因很直接:区块最大序列化大小受 400 万权重上限约束,3 字节能表示 16,777,216,留有 4 倍以上余量;相比旧格式三个 varint,空间更稳定且省去变长开销。

体积对比:旧条目 = 33 字节键 + 约 3~5 字节值;新条目 = 1 + 5 + 1~5 + 3 ≈ 10~14 字节键 + 0 字节值(空值,EMPTY_VALUE)。每笔交易省下一半以上的字节,乘以全链交易数量,即得到“重建后不足一半空间”的效果。代价是前缀冲突:不同 txid 可能共享同一个 5 字节前缀(每 2^25 个前缀桶约 3 万笔交易),因此读路径必须做全量交易校验(见下节)。

五、写入路径:WriteTxs 如何落库

区块进入索引时调用 CustomAppend,创世块因输出不可花费而被排除(src/index/txindex.cpp):

bool TxIndex::CustomAppend(const interfaces::BlockInfo& block)
{
    // Exclude genesis block transaction because outputs are not spendable.
    if (block.height == 0) return true;
    assert(block.data);
    m_db->WriteTxs(block);
    return true;
}

WriteTxs 的完整逻辑(src/index/txindex.cpp):

  1. 去重:若该区块哈希已有 'h' 条目则直接返回。注释解释了动机——区块在重组重连或非干净关闭后重放时会再次提交,必须保持它原有的 block_seq,避免产生重复条目;
  2. 分配序号:从 next_block_seq 读出当前序号,随后在同一个 CDBBatch 中原子写入 'h''s' 映射并递增 next_block_seq
  3. 逐笔交易写键:交易偏移从区块头(80 字节)加交易计数 compact size 之后开始累加,每笔交易写一条 DBKey{前缀, {block_seq, tx_offset_in_block}} -> 空值,然后 tx_offset_in_block += tx->ComputeTotalSize()
uint32_t tx_offset_in_block{txindex::BLOCK_HEADER_SIZE + GetSizeOfCompactSize(block.data->vtx.size())};
for (const auto& tx : block.data->vtx) {
    const txindex::DBKey key{txindex::CreateKeyPrefix(m_hasher, tx->GetHash()),
                             txindex::BlockTxPosition{block_seq, tx_offset_in_block}};
    batch.Write(key, txindex::EMPTY_VALUE);
    tx_offset_in_block += tx->ComputeTotalSize();
}
WriteBatch(batch);

六、读取路径:前缀定位 + 区块文件回放 + 前缀冲突校验

FindTxgetrawtransaction 等 RPC 的底层查询入口(src/index/txindex.cpp),流程为:

  1. 用同一 salt 计算查询 txid 的 5 字节前缀,构造 DBKey{prefix, {}}
  2. 迭代器 Seek 到该前缀起点,遍历所有同前缀条目;对每条候选,用 's' 条目把 block_seq 解析为区块哈希,再确认该区块存在于区块索引且 BLOCK_HAVE_DATA(有本地区块文件);
  3. 打开区块文件,按 nFile + nDataPos + tx_offset_in_block 定位,反序列化候选交易并比较完整 txid,以此过滤前缀冲突的“邻居”交易;
  4. 候选优先级:活动链上的区块优先,其次按 block_seq 大的优先(即后连接的区块优先)。注释明确说明:“active chain candidates are attempted first, so duplicate entries in both active and stale blocks will always return the active block hash”——这保证了重组期间陈旧区块与活动区块各有一条记录时,RPC 返回的一定是活动链的区块哈希;
  5. legacy 回退:若新格式没查到且库里存在旧格式条目,则回退到 FindLegacyTxsrc/index/txindex.cpp),按完整 txid 键读取 CDiskTxPos 再定位。注释坦承这是“miss 多付一次查找的代价,以保证升级后旧条目仍可读”。

库内是否含 legacy 条目在打开数据库时就探测一次:src/index/txindex.cpp 通过 CDBWrapper::HasKeyStartingWith(TxIndexDBPath(), txindex::DB_TXINDEX) 扫描 't' 前缀,并只在存在旧条目时才启用 LevelDB 布隆过滤器——因为新格式的点查都走迭代器 Seek(绕过过滤器),过滤器只对 legacy 的逐交易点查有益,注释对这一点有完整说明。

七、重建、降级与兼容性的工程细节

把发布说明的操作流程落到行为层面:

  • 为什么必须删目录而不是 -reindex:重建索引依赖的是 <datadir>/indexes/txindex 本身被清空后按新格式重写。旧格式条目是“增量共存”的——升级后新交易写新格式,旧交易仍是 't' 键,磁盘占用并不下降。只有整体删除后从零重建,才能得到“不足一半”的空间收益。
  • 重建耗时:需重放全部历史区块,取决于硬件,文档给出“up to a few hours”的预期;期间节点正常同步,建议用 getindexinfo txindex 观察索引 synced 字段与 best_block_hash 是否追平链尖。
  • 前向兼容:重建后新格式索引对当前版本完全可用;但对更早版本而言该库不可读。若回退到旧版本运行,旧代码会按自己的逻辑重建旧格式索引(再次膨胀)。
  • 永久降级的注意事项:旧版本不会主动回收新格式条目占用的空间,所以若确定不再升级回去,应先在旧版本运行前删除 indexes/txindex,让旧版本干净地重建,否则磁盘上会长期保留一份无用的新格式数据。
  • 混合期提示:升级后未重建时,新旧条目并存,启动日志会提示“legacy format, which uses excessive disk space”并给出删除路径,作为人工介入的明确信号(src/index/txindex.cpp)。

八、单元测试对编码的“钉死”

新格式的二进制编码由单元测试中的向量显式固化(src/test/txindex_tests.cpp),任何序列化改动都会立即被这些测试捕获:

constexpr struct { txindex::BlockTxPosition position; std::string_view encoded; } test_vectors[]{
    {{0, 0}, "00000000"},
    {{1, 2}, "01000002"},
    {{10'000'000, 123}, "83e1ac0000007b"},
    {{456, 3'999'999}, "82483d08ff"},
};

BlockTxPosition{1,2} 编码为 01 000002(1 字节 varint 序号 + 3 字节大端偏移)正是上节编码的直观印证;同一测试文件还固定了各类型前缀键的编码(如 BlockSeqKey{1} 编码为 7301,即前缀 's'=0x73),并覆盖重组、legacy 迁移与前缀冲突等场景。这说明磁盘格式是有意保持稳定的:它决定了索引能否跨版本兼容,因此用向量测试把每一字节都“钉死”。

小结

  • -txindex 默认关闭、与 -prune 互斥,索引位于 <datadir>/indexes/txindex,LevelDB 缓存占总 db 缓存的 10%(上限 1 GiB)。
  • #35531 把每笔交易的 LevelDB 记录从“32 字节 txid 键 + CDiskTxPos 值”压缩为“SipHash 5 字节前缀键 + 位置编码进键 + 空值”,重建后全索引占用不足一半。
  • 新格式向后兼容但旧版本读不了重建后的库:想回收空间就删目录重建并用 getindexinfo 跟踪进度;想永久降级就先删目录再回退。
  • 读路径通过“前缀 Seek + 区块文件回放 + 全 txid 校验”处理前缀冲突,并按活动链、后连接优先的次序选择候选,重组期间的正确性由该排序保证。
  • 关键实现见 src/index/txindex.cppsrc/index/txindex_key.h,编码约束由 src/test/txindex_tests.cpp 固化。
登录后查看全文
热门项目推荐
相关项目推荐