Bitcoin Core 交易索引(txindex)新存储格式:磁盘占用减半的升级原理与重建指南
本文基于 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), ...);
从源码结构看,有三个值得注意的事实:
- 默认关闭。src/index/txindex.h 中
inline constexpr bool DEFAULT_TXINDEX{false};,即不开启时不会建立交易索引,getrawtransaction等依赖索引的 RPC 也不可用。 - 与剪枝互斥。src/init.cpp 中,
-prune与-txindex同时启用会直接报InitError("Prune mode is incompatible with -txindex."),因为按 txid 查历史交易必须随时能读回对应区块文件。 - 索引数据库路径固定。src/index/txindex.cpp 中:
即索引存放在static fs::path TxIndexDBPath() { return gArgs.GetDataDirNet() / "indexes" / "txindex"; }<datadir>/indexes/txindex(LevelDB 数据库)。这正是发布说明中要求手动删除的目录。 - 缓存分配。索引的 LevelDB 缓存占总数据库缓存的 10%,上限 1 GiB,见 src/node/caches.cpp:
注释解释了理由:txindex 主要服务index_sizes.tx_index = std::min(total_cache * 10 / 100, args.GetBoolArg("-txindex", DEFAULT_TXINDEX) ? MAX_TX_INDEX_CACHE : 0);getrawtransaction这类“全链范围、低重复”的点查,因此分配比例高于 filter 索引。
二、#35531 变更内容:重建后索引占用不足一半
发布说明的核心结论是:
交易索引(
-txindex)现在在磁盘上存储的数据更少;一个完全重建的索引占用空间不到原来的一半。索引保持向后兼容,因此现有用户除非重建索引,否则不会看到空间节省。
操作层面的完整说明如下(这是存量节点回收空间的标准流程):
- 停止节点;
- 删除
<datadir>/indexes/txindex目录(注意:只删这个目录,不要动blocks、chainstate等); - 重新启动节点,txindex 会从已有区块数据中重建。根据硬件不同,重建可能需要数小时;
- 用
getindexinfoRPC 监控进度。该 RPC 定义于 src/rpc/node.cpp,可查询全部索引或按名称过滤,例如:
返回结果中包含索引的当前同步高度与bitcoin-cli getindexinfo bitcoin-cli getindexinfo txindexbest_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.h 的LegacyTxKey; - 值:
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.cpp 的 ReadOrCreateTxidHasher):
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));
}
位置部分 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):
- 去重:若该区块哈希已有
'h'条目则直接返回。注释解释了动机——区块在重组重连或非干净关闭后重放时会再次提交,必须保持它原有的block_seq,避免产生重复条目; - 分配序号:从
next_block_seq读出当前序号,随后在同一个CDBBatch中原子写入'h'、's'映射并递增next_block_seq; - 逐笔交易写键:交易偏移从区块头(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);
六、读取路径:前缀定位 + 区块文件回放 + 前缀冲突校验
FindTx 是 getrawtransaction 等 RPC 的底层查询入口(src/index/txindex.cpp),流程为:
- 用同一 salt 计算查询 txid 的 5 字节前缀,构造
DBKey{prefix, {}}; - 迭代器
Seek到该前缀起点,遍历所有同前缀条目;对每条候选,用's'条目把block_seq解析为区块哈希,再确认该区块存在于区块索引且BLOCK_HAVE_DATA(有本地区块文件); - 打开区块文件,按
nFile+nDataPos + tx_offset_in_block定位,反序列化候选交易并比较完整 txid,以此过滤前缀冲突的“邻居”交易; - 候选优先级:活动链上的区块优先,其次按
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 返回的一定是活动链的区块哈希; - legacy 回退:若新格式没查到且库里存在旧格式条目,则回退到
FindLegacyTx(src/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.cpp、src/index/txindex_key.h,编码约束由 src/test/txindex_tests.cpp 固化。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00