原子写入与自动备份:配置不被写坏靠的是哪两件事

2026-08-10

「配置不会被写坏」这句话在 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.2package.jsonsrc-tauri/Cargo.toml 两处一致)。我们只读源码文本,没有安装也没有运行过这个桌面应用

第一件:原子写入,只有一个函数

实现集中在一处:src-tauri/src/config.rs:327pub 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_writeconfig.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 要开 bundledbackuphooks 三个特性,其中 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-1074backup.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 补价重算的机会」,而这个回填失败也只是 warndao/usage_rollup.rs:79-86)。用量数据那条线另有专门篇目在讲,这里只指出它和备份共用同一个周期任务这个事实。

保留 10 份:README 写死,代码里是默认值

这是一处可核实的口径差,两边位置都能自己去翻:

位置写的是什么
README_ZH.md:306README 自称备份「保留最近 10 个」
src-tauri/src/settings.rs:1077-1087effective_backup_retain_count() 默认 10,最小 1,可由 AppSettings.backup_retain_count 覆盖

README 同一处也没有提到自动备份间隔是可配的(默认 24 小时,settings.rs:1065-1074)。两处不一致,以我们实读的仓库状态为准。至于哪一处「才算数」、为什么会这样,本文不做推断,也不拿它去评价这个项目。

需要提醒的是第二行的另一半:下限是 1。这个参数并没有「设成 0 就不留备份」的语义。至于该调成多少,取决于你的库有多大、你能接受回退到多久之前——项目没有给出通用值,我们也不给。

你可以自己核的四件事

这四步都不需要安装应用,clone 仓库就能做完:

  1. 看原子写入的实现边界:打开 src-tauri/src/config.rs,跳到 327 行看 atomic_write 本体,再往上看 294-325 的三个封装函数。在仓库里搜 atomic_write 的调用点,就能看清哪些文件受这套机制保护。
  2. 看 Windows 分支:同一文件 390-451 行,确认 ReplaceFileW、3 次重试、ERROR_NOT_SUPPORTED 与 NotFound 的回退条件。Windows 用户尤其应该亲眼看一下这段。
  3. 比对备份份数的两处口径README_ZH.md:306src-tauri/src/settings.rs:1077-1087 对着读,就能看到「写死 10」与「默认 10、下限 1、可覆盖」的差别。
  4. 看备份触发条件src-tauri/src/database/mod.rs:128-140 是迁移前那一次,backup.rs:314-315340-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。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。