restic 报 repository is already locked:锁文件、stale 判定与 unlock 的边界
报错原文
restic 拿不到锁时直接 Fatal 退出:
Fatal: unable to create lock in backend: repository is already locked exclusively by PID 12345 on host db01 by root (UID 0, GID 0)
lock was created at 2026-11-08 23:41:02 (18m12s ago)
storage ID 1a2b3c4d
这个格式来自源码里的 lockHandle.String();alreadyLockedError.Error() 的输出模板是 repository is already locked %sby %v,独占锁时中间会多插入一个 exclusively。所以看到 exclusively 就知道挡路的是 forget/prune/check 这类独占操作,而不是另一台机器上的 backup。
锁文件与字段
锁就是仓库 locks/ 目录下的一个文件,文件名是内容 ID,内容是 JSON:
{"time":"...","exclusive":false,"hostname":"kasimir","username":"fd0","pid":13607,"uid":1000,"gid":100}
对应的结构体字段是 Time、Exclusive、Hostname、Username、PID、UID、GID。大小为 0 的空文件会被忽略——部分 backend 的上传不是原子操作,读到半截文件不能当成有效的锁。
锁分两类:独占锁同时只能有一个,且它存在时不允许任何非独占锁;非独占锁可以多个并存。创建流程是先检查已有锁,再写入自己的锁,sleep 200ms 后重新检查一次,避免两个进程同时通过第一次检查。
stale 判定
lock_file.go 里写死了:
var staleLockTimeout = 30 * time.Minute
stale() 的判断顺序是:
- 时间戳距今超过 30 分钟 → 判为 stale;
- 否则 hostname 与当前机器不同 → 不判 stale;
- hostname 相同 → 调
processExists()(本质是kill(pid, 0)),进程已不存在 → 判为 stale。
正常运行时锁每 5 分钟刷新一次(defaultRefreshInterval = 5 * time.Minute),刷新方式是新建一个锁文件再删掉旧的(createReplacementLock + adoptReplacementLock)。留给刷新的窗口是 refreshabilityTimeout = 30m - 7.5m = 22.5 分钟,超过这个时间还没刷新成功,进程自己就会退出:
Fatal: failed to refresh lock in time
不想立刻失败就加 --retry-lock,指定最长等待时间。首次重试间隔 5s,之后指数退避,上限 60s,期间会打印:
repo already locked, waiting up to ... for the lock
哪些命令要独占
forget:即便不带--prune,也走openWithExclusiveLock(见cmd_forget.go)。check:会打印create exclusive lock for repository。prune、repair:需要独占。backup:只拿非独占锁,所以多台机器对同一仓库并行backup是允许的;但只要有一个独占锁在跑,新的backup就会被挡住。
--no-lock 是全局参数,可以让命令完全不拿锁,但 forget 只在 --dry-run 时允许搭配它。
排障顺序
先看锁,再决定动不动手删:
restic list locks
restic cat lock
cat lock 拿到的 JSON 里重点看三样:hostname 是不是本机、pid 对应进程还在不在、time 距今多久。三项对不上就是 stale,可以直接:
restic unlock
unlock 默认调 RemoveStaleLocks,只删 stale 的锁;--remove-all 调 RemoveAllLocks,无差别强删。帮助文本写的是 unlock removes stale locks,在 append-only 模式的 rest-server 下也能删锁。
锁文件本身损坏时报的是 invalid lock file,此时会提示用 unlock --remove-all,并要你确认没有其他 restic 进程正在访问仓库——这一步不能省,强删正在被持有的锁会让两个写操作同时进行。
能等就先用重试,别急着删:
restic -r ... --retry-lock 10m backup /data
确认只是读操作、不需要一致性保护时,可以绕开锁:
restic -r ... --no-lock snapshots
restic unlock --remove-all
容器与主机名
stale 判定的第二、三条决定了容器场景下的行为:hostname 与当前机器不同就直接不判 stale,后面的 PID 检查根本不会执行。容器默认 hostname 是随机 ID,于是从宿主机或另一个容器看,这把锁永远不属于“同机”,只能干等 30 分钟超时。PID 同理,容器内的 PID 命名空间和外面不是一套,kill(pid, 0) 在跨命名空间时没有参考价值。
要么让容器用可预期的 hostname(同主机多容器时还得注意别撞名),要么在确认无进程持锁后用 unlock --remove-all 清掉。反过来,同机多容器共用 hostname 也有风险:PID 恰好撞上外部一个存活进程,stale 判定会认为锁主人还活着。
0.18.0 在 CIFS 上的 chmod bug
0.18.0 引入的缺陷:在 CIFS、FUSE 挂载的 WebDAV 这类不支持 chmod 的后端上,删除 stale 锁会失败,报:
chmod ...: operation not supported
结果就是 stale 锁清不掉,仓库一直处于“已锁”状态。0.18.1 修复(#5342)。同一版本还修了两个问题:rest-server 返回 507 Insufficient Storage 时仍继续重试上传(#5429),以及 check 的下载重试被误判为仓库损坏(#5467)。
功能面上,0.18.0 起 check 支持 --json;0.18.1 起 check 可以用 --host/--path/--tag/快照 ID 限定要验证的 pack,不必全量扫。
退出码 11 与 3
forget 的文档字符串列出的退出码:
| 码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 出错 |
| 3 | 删除快照失败 |
| 10 | 仓库不存在 |
| 11 | 仓库已被锁 |
| 12 | 密码错误 |
锁冲突是 11。脚本里把 3 当成锁冲突处理,会把“删快照失败”和“拿不到锁”混为一谈,重试逻辑也跟着跑偏。
项目信息
- 项目地址:
- 仓库结构与锁设计:
- 排障文档: