使用原檔案的空間壓縮檔案而不佔用額外空間

標題想講的是使用 gzip 或其它壓縮程式時 in-place 壓縮, in-place 是指直接改變部份原有檔案內容,如 rsync 有類似的功能。 因為壓縮時輸入的速度理論上會大於輸出, 所以理論上能夠使用 in-place,但 gzip 本身沒有實作此一功能。 配合外部工具的話,可以使用 dd 或者將檔案映射為塊裝置, 使得程式可以 in-place 部份覆寫原有檔案的內容。

理論上使用 gzip in-place 壓縮

假設壓縮時 gzip 先讀入了檔案開頭的 N byte, 接著將 N byte 壓縮為 M byte,預期 M 會小於 N。 因此此時可以將壓縮後的 M byte 從檔案的開頭寫入, 而不影響 gzip 將要讀取的 N byte 後的資料。

這種策略稱為 in-place,也就是在程式轉換檔案格式時, 直接將結果寫入原有檔案中,不佔用額外空間。 但壞處是寫入過程如果出錯,檔案會卡在一半被覆寫的狀態難以復原。

讓 gzip in-place 寫入

gzip 預設壓縮或解壓縮後會刪除原檔案, 但由於壓縮結束前不會刪除原檔案, 故執行時還是會佔用原檔案大小加上壓縮後大小的空間, 對於壓縮映像檔等較大檔案時是很大的負擔。

gzip 或 bzip xz 7z 等壓縮工具,都沒有內建提供 in-place 功能, 可能也是因為壓縮時不能夠保證輸出一定小於輸入, 因此想了一些方式讓 gzip 可以達到 in-place。

其中的關鍵在於,多數程式在寫入檔案時,若是檔案存在, 多是如同 shell 的重導向符 > ,直接清空原檔案後寫入; 要不就是像 >> 是附加在原檔案後面。 清空原檔案的問題是,會造成原檔案的內容不能被讀取, 和 in-place 要求的只寫入部份資料不同。

要以此類方式寫入,以 c 語言來說可以以 fseek 之類, 移動寫入指標的位置來改變寫入的方式。 linux 中可以用 dd 達到這種功能, 如果使用 conv=notrunc 寫入模式,就只會覆蓋寫入部份的資料, 同時可以用 seek 來指定從檔案的特定位置開始寫入。

gzip 與 dd in-place 的指令

所以使用 gzip 搭配 dd 可以寫成以下的 in-place 指令, 其中 dd 的其它選項可以自己搭配,一般 bs 太小或太大都不宜, 會讓整體寫作速度變慢。

gzip --stdout file | dd conv=notrunc of=file

之後可以再用 truncate -s $size file 把壓縮檔後沒有刪掉的原始檔案內容刪掉; 至於壓縮檔有多大,則可以用 dd 結束時輸出的摘要看輸出了多少 byte。

覆寫原始檔案的缺點

使用 dd 與 gzip 缺點是,dd 並不知道事實上 gzip 讀到哪, 所以可能會寫入到 gzip 還沒讀到的地方,就會造成整個過程錯誤。 整個過程只是因為壓縮後的成果比較小,才不會有問題。

例如若是原本的檔案已經壓縮過,又或原本的檔案過於隨機難以壓縮, 那輸出就不一定會比輸入小,就會有問題。 理論上如果 gzip 可以控制讀取和寫入的速度, 就能達到沒有問題的 in-place 寫入。

使用 block device 的覆寫特性

除了 dd,另一種方式是使用 block device, 預設寫入是 no truncate 模式。 所以只要把檔案當成塊裝置寫入,就可以簡單達成 in-place 寫入。

linux 下可以用 losetup 指令,把檔案映射成 block device; 之後對 block device 用重導向符時,就會使用 no truncate 模式。

loop_device=$(losetup --show --find file)
gzip -c $loop_device > $loop_device
losetup --detach $loop_device
truncate -s $size file