原子写入与自动备份:配置不被写坏靠的是哪两件事
「配置不会被写坏」这句话在 cc-switch 仓库里其实对应两套完全不同的机制,它们保护的对象也不一样:一套是 src-tauri/src/config.rs 里的原子写入,管的是 JSON 与 TOML 这类文本配置文件;另一套是 src-tauri/src/database/backup.rs 里的数据库备份,管的是 ~/.cc-switch/cc-switch.db 这个 SQLite 库文件。库文件不走原子写入那条路。
把这两条路径分清楚,是读懂这块代码的关键。以下全部基于我们本地 clone 的 cc-switch 仓库快照 c39c903(提交日期 2026-08-10),仓库内版本号 3.19.2(package.json 与 src-tauri/Cargo.toml 两处一致)。我们只读源码文本,没有安装也没有运行过这个桌面应用。
第一件:原子写入,只有一个函数
实现集中在一处:src-tauri/src/config.rs:327 的 pub fn atomic_write(path: &Path, data: &[u8])。函数上方的文档注释写得很直白——原子写入是「写入临时文件后 rename 替换,避免半写状态」。
它的执行序列是这样的:
先造临时文件名。格式是 {file_name}.tmp.{进程 pid}.{纳秒时间戳}.{进程内 AtomicU64 计数器},用 create_new(true) 独占创建,遇到重名就换一个计数器再试,最多重试 16 次(config.rs:341-372)。三段拼起来的意思是:跨进程靠 pid 区分,跨时刻靠纳秒时间戳区分,同进程同一瞬间的并发写靠那个原子计数器区分。
写入过程中一旦失败,会先 fs::remove_file(&tmp) 把临时文件清掉再返回错误(config.rs:374-378)。也就是说失败路径不留垃圾,原文件也没被动过。
接下来才是替换。替换之前还有一个准备动作:Unix 下会先把原文件的权限位复制到临时文件(config.rs:381-388),这样替换完的新文件不会莫名其妙换掉权限。
真正执行替换的只有两条路径:
- Windows:用
ReplaceFileW做替换,最多重试 3 次;ReplaceFileW失败且返回ERROR_NOT_SUPPORTED或 NotFound 时,才在同一次重试循环里回退到fs::rename——源码注释点名 WSL 的 UNC 路径是ERROR_NOT_SUPPORTED的一个来源(config.rs:390-451)。 - 非 Windows:直接
fs::rename(&tmp, path)(config.rs:463-470)。
Windows 这段值得单独记一笔:ReplaceFileW 一定会先被调用,fs::rename 是它失败之后的补救,不是另一条并行入口;源码里写明的判断条件是错误码,UNC 路径只是注释里点到的一个成因场景。至于回退之后行为上有什么差别,源码没写,我们也没有依据,不做延伸。
谁在调 atomic_write,谁不调
这一点比函数本身更容易搞错。atomic_write 是底层原语,config.rs 里有三个走它的封装函数(294-325):
write_json_file/write_json_file_with_contents:先把 JSON 的键按字母排序(sort_json_keys),再 pretty 序列化,最后调atomic_write。注释说明排序是为了「确定性输出」(config.rs:294-317)。write_text_file:用于 TOML 与纯文本,同样落到atomic_write(config.rs:319-325)。
键排序这件事看着不起眼,实际影响很具体:同一份配置,无论内部字段是按什么顺序构造出来的,落盘后的文本都是同一个样子。对于要放进版本管理、或者要在两台机器之间比对的配置文件来说,这决定了 diff 里出现的是真实变更还是键序噪音。
而 SQLite 库文件不在这条路径上。Database 通过 conn: Mutex<Connection> 持有连接(src-tauri/src/database/mod.rs:80-82),写库靠的是 SQLite 自己的事务与文件格式,不是「临时文件加重命名」。这就是为什么库那一侧需要另一套东西兜底。
第二件:备份走的是 rusqlite 的在线备份 API
backup.rs:459-470 里的实现是 Backup::new(&conn, &mut dest_conn) 加 backup.step(-1)——rusqlite 的在线备份接口,产出的是一致性快照。这也是为什么 src-tauri/Cargo.toml:76 里 rusqlite 0.31 要开 bundled、backup、hooks 三个特性,其中 backup 就是给这里用的。
落地位置很好找:数据库同级目录下的 backups/ 子目录,文件名 db_backup_YYYYMMDD_HHMMSS.db;同一秒内重名时追加 _1、_2 递增(backup.rs:381-397)。按默认路径,就是 ~/.cc-switch/backups/。
轮换逻辑在 backup.rs:414-446:保留数由 crate::settings::effective_backup_retain_count() 决定,超出后按文件 mtime 排序、删最旧的那几个。
反直觉的地方:备份不是「每次写都做」
这是本篇最值得讲透的一处。看到「自动备份,保留最近 10 个」,很容易理解成每次改配置都会留一份,攒够 10 份开始滚。而源码里覆盖到的自动备份触发点有两个:
第一个触发点是迁移前。 Database::init() 中,当读到的库版本满足 0 < version < SCHEMA_VERSION 时——也就是确实要做 schema 迁移时——才先调 backup_database_file()(database/mod.rs:128-140)。而且这里还有一句要照实写出来:备份失败只 warn,不阻断迁移。迁移照常往下走。
第二个触发点是周期备份。 间隔默认 24 小时,由 AppSettings.backup_interval_hours 覆盖;设为 0 时不做自动备份(settings.rs:1065-1074、backup.rs:314-315)。
要补一句边界:自动触发之外还有用户主动的那一路——commands/import_export.rs 这一组命令里就包含数据库备份的创建、列出、恢复、重命名与删除。那是你自己点出来的动作,不在本文讨论的自动触发范围内。
把这几条合起来看:日常增删供应商、改设置这类操作本身不会自动触发数据库备份。你看到 backups/ 目录里躺着几个文件,其中自动产生的那些对应的是「跨版本升级」和「周期任务」这两类时刻,而不是你每一次改动的快照;手动执行备份命令留下的文件则另算。
顺带还有一个容易反过来理解的点:周期维护和自动备份是两回事,前者无论后者开不开都会跑。 backup.rs:340-370 里,周期任务除了备份,还会跑 cleanup_old_stream_check_logs(7) 和 rollup_and_prune(30),有行被回收时再执行一次 PRAGMA incremental_vacuum;——这一段的注释明确写了「无论自动备份是否开启都会跑」。启动时也跑同一套清理(database/mod.rs:151-164)。所以把 backup_interval_hours 设成 0,关掉的是备份,不是清理。
关于剪枝,rollup_and_prune 里还埋了一句写得很清楚的注释:剪枝之前会先尝试 backfill_missing_usage_costs_on_conn,因为「明细一旦汇总删除,0 成本行就永远失去按 pricing_model 补价重算的机会」,而这个回填失败也只是 warn(dao/usage_rollup.rs:79-86)。用量数据那条线另有专门篇目在讲,这里只指出它和备份共用同一个周期任务这个事实。
保留 10 份:README 写死,代码里是默认值
这是一处可核实的口径差,两边位置都能自己去翻:
| 位置 | 写的是什么 |
|---|---|
README_ZH.md:306 | README 自称备份「保留最近 10 个」 |
src-tauri/src/settings.rs:1077-1087 | effective_backup_retain_count() 默认 10,最小 1,可由 AppSettings.backup_retain_count 覆盖 |
README 同一处也没有提到自动备份间隔是可配的(默认 24 小时,settings.rs:1065-1074)。两处不一致,以我们实读的仓库状态为准。至于哪一处「才算数」、为什么会这样,本文不做推断,也不拿它去评价这个项目。
需要提醒的是第二行的另一半:下限是 1。这个参数并没有「设成 0 就不留备份」的语义。至于该调成多少,取决于你的库有多大、你能接受回退到多久之前——项目没有给出通用值,我们也不给。
你可以自己核的四件事
这四步都不需要安装应用,clone 仓库就能做完:
- 看原子写入的实现边界:打开
src-tauri/src/config.rs,跳到327行看atomic_write本体,再往上看294-325的三个封装函数。在仓库里搜atomic_write的调用点,就能看清哪些文件受这套机制保护。 - 看 Windows 分支:同一文件
390-451行,确认ReplaceFileW、3 次重试、ERROR_NOT_SUPPORTED与 NotFound 的回退条件。Windows 用户尤其应该亲眼看一下这段。 - 比对备份份数的两处口径:
README_ZH.md:306与src-tauri/src/settings.rs:1077-1087对着读,就能看到「写死 10」与「默认 10、下限 1、可覆盖」的差别。 - 看备份触发条件:
src-tauri/src/database/mod.rs:128-140是迁移前那一次,backup.rs:314-315与340-370是周期那一次。确认一下「备份失败只 warn」这一行确实在那里。
如果你已经在用这个应用,还有一个不用读代码的判断动作:去 ~/.cc-switch/backups/ 看文件名上的时间戳。文件名格式是 db_backup_YYYYMMDD_HHMMSS.db,时间戳能告诉你这些备份产生在什么时刻,你可以拿它和上面两个触发点对照。
两条路径都覆盖不到的部分,直说
有几处必须交代清楚边界,免得读者把这篇当成安全保证:
原子写入解决的是「写到一半崩了留下半个文件」这一类问题,它不承诺内容本身是对的——写进去的数据要是本来就错,原子地写进去也还是错的。数据库备份解决的是「有一个可回退的历史副本」,它不是实时的,两个触发点之间发生的变更不在任何快照里。这两条都不构成「不会出问题」的承诺,本文也不做这种承诺。
还有一处必须如实说明:~/.cc-switch/ 下的库文件与备份文件里,保存着你配置过的 API Key 等本机敏感数据;CC Switch 本身也会读写 ~/.claude、~/.codex 这些真实的 CLI 配置文件。backups/ 目录里躺着的每一个 .db 快照,都是这些内容的副本。要不要同步、要不要放进云盘、要不要纳入版本管理,请自己按所在环境评估——本文只描述仓库里的机制,不给安全方案。
最后一点分寸:本文引用的所有阈值(保留 10 份、间隔 24 小时、重试 16 次与 3 次、清理 7 天与 30 天)都是源码里的默认配置,不是「你用起来会怎样」的保证,更不能据此推算你会不会丢数据。它们的价值在于告诉你去哪一行确认,而不是替你下结论。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、
src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。