我盯上临时文件清理这个需求,是因为一次实打实的磁盘灾难。C盘只剩不到5GB,那个周末我又急着装一个大型软件,安装程序写到一半报"磁盘空间不足",装到一半的东西停在那里,系统索引、浏览器缓存、各种日志全都挤在临时目录里。我翻了半天,发现系统自带的"磁盘清理"只处理它认识的那部分文件,真正占空间的大头——各种软件运行期间散落在临时目录里的包、缓存、分块文件——它根本不管。那之后我就决定自己写一个跨平台临时文件清理工具,不依赖各家系统那套割裂的清理逻辑。
这个工具的定位很直接:只要是临时目录里的东西,按规则筛选后,该清的清、该跳过的跳过、该留的留。它适合有这类痛点的人——常年在Windows和Linux两套环境之间切来切去,或者帮家里人、帮公司同事维护电脑,又或者纯粹想守住磁盘上一块整洁的阵地。这篇文章我会把整套思路和踩坑过程完整写出来,从目录识别、安全筛选、删除兜底,到打包分发,每一步都是实测过的方案。
1. 临时文件为什么值得专门写代码清理
先说清楚我反对的几种做法,再讲这个工具应该怎么设计,不然你可能会直接写一个"删掉所有东西"的脚本,然后哪天对着误删的数据欲哭无泪。
1.1 系统自带清理能力的盲区
Windows 自带的"磁盘清理"和 macOS 的"优化存储",本质上都依赖系统内置的数据库,知道自己哪些文件是缓存、哪些是临时文件。可现实是第三方软件的临时文件没那么听话。举几个我见过的实际例子:
- 浏览器下载到一半的
.crdownload、.part分块文件,任务取消后没人清理,长期堆在临时目录里。 - 安装包解压器在
%TEMP%下建的is-xxxxx.tmp目录,安装结束异常退出时,这些目录不会自动删除。 - Java 虚拟机创建的
hsperfdata_<用户名>目录,进程杀掉后残留。 - pytest、cmake、各种构建工具在系统临时目录下建的测试缓存和对象文件。
我在一台跑了半年的 Windows 机器上扫描,临时目录总占用接近34GB。系统自带清理统计出来只有几百MB,剩余全被忽略了。macOS 的/var/folders/...也不遑多让,Linux 的/tmp平时看起来干净,但遇到长期运行的服务器进程,积累的文件数量同样惊人。
1.2 我看到的核心瓶颈
完整的跨平台临时文件清理工具,必须处理三件事:
- 目录识别:Windows、macOS、Linux 的临时目录位置不一样,变量来源也不一样,不能写死路径。
- 安全筛选:临时目录里不是所有东西都能删,正在被进程占用的文件、正在写入中的文件、用户误放的重要数据,都必须有保护机制。
- 执行兜底:删除过程中会遇到权限不足、文件被锁、符号链接指向外部目录这些异常,一键脚本会直接崩溃,工具必须逐项处理并继续。
明白这三点之后,你会发现临时文件清理不是一个"删目录"的活,而是一个"识别、筛选、保护、兜底"的系统工程。我这篇文章的代码和实践,也都是围绕这套逻辑展开的。
2. 技术选型:跨平台清理工具用什么写最划算
动手之前我也纠结过一轮:写 Shell 脚本?还是用 Go?最后选了 Python。这个选择不是拍脑袋,背后有几条很实在的理由。
2.1 为什么不用 Shell,也不用 Go 一把梭
Shell 脚本的跨平台能力实在弱。在 Linux/macOS 上是rm -rf /tmp/*的天下,但到 Windows 上就脱节了,要么依赖 Git Bash,要么依赖 WSL,分发成本陡增。你可以让目标机器装 WSL,问题是这不是一个"随手就能用"的工具,而是要带给普通用户或运维同事的工具,多一层依赖就多一个不用的理由。
Go 完全可行,但开发效率不如 Python。Go 编译出来的单二进制文件确实漂亮,不依赖解释器,体积也小。可临时文件清理这种工具,核心逻辑倒不复杂,复杂的是各种平台特性和异常处理,Python 标准库打包得相对完整,改一版规则马上能跑,不需要为了读一个环境变量去查一堆系统包。如果你后续想让规则可配置化、加个 JSON 配置、对接 Web 界面,Python 的迭代速度优势更明显。
2.2 用到的核心庫,以及各自的责任
我最终的依赖列表保持在纯标准库级别,一个第三方包都不用。这样在用户机器上跑起来最简单,也方便打包:
| 模块 | 职责 |
|---|---|
tempfile | 获取当前系统的临时目录,跨平台兼容的关键 |
platform | 识别 Windows / Linux / macOS,决定不同的策略分支 |
os/os.scandir | 遍历目录、读取文件状态、删除文件 |
pathlib | 统一路径操作,规避 Windows 反斜杠带来的各类坑 |
shutil | 目录树删除rmtree |
argparse | 命令行参数,比如--dry-run、--older-than |
logging | 输出清理日志,方便排查 |
json | 读配置文件,管理白名单/黑名单规则 |
关键是tempfile.gettempdir()。它在 Windows 上会依次检查TMP、TEMP、USERPROFILE等环境变量,在 Linux/macOS 上会检查TMPDIR环境变量,最终返回一个确定的临时目录。直接用它,比自己在代码里拼环境变量名更稳。
3. 核心实现:识别、筛选、执行三步走
写代码的顺序很讲究——不要一开始就写删除逻辑。我建议严格按照"识别临时目录 → 统计并筛选 → 执行删除"的节奏推进,每步都单独验证。
3.1 第一步:跨平台定位临时目录
下面的函数可以同时收集用户级临时目录和系统级临时目录:
import os import platform import tempfile from pathlib import Path def collect_temp_roots() -> list[Path]: roots = [] system = platform.system() # 用户级临时目录,三个系统都有 user_tmp = Path(tempfile.gettempdir()).resolve() if not user_tmp.exists(): user_tmp.mkdir(parents=True, exist_ok=True) roots.append(user_tmp) # 系统级临时目录 if system == "Windows": windir = os.environ.get("SystemRoot", r"C:\Windows") roots.append(Path(windir) / "Temp") elif system == "Darwin": # macOS 的每个用户还有独立缓存域,路径中包含随机 ID roots.append(Path("/var/folders")) else: roots.append(Path("/tmp")) roots.append(Path("/var/tmp")) return sorted(set(roots), key=lambda p: str(p))请注意resolve()。在 macOS 上,/tmp其实是指向/private/tmp的符号链接,如果我们的代码不 resolve,后续判断"这个路径是否在我允许的临时目录集合内"时,会出现同一个目录被反复识别的混乱。先把路径规范化,后面的所有比较才有意义。
3.2 第二步:安全筛选策略的设计
直接遍历目录然后删掉所有文件,非常危险。我设计的筛选规则一共有四层,缺一不可:
- 年龄阈值:文件修改时间距今超过 N 天,默认 7 天。正在使用的临时文件往往 mtime 很新,这个阈值能把它们保护住。
- 大小阈值:小于 1MB 的文件跳过?这个不一定,我会作为可选参数;但如果文件大小还在剧烈变化中,说明可能正在写入,要跳过。
- 目录保护名单:临时目录下若存在
.git、important、keep等名称的目录,默认不删除,除非用户显式传入--force-keep。 - 删除范围校验:所有要删除的路径必须位于已识别的临时根目录内,绝不允许删除根目录之外的目标。
筛选逻辑大致如下:
from datetime import datetime, timedelta import os from pathlib import Path def _is_old_enough(path: Path, older_than_days: int) -> bool: try: mtime = datetime.fromtimestamp(path.stat().st_mtime) except FileNotFoundError: return True except OSError: return False return mtime < datetime.now() - timedelta(days=older_than_days) def _is_inside_roots(path: Path, roots: list[Path]) -> bool: resolved = path.resolve() for root in roots: try: if resolved.is_relative_to(root.resolve()): return True except ValueError: continue return False这段代码有一个细节值得注意:用st_mtime而不是st_atime。Linux 的 atime 更新策略是按需更新的,有些发行版默认relatime,这意味着一个文件可能昨天被读过但 atime 还停留在几个月前。如果按 atime 判断,容易把常用文件误判成"很久没碰"。mtime 在这个场景下稳定得多。
3.3 第三步:删除与异常兜底
到了真正删除这一步,设计原则是:能删就删,删不了的记下来,绝不能循环卡死。
import shutil import logging logger = logging.getLogger("tmpclean") def safe_remove(path: Path) -> tuple[bool, str]: try: if path.is_dir(): shutil.rmtree(path, ignore_errors=False) else: path.unlink() return True, "" except PermissionError as exc: return False, f"permission: {exc}" except OSError as exc: return False, f"os error: {exc}"这里有一个关键点:shutil.rmtree如果遇到目录内某个文件被占用,会抛出异常,而且可能删掉一部分文件、留一部分文件——这个时候你重试整个目录树不一定有用,因为文件可能仍被进程锁着。更稳妥的做法是:先用os.scandir从底层遍历,对每个文件单独删除,对每个空目录再用os.rmdir;目录删不掉也没关系,留着,下次再跑。
下面的清理函数体现了这个思路:
def clean_tree(root: Path, roots: list[Path], older_than_days: int): removed_count = 0 failed_log = [] for entry in sorted(os.scandir(root), key=lambda e: e.name): path = Path(entry.path) # 防止符号链接逃逸 if path.is_symlink(): if _is_old_enough(path, older_than_days): try: path.unlink() removed_count += 1 except OSError as exc: failed_log.append((str(path), str(exc))) continue try: if entry.is_dir(follow_symlinks=False) and not entry.is_symlink(): if _is_inside_roots(path, roots) and _is_old_enough(path, older_than_days): ok, msg = safe_remove(path) if ok: removed_count += 1 else: failed_log.append((str(path), msg)) elif entry.is_file(follow_symlinks=False): if _is_old_enough(path, older_than_days): ok, msg = safe_remove(path) if ok: removed_count += 1 else: failed_log.append((str(path), msg)) except OSError as exc: failed_log.append((str(path), str(exc))) return removed_count, failed_log核心保护逻辑在前面加了一层:先判断路径在不在根目录集合内。我用entry.is_dir(follow_symlinks=False)和entry.is_symlink()双保险,避免符号链接目录通过is_dir()被误判成真目录,然后被rmtree一路删到外部去。Windows 上还要额外小心 junction/reparse point,这类"假目录"按软链接处理,只删链接本身。
4. 实测中踩过的坑:权限、占用、符号链接与中文路径
这一部分是我最想强调的。代码看起来简单,但每换一个操作系统,都有不同的问题等着你。我在三个平台上分别跑过,以下全是真实遇过的坑。
4.1 Windows 下的文件占用与 PermissionError
Windows 上删除临时文件,最常见的失败就是文件被占用。比如浏览器某个下载进程还握着.crdownload,或者杀毒软件正在扫描某个.exe。这时os.remove和shutil.rmtree都会抛PermissionError。
我的对策是加一个重试机制:
import time def safe_remove_with_retry(path: Path, retries: int = 3, delay: float = 0.5): for attempt in range(retries): ok, msg = safe_remove(path) if ok: return True, msg if attempt < retries - 1: time.sleep(delay) logger.warning("删失败,重试 %s: %s", path, msg) return False, msg实测下来,对于杀毒软件短时间扫描导致的瞬时占用,重试很有效。对于长时间占用(比如某个服务持续锁定文件),重试也没用,那就跳过并记录,不要影响其他清理。清理工具不是修复工具,它不该因为个别顽固文件就卡死。
4.2 Linux/macOS 下的符号链接陷阱
Linux 的/tmp里经常有符号链接,Web 服务、容器运行时都会在里面留下指向持久卷的链接。如果你的代码没有检查符号链接,直接使用shutil.rmtree——虽然它默认不会跟随软链接,但在某些边界情况(比如路径带.或..组件)仍可能出问题。
更严峻的是 macOS 的/var/folders结构。每个用户有独立的临时目录,里面大量使用符号链接。我建议所有对目录的判断都加一个follow_symlinks=False的标志。例如entry.is_dir(follow_symlinks=False)只有真目录才返回 True,软链接或 junction 一律只做unlink处理。这样就算临时目录里出现一个指向/home/user/documents的链接,也只是删掉链接,绝不触碰目标数据。
4.3 中文路径与编码问题
Windows 控制台和历史遗留程序对编码的处理比较混乱,临时目录里可能出现中文或特殊字符的文件名。Python 3 内部用 Unicode,没问题,但把文件名拼进报错信息输出到终端时,Windows 的 GBK 终端可能直接抛UnicodeEncodeError。
解决方案很简单,日志输出统一走logging,并显式配置errors="replace":
import sys import logging handler = logging.StreamHandler(sys.stdout) handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) handler.stream.errors = "replace" # 避免编码异常导致整个日志中断 logger.addHandler(handler)一个编码异常导致整个清理任务崩溃,这在长任务里非常难受。加了容错后,就算个别日志行变成问号,主流程依然能跑完。
4.4 Linux 的权限位差异
Linux 的/tmp有 sticky bit,目录内的文件如果没有写权限,可能因属主不同而无法删除。特别是/var/tmp,长期运行的服务创建的临时文件可能属于root,以普通用户身份清理时,会成批失败。
我不建议直接在工具里强制提权。更合理的做法是:默认以当前用户权限运行,遇到权限不足时明确提示"哪些文件需要 root 权限,建议用 sudo 重新执行一遍",但不要自动调用 sudo。这个决策很简单:清理工具应该克制,损坏数据的概率随着提权操作而上升。当然,如果你明确知道这台机器只是你一个人用,也可以在打包时固定用 sudo,但要谨慎。
5. 从脚本到产品化:参数、日志、打包与定时清理
核心功能跑通只是第一步。一个工具要真正用于日常工作,还得解决"怎么跟人交互""怎么知道它干了什么""怎么分发到其他机器"这三件事。
5.1 CLI 参数设计
我最终做成了这样一套命令:
# 只看会删什么,不实际删除 python tmpclean.py --dry-run --older-than 14d # 真正执行,并输出日志文件 python tmpclean.py --clean --older-than 7d --log-file /var/log/tmpclean.log # 只清理指定目录 python tmpclean.py --clean --path /home/lucas/.cache/tmp --min-size 1M相关参数解析使用argparse,体验很顺:
import argparse def build_parser(): parser = argparse.ArgumentParser(description="跨平台临时文件清理工具") parser.add_argument("--dry-run", action="store_true", help="只扫描不删除") parser.add_argument("--clean", action="store_true", help="实际执行清理") parser.add_argument("--older-than", default="7d", help="文件修改时间超过该值才清理,支持 d/h 单位") parser.add_argument("--path", action="append", help="额外指定清理目录,可多次传") parser.add_argument("--min-size", default="1M", help="小于该大小的文件不清理") parser.add_argument("--log-file", help="日志输出文件路径") return parser.parse_args()--dry-run我强烈建议保留,甚至可以反过来做成默认模式。我在自己电脑上跑了整整两天的 dry-run,观察它到底会删什么、统计删多少字节,最后确认规则没问题才切换到自动清理。不要一上来就--clean,根据我的经验,首跑必然出现规则漏洞。
5.2 日志与审计
清理工具本质上在动用户的文件,所以每一步都要有记录。我设计的日志格式是:
2025-06-14 10:22:31 INFO 扫描根目录: C:\Users\lucas\AppData\Local\Temp 2025-06-14 10:22:32 INFO 删除文件: C:\Users\lucas\AppData\Local\Temp\installer\setup-1624.tmp (12.3MB) 2025-06-14 10:22:33 INFO 目录删除失败: C:\Users\lucas\AppData\Local\Temp\locked-chrome-cache, 原因: 文件被占用 2025-06-14 10:22:40 WARN 以下路径需要手动检查: ...另外我还会输出一份 summary,包括扫描文件数、删除文件数、释放空间大小、失败项数,方便和同事分享清理战果,也让非技术用户知道工具到底干了什么。
5.3 PyInstaller 打包与跨平台分发
Python 脚本在目标机器上跑需要解释器,这算是使用门槛。我用 PyInstaller 做单文件打包:
pyinstaller --onefile --name tmpclean main.py这里必须强调:PyInstaller 只能在哪个系统上打包成哪个系统的可执行文件,不能在 Windows 上打包 Linux 版本,反之亦然。所以我的发布流程是分别在三台机器上执行一遍:Windows 用 GitHub Actions 的 windows runner,Linux 用 ubuntu runner,macOS 用 macos runner。三个平台各出一份二进制,再对应发布。
打包后有两个小坑:
- 单文件模式启动慢。PyInstaller
--onefile启动时要先把内嵌的依赖解压到临时目录,双击时会有两秒延迟,这正常。如果你更在意启动速度,可以改用--onedir。 - 杀毒软件误报。第一次打出来的 exe 可能被 Defender 报毒。一个直接原因是 PyInstaller 的引导程序特征明显,另一个是确实有恶意软件使用同样的打包方式。我通常会给发布工具做代码签名,签名后误报率会大幅降低。
5.4 定时清理的落地姿势
工具做完后,定时清理的需求几乎是立刻涌上来的。我推荐的做法:
- Windows:用任务计划程序每天触发一次
tmpclean.exe --clean --older-than 14d。 - Linux:写一个 systemd service + timer,间隔可以设为每周一次。
- macOS:用 launchd plist,标记
StartCalendarInterval。
定时任务的重点是把--older-than调大一点。手动手工跑的时候,7 天阈值很舒服;但定时任务无人值守,一旦误判,也许到你发现时数据已经没了。我自己平常设到 21 天,宁可不清理,不可误清理。这个权衡,你跑一段时间也会有感觉。
最后再说一个我实际用过的小技巧:把这份工具放到一个带--dry-run的定时任务里,每天只统计不删除,日志自动归档。这样持续跑一个月,你对这台机器的临时文件增长速度会有一个非常直观的感知——哪种软件最会堆垃圾、每周各增长多少,全都清楚了。有了数据基础,再调整清理策略,就不会是拍脑袋了。