2023年年底,我把手头一台旧电脑重新装成备份存储机,原想直接用现成的同步软件应付过去,结果试用一圈下来,要么数据要经过别人的服务器,要么配置复杂到让人直接放弃。最后我一咬牙,用 Python 从零写了一个本地智能文件备份工具,把定时备份、增量去重、历史版本保留、系统托盘监控全部做了进去。前后断断续续写了大概两周,后续在自用验证中又迭代了几轮,整理后把这个项目开源了出来。这篇文章不是贴满代码就完事的教程,而是完整记录我的需求和取舍思路——为什么用 Python、定时调度器怎么选、文件指纹怎么做、pystray 托盘监控里那些坑怎么填。如果你有 Python 基础,想自己写一个靠谱的本地备份工具,或者对“常驻系统托盘的小程序”这类开发场景感兴趣,这篇实战过程应该能帮你省掉不少弯路。
1. 为什么放着现成备份软件不用,偏要自己写一个
动手之前,我先花了一天时间把需求彻底想清楚。任何技术方案的第一步都是确认需求真实存在,而不是为了造轮子而造轮子。
1.1 现成方案到底哪里不满意
我把自己的核心诉求列成了一份清单:
- 数据必须完全留在本地,不经过任何中转服务器。
- 支持多个源目录对应多个目标目录的自定义映射,而不是只允许备份固定的“用户目录”。
- 既能定时自动触发,也能在托盘里一键手动备份。
- 必须做增量备份,并且保留历史版本。哪天手滑误改、误删了文件,我能随时回退。
- 工具本身要轻,不能占用几百 MB 内存,更不想要一个每天弹窗的“全家桶”式软件。
拿着这份清单去对照市面方案,问题就非常明显了。
云盘同步类工具首先被排除。数据上传到别人服务器这种事,对普通照片还好,但换成合同、财务报表、个人笔记这类敏感内容,心理上总过不去。而且云盘客户端普遍会接管整个磁盘的同步策略,我只想同步指定几个目录,它偏要管全部。
系统自带的备份,Windows 的文件历史记录算能用,但对“备份到哪里、副本怎么命名、保留多久”控制得很死,恢复界面也谈不上友好。macOS 的整机备份更适合做灾难恢复,不适合“只把我的项目文件夹复制到另一块盘”。命令行工具 rsync 很经典,可 Windows 下的体验一言难尽,要么借助第三方运行环境,要么用参数极其繁琐的移植版。普通用户包括我自己想偷懒的时候,都不愿意为一次备份敲一长串命令。
商业备份软件功能确实强,但普遍偏重量级。安装完先要配置一堆计划、策略、存储池,界面复杂得让我怀疑自己是在用备份工具还是在学 ERP。很多还带订阅收费,对一个纯本地小工具来说严重过剩。
结论很明确:自己写一个能完全控制的小工具。以 Windows 为主,但不排斥 macOS,数据全本地,功能收敛。
1.2 最小可用版本要包含哪些功能
第一版我刻意控制了功能边界,没有上来就做图形界面、双向同步、云端对接。真正有用的本地备份工具,最核心的能力其实只有四件事:到点自动跑、增量复制、历史版本、状态可查。我把 MVP 版本的功能列表和对应技术方案定成了下面这样:
| 功能模块 | 具体作用 | 关键技术依赖 |
|---|---|---|
| 定时调度 | 支持每天 / 每周 / 自定义 cron 规则触发备份 | APScheduler |
| 备份执行器 | 目录遍历、文件对比、增量复制、历史版本重命名 | pathlib / shutil |
| 文件指纹 | 先比较修改时间与文件大小,不一致时再算 SHA-256 | hashlib |
| 元数据存储 | 记录已备份文件清单、备份批次、历史版本 | sqlite3 |
| 托盘监控 | 常驻系统托盘,提供菜单、动态状态与气泡通知 | pystray / Pillow |
| 日志系统 | 记录每次备份结果,支持轮转与按需查看 | logging |
托盘菜单足够支撑日常操作,配置用 YAML 文件手工编辑。这一版已经能解决 80% 的需求,剩下的功能等真正用起来以后再迭代。
1.3 技术栈确定:为什么 Python 适配这个场景
选择 Python 有三个实际理由。
第一,标准库覆盖面非常合适。文件操作、路径处理、数据库、日志,全都在标准库里,不需要额外引一堆麻烦的依赖。第二,pystray 可以用很少的代码实现系统托盘,配合 Pillow 在内存里画图标,连图标文件都不需要准备。第三,开发迭代速度快。改动一个逻辑,从保存到验证完整备份流程只需要十几秒,桌面常驻工具的试错频率很高,这种效率优势很关键。
Python 的短板我也认:单文件打包体积偏大,常驻内存比同等功能的 Go 或 C# 高一些。但备份工具不需要极致性能,它需要的是可靠和可维护。如果一份代码能在三个平台跑同一个核心备份逻辑,维护成本远低于给每个平台单独写原生工具。
2. 架构设计与目录结构:先定边界,再写逻辑
写这类工具最容易犯的错,是一上来就在一个 main.py 里堆代码,后面加功能只能不停打补丁。这次我把边界先划清楚再动手,顺利支撑了后续五轮迭代。
2.1 四层拆分:调度、业务、界面、存储
整个程序拆成四层,每一层只依赖它下面的层,不跨层调用:
- 调度层只负责“什么时候运行”。它不关心备份怎么做,到点把任务交给业务层即可。
- 业务层只负责“备份怎么做”。包括遍历目录、新旧文件对比、复制、哈希校验、版本清理。
- 界面层只负责“用户怎么看见和操作”。托盘菜单、气泡通知都在这一层。
- 存储层只负责“上次备份了什么”。用 SQLite 持久化,避免每次启动都全量扫描。
这个分层最大的好处是,我可以单独测试定时调度而不碰文件复制,也可以手工构造一批脏数据去测业务层,不需要真的等定时器触发。后面如果打算加 Web 控制台,只需要在界面层加一个 HTTP 服务,业务层完全不用动。
2.2 目录结构长什么样
我建立的目录结构大致如下,每个模块职责单一,测试文件和源码分开放:
miniback/ ├── main.py # 程序入口:读取配置,组装各层,启动托盘 ├── config.yaml # 备份映射、调度规则、保留策略 ├── core/ │ ├── __init__.py │ ├── scheduler.py # APScheduler 封装,任务注册与热更新 │ ├── backup.py # 备份执行器:遍历、对比、复制 │ ├── hasher.py # 文件指纹计算,分块读取 SHA-256 │ └── cleaner.py # 历史版本清理与保留策略 ├── storage/ │ ├── __init__.py │ ├── database.py # SQLite 读写封装 │ └── models.py # 数据表结构定义 ├── ui/ │ ├── __init__.py │ ├── tray.py # pystray 托盘封装 │ └── notify.py # 气泡通知封装,带降级逻辑 ├── utils/ │ ├── __init__.py │ ├── logger.py # 日志轮转配置 │ └── paths.py # 路径规范化工具 └── tests/ ├── test_backup.py └── test_hasher.pymain.py 入口的逻辑非常短:读取配置、初始化数据库、启动调度器、启动托盘。真正的实现全部在各模块里,既不臃肿,也对后续打包、加测试友好。
2.3 一次完整备份的数据流
可以把一次备份理解成一次“文件搬运工派单”的过程。
调度器到点后产生一个派单事件;业务层接到事件后先查数据库里上次备份的文件清单作为参照;然后遍历源目录,对每个文件做新旧对比,得出需要复制的增量文件列表;复制时对目标路径做版本重命名保护,保留旧文件副本;复制完成后把新指纹信息写回数据库;最后通过界面层弹一个“备份完成”的气泡通知。
这个流程里最关键的一点是,所有状态以数据库为准,而不是每次都全量扫描后盲目复制。它决定了工具在数据量变大之后能不能继续跑得动。
3. 定时调度模块详解:从 schedule 到 APScheduler 的演进
定时功能看似简单,实际藏了不少坑。我最早用的是 schedule 库,只跑了一周就换掉了。
3.1 schedule 库为什么不适合长驻程序
schedule 库写起来非常优雅,十几行代码就能搞定:
import schedule import time def backup_job(): print("run backup") schedule.every().day.at("02:00").do(backup_job) while True: schedule.run_pending() time.sleep(1)但如果你要的是一个长期常驻的托盘工具,问题就来了。
第一个问题是:schedule 只管到点执行,不管执行失败后的状态恢复。备份任务一旦抛异常,虽然循环不会崩,但任务本身没有状态记录,你很难判断上一次到底成功没有。
第二个问题是:schedule 的任务定义只存在于内存中。重启程序后所有定时配置都要靠启动时重新注册一遍。如果用户改了 config.yaml 里的备份时间,必须重启程序才能生效。这对托盘程序来说体验很差。
第三个问题是:schedule 的 while True 循环和托盘线程的 icon.run() 互相挤占。两个阻塞式的死循环必须分处不同线程,而主线程往往被托盘占住,就得额外开线程跑循环,线程一多,程序状态就乱了。
3.2 换成 APScheduler 的核心配置
APScheduler 的 BackgroundScheduler 是一个后台线程,不会阻塞主流程。它还自带 jobstore 和 misfire_grace_time 机制,正好弥补 schedule 缺的那几块。我最终的核心配置是这样:
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.jobstores.memory import MemoryJobStore from apscheduler.executors.pool import ThreadPoolExecutor scheduler = BackgroundScheduler() scheduler.add_jobstore(MemoryJobStore()) scheduler.add_executor(ThreadPoolExecutor(max_workers=1)) def add_backup_job(cron_expr: str, job_func): trigger = CronTrigger.from_crontab(cron_expr) scheduler.add_job( job_func, trigger=trigger, id="backup_job", replace_existing=True, coalesce=True, max_instances=1, misfire_grace_time=3600, )这里有几个参数值得反复强调。
max_instances=1 防止上一次备份还没跑完,下一次触发又到来,导致两个备份任务同时读写同一块目标磁盘。coalesce=True 确保如果因为休眠或关机错过了一个触发点,恢复运行后只补执行一次,而不是把过去错过的全部任务瞬间跑一遍。misfire_grace_time=3600 表示错过任务后一小时内还愿意补跑,超过一小时直接丢弃。
这三点恰好对应长驻程序最容易踩的三个坑:任务重叠、任务积压、任务追补。尤其是笔记本用户,合盖睡一晚第二天打开,如果没有 misfire 机制,程序可能会疯了一样连续补跑十几个错过的备份。
3.3 配置热更新:改 yaml 不用重启
为了让用户修改 config.yaml 后不用重启整个程序,我加了一个简单的“配置热加载”机制。每 30 秒检查一次配置文件修改时间,发生变化就重新读取调度规则,更新任务:
import time from pathlib import Path last_mtime = 0.0 def watch_config_loop(): global last_mtime while True: mtime = Path("config.yaml").stat().st_mtime if mtime != last_mtime: last_mtime = mtime reload_config() time.sleep(30)这个后台监视循环放在独立 daemon 线程里,不影响其他部分。修改调度频率后最多半分钟就能生效,不用重启,体验提升非常明显。
4. 备份策略与增量算法:这个工具的核心价值
定时调度只是一层外衣,真正决定这个工具有没有用的,是备份策略。第一版直接做全量复制,数据量一涨,磁盘很快就撑不住了。
4.1 全量、增量与差异备份,到底该选哪种
先看三者的核心区别:
| 备份方式 | 特点 | 适合场景 |
|---|---|---|
| 全量备份 | 每次完整复制整个源目录 | 数据量小、追求恢复简单 |
| 增量备份 | 只备份自上次备份后变化的内容 | 日常主力,最省空间 |
| 差异备份 | 每次备份自上次全量后的全部变化 | 恢复速度较快,但占用会累积 |
我最终的选择是:首次运行做一次全量备份,之后每次做增量;同时每周做一次全量校验,对源文件与目标文件哈希比对,发现异常重新复制。这样既省空间,又能防止复制过程中出现的静默数据故障。
4.2 文件变化判断:mtime + 大小为主,哈希为辅
增量备份最核心的问题是判断“文件是否变了”。最简单也最笨的办法是每次算所有文件的哈希再比对,但照片库里几千个大文件,每次备份都把全部读一遍,耗时完全不可接受。
我采用的策略分两级:
- 快筛。先比较源文件的最后修改时间(mtime)和文件大小(size)与数据库里记录的库存信息。两者都相同,默认文件没变,直接跳过。
- 精查。对大小或修改时间不一致的文件,再计算 SHA-256。如果哈希一致,说明只是文件属性变了,内容没变,也可以跳过复制。
哈希计算必须避免一次性把整个文件读进内存,用分块读取的方式:
import hashlib def file_sha256(path: Path, chunk_size: int = 1024 * 1024) -> str: sha = hashlib.sha256() with open(path, "rb") as f: while True: chunk = f.read(chunk_size) if not chunk: break sha.update(chunk) return sha.hexdigest()chunk_size 选 1MB 比较平衡。分块太大,内存占用高;分块太小,IO 次数变多反而更慢。实测读取 1GB 文件大约耗时 3 到 5 秒,所以哈希只承担“补充确认”的角色,绝不在每次增量备份里对全部文件做哈希扫描。
4.3 版本管理:别让备份变成“复制坏数据”
很多新手写备份工具时会犯一个致命错误:发现文件变了,直接往目标路径复制覆盖。这样一来,如果源文件被误删或者误改,目标盘上也是一份同样损坏的数据,备份就彻底失去了意义。
正确做法是:每次准备覆盖目标文件前,先把目标路径的旧文件重命名成带时间戳的版本文件,再写入新文件。例如:
目标盘:/backup/docs/report.docx 旧版本: /backup/docs/report_20250301_143000.docx.bak 新版本: /backup/docs/report.docx历史版本的命名规则是文件名_YYYYMMDD_HHMMSS.bak,具体保留多少份由 cleaner 模块统一控制。默认每个文件最多保留 5 个历史版本,超出部分按时间从旧到新删除。这套机制让备份不仅能防止数据丢失,还能防止“当前错误状态污染历史好数据”。
4.4 SQLite 元数据表设计
元数据存储选择 SQLite,因为它单文件、免运维、标准库直接驱动。设计了三个核心表:
CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, relative_path TEXT NOT NULL, source_root TEXT NOT NULL, size INTEGER NOT NULL, mtime REAL NOT NULL, sha256 TEXT, last_backup_at TEXT NOT NULL, UNIQUE(source_root, relative_path) ); CREATE TABLE IF NOT EXISTS backup_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, files_copied INTEGER DEFAULT 0, files_skipped INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL, version_path TEXT NOT NULL, created_at TEXT NOT NULL, size INTEGER NOT NULL );files 表记录当前备份状态,backup_runs 表记录每次任务的执行结果,versions 表记录历史版本路径。三张表配合可以回答运维中最常见的三个问题:这份文件上次备份是什么状态?上次备份整体成功没有?某个文件的历史版本在哪里?
一个使用上的教训是:relative_path 字段不要存绝对路径。绝对路径会随着存储迁移全部失效,保存相对路径,读取时拼接当时记录的 source_root 即可。
5. 托盘监控与交互实现:pystray 的正确打开方式
Python 脚本再强大,如果只能靠命令行启动,它始终只是开发者自己的玩具。托盘监控是这个工具从“脚本”变成“常驻小程序”的关键一步。
5.1 用 Pillow 在内存里生成图标,摆脱外部文件依赖
如果要额外分发一个 .ico 文件,打包和路径处理都会多一层麻烦。我的做法是直接用 Pillow 在内存里画一个图标,彻底免掉图标文件:
from PIL import Image, ImageDraw import pystray def create_image(): image = Image.new("RGB", (64, 64), "#2E3440") draw = ImageDraw.Draw(image) draw.ellipse((16, 16, 48, 48), fill="#88C0D0") draw.polygon([(32, 20), (44, 32), (32, 44)], fill="#2E3440") draw.polygon([(20, 20), (32, 32), (20, 44)], fill="#2E3440") return image icon = pystray.Icon( "miniback", create_image(), "MiniBack", menu=pystray.Menu( pystray.MenuItem("立即备份", on_backup_now), pystray.MenuItem("暂停定时备份", on_toggle_pause, checked=lambda item: paused), pystray.Menu.SEPARATOR, pystray.MenuItem("打开备份目录", on_open_backup_dir), pystray.MenuItem("退出", on_exit), ), )图标不需要多精美,只需要传递一个信息:程序还活着。为了区分“正常运行”和“正在备份”,我还让回调函数在备份开始时临时换一个红色图标,备份结束后恢复蓝色。pystray 支持通过 icon.icon 属性动态替换图片,实现起来很简单。
5.2 线程模型:托盘程序最容易翻车的地方
pystray 的 icon.run() 自带消息循环,会阻塞当前线程。如果你直接把备份逻辑写在菜单回调函数里,托盘消息循环会被长时间卡住,表现出来就是图标变成“幽灵”,点击毫无反应。
我的线程模型是这样的:
- 主线程跑 icon.run(),只负责处理托盘菜单事件。
- 调度线程由 APScheduler 管理,负责定时触发任务。
- 备份执行放在单独的 worker 线程,避免耗时操作阻塞调度。
菜单回调函数里只做一件事:把“立即备份”请求放进一个 queue.Queue,由 worker 线程取出执行。菜单点击秒开、系统不卡,还能顺带实现“正在备份时忽略新的手动触发”的效果。
import threading import queue task_queue = queue.Queue() def on_backup_now(icon, item): task_queue.put("backup_now") def worker_loop(): while True: task = task_queue.get() if task == "backup_now": run_backup_with_lock()run_backup_with_lock 内部用线程锁保证同一时刻只有一个备份任务执行,和调度层的 max_instances=1 形成双保险。这个线程模型是我在踩过“托盘假死”的坑之后才总结出来的,强烈建议所有 pystray 项目直接照搬。
5.3 气泡通知与状态提示
备份完成后,用户需要第一时间收到反馈。pystray 自带一个 notify 方法,但显示效果在不同平台不一致。我在 Windows 上测试时自带通知基本可用,但样式朴素。为了不为了一个好看通知而引入重依赖,我把通知封装成一层,底层优先用 plyer,导入失败时降级为 pystray.notify:
def show_notification(title: str, message: str): try: from plyer import notification notification.notify(title=title, message=message, timeout=5) except Exception: icon.notify(message, title)这种降级思路适用于所有平台特性功能。不要为了让某个弹窗效果更好就引入一个可能崩溃的依赖,一个备份工具的核心使命是可靠,而不是花哨。
5.4 开机自启与暂停备份
备份工具通常需要开机后自动运行。Windows 下不需要管理员权限的最省事方案,是往启动文件夹放一个快捷方式。我封装了一个基于 winshell 的实现,做成菜单项“开机自动启动”,用户可以随时取消:
import winshell from pathlib import Path def enable_autostart(exe_path: str) -> bool: startup_dir = Path(winshell.folder("startup")) link_path = startup_dir / "miniback.lnk" winshell.create_shortcut( str(link_path), str(Path(exe_path).resolve()), working_directory=str(Path(exe_path).parent), ) return Truewinshell 通过 Windows COM 接口创建快捷方式,全程不需要管理员权限,非常适合绿色小工具。
6. 打包发布与实操避坑:从脚本到 exe 的最后一公里
代码写完只是第一步,分发才是真正的最后一公里。用 PyInstaller 打包时我踩了几个坑,以下是把关键参数和问题一起梳理后的经验。
6.1 打包参数:为什么我放弃 --onefile
先说结论:我用的是 --onedir 而不是 --onefile。
--onefile 会把所有内容塞进一个 exe,分发方便,但启动时要先把资源解压到临时目录,启动慢,而且杀毒软件误报率明显更高。--onedir 生成一个目录,启动快,资源路径处理简单,更适合长驻工具。
我实际的打包命令:
pyinstaller --noconsole --onedir --name MiniBack ^ --icon=assets/icon.ico ^ --add-data "config.yaml;." ^ --collect-all pystray ^ --collect-all PIL ^ main.py几个参数逐个解释。
--noconsole 在 Windows 下隐藏命令行窗口。如果不加,托盘程序每次启动都会带出一个黑色控制台窗口,观感极差。--collect-all pystray 是为了处理 pystray 在 Windows 下使用的 win32 后端动态导入问题,静态分析经常漏掉这些后端模块。--add-data 把配置文件拷到打包目录,注意 Windows 下分隔符是分号,Linux 和 macOS 下是冒号,跨平台脚本里要区分。
6.2 长驻程序打包后最常见的三个坑
坑一:--noconsole 模式下日志全消失。
没有控制台,print 天然就没有了。这是所有托盘工具打包后遇到的第一个问题。解决办法是把所有日志输出配置到文件:
import logging from logging.handlers import RotatingFileHandler handler = RotatingFileHandler( "miniback.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8", ) logging.basicConfig(level=logging.INFO, handlers=[handler])日志文件是长驻程序的生命线。没有它,线上排障就是纯靠猜。我后来排查备份失败问题时,全靠日志里记录的异常堆栈和文件路径定位。
坑二:打包后 Pillow 绘制的图标变成纯色块或透明。
原因多半是 PIL 图片模式和 ico 图标资源的兼容性问题。我的处理是 create_image 里统一转成 RGBA 模式,并在打包时额外加上 --collect-all PIL。另外,托盘标题如果用了中文字符串,要确保打包环境有对应字体支持,或者干脆改用英文标题,避免在精简版系统上出现问号。
坑三:路径含空格或中文时,备份目录识别失败。
PyInstaller 打包后程序所在路径可能包含中文或空格,如果用字符串拼接路径而不做规范化,很容易出问题。我的统一方案是入口处先做 Path(path).resolve(),后续全程用 pathlib 操作,不用字符串拼路径。pathlib 天然规避了 Windows 大量反斜杠转义的坑,这个建议适用于所有桌面工具项目。
6.3 跨平台经验:托盘在三个平台上的差异
pystray 在 macOS 上走的是菜单栏接口,菜单行为和 Windows 差异很大;在 Linux 上依赖 AppIndicator,不同桌面环境支持度参差不齐。
我实测下来的结论:Windows 上体验最完整;macOS 能用,但菜单退出行为需要额外兼容;Linux 托盘在 GNOME 桌面环境下经常不显示。如果你主要用 Linux,建议把这个工具作为纯定时任务运行,不启动托盘,用系统 crontab 直接调用备份函数即可。这也正是我坚持把业务层和界面层严格分开的原因——剥离托盘后,工具照样能作为命令行任务运行。
7. 实测上线后的性能问题与优化记录
代码写完不是结束,真实环境会给你上课。这里把我在实际运行中遇到的问题和优化方案记录下来,每一条都是被数据打脸后总结的。
7.1 大目录首次备份的耗时与并发优化
首次全量备份一个约 200GB 的照片素材库时,单线程复制跑了一整晚。原因不在于大文件多,而在于零散文件数量巨大。复制一万个小文件,比复制一个 100GB 的大文件更慢,瓶颈在 IOPS 而不是带宽。
优化方案是线程池并发复制,但要控制并发数,避免把系统 IO 全部吃光:
from concurrent.futures import ThreadPoolExecutor def bulk_copy(file_items: list, max_workers: int = 4): with ThreadPoolExecutor(max_workers=max_workers) as pool: futures = [pool.submit(copy_one, item) for item in file_items] for f in futures: f.result()实测中 4 个并发线程提升最明显;继续加到 8 个,磁盘磨损和系统卡顿就开始显现。注意这里特意做了异常聚合:某个文件复制失败不能拖垮整个批次,失败清单记录到日志,下次任务重试。
7.2 哈希校验拖慢增量备份
最初版本里,我对每个源文件都计算 SHA-256 再入库,结果增量备份同样变得很慢,因为大文件每次都要完整读一遍。后来把策略调成前面 4.2 节描述的两级方案:先比较 mtime 和 size,一致就跳过;只有不一致才算哈希。
调整之后,增量备份平均耗时从十几分钟降到不到一分钟。测试数据是 150GB 总容量,日增变化约 2GB。代价是“只改属性不改内容”的文件会被多读一次,但换来绝大多数未变更文件的零 IO 跳过,收益远大于成本。之后又加了一个“超大文件跳过哈希”的配置:超过 2GB 的文件,如果 mtime 和 size 一致,直接视为未变更,不再做哈希复核。这个阈值可以根据数据重要程度自由调整,对照片、视频这类不会静默变更的文件是安全的。
7.3 文件被占用和权限拒绝
Windows 环境下,正在被 Office 编辑的文档、被邮件客户端锁定的数据文件,复制时极易抛出权限错误。处理方法是捕获异常后把文件加入“失败列表”,等待下一次备份任务重试:
class FileRetryList: def __init__(self, db): self.db = db def record_failure(self, relative_path: str): self.db.execute( "INSERT OR REPLACE INTO retry_files (relative_path, failed_at) VALUES (?, ?)", (relative_path, datetime.now().isoformat()), ) def get_pending(self): rows = self.db.query("SELECT relative_path FROM retry_files LIMIT 100") return [r[0] for r in rows]备份工具最重要的一点是“可完成性”。个别文件失败时立即中断整个备份是错误的,应该让整体流程继续跑完,把失败项记录在案,留给下一次任务处理。
7.4 日志文件无限膨胀
上线运行三个月后,miniback.log 体积膨胀到了几百兆。加上 RotatingFileHandler 后自动按大小轮转,问题解决。这个教训很基础,但值得所有写长驻程序的朋友重视:日志要能轮转、能按天切分,否则内存不足不是问题,磁盘满才是问题。
8. 从第一版到开源:迭代记录和后续方向
工具在自用阶段迭代了五个小版本,从只支持单个目录备份,到多映射配置、版本保留、托盘提示、自启开关,功能逐步补齐。整理成开源小项目之前,我做了一次关键调整:移除所有个人路径和机器专属配置,把 config.yaml 改成通用示例模板,并补了一份清晰的说明文档。
这件事对任何准备发布自己工具的人都很重要。用户拿到你的工具后,发现里面写死了你的用户名或专属路径,第一反应就是这个项目“很私人,不适合我用”,可信度大打折扣。
如果后续继续扩展,我比较看好的方向有三个:
- 加密备份。目标盘如果是移动硬盘,丢失后数据直接裸露,加上对称加密会让工具的适用范围提升一个级别。
- 远端推送。备份完成后通过 Webhook 推送通知到手机或服务器,适合无人值守场景。
- 双向同步。当前是单向备份,改成双向同步需要新增冲突检测机制,复杂度上升不少。
最后分享一个我个人的开发习惯:这类小而美的工具,不要盲目追求大而全。把备份这件事做到可靠、可观测、可恢复,远比堆砌一堆华而不实的功能有价值。我自己的使用体会是,每次从历史版本中找回被误删的文件时,都会觉得当初多写的几十行版本管理代码非常值得。