ZIP 流式压缩:为什么可以边生成边写入
AI 参与说明(Agent:Codex):本文依据公开的 ZIP 格式规范、DEFLATE 规范、zlib 手册和 Python 文档整理;文中的示例在 Python 3.14.4 上运行验证。示例数据均为独立构造。模型完整标识与 reasoning effort 未取得运行记录。
直觉上,压缩似乎必须先拿到完整源文件,才能算出压缩结果和最终大小。那么,一个程序为何能一边接收数据,一边向同一个 ZIP 写入新文件?写入结束时又做了什么?
要回答这个问题,需要先把两件事分开:DEFLATE 如何把字节编码成更短的表示;ZIP 如何把多个文件及其索引组织成一个归档。 前者是压缩算法,后者是容器格式。ZIP 可以不压缩而只打包,也可以为不同文件选择不同的压缩方法;常见的 DEFLATE 是 ZIP 的一种压缩方法,不等于 ZIP 格式本身。PKWARE ZIP File Format Specification,4.1 节
| 英文术语 | 中文名称 | 在本文中的作用 |
|---|---|---|
| Entry | 文件条目 | ZIP 中的一个文件及其元数据;每个 Entry 独立存储 |
| DEFLATE | 无损压缩格式 | 把一个 Entry 的字节编码为压缩字节 |
| Local File Header | 本地文件头 | 放在一个 Entry 的数据之前,记录文件名和压缩方法等 |
| Data Descriptor | 数据描述符 | 在数据之后补记该 Entry 的 CRC-32 与最终大小 |
| Central Directory | 中央目录 | 在归档末尾列出所有 Entry 的信息与位置 |
| End of Central Directory(EOCD) | 中央目录结束记录 | 在末尾指出 Central Directory 的位置、大小和 Entry 数量 |
| ZIP64 | ZIP 的 64 位扩展 | 普通字段装不下大小、偏移量或数量时使用 |
1. 压缩的直觉:重复与冗余
无损压缩的目标,是在不丢失任何信息的前提下,用更少的比特表示同一份数据。它并不创造信息,而是利用数据里已经存在的结构。
最常见的结构是重复。一段文本里反复出现 the 、http://、相同的 JSON 字段名,或者二进制里重复出现的魔数和块头,都意味着:后面的字节和前面的字节很像。如果编码器能记录“这里和前面某处相同,重复 N 个字节”,就不必把 N 个字节原样再写一遍。
另一类结构是符号分布不均。英文字母里 e 比 z 常见得多;很多文件里 0x00 或空格出现频率远高于随机字节。如果给常见符号分配更短的编码、给罕见符号分配更长的编码,整体比特数就会下降。
把这两类直觉形式化,就得到现代通用压缩算法的两个核心构件:LZ77 风格的向后引用和霍夫曼编码。DEFLATE 把二者组合在一起;ZIP 则把 DEFLATE(或其他方法)的输出装进自己的记录结构里。
已经压缩过或接近随机的数据,进一步压缩可能效果很差,甚至因元数据而变大。ZIP 还允许 Stored 方法:直接保存原始字节,不做压缩。PKWARE 规范,4.1 节
2. LZ77:滑动窗口、长度和距离
LZ77 的基本想法是:维护一个滑动窗口,在已处理的字节里寻找与当前位置匹配的最长片段。
假设输入依次是 ABRACADABRA。当编码器处理到第二个 ABRA 时,它可以在窗口内发现前面已经出现过同样的四字节序列。于是它不输出 A B R A 四个字面量,而是输出一个长度-距离对(length-distance pair):
- 长度(length):要复制的字节数,例如 4;
- 距离(distance):从当前位置向前回溯多少字节开始复制,例如 7。
解码器看到这对符号后,从已解码输出里按距离回溯,复制指定长度的字节,就能还原原文。窗口大小决定了最远能引用多远的历史;DEFLATE 规定距离最大为 32 KiB(32768 字节)。RFC 1951,3.2.3 节
已处理输出(滑动窗口) 当前输入
... A B R A C A D A B R A ... → 匹配到前面的 "ABRA"
│
▼
(length=4, distance=7)
LZ77 并不要求编码器在处理当前字节时预知整个文件。它只需要窗口内的历史:新字节进入窗口,最老的字节被挤出。因此输入可以按流式到达,编码器边读边编码。
匹配不到足够长的片段时,编码器直接输出字面量字节(literal)。一个 DEFLATE 流最终由字面量和长度-距离对两类符号交替组成。
3. 霍夫曼编码:静态表与动态表
LZ77 把输入变成一串符号,但符号本身还要变成比特才能写入文件。霍夫曼编码根据符号出现频率分配变长码字:越常见的符号,码字越短。
DEFLATE 在 block 级别使用霍夫曼树。规范定义了两种预设方式:
| 方式 | 含义 | 何时使用 |
|---|---|---|
| 固定霍夫曼表(fixed Huffman codes) | 整张码表由规范预先定义,编码器和解码器共用 | 数据很短、或动态表开销不值得时 |
| 动态霍夫曼表(dynamic Huffman codes) | 编码器根据本 block 的符号频率生成码表,并把它写在 block 头部 | 符号分布与预设表差异大、能明显节省空间时 |
动态表需要额外比特来描述树结构,所以小 block 未必划算。编码器通常按启发式在 fixed 与 dynamic 之间选择。RFC 1951,3.2.6–3.2.7 节
霍夫曼 编码同样不依赖“先看完整文件”。每个 block 只统计本 block 内符号的频率,生成对应的树,然后编码该 block 的内容。block 结束后,下一 block 可以重新建树。
4. DEFLATE 分块:BFINAL、BTYPE 与流式处理
DEFLATE 把压缩数据组织为一系列 block。每个 block 开头有三个关键字段:RFC 1951,3.2.3 节
- BFINAL:1 位,标记这是否是流中的最后一个 block;
- BTYPE:2 位,指定本 block 的编码类型。
BTYPE 有三种取值:
| BTYPE | 名称 | 内容 |
|---|---|---|
| 0 | stored | 不压缩,原样存储(带长度和 CRC) |
| 1 | fixed | 使用固定霍夫曼表压缩 |
| 2 | dynamic | 使用动态霍夫曼表压缩 |
| 3 | — | 保留,非法 |
输入字节流 ──→ [block 0: BFINAL=0, BTYPE=2] ──→ 压缩比特
│
▼
[block 1: BFINAL=0, BTYPE=1] ──→ 更多压缩比特
│
▼
[block N: BFINAL=1, BTYPE=2] ──→ 流结束
为什么 DEFLATE 能流式处理? 因为编码器只需要:
- 滑动窗口内的历史(有界,最多 32 KiB);
- 当前正在形成的 block 的缓冲区和符号统计;
- 在输入结束时,写出
BFINAL=1的最后一个 block。
它不需要在处理第一个字节时预知最后一个字节。以 zlib 接口为例,普通输入用 Z_NO_FLUSH,输入结束用 Z_FINISH。ZIP 的 DEFLATE Entry 保存的是 raw DEFLATE:没有 zlib 格式自带的头和 Adler-32 尾;ZIP 在自己的记录中保存 CRC-32。zlib 手册
“流式”不表示每送入一块就立刻吐出一块输出。编码器可能暂存字节以形成 block,也可能等结束信号才输出最后一部分。不同编码器、参数和分块决策可以得到不同大小的合法输出,压缩率也不是“必须先看完整文件才能达到的唯一最优值”。RFC 1951
5. ZIP 是容器,不是算法:Local File Header + 数据
理解了 DEFLATE,还需要理解 ZIP 在文件里实际长什么样。ZIP 是一个归档容器:它把多个文件(Entry)和元数据按固定记录格式串联起来,每个 Entry 可以独立选择压缩方法。
一个 Entry 在文件中的典型布局:
[Local File Header][文件名与扩展字段][压缩或未压缩的数据][可选的 Data Descriptor]
Local File Header 以签名 50 4B 03 04(ASCII PK 加版本号)开头,记录压缩方法、标志位、CRC-32、压缩前后大小、文件名长度等。数据紧跟其后。PKWARE 规范,4.3.7 节
ZIP 没有要求先在文件开头写一张完整目录。每个 Entry 的 Local File Header 紧邻自己的数据;规范规定每个 Entry 都有对应的 Local File Header 与 Central Directory 项;一个有效 ZIP 必须有 EOCD。PKWARE 规范,4.3.1–4.3.6 节
| 记录 | 典型标识字节 | 主要内容 | 写入时机 |
|---|---|---|---|
| Local File Header | 50 4B 03 04 | 文件名、方法、标志位,以及已知的 CRC-32 和大小 | 一个 Entry 开始前 |
| Data Descriptor | 常见为 50 4B 07 08,标识可省略 | 最终 CRC-32、压缩前后大小 | 一个 Entry 数据结束后,且使用 bit 3 时 |
| Central Directory 项 | 50 4B 01 02 | Entry 元数据、最终大小、Local File Header 的偏移量 | 所有 Entry 完成后 |
| EOCD | 50 4B 05 06 | 目录位置、目录大小和 Entry 数量 | 最后 |
表里的 50 4B 对应 ASCII 的 PK。ZIP 的多字节数值字段通常按 little-endian 存储,所以规范中的签名整数值看起来字节顺序相反。PKWARE 规范,4.2–4.3 节
每个 Entry 有自己的压缩流。ZIP 不会跨 Entry 共用一个 DEFLATE 历史窗口,所以小文件很多时,重复的 Entry 元数据和压缩流开销会变得明显。PKWARE 规范,4.1.8 与 4.3.6 节
6. Central Directory 与 EOCD:为什么索引在末尾
全部 Entry 的数据写完后,写入器在文件末尾追加 Central Directory 和 EOCD(End of Central Directory Record)。
Central Directory 为每个 Entry 写一个目录项,包含最终 CRC-32、大小、压缩方法、文件名和 Local File Header 在文件中的偏移量。EOCD 再记录 Central Directory 的起始位置、大小与 Entry 数量。PKWARE 规范,4.3.12–4.3.16 节
文件开头
[Entry A: Local File Header][A 的数据][A 的 Data Descriptor]
[Entry B: Local File Header][B 的数据][B 的 Data Descriptor]
...
[Central Directory: A、B、... 的目录项]
[EOCD]
文件末尾
为什么索引放在末尾? 因为在流式写入时,写入器一开始并不知道会有多少个 Entry、每个 Entry 最终占多少字节、Central Directory 本身有多大。只有把 Entry 一个个写完,才能汇总出完整的目录。把索引放在末尾,写入器可以按顺序追加数据,最后在封口时一次性写出目录和 EOCD。
代价是:在 EOCD 写完之前,普通 ZIP 阅读器可能因缺少 Central Directory 或 EOCD 而打不开文件,即使文件里已经有很多字节。中断后能否从残留字节恢复部分数据,取决于额外的恢复工具和实际损坏位置,不能把它当作格式保证。
7. 大小未知时:general purpose bit 3 与 Data Descriptor
流式写入最常见的障碍是:写 Local File Header 时,还不知道 CRC-32、原始大小和压缩后大小。
ZIP 用两种方式解决:
- 可 seek 的输出:先写占位字段,处理完数据后回头改写 Local File Header;
- Data Descriptor:在 Local File Header 的通用标志位(general purpose bit flag)里把 bit 3 设为 1,表示 CRC-32 和大小写在 Entry 数据之后。
写入器一开始已知道文件名和压缩方法,却可能不知道最终数值。设置 bit 3 后,它先写 Local File Header 和文件名;每收到一块输入,更新原始字节数和 CRC-32,把字节送入编码器,并记录实际写出的压缩字节数。收到该 Entry 的结束信号后,先完成 DEFLATE stream,再写 Data Descriptor,补记最终的 CRC-32 和大小。PKWARE 规范,4.3.7–4.3.9 节
flowchart TB
A["开始 Entry:写 Local File Header,设置 bit 3"] --> B["逐块处理数据;写压缩数据,累计 CRC-32 与大小"]
B --> C["结束 Entry:完成 DEFLATE,写 Data Descriptor"]
C --> D["开始并完成下一个 Entry"]
D --> E["写 Central Directory"]
E --> F["写 EOCD;需要时补 ZIP64 记录"]
F --> G["关闭输出:得到完整 ZIP"]
Data Descriptor 的常见签名 50 4B 07 08 并非必有;读取器不能仅靠扫描这四个字节判断边界。若使用 ZIP64,大小字段改为 8 字节。PKWARE 规范,4.3.9 节
到这里,三个机制各管一件事:DEFLATE 允许“现在处理已经到达的字节”;Data Descriptor 允许“稍后填写当前 Entry 的最终数值”;Central Directory 允许“最后统一建立所有 Entry 的索引”。 三者配合,才形成可逐步构建、最终仍可随机访问的 ZIP。
8. 读取流程:从末尾找 EOCD 再随机读取
ZIP 阅读器通常先从文件末尾寻找 EOCD 签名 50 4B 05 06,再根据 EOCD 定位 Central Directory。目录项提供每个 Entry 的名称、方法、大小和 Local File Header 偏移量。要读取某个 Entry,阅读器据此跳到对应位置,越过 Local File Header 与文件名等字段,取得压缩数据,解码并核对 CRC-32。PKWARE 规范,4.3.12–4.3.16 节
flowchart TB
A["从文件末尾定位 EOCD"] --> B["根据 EOCD 找到 Central Directory"]
B --> C["在目录中选择目标 Entry"]
C --> D["跳到该 Entry 的 Local File Header"]
D --> E["读取并解码 Entry 数据"]
E --> F["核对 CRC-32"]
Central Directory 让阅读器不必从第一个 Entry 起逐个扫描,才能找到后面的文件。CRC-32 适合发现意外损坏,不是防篡改签名;若需要对来源或内容做安全认证,必须另设可信的校验机制。
9. 延伸:ZIP64、追加写入与常见误解
ZIP64
普通 ZIP 字段用 32 位表示大小和偏移量,上限约为 4 GiB;Entry 数量也有 16 位上限。超过这些边界时,需要 ZIP64 扩展:在 Local File Header、Central Directory 和 EOCD 里用 0xFFFFFFFF 等哨兵值标记“真实数值在 ZIP64 扩展字段里”,并在文件末尾追加 ZIP64 End of Central Directory 等记录。PKWARE 规范,4.5 节
“追加 ZIP”的两种含义
- 向未封口的 ZIP writer 增加 Entry:先写 A,再写 B,最后写一次 Central Directory 和 EOCD。本文讨论的流式构建属于这一种。只要 writer 仍开放,就可以继续写新 Entry;不要求所有未来 Entry 事先存在。
- 修改已经封口的 ZIP:旧文件末尾已有 Central Directory 和 EOCD。要增加 Entry,必须让末尾索引包含新 Entry,并保持偏移量正确;不能在旧 EOCD 后面随意拼几个数据字节。
把两个各自完整的 ZIP 做字节拼接,也不会自动得到一个含两者全部 Entry 的标准 ZIP。 ZIP 的目录与偏移量必须与最终文件结构一致。PKWARE 规范,4.3.1–4.3.6 节
常见误解
- 内存:流式处理不要求把全部文件内容放进内存;但 writer 仍需维护 Entry 元数据,Entry 极多时目录和实现自身的内存占用不可忽视。
- 输入结束边界:编码器可以接收持续到来的字节,但必须知道某个 Entry 何时结束,才能写最终大小、CRC-32 和下一个 Entry。
- 压缩效果:
Stored不压缩;DEFLATE的压缩率取决于内容与编码器决策,ZIP 外壳本身不保证体积缩小。 - 完整性:临时输出有字节不代表归档可正常打开;应等 EOCD 写完,再通过阅读器验证结构与内容。
10. 可运行示例:观察真实记录
以下示例只依赖 Python 标准库。它用一个不支持 seek 的输出对象写两个小文本 Entry,迫使 zipfile 使用 bit 3 和 Data Descriptor;随后直接读取 ZIP 字节,检查 Local File Header、Data Descriptor、Central Directory 和 EOCD 的位置。保存为 inspect_zip.py,运行 python3 inspect_zip.py。Python 文档确认 zipfile 支持写入不可 seek 的流,ZipFile.open(..., "w") 可逐块写 Entry。Python zipfile 文档
import struct
import tempfile
import zipfile
from pathlib import Path
class WriteOnly:
def __init__(self, path: Path):
self.file = path.open("wb")
def write(self, data: bytes) -> int:
return self.file.write(data)
def flush(self) -> None:
self.file.flush()
def close(self) -> None:
self.file.close()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "example.zip"
output = WriteOnly(path) # 没有 seek/tell,不能回头改写文件头
sizes = []
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as writer:
for name in ("a.txt", "b.txt"):
with writer.open(name, "w") as entry:
for _ in range(10):
entry.write((name + "\n").encode() * 100)
output.flush()
sizes.append(path.stat().st_size)
output.close() # ZipFile 退出时已写 Central Directory 和 EOCD
data = path.read_bytes()
with zipfile.ZipFile(path) as reader:
for info in reader.infolist():
header = struct.unpack_from("<IHHHHHIIIHH", data, info.header_offset)
signature, _, flags, method, _, _, _, _, _, name_len, extra_len = header
assert signature == 0x04034B50 # Local File Header
assert flags & 0x08 and method == 8 # bit 3、DEFLATE
payload_end = info.header_offset + 30 + name_len + extra_len + info.compress_size
descriptor = struct.unpack_from("<IIII", data, payload_end)
assert descriptor == (0x08074B50, info.CRC, info.compress_size, info.file_size)
print(info.filename, "descriptor OK")
assert reader.testzip() is None
eocd_at = data.rfind(b"PK\x05\x06")
eocd = struct.unpack_from("<IHHHHIIH", data, eocd_at)
_, _, _, _, entry_count, directory_size, directory_at, comment_len = eocd
assert entry_count == 2 and comment_len == 0
assert data[directory_at:directory_at + 4] == b"PK\x01\x02"
assert directory_at + directory_size == eocd_at
print("grew before closing:", sizes[0] < sizes[1])
print("central directory and EOCD OK")
在 Python 3.14.4 上运行,输出为:
a.txt descriptor OK
b.txt descriptor OK
grew before closing: True
central directory and EOCD OK
这个示例故意只解析无注释、未触发 ZIP64 的小型归档,不能当通用 ZIP 解析器。它验证的是:Entry 数据先进入输出文件,各 Entry 后面确有 Data Descriptor,Central Directory 与 EOCD 在关闭 writer 后才齐备。testzip() 可检查解压时的 CRC 和文件头,但不能证明调用者原本打算加入的文件没有遗漏。