zstd Seekable 格式:可随机访问压缩档案的字节级布局与源码实现解析
【免费下载链接】zstdZstandard - Fast real-time compression algorithm项目地址: https://gitcode.com/gh_mirrors/zs/zstd
zstd 的 seekable format 允许将压缩数据切分为若干相互独立的 frame,并在文件尾部追加一张“seek table”,使解码器能够直接跳转到目标区段,而无需解压整个档案。本篇以仓库中的格式规范 zstd_seekable_compression_format.md 为主体,逐字段讲解该格式的二进制布局、位域约定与校验规则,并结合 contrib/seekable_format 目录下的参考实现(zstdseek_compress.c、zstdseek_decompress.c)与示例程序,说明压缩、随机读、并行构建等实际使用方式。读完本文,你可以完整理解 seek table 帧的每个字段含义,并会用ZSTD_seekable_*API 构建和随机读取 seekable 档案。
一、什么是 Seekable Format:设计动机与整体思路
标准 zstd 流中,后续 block 的解码通常依赖前文上下文(match 可能跨越文件前部)。因此若要获取档案中间一小段数据,往往必须从头解压。Seekable 格式通过两个机制解决这一问题:
- 数据切帧:输入数据被切分为多个 frame,每个 frame 独立压缩、独立解码,因此可以只解压目标 frame;
- seek table(跳转表):在文件末尾附加一张表,记录每个 frame 的压缩后大小、解压后大小(及可选校验和),解码器据此 O(log N) 定位目标 frame。
从 README 的描述看,帧是顺序追加的,因此整个负载按顺序解压后仍能还原原始内容;而 seek table 存放在一个Zstandard skippable frame(可跳过帧)中,对不认识该格式的标准 zstd 解码器而言会被直接跳过、完全不影响解码,这正是该格式“向后兼容”的关键。
一个完整的 seekable 文件结构为:
[ frame 0 ] [ frame 1 ] ... [ frame N-1 ] [ seek table skippable frame ]其中前 N 个是普通 zstd 压缩帧(也允许 skippable/空帧,其Decompressed_Size记 0),最后一个 skippable 帧内容即 seek table。
二、通用约定
格式规范开头给出了三条书写约定,阅读下文所有字段时应注意:
- 方括号
[...]表示该字段是可选的(例如[Checksum]、[Seek_Table_Entries]); - 标识符命名约定为
Mixed_Case_With_Underscores; - 所有数值字段默认小端序(little-endian),除非另有说明。
这些约定在参考实现中同样成立:写入侧使用MEM_writeLE32写 32 位字段(见 zstdseek_compress.c 中的ZSTD_stwrite32),读取侧使用MEM_readLE32解析(见 zstdseek_decompress.c)。
三、Seek Table 帧的完整布局
Seek table 本身是一个标准 zstd skippable frame,其内部结构为:
Skippable_Magic_Number | Frame_Size | [Seek_Table_Entries] | Seek_Table_Footer |
|---|---|---|---|
| 4 字节 | 4 字节 | 每条 8~12 字节 | 9 字节 |
3.1 Skippable_Magic_Number
- 值:
0x184D2A5E,用于兼容 zstd 可跳过帧规范。 - 规范明确指出:由于其他 zstd skippable 帧合法地可以使用同一个 magic number,不建议解码器仅凭该 magic 就认定这是一个 seek table 帧——必须结合帧末尾的
Seekable_Magic_Number来确认。 - 从源码看,写入侧生成的 magic 是
ZSTD_MAGIC_SKIPPABLE_START | 0xE(即0x184D2A5E),见 zstdseek_compress.c;读取侧则同时校验文件头处的 skippable magic 与末尾的 seekable magic,见 zstdseek_decompress.c。
3.2 Frame_Size
该 skippable 帧的总大小,不含Skippable_Magic_Number与Frame_Size字段本身,同样用于兼容 zstd skippable frame 规范。对应实现中Frame_Size = seekTableLen - ZSTD_SKIPPABLEHEADERSIZE,即“表条目 + 9 字节 footer”的总长(zstdseek_compress.c)。
3.3 Seek_Table_Footer(9 字节)
footer 位于 seek table 帧末尾,也是整个文件的最后 9 字节:
Number_Of_Frames | Seek_Table_Descriptor | Seekable_Magic_Number |
|---|---|---|
| 4 字节 | 1 字节 | 4 字节 |
Seekable_Magic_Number:值为0x8F92EAB1(即 zstd_seekable.h 中的ZSTD_SEEKABLE_MAGICNUMBER)。规范要求它必须是压缩文件最后存在的字节序列,这样解码器可以一次seek到文件末尾读 9 字节,低成本地判断文件是否带有 seek table。参考实现的ZSTD_seekable_loadSeekTable正是先SEEK_END回退 9 字节读取 footer,并检查偏移 5 处是否为该 magic,不匹配则返回prefix_unknown错误(zstdseek_decompress.c)。
Number_Of_Frames:数据中包含的 frame 数量(不包括 seek table 帧自身)。
Seek_Table_Descriptor:一个描述表格式的位域(bitfield):
| 位号 | 字段名 |
|---|---|
| 7 | Checksum_Flag |
| 6–2 | Reserved_Bits |
| 1–0 | Unused_Bits |
Checksum_Flag(bit 7):置位时表示每条Seek_Table_Entry额外包含 4 字节校验和(条目长度从 8 字节变为 12 字节);Reserved_Bits(bit 6–2):当前未使用,但保留给未来的破坏性变更(例如引入内嵌字典)。合规的解码器应当校验这些位必须为 0,否则报损坏。这一点在读取实现中被严格执行:if ((sfd >> 2) & 0x1f) return ERROR(corruption_detected);(zstdseek_decompress.c);Unused_Bits(bit 1–0):留给未来的非破坏性变更,解码器不应解释这些位。
footer 中另有一个 9 字节常量ZSTD_seekTableFooterSize,在 zstd_seekable.h 中定义,与规范的 4+1+4=9 完全一致。
3.4 Seek_Table_Entries(每条 8 或 12 字节)
条目共有Number_Of_Frames个,按 frame 顺序(0 到 N-1)排列,不含 seek table 帧本身。每条格式为:
Compressed_Size | Decompressed_Size | [Checksum] |
|---|---|---|
| 4 字节 | 4 字节 | 4 字节 |
Compressed_Size:该 frame 的压缩后大小。规范的巧妙之处在于:frame 0 到 i 的Compressed_Size累加和,恰好等于 frame i+1 在压缩文件中的偏移——条目本身不存偏移,而是存“宽度”,解码器在加载表时做一次前缀和即可得到每个 frame 的绝对偏移(参考实现即如此,见 zstdseek_decompress.c 中cOffset/dOffset的累计逻辑)。Decompressed_Size:该 frame 内解压后数据的大小;对于 skippable 帧或空帧,该值为 0。Checksum(仅当Checksum_Flag置位时存在):值为该 frame 解压数据的XXH64 摘要的最低 32 位,小端存储。这与写入侧代码XXH64_digest(&zcs->xxhState) & 0xFFFFFFFFU(zstdseek_compress.c)一致;读取侧在解完一个 frame 后同样计算并比对,不匹配则返回corruption_detected(zstdseek_decompress.c)。
四、从实现角度看格式的三个关键细节
4.1 帧大小上限:1 GiB 与 21 亿帧
规范中Compressed_Size/Decompressed_Size均为 4 字节无符号数,因此单帧大小天然受 2^32 限制。zstd_seekable.h 中定义了两个约束:
#define ZSTD_SEEKABLE_MAXFRAMES 0x8000000U /* Limit maximum size to avoid potential issues storing the compressed size */ #define ZSTD_SEEKABLE_MAX_FRAME_DECOMPRESSED_SIZE 0x40000000U- 帧数上限为
0x8000000(21,474,836 帧),ZSTD_seekable_logFrame在超限时返回frameIndex_tooLarge错误; - 单帧解压后大小上限为
0x40000000(1 GiB),ZSTD_seekable_initCStream对超出的maxFrameSize直接拒绝并返回frameParameter_unsupported错误(zstdseek_compress.c)。
4.2maxFrameSize的自动切帧机制
ZSTD_seekable_initCStream(zcs, compressionLevel, checksumFlag, maxFrameSize)的第四个参数决定切帧粒度;maxFrameSize == 0时使用默认上限。从 zstdseek_compress.c 的ZSTD_seekable_compressStream实现看:每次调用先将被消费长度钳制在maxFrameSize - frameDSize以内喂给底层ZSTD_compressStream,当本帧解压字节数达到maxFrameSize时自动调用ZSTD_seekable_endFrame结束当前帧、记录帧日志并复位会话(ZSTD_reset_session_only),保证下一帧不依赖上一帧上下文——这是“各帧可独立解码”在实现上的落点。
如何选取maxFrameSize?README 给出了明确建议:
- 帧越小,随机读取小段数据时成本越低(因为取 1 个字节也必须解压它所在整帧);
- 经验法则是让最大帧大小与已知的访问粒度同量级——例如应用倾向于请求 4 KB 块,就把帧大小设在 4 KB 附近;
- 但帧过小会同时降低压缩率并增大 seek table 开销(每帧固定 8 或 12 字节条目),需要权衡;
- 一般应避免过小的帧(< 1 KB),对压缩率伤害明显。
4.3 随机定位:二分查找与“帧前缀丢弃”
ZSTD_seekable_offsetToFrameIndex对 seek table 做二分查找,找出解压偏移<= pos的最后一个 frame(zstdseek_decompress.c),使定位复杂度为 O(log N)。ZSTD_seekable_decompress(zs, dst, len, offset)的调用流程为:
- 二分找到目标 frame,
seek到该 frame 的压缩偏移处; - 若目标偏移不在 frame 头部,则先把 frame 前缀解压到内部丢弃缓冲(
outBuff),直到推进到目标偏移,再写入用户缓冲; - 若连续多次调用请求连续区段,实现会保留
ZSTD_DStream会话(zs->curFrame/decompressedOffset),避免重复解压帧前缀; - 为防止损坏数据导致的死循环,连续 16 次(
ZSTD_SEEKABLE_NO_OUTPUT_PROGRESS_MAX)无输出进展即返回seekableIO错误(zstdseek_decompress.c)。
五、压缩侧实战:流式 API 与示例
仓库提供完整示例 examples/seekable_compression.c,用法为./seekable_compression FILE FRAME_SIZE [LEVEL](压缩级别缺省为 5),核心调用序列如下:
ZSTD_seekable_CStream* cstream = ZSTD_seekable_createCStream(); ZSTD_seekable_initCStream(cstream, cLevel, 1 /* checksumFlag */, frameSize); /* 循环喂入数据:返回值为输入提示值,input.pos 可能 < input.size,需续喂 */ while (input.pos < input.size) { ZSTD_outBuffer output = { buffOut, buffOutSize, 0 }; toRead = ZSTD_seekable_compressStream(cstream, &output, &input); /* 将 output 已写部分落盘 */ } /* 结束:先收尾当前帧,再写 seek table; 返回 >0 表示 output 缓冲区不足,需再次调用直至返回 0 */ while (1) { ZSTD_outBuffer output = { buffOut, buffOutSize, 0 }; size_t const remaining = ZSTD_seekable_endStream(cstream, &output); /* 写出 output,remaining == 0 时 break */ } ZSTD_seekable_freeCStream(cstream);要点说明(均来自 zstd_seekable.h 的 HowTo 注释):
checksumFlag为 1 时,seek table 中每个 frame 会附带其解压数据的校验和,用于读取时验证;ZSTD_seekable_endStream会先结束当前 frame、再写 seek table;若 output 缓冲区装不下,返回剩余字节数,应重复调用直到返回 0;- 流对象可复用:再次压缩前调用
ZSTD_seekable_initCStream即可,避免重复分配。
六、解码侧实战:三种初始化模式
ZSTD_seekable对象提供三种初始化入口(zstd_seekable.h):
| 函数 | 适用场景 | 说明 |
|---|---|---|
ZSTD_seekable_initBuff(zs, src, srcSize) | 内存缓冲 | src必须包含整个 seekable 文件(含 seek table),且在对象释放/重置前必须保持存活且不被修改 |
ZSTD_seekable_initFile(zs, FILE*) | 文件(stdio) | 内部使用fread/fseek;FILE*在释放/重置前不应关闭或修改 |
ZSTD_seekable_initAdvanced(zs, customFile) | 自定义 I/O | 用户提供read(必须恰好读满 n 字节,提前 EOF 视为错误)与seek(支持SEEK_SET/SEEK_END)回调,成功返回非负、失败返回负值;文档同时提醒基于 stdio 实现时注意 >4 GB 文件与fseek的限制 |
示例程序 examples/seekable_decompression.c 演示了从START到END区段的随机读取:
ZSTD_seekable* seekable = ZSTD_seekable_create(); ZSTD_seekable_initFile(seekable, fin); while (startOffset < endOffset) { size_t const result = ZSTD_seekable_decompress( seekable, buffOut, MIN(endOffset - startOffset, buffOutSize), startOffset); /* 写出 result 字节,startOffset += result */ } ZSTD_seekable_free(seekable);除按字节偏移解压外,还提供ZSTD_seekable_decompressFrame(zs, dst, dstSize, frameIndex)按帧索引整帧解压,以及一组表访问函数(ZSTD_seekable_getNumFrames、getFrameCompressedOffset、getFrameDecompressedOffset、getFrameCompressedSize、getFrameDecompressedSize、offsetToFrameIndex)。注意越界语义的差异:越界的索引访问函数(如 getNumFrames 系列的 size 查询)返回可用ZSTD_isError()判定的错误码,而返回unsigned long long的偏移查询函数越界时返回哨兵值ZSTD_SEEKABLE_FRAMEINDEX_TOOLARGE(0ULL-2)。
七、Raw Seek Table API 与并行压缩
对于希望“帧并行独立压缩、事后汇总”的场景(多线程或分布式),规范配套了 Raw seek table API(zstd_seekable.h):
ZSTD_seekable_createFrameLog(checksumFlag)创建一个帧日志(checksumFlag 为 0 时传入的 checksum 将被忽略);- 每压好一个帧,调用一次
ZSTD_seekable_logFrame(fl, compressedSize, decompressedSize, checksum); - 全部帧落盘后,
ZSTD_seekable_writeSeekTable(fl, output)将日志序列化为 seek table(即第三节的 skippable 帧),追加到帧文件末尾即可。若输出缓冲区不足,返回值为剩余待写字节数,可续写。
examples/parallel_compression.c 完整演示了该模式:将输入按frameSize切片提交线程池(POOL_add),每帧独立调用ZSTD_compress并计算XXH64校验和;由于各线程乱序完成,用一把互斥锁维护“按 id 顺序刷盘”的 pending 链,保证文件内帧顺序正确、ZSTD_seekable_logFrame按序记录;最后循环调用ZSTD_seekable_writeSeekTable把表追加到文件尾。该示例要求多线程版本 libzstd(examples/Makefile 中针对并行工具链接libzstd.a-mt)。
八、独立 Seek Table 管理与验证
ZSTD_seekTable:内存受限、需要同时缓存多份档案索引的场景下,可以ZSTD_seekTable_create_fromSeekable从ZSTD_seekable中摘出较小的ZSTD_seekTable,随即释放ZSTD_seekable本体,之后仅凭 seek table 的偏移信息配合标准 zstd 解码即可取帧。它提供与ZSTD_seekable_*同构的一整套查询函数(ZSTD_seekTable_getNumFrames等,zstd_seekable.h)。- 单元测试:tests/seekable_tests.c 验证了基本的压缩—加载—随机读回环,包括 4 KB 数据压缩后
ZSTD_seekable_initBuff加载、整帧解回、以及从ZSTD_seekable导出ZSTD_seekTable后断言第 0 帧偏移为 0 等表查询行为。 - 模糊测试:tests/fuzz/seekable_roundtrip.c 对 seekable 格式做随机读写回环 fuzz,是格式健壮性的持续保障。
九、版本变更与兼容性说明
格式规范当前版本为0.1.0(2017-04-11 初始版本),Version Changes 记录如下:
- 0.1.0:初始版本。
与 zstd 主格式的兼容性边界可以概括为:
- 任何不认识 seekable 格式的 zstd 解码器,会将末尾的 seek table 当作普通 skippable 帧跳过,只解出前 N 个数据帧的内容;
- 合规的 seekable 解码器不能只靠
0x184D2A5E判定帧类型(其他 skippable 帧可合法使用该 magic),应以文件末尾 4 字节是否为0x8F92EAB1为最终判据,并校验 descriptor 中的Reserved_Bits全为 0; - 所有 32 位字段小端序;帧数受
ZSTD_SEEKABLE_MAXFRAMES(0x8000000)约束,单帧解压大小受ZSTD_SEEKABLE_MAX_FRAME_DECOMPRESSED_SIZE(0x40000000,1 GiB)约束,超出时 API 返回错误而非静默截断。
综合来看,这份格式规范与 contrib/seekable_format 参考实现是一一对应的:规范定义字节布局与合规解码器的校验义务,实现则把“magic 校验、保留位检查、前缀和偏移、二分定位、帧前缀丢弃、进度保护”逐条落实,可直接作为第三方实现的对照基准。
【免费下载链接】zstdZstandard - Fast real-time compression algorithm项目地址: https://gitcode.com/gh_mirrors/zs/zstd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考