一个带进度条的 tar.gz 多核解压脚本

大文件解压这件事,平时看起来很小,真遇到几十 GB 的 tar.gz 包时就会变得很烦。

最常见的命令是:

tar -xzf archive.tar.gz -C output/

它能用,但有几个问题:

  • gzip 解压基本是单核,机器有很多核也用不上。
  • 没有进度条,不知道还要跑多久。
  • 目标目录已经存在时容易把新旧文件混在一起。
  • 脚本化重跑时,参数和目录约定容易写散。

所以我写了一个小脚本:extract.sh。它不是为了炫技,而是把一次大包解压里最容易踩坑的地方都收起来。

它解决什么问题

这个脚本做的是一件很具体的事:

.tar.gz.tgz 文件解压到普通目录,同时显示百分比、速度和 ETA;如果机器上有 pigz,自动使用多核解压。

典型用法:

bash extract.sh archive.tar.gz output/

也可以不传压缩包路径。脚本会在自己所在目录找唯一一个 .tar.gz.tgz

bash extract.sh

如果目标目录已经存在,默认会停下来,避免把新文件混进旧目录:

错误:目标目录已存在:output/20260502
为避免覆盖或混入旧文件,本次停止。

确认要续跑或覆盖时,再显式加环境变量:

ALLOW_EXISTING=1 bash extract.sh archive.tar.gz output/

这个默认行为非常重要。大文件解压一旦混入旧数据,后面排查通常比重新跑还贵。

核心思路

脚本的核心管线是:

python3 progress_reader.py archive.tar.gz \
  | pigz -dc \
  | tar -xf - -C output/

如果没有安装 pigz,就自动降级为:

python3 progress_reader.py archive.tar.gz \
  | gzip -dc \
  | tar -xf - -C output/

这里分成三段:

  • Python 负责按块读取压缩包,并在 stderr 上刷新进度。
  • pigzgzip 负责把 gzip 流解开。
  • tar 负责把 tar 内容写到目标目录。

进度条统计的是压缩包读取进度,而不是解压后的文件体积。这个口径足够稳定,因为读完整个压缩包就代表解压输入已经消费完成。

为什么用 pigz

pigz 可以理解成并行版 gzip。对 .tar.gz 这种格式来说,普通 gzip -dc 经常只能吃一个 CPU 核心;pigz -dc -p N 可以用多个线程做解压,速度通常会明显更好。

脚本里线程数默认取机器 CPU 数:

PIGZ_THREADS="${PIGZ_THREADS:-$(nproc 2>/dev/null || echo 4)}"

如果想限制线程数,可以这样跑:

PIGZ_THREADS=8 bash extract.sh archive.tar.gz output/

这在生产机上很有用。解压很吃 IO 和 CPU,不一定应该把所有核都打满。

为什么不用 pv

pv 当然也能做进度:

pv archive.tar.gz | pigz -dc | tar -xf - -C output/

pv 不是所有机器都有。这个脚本把进度读取器写成内嵌 Python,依赖更少,只要有 python3targzip 就能跑;有 pigz 时自动加速,没有也不影响功能。

Python 进度器做的事情很简单:

with open(path, "rb") as f:
    while True:
        chunk = f.read(4 * 1024 * 1024)
        if not chunk:
            break
        stdout.write(chunk)
        # 每秒刷新一次百分比、速度、ETA

它把压缩包原样写到标准输出,所以后面的解压器完全不用知道前面发生了什么。

目录命名的小细节

脚本会根据压缩包名字推断预期顶层目录。

例如:

20260502.tar.gz  ->  output/20260502
20260502_xxx.tgz ->  output/20260502
foo.tar.gz       ->  output/foo

这段逻辑的目的不是替代 tar 自己的目录结构,而是在解压前先做一层安全检查:如果这个预期目录已经存在,脚本就先停下来。

ARCHIVE_NAME="$(basename -- "$ARCHIVE")"
EXPECTED_TOP="${ARCHIVE_NAME%.tar.gz}"
EXPECTED_TOP="${EXPECTED_TOP%.tgz}"
if [[ "$ARCHIVE_NAME" =~ ^([0-9]{8}) ]]; then
  EXPECTED_TOP="${BASH_REMATCH[1]}"
fi
TARGET_PATH="$DEST_DIR/$EXPECTED_TOP"

对按日期归档的数据包来说,这个约定很实用。

失败处理

脚本开头用了:

set -euo pipefail

这几个选项组合起来,能让脚本在出错时尽早失败:

  • -e:命令失败就退出。
  • -u:引用未定义变量就报错。
  • pipefail:管线中任意一段失败,整个管线都失败。

这对解压管线尤其关键。否则可能出现 pigz 失败了,但最后一段命令退出码看起来正常的情况。

完整使用示例

指定压缩包和目标目录:

bash extract.sh 20260502.tar.gz ./data/

使用 8 个线程:

PIGZ_THREADS=8 bash extract.sh 20260502.tar.gz ./data/

允许目标目录已存在:

ALLOW_EXISTING=1 bash extract.sh 20260502.tar.gz ./data/

把脚本放到数据包同目录,并自动选择唯一压缩包:

bash extract.sh

运行时会输出类似这样的信息:

归档: 20260502.tar.gz
大小: 55.00 GiB
目标: ./data/
预期顶层目录: ./data/20260502
解压器: pigz (32 threads)

解压过程中会刷新:

[###############---------------]  50.12%  27.56 GiB / 55.00 GiB  180.00 MiB/s  ETA 2m35s

结束后会打印耗时和输出目录大小。

完整脚本

下面是完整版本,可以保存为 extract.sh 后直接使用:

#!/usr/bin/env bash
# 解压 tar.gz 到普通文件夹,带进度显示(百分比 / 速度 / ETA)。
# 用 pigz 多核解压加速,用 python 封装读取做进度条。
#
# 用法:
#   ./extract.sh                          # 默认解压脚本同目录下唯一的 *.tar.gz
#   ./extract.sh /path/to/archive.tar.gz  # 指定压缩包
#   ./extract.sh archive.tar.gz DEST_DIR  # 指定压缩包 & 解压目标目录

set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"

# ---------- 解析参数 ----------
ARCHIVE="${1:-}"
DEST_DIR="${2:-$SCRIPT_DIR}"

if [[ -z "$ARCHIVE" ]]; then
  # 自动挑选同目录下唯一的 .tar.gz
  mapfile -t CANDIDATES < <(find "$SCRIPT_DIR" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.tgz' \) | sort)
  if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
    echo "错误:未找到 *.tar.gz,请显式传入路径。" >&2
    exit 1
  elif [[ ${#CANDIDATES[@]} -gt 1 ]]; then
    echo "同目录下存在多个归档,请指定一个:" >&2
    printf '  %s\n' "${CANDIDATES[@]}" >&2
    exit 1
  fi
  ARCHIVE="${CANDIDATES[0]}"
fi

if [[ ! -f "$ARCHIVE" ]]; then
  echo "错误:找不到文件 $ARCHIVE" >&2
  exit 1
fi
mkdir -p "$DEST_DIR"

ARCHIVE_NAME="$(basename -- "$ARCHIVE")"
EXPECTED_TOP="${ARCHIVE_NAME%.tar.gz}"
EXPECTED_TOP="${EXPECTED_TOP%.tgz}"
if [[ "$ARCHIVE_NAME" =~ ^([0-9]{8}) ]]; then
  EXPECTED_TOP="${BASH_REMATCH[1]}"
fi
TARGET_PATH="$DEST_DIR/$EXPECTED_TOP"

if [[ -e "$TARGET_PATH" && "${ALLOW_EXISTING:-0}" != "1" ]]; then
  echo "错误:目标目录已存在:$TARGET_PATH" >&2
  echo "为避免覆盖或混入旧文件,本次停止。" >&2
  echo "如确认要续跑/覆盖,可执行:ALLOW_EXISTING=1 $0 \"$ARCHIVE\" \"$DEST_DIR\"" >&2
  exit 1
fi

TOTAL_BYTES=$(stat -c%s "$ARCHIVE")
TOTAL_GB=$(awk -v b="$TOTAL_BYTES" 'BEGIN{printf "%.2f", b/1073741824}')
PIGZ_THREADS="${PIGZ_THREADS:-$(nproc 2>/dev/null || echo 4)}"

if command -v pigz >/dev/null; then
  DECOMP_LABEL="pigz (${PIGZ_THREADS} threads)"
else
  DECOMP_LABEL="gzip"
fi

echo "=============================================="
echo " 归档: $ARCHIVE"
echo " 大小: ${TOTAL_GB} GiB  (${TOTAL_BYTES} bytes)"
echo " 目标: $DEST_DIR"
echo " 预期顶层目录: $TARGET_PATH"
echo " 解压器: $DECOMP_LABEL"
echo " 开始时间: $(date '+%F %T')"
echo "=============================================="

# ---------- 选择解压工具 ----------
if command -v pigz >/dev/null; then
  DECOMP=(pigz -dc -p "$PIGZ_THREADS")
else
  DECOMP=(gzip -dc)
fi

# ---------- 进度器(内嵌 python)----------
# 从参数里的文件按 4 MiB 块读 -> stdout(管道给 pigz),
# 同时在 stderr 上每秒刷新一次进度。
PROGRESS_PY='
import os, sys, time
path = sys.argv[1]
total = os.path.getsize(path)
BUF = 4 * 1024 * 1024
read = 0
start = time.time()
last = 0.0
out = sys.stdout.buffer
err = sys.stderr
def fmt_size(n):
    for u in ("B","KiB","MiB","GiB","TiB"):
        if n < 1024: return f"{n:6.2f} {u}"
        n /= 1024
    return f"{n:.2f} PiB"
def fmt_eta(s):
    s = int(s)
    h, s = divmod(s, 3600)
    m, s = divmod(s, 60)
    if h: return f"{h:d}h{m:02d}m{s:02d}s"
    if m: return f"{m:d}m{s:02d}s"
    return f"{s:d}s"
with open(path, "rb") as f:
    while True:
        chunk = f.read(BUF)
        if not chunk:
            break
        out.write(chunk)
        read += len(chunk)
        now = time.time()
        if now - last >= 1.0 or read == total:
            elapsed = now - start
            speed = read / elapsed if elapsed > 0 else 0
            pct = read * 100.0 / total if total else 100.0
            eta = (total - read) / speed if speed > 0 else 0
            bar_w = 30
            filled = int(bar_w * pct / 100)
            bar = "#" * filled + "-" * (bar_w - filled)
            err.write(
                f"\r[{bar}] {pct:6.2f}%  "
                f"{fmt_size(read)} / {fmt_size(total)}  "
                f"{fmt_size(speed)}/s  ETA {fmt_eta(eta)}   "
            )
            err.flush()
            last = now
err.write("\n")
'

# ---------- 执行管线 ----------
# python 读压缩包 -> pigz 解压 -> tar 写入目标目录
# 用 pipefail 确保任意一环失败都能被捕获
START_TS=$(date +%s)

python3 -c "$PROGRESS_PY" "$ARCHIVE" \
  | "${DECOMP[@]}" \
  | tar -xf - -C "$DEST_DIR"

END_TS=$(date +%s)
ELAPSED=$((END_TS - START_TS))
H=$((ELAPSED/3600)); M=$(((ELAPSED%3600)/60)); S=$((ELAPSED%60))

echo "=============================================="
echo " 完成!耗时 ${H}h${M}m${S}s"
echo " 输出目录大小:"
du -sh "$DEST_DIR"/*/ 2>/dev/null | sed 's/^/   /' || true
echo "=============================================="

适合放在哪

这个脚本适合放在数据目录旁边,也适合放到项目的 scripts/ 目录里。

我的建议是:如果它服务于某类固定数据包,就放到这类数据的目录下;如果多个数据源都会用它,就放到项目根目录的 scripts/extract.sh,并在 README 里写一行标准命令。

还能继续增强什么

目前这个脚本已经覆盖了大文件解压里最关键的几个点。后续如果要继续增强,可以考虑:

  • 解压前校验 sha256sum
  • 支持 .tar.zst,用 zstd -dc 替代 gzip/pigz
  • 解压失败时自动提示是否清理半成品目录。
  • 增加 --strip-components 参数,处理不规范的包结构。
  • 解压前用 tar -tf 快速检查顶层目录是否符合预期。

但这些都不是第一优先级。对大多数场景来说,一个稳定、可重跑、有进度、有保护的解压脚本,已经能省掉很多等待和误操作。

总结

这个脚本的价值不在于某一行命令有多复杂,而在于它把大文件解压这件事变成了一个可预期的流程:

  • 有进度,不用盲等。
  • 有多核,速度更好。
  • 有默认保护,不轻易污染旧目录。
  • 有明确参数,适合写进数据导入流程。

很多工程效率不是靠大系统提升的,而是靠这种小工具把重复操作里的不确定性一点点拿掉。

Read more

Mac 上 Skill CLI 无法执行的坑:最后其实一条命令就够了

我在做 Amazon skills 的过程中,逐步把本地 CLI 从 Python 脚本切到 Go 二进制。这样做的好处很明显:用户不用装 Python、不用配依赖,解压 skill 后直接运行。但在 macOS 上,我们反复遇到一个看起来很玄的问题:同一个二进制,在 Linux/Windows 上正常,在 Mac 上就是执行不了。 当时遇到的现象 常见报错大概有几类: * 双击或 agent 调用 CLI 时,系统提示文件来自未知开发者,无法打开。 * 终端里执行时提示 Permission denied。 * 已经 chmod +x 了,仍然被 macOS 拦截。 * Apple

By ladydd

当我把全世界人群的基因 PCA 跑出来后,看见了一个倒 L 型

最近我把之前学的一些分子人类学知识,终于真正落地了。 不是停留在看论文、看别人画图、看别人解释“人群结构”这些概念,而是自己把数据处理完,自己跑 PCA,自己把全世界不同人群放到一张图上。 然后那一刻,我真的被击中了。 图上出现了一个非常漂亮的倒 L 型。 一端是非洲,另一端逐渐拉向东亚,中间有中东、欧洲、南亚、欧亚大陆上的各种过渡人群。它不是那种随机散点图,而是有方向、有骨架、有历史感的结构。 我第一眼看到的时候,脑子里直接冒出一句话: 这不像是一张普通统计图,这像是人类迁徙史在二维空间里留下的影子。 当然,后来我也提醒自己,PCA 不能被过度浪漫化。它不是地图,也不是时间轴,更不是“谁从哪里走到哪里”的直接证据。PCA 本质上是把高维基因差异压缩到几个主成分上,用最大方差方向把样本摊开。它可以帮助我们观察人群结构、相似性、分化和混合,但不能单独承担全部历史解释。PCA 在群体遗传学里常用于观察 population structure

By ladydd

吞吐与延迟:一个厨房比喻讲透性能压测

写于 2026-06-26。背景: MCP 服务 跑在 3 台 ClickHouse(每台 16 核 / 64G,1 分片 3 副本)上。 我们花了一整轮做公网压测,把这套系统的极限、天花板和杠杆全摸清了。这篇把"吞吐 / 延迟 / 排队"这三个最容易混的概念讲透,配我们自己的真实实测数据。 一句话结论 我们这套系统的吞吐天花板 ≈ 76 req/s。 往里塞再多并发(100、200、300、500),每秒"做完"的还是大约 76 个,多出来的全在排队。系统不会崩,只会让每个人等得更久。 天花板能不能抬?

By ladydd

四卡 3090 本地模型部署复盘:Ollama 跑通 35B,以及 GPU0 掉卡问题

这次做的是一轮真实的本地模型部署摸底。 目标不是搭一个临时 Demo,而是把一台四卡 3090 GPU 机器接进自己的日常 AI 使用环境:本机跑 Open WebUI,负责账号、会话和前端配置;GPU 机器只负责模型推理。这样以后换模型、换推理框架、重启服务,都尽量不影响本机的使用入口。 最后结论比较清楚:qwen3.5:35b 的 GGUF Q4_K_M 量化版已经通过 Ollama 跑通,本机 Open WebUI 可以接入,热加载后的聊天速度也能用;但 GPU0 存在明显稳定性问题,重启后能短暂恢复,跑过负载后又会掉到 NVML 异常状态。 状态快照 当前能用的部分: * 本机 Open WebUI 已部署,

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号