文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践
· 小村 豪 · 文件集成, 互斥控制, 设计, Windows 开发
文件集成的互斥控制,在共享文件夹、夜间批处理、跨进程集成中几乎必然会成为问题。 搜索中常见的困惑主要有:光靠文件锁是否够用、如何避免多个 worker 抓到同一个文件、如何避开写入中途的文件等。
本文将以文件锁、原子 claim、temp -> rename、idempotency 为主线,来审视文件集成的互斥控制。
目录
- 先说结论(一句话)
- 文件集成中出现的竞争模式(图)
- 2.1. 读到写入中途的文件
- 2.2. 多个 worker 同时抓到同一个文件
- 2.3. stale lock 导致所有人都卡住
- 反模式
- 3.1.
Exists -> Create的两段式检查 - 3.2. 直接写入最终文件名
- 3.3. 文件大小停止变化就视为完成
- 3.4. 大家一起更新共享文件
- 3.5. 以为锁 API 是万能的
- 3.1.
- 最佳实践
- 4.1. 以
temp -> close -> rename / replace方式公开 - 4.2. 用
done/ manifest 明确标示完整性 - 4.3. 接收方以原子方式获取 claim
- 4.4. 若依赖 lock file,就把它做成 lease
- 4.5. 以 idempotency 为前提
- 4.1. 以
- 伪代码(节选)
- 大致的选用方式
- 总结
- 参考资料
文件集成是一个「交接约定」比代码本身更容易出问题的领域。 单元测试都能通过,却唯独在生产环境的共享文件夹或夜间批处理中偶尔出问题,而且难以复现——这种情况相当常见。
大多数原因,与文件 I/O 的 API 本身无关,而在于以下三点含糊不清:
- 什么时候才可以读取
- 谁拥有处理权
- 失败时该如何恢复
本文不会把文件集成的互斥控制仅仅停留在操作系统锁的话题上,而是将其作为一种交接协议来加以整理。
另外,本文中出现的代码,也以一整套可构建、可运行的示例(库、演示两个 worker 之间 claim 竞争与 lease 交接的演示程序,以及重现竞争、损坏、stale lock 的单元测试)的形式发布在 GitHub 上。
file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)
1. 先说结论(一句话)
- 文件集成中最重要的,是让「最终文件名一出现,就已经可以读取」这一状态成立
- 用文件名或目录来区分表达「生成中 / 已公开 / 处理中 / 已处理」
- 如果有多个 worker,读取之前要先以原子方式获取 claim
- lock file 或操作系统锁只作为辅助手段,最终以 idempotency 来兜底
说到底,在文件集成中,与其说是「互斥控制」,不如说「交接协议」的设计才是主体。 调用一个锁函数就完事,这种情况是不存在的。
2. 文件集成中出现的竞争模式(图)
2.1. 读到写入中途的文件
一旦直接对最终文件名开始写入,就会发生这种事故。 JSON 会缺少闭合括号,CSV 行数不够,ZIP 则会直接损坏。
sequenceDiagram
participant 发送 as 发送方
participant 共享 as 共享文件夹
participant 接收 as 接收方
发送->>共享: 以最终文件名创建 orders.csv
发送->>共享: 正在写入第 1~5000 行
接收->>共享: 检测到 orders.csv
接收->>共享: 直接开始读取
Note over 接收: 还在写入中
发送->>共享: 写入剩余内容
Note over 接收: 行数不足 / 解析失败 / 只处理了部分内容
2.2. 多个 worker 同时抓到同一个文件
「查看列表,如果未处理就打开」这种流程,很容易让两个 worker 同时抓住同一个文件。 这正是重复计算或重复发送问题的开端。
sequenceDiagram
participant W1 as worker1
participant W2 as worker2
participant Dir as incoming
W1->>Dir: 找到 a.csv
W2->>Dir: 找到 a.csv
W1->>Dir: 开始读取
W2->>Dir: 开始读取
Note over W1,W2: 同一份输入被重复处理
2.3. stale lock 导致所有人都卡住
只是放置一个 lock file 的设计,很容易在异常终止时卡住。 如果不知道这是谁的 lock、它是否仍然有效、有效期到什么时候,后续的处理就会永远等待下去。
sequenceDiagram
participant A as workerA
participant Lock as lock 文件
participant B as workerB
A->>Lock: 创建 lock
Note over A: 这里发生异常终止
B->>Lock: 确认 lock 是否存在
B->>Lock: 暂缓开始处理
B->>Lock: 继续等待
Note over B,Lock: 无法判定是否 stale,全员停摆
3. 反模式
3.1. Exists -> Create 的两段式检查
这里的问题在于,「确认」与「确保」是两个独立的操作。 中间会有其他进程插入进来,因此无法实现互斥。
sequenceDiagram
participant A as 进程A
participant B as 进程B
participant FS as 文件系统
A->>FS: 确认 lock 是否不存在
B->>FS: 确认 lock 是否不存在
FS-->>A: 不存在
FS-->>B: 不存在
A->>FS: 创建 lock
B->>FS: 创建 lock
Note over A,B: 两边都被放行
典型的错误写法是这样的。
if (!File.Exists(lockPath))
{
File.WriteAllText(lockPath, Environment.ProcessId.ToString());
ProcessFile();
}
需要做的是,把「不存在就创建」变成一个单一操作。
在 .NET 中可以使用 FileMode.CreateNew 系列,POSIX 系统则使用 O_CREAT | O_EXCL 这类原子创建方式。
3.2. 直接写入最终文件名
如果接收方的理解是「看到那个名字就可以读取」,那么从直接对最终文件名开始写入的那一刻起,就已经输了。 基本原则是不要把「可见」与「可以读取」等同起来。
flowchart LR
A[final 名可见] --> B[接收方检测到]
B --> C[发送方仍在写入]
C --> D[读到不完整的数据]
using var writer = OpenForWrite(finalPath); // 这里 finalPath 就已经变得可见
foreach (var row in rows)
{
writer.WriteLine(row);
}
这种做法,等于自己主动招来了 2.1 中的事故。
3.3. 文件大小停止变化就视为完成
这看起来很方便,但相当危险。 跨网络的复制、发送方的临时暂停、缓冲、重试,都会让文件大小很自然地出现波动。
sequenceDiagram
participant 发送 as 发送方
participant 共享 as 共享文件夹
participant 接收 as 接收方
发送->>共享: 开始复制 data.zip
发送->>共享: 中途暂停
接收->>共享: 大小 10 秒未变化
Note over 接收: 误判为已完成
接收->>共享: 开始读取
发送->>共享: 复制恢复
if (currentLength == lastLength && stableSeconds >= 10)
{
return Ready;
}
靠「推测」来判断完成,在共享文件夹或大文件场景下很容易被绊倒。 完成状态最好通过 manifest 或 done file 来明确表示,这样才更稳定。
3.4. 大家一起更新共享文件
让大家都去读取和更新同一个 status.csv 或 counter.json 的设计,基本上会是最后写入的人「获胜」。
一旦开始把文件集成当作简易数据库来用,就会在这里吃苦头。
sequenceDiagram
participant A as batchA
participant B as batchB
participant F as status.csv
A->>F: 读取 v1
B->>F: 读取 v1
A->>F: 写入 v2-A
B->>F: 写入 v2-B
Note over F: A 的更新丢失
也有人会选择 append-only 的折中方案,但其含义会随文件系统或部署形态而变动。 如果确实需要共享更新,那么这一环节最好不要硬靠文件集成来撑。
3.5. 以为锁 API 是万能的
锁 API 很重要,但它只在「所有参与者都遵循同一套约定」时才有效。 在异构系统集成中,最好不要过度信赖这一点,这样更安全。
补充说明:
- Linux 的
flock是 advisory lock,不遵守约定的一方照样可以正常写入 - Windows 的 byte-range lock,在内存映射文件中会被忽略
- 也就是说,不应该让操作系统级别的锁单独承担完成通知或所有权设计的全部责任
4. 最佳实践
4.1. 以 temp -> close -> rename / replace 方式公开
这是王道做法。 把生成中的文件封闭在 temp 文件名下,close 之后再切换为 final 文件名。 接收方只查看 final 文件名。
flowchart LR
A[生成唯一的 temp 文件名] --> B[将全部内容写入 temp]
B --> C[flush / close]
C --> D[在同一目录中 rename / replace 为 final 文件名]
D --> E[接收方只监视 final 文件名]
要点:
- temp 与 final 要放在 同一目录,至少要在 同一个卷/文件系统 上
- 在 Windows / .NET 上,可以考虑使用
File.Replace系列方法 - 约定:final 文件名一出现,内容就已经完成
如果把 temp 放在另一个驱动器上,rename 就会相当于一次单纯的复制,或者 Replace 会失败。
这个前提看起来不起眼,但非常重要。
4.2. 用 done / manifest 明确标示完整性
除了数据本体之外,用另一个文件明确标示「什么已经完成」,可以让接收方更稳定。 在异构系统集成中尤其有效。
flowchart TD
A[生成 data.tmp] --> B[公开为 data.csv]
B --> C[创建 data.done / manifest.json]
C --> D[接收方检测到 done / manifest]
D --> E[验证文件名、大小、哈希]
希望放入 manifest 中的字段大致有这些:
- 目标文件名
- 大小
- 哈希
- 记录数
- 集成 ID / idempotency key
- 生成时间
顺序也很重要。
如果比本体公开更早放置 done,那它就不是完成通知,而是「事故预告」。
4.3. 接收方以原子方式获取 claim
如果多个 worker 都在查看同一个 incoming,那么「读取之前先把它移到自己名下」是最容易理解的做法。
只有把文件从 incoming 重命名到 processing/<worker>/ 成功的 worker 才会进行处理。
sequenceDiagram
participant W1 as worker1
participant W2 as worker2
participant IN as incoming
participant PR as processing
W1->>IN: 找到 a.csv
W2->>IN: 找到 a.csv
W1->>PR: rename a.csv
W2->>PR: rename a.csv
Note over W1,W2: 先成功的一方获得所有权
在运维层面,把目录也分开,会更便于追踪。
flowchart LR
T[temp] -->|publish| I[incoming]
I -->|claim| P[processing]
P -->|成功| A[archive]
P -->|失败| E[error]
用于 claim 的 rename 操作,前提同样是要在同一文件系统上进行。
4.4. 若依赖 lock file,就把它做成 lease
如果要使用 lock file,就不要只做成一个空文件,而要做成 带有有效期的所有权信息。 不知道是谁获取的 lock,之后必然会引发纠纷。
flowchart TD
L[lock.json] --> A[ownerId]
L --> B[host]
L --> C[pid]
L --> D[acquiredAt]
L --> E[expiresAt]
L --> F[heartbeatAt]
要点:
- 创建要以原子方式进行
- 把「停止更新」作为 stale 判定的依据
- 删除原则上只由 创建者本人 进行
- 预先假定会有解锁遗漏的情况,制定好恢复流程
lock file 终究只是 用于协作的一张「号牌」。 想靠这一张号牌就保证完全的一致性,基本上都会很吃力。
4.5. 以 idempotency 为前提
互斥控制固然重要,但在实际运维中,「偶尔重复送达」「中途重新执行」是无法归零的。 最终,能够「即使同一输入再吃一次也不会出问题」的设计才会真正起作用。
flowchart LR
A[输入 + idempotency key] --> B{是否已处理}
B -- 是 --> C[不重复执行,视为成功]
B -- 否 --> D[执行处理]
D --> E[记录到已处理台账]
例如,为每个接收文件配上集成 ID,并记录到已处理台账中。 即使互斥控制曾被打破一次,只要保证结果不会被重复计算,运维就会轻松很多。
5. 伪代码(节选)
5.1. 典型的失败模式
var lockPath = finalPath + ".lock";
if (!File.Exists(lockPath))
{
File.WriteAllText(lockPath, "");
using var writer = OpenForWrite(finalPath); // 直接写入最终文件名
WritePayload(writer);
File.Delete(lockPath);
}
存在三个问题:
Exists与WriteAllText是两个独立操作finalPath从写入过程中途就已经可见- 异常终止时
lock会残留
5.2. 正确方向的示例(粗略写法)
var tempPath = MakeTempPathSameDirectory(finalPath);
WritePayload(tempPath);
FlushAndClose(tempPath);
PublishByRenameOrReplace(tempPath, finalPath); // 前提:同一 FS / 同一 volume
PublishDoneFile(finalPath + ".done", new
{
FileName = Path.GetFileName(finalPath),
Size = GetFileSize(finalPath),
Hash = ComputeHash(finalPath),
IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
return; // 已被其他 worker 先行获取
}
var manifest = ReadDoneFile(Path.Combine(processingDir, baseName + ".done"));
VerifyPayload(Path.Combine(processingDir, baseName), manifest);
if (AlreadyProcessed(manifest.IdempotencyKey))
{
MoveBundle(processingDir, archiveDir, baseName);
return;
}
Process(Path.Combine(processingDir, baseName));
RecordProcessed(manifest.IdempotencyKey);
MoveBundle(processingDir, archiveDir, baseName);
这里重要的是 顺序,而不是实现细节。 不要把「写入」「公开」「获取所有权」「记录已处理」混在一起,这样会更不容易出问题。
6. 大致的选用方式
- 单一 writer/单一 reader/同一主机的情况下,光靠
temp -> rename就已经相当稳定 - 如果存在多个 consumer,就加入
incoming -> processing的 claim rename - 异构系统集成、NAS、共享文件夹的场景下,把 manifest / done 与 idempotency 都加进去会更安全
- 如果多个 writer 想更新同一个逻辑状态,不要在文件集成上硬撑,也可以考虑 DB 或队列
- 操作系统级别的锁在同一应用群、同一前提下是有效的,但不能替代交接协议
最后一项其实也是一种「撤退判断」。 确实存在一些问题,用文件来处理会相当痛苦。
7. 总结
文件集成的互斥控制,重点不在于调用锁函数,而在于决定状态迁移——这就是本文的核心主张。用名称或目录来表达「生成中 / 已公开 / 处理中 / 已处理」,避免 Exists -> Create 的两段式检查、直接写入最终文件名、等待大小稳定、共享文件的相互更新、以及对锁 API 的过度信赖。在此基础上,再组合使用 temp -> close -> rename / replace、done / manifest、claim rename、lease 与 idempotency,就能相当有效地防止共享文件夹集成中的事故。
在文件集成中,诀窍在于不要把「可以读到」与「可以读取」混为一谈。只要把这两者区分开,那些只在深夜才会出现的事故就会大幅减少。
8. 参考资料
- 此文的示例代码一整套(库、演示、单元测试) - komurasoft-blog-samples (GitHub)
- LockFileEx function (Win32)
- Locking and Unlocking Byte Ranges in Files (Win32)
- Moving and Replacing Files (Win32)
- File.Replace Method (.NET)
- rename — POSIX
- open — POSIX (
O_CREAT | O_EXCL) - flock(2) — Linux manual page
- open(2) — Linux manual page
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
意外异常发生时,应用该终止还是继续的判断表
本文从状态破坏、外部副作用、线程与原生边界等角度,整理发生意外异常时应用应该终止还是继续运行的判断依据,并给出常见场景下的判断表与建议。
Windows 应用程序开发安全性最低限度检查清单
本文以检查清单的形式,整理 WPF / WinForms / WinUI / C++ / C# 业务应用程序在权限、签名、更新、机密信息、HTTPS、输入验证、DLL 加载、日志等方面不想遗漏的基本要点。
FileSystemWatcher 实务指南:应对遗漏通知与重复通知
本文从遗漏通知、重复通知、完成判定的陷阱、重新扫描、原子式 claim、idempotency(幂等性)等角度,整理 FileSystemWatcher 的使用方法与注意事项。
不只是 appsettings.json ── Windows 业务应用的配置管理实务(环境差异化设置・敏感信息・写入位置)
本文从实务角度整理 Windows 业务应用的配置管理,涵盖 appsettings.json 的分层、IConfiguration 与 IOptions/IOptionsSnapshot/IOptionsMonitor 的选用方式、环境差异化设置、可写入配置的存放位置、敏...
Windows 业务应用程序的打印与 PDF 输出 ── System.Drawing.Printing / WPF / 报表库的取舍选型
本文按需求整理成判断表,梳理 PrintDocument 的 WinForms 打印、WPF 的 FlowDocument/FixedDocument 打印,以及 PDF 输出的可选方案。并从实务角度讲解分页控制、DPI 偏差等实现陷阱,以及从 Windows 服务打印、依...
常见问题
汇总了咨询这一主题时常见的问题。
- 文件集成的互斥控制只靠锁 API 就够了吗?
- 多数情况下并不够。Linux 的 flock 是 advisory lock,不遵守约定的一方照样可以写入;Windows 的 byte-range lock 在内存映射文件(memory-mapped file)中会被忽略。操作系统级别的锁在同一应用群、同一前提下是有效的,但应当作为辅助手段来使用,基本做法是把 temp -> rename、done/manifest、原子 claim、idempotency 这类交接协议的设计作为主体。
- 如何避免文件被读取到写入中途的内容?
- 王道做法是通过 temp -> close -> rename/replace 来公开文件。把生成中的文件封闭在 temp 文件名下,close 之后再在同一目录中切换为 final 文件名,接收方只查看 final 文件名。前提是 temp 与 final 要放在同一目录,至少要在同一个卷/文件系统上,并约定「final 文件名一出现,内容就已经完成」。
- 如何防止多个 worker 同时处理同一个文件?
- 在读取之前以原子方式获取 claim。具体做法是让只有把文件从 incoming 重命名到 processing/<worker>/ 成功的 worker 才进行处理。Exists -> Create 这种两段式检查,由于「确认」与「确保」是两个独立操作,中间会有其他进程插入,因此无法实现互斥。如果需要原子创建,可以使用 .NET 的 FileMode.CreateNew 系列或 POSIX 的 O_CREAT | O_EXCL。
- 使用 lock file 时有哪些注意事项?
- 不要只做成一个空文件,而应做成带有 ownerId、host、pid、acquiredAt、expiresAt、heartbeatAt 的、具有有效期的 lease(所有权信息)。创建要以原子方式进行,把「停止更新」作为 stale 判定的依据,删除原则上只由创建者进行,并预先假定会有解锁遗漏的情况,制定好恢复流程。不要指望靠一个 lock file 就能保证完全的一致性,最终以 idempotency 来兜底的设计在实务中更为可靠。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。