文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践

· 更新日期: · · 文件集成, 互斥控制, 设计, Windows 开发

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276563)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615421)
首次发布
引用本文(DOI: 10.5281/zenodo.21615420)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615420 https://comcomponent.com/zh-CN/blog/2026/03/07/001-file-integration-locking-best-practices-komurasoft-style/

DOI(最新版本)
10.5281/zenodo.21615420
DOI(此版本)
10.5281/zenodo.22281920

文件集成的互斥控制,在共享文件夹、夜间批处理、跨进程集成中几乎必然会成为问题。 搜索中常见的困惑主要有:光靠文件锁是否够用、如何避免多个 worker 抓到同一个文件、如何避开写入中途的文件等。

本文将以文件锁、原子 claim、temp -> rename、idempotency 为主线,来审视文件集成的互斥控制。

先统一术语

这个领域里有很多直接沿用英文的说法,如果含义一直含糊,读起来就会很吃力。这里先把它们在本文中的含义固定下来。

术语 本文中的含义
原子的(atomic) 指中间状态不会被外部看到的操作。结果只有两种:要么成功,要么什么都没发生
claim 指宣告“这个文件由我来处理”,从而确保处理权。本文中主要指这样一种形式:只有成功把文件从 incoming rename 到 processing/<worker>/ 的一方才成为所有者
原子 claim 指用一次操作完成上述 claim。如果确认与确保被拆成两步,其他进程就能挤进这个空隙(3.1)
lease 指带有效期的所有权。在 lock file 中写明谁持有、有效到什么时候,一旦过期就让其他 worker 可以接管(4.4)
stale 指持有者已经异常终止、但 lock 或 claim 仍然残留的状态。如果无法判定它是活的还是死的,所有人都会停下来(2.3)
manifest 指与数据本体分开放置的内容说明文件。其中写入文件名、大小、哈希、记录数等信息,供接收方校验使用。done 文件是它的最小版本(4.2)
idempotency(幂等性) 指同一份输入再处理一次,结果也不会改变的性质(4.5)
advisory lock 只有在所有参与者都遵守该约定的前提下才生效的锁。操作系统并不强制,因此完全可以写出无视它照样读写的程序。Linux 的 flock 就属于这一类
byte-range lock 不以整个文件为对象,而只针对指定范围的锁。代表是 Windows 的 LockFileEx,这一类由操作系统强制执行。不过也存在例外(3.5)

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 29 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

目录

  1. 先说结论(一句话)
  2. 文件集成中出现的竞争模式(图)
    • 2.1. 读到写入中途的文件
    • 2.2. 多个 worker 同时抓到同一个文件
    • 2.3. stale lock 导致所有人都卡住
  3. 反模式
    • 3.1. Exists -> Create 的两段式检查
    • 3.2. 直接写入最终文件名
    • 3.3. 文件大小停止变化就视为完成
    • 3.4. 大家一起更新共享文件
    • 3.5. 以为锁 API 是万能的
  4. 最佳实践
    • 4.1. 以 temp -> close -> rename / replace 方式公开
    • 4.2. 用 done / manifest 明确标示完整性
    • 4.3. 接收方以原子方式获取 claim
    • 4.4. 若依赖 lock file,就把它做成 lease
    • 4.5. 以 idempotency 为前提
  5. 伪代码(节选)
  6. 大致的选用方式
  7. 总结
  8. 参考资料

文件集成是一个“交接约定”比代码本身更容易出问题的领域。 单元测试都能通过,却唯独在生产环境的共享文件夹或夜间批处理中偶尔出问题,而且难以复现——这种情况相当常见。

大多数原因,与文件 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 则会直接损坏。

接收方共享文件夹发送方接收方共享文件夹发送方还在写入中行数不足 / 解析失败 / 只处理了部分内容以最终文件名创建 orders.csv正在写入第 1~5000 行检测到 orders.csv直接开始读取写入剩余内容

2.2. 多个 worker 同时抓到同一个文件

“查看列表,如果未处理就打开”这种流程,很容易让两个 worker 同时抓住同一个文件。 这正是重复计算或重复发送问题的开端。

incomingworker2worker1incomingworker2worker1同一份输入被重复处理找到 a.csv找到 a.csv开始读取开始读取

2.3. stale lock 导致所有人都卡住

只是放置一个 lock file 的设计,很容易在异常终止时卡住。 如果不知道这是谁的 lock、它是否仍然有效、有效期到什么时候,后续的处理就会永远等待下去。

workerBlock 文件workerAworkerBlock 文件workerA这里发生异常终止无法判定是否 stale,全员停摆创建 lock确认 lock 是否存在暂缓开始处理继续等待

3. 反模式

3.1. Exists -> Create 的两段式检查

这里的问题在于,“确认”与“确保”是两个独立的操作。 中间会有其他进程插入进来,因此无法实现互斥。

文件系统进程B进程A文件系统进程B进程A两边都被放行确认 lock 是否不存在确认 lock 是否不存在不存在不存在创建 lock创建 lock

典型的错误写法是这样的。

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, Environment.ProcessId.ToString());
    ProcessFile();
}

需要做的是,把“不存在就创建”变成一个单一操作。 在 .NET 中可以使用 FileMode.CreateNew 系列,POSIX 系统则使用 O_CREAT | O_EXCL 这类原子创建方式。

3.2. 直接写入最终文件名

如果接收方的理解是“看到那个名字就可以读取”,那么从直接对最终文件名开始写入的那一刻起,就已经输了。 基本原则是不要把“可见”与“可以读取”等同起来。

final 名可见接收方检测到发送方仍在写入读到不完整的数据
using var writer = OpenForWrite(finalPath); // 这里 finalPath 就已经变得可见
foreach (var row in rows)
{
    writer.WriteLine(row);
}

这种做法,等于自己主动招来了 2.1 中的问题。

3.3. 文件大小停止变化就视为完成

这看起来很方便,但相当危险。 跨网络的复制、发送方的临时暂停、缓冲、重试,都会让文件大小很自然地出现波动。

接收方共享文件夹发送方接收方共享文件夹发送方误判为已完成开始复制 data.zip中途暂停大小 10 秒未变化开始读取复制恢复
if (currentLength == lastLength && stableSeconds >= 10)
{
    return Ready;
}

靠推测来判断完成,在共享文件夹或大文件场景下很容易被绊倒。 完成状态用 manifest 或 done file 明确标示出来,才会更稳定。

3.4. 大家一起更新共享文件

让大家都去读取和更新同一个 status.csv 或 counter.json 的设计,基本上都是最后写入的人胜出。 一旦开始把文件集成当作简易数据库来用,就会在这里吃苦头。

status.csvbatchBbatchAstatus.csvbatchBbatchAA 的更新丢失读取 v1读取 v1写入 v2-A写入 v2-B

也有人会退一步选择 append-only,但其含义会随文件系统或部署形态而变动。 如果确实需要共享更新,那么这一环节最好不要硬靠文件集成来撑。

3.5. 以为锁 API 是万能的

锁 API 很重要,但它只在所有参与者都遵循同一套约定时才有效。 在异构系统集成中,最好不要过度信赖它,这样更安全。

补充说明:

  • Linux 的 flock 是 advisory lock,不遵守约定的一方照样可以正常写入
  • Windows 的 byte-range lock,在内存映射文件中会被忽略
  • 也就是说,不应该让操作系统级别的锁单独承担完成通知或所有权设计的全部责任

第二点在 Windows 的规范中有明确记载。Microsoft Learn 的 Locking and Unlocking Byte Ranges in Files 先写明:其他进程访问已被锁定的范围时必定失败(也就是说 Windows 的范围锁不是 advisory,而是由系统强制执行的),紧接着又补上一条提醒——使用内存映射文件时,byte-range lock 会被忽略。也就是说,只要对方是经由 CreateFileMapping 操作同一个文件,我方的锁就会被直接绕过。

在 .NET 中获取范围锁,用的是 FileStream.Lock / Unlock(Windows 环境下)。

using var stream = new FileStream(
    path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);

// 只把开头的 1 个字节当作“处理中”的标记加排他锁
stream.Lock(0, 1);
try
{
    // 在这里读写数据本体
}
finally
{
    // 关闭之前必须解除锁
    stream.Unlock(0, 1);
}

这种写法在遵循同一套约定的应用之间是有效的。但正如上面所说,如果对方经由内存映射访问,它就不起作用;而且本来也无法保证其他系统会去查看这张标记。所以真正的主体是第 4 章的交接协议一侧。

4. 最佳实践

先把这些做法与第 3 章的反模式一一对应地列出来。如果你意识到自己正踩中其中某一条,直接从对应的小节读起也没问题。

反模式 会发生什么 对应的对策
3.1. Exists -> Create 的两段式检查 确认与确保之间的空隙被插入,两个进程同时向前推进 4.3 以原子方式获取 claim(用 rename 或 FileMode.CreateNew)
3.2. 直接写入最终文件名 接收方读到正在写入中途的文件 4.1 以 temp -> close -> rename / replace 方式公开
3.3. 文件大小停止变化就视为完成 把复制的临时暂停误判为已完成 4.2 用 done / manifest 明确标示完成
3.4. 大家一起更新共享文件 被后写入的一方覆盖,更新丢失 用 4.3 把写入方收敛为一个,用 4.5 吸收重复处理。如果还不够,就参考第 6 章的撤退判断
3.5. 以为锁 API 是万能的 会被不遵守约定的一方或经由内存映射的访问打破 用 4.4 中作为 lease 的 lock file,以及 4.5 的 idempotency 来兜底

4.1. 以 temp -> close -> rename / replace 方式公开

这是王道做法。 把生成中的文件封闭在 temp 文件名下,close 之后再切换为 final 文件名。 接收方只查看 final 文件名。

生成唯一的 temp 文件名将全部内容写入 tempflush / close在同一目录中 rename / replace 为 final 文件名接收方只监视 final 文件名

要点:

  • temp 与 final 要放在 同一目录,至少要在 同一个卷 / 文件系统 上
  • 在 Windows / .NET 上,可以考虑使用 File.Replace 系列方法
  • 约定:final 文件名一出现,内容就已经完成

如果把 temp 放在另一个驱动器上,rename 就会相当于一次单纯的复制,或者 Replace 会失败。 这个前提看起来不起眼,但非常重要。

跨共享文件夹(SMB)时,还有下面 4 点会变得不确定。本文的主战场恰恰就在这里,所以单独列出来。

  • 在同一个共享的同一目录内进行 rename,是在服务器端执行的。 因此“看不到中间文件名”这一性质本身仍然成立。反过来,如果像从 \\server\shareA 到 \\server\shareB 这样跨越共享,就会被当作不同的卷来处理,而 Windows 的 MoveFileEx 在指定了 MOVEFILE_COPY_ALLOWED 时,会用复制加删除来代替移动。也就是说它不再是原子的,中间状态会被看到。把 temp 与 final、incoming 与 processing 放在同一个共享之内这一前提,比在本地时更加重要
  • 只要有人打开着,rename 就会失败。 共享文件夹上会有杀毒软件、搜索索引器、其他站点的客户端等我方并不掌握的访问方。对于 publish 与 claim 的 rename,把失败当作正常分支而不是异常来处理,中间插入一段短暂等待后重试,这才是现实的做法
  • 时间戳不能作为判断依据。 Microsoft Learn 的 File Times 中写明,关于文件时间,唯一能保证的只有“在关闭做出修改的那个句柄时会正确写入”这一点。写入过程中的最后修改时间,在所有写入用句柄全部关闭之前不会被完整更新。精度也取决于文件系统:FAT 的最后修改时间以 2 秒为单位,NTFS 的最后访问时间最多会延迟 1 小时才更新。再加上跨 SMB 时,时间是按服务器端的时钟打上的,如果客户端与服务器的时钟存在偏差,“距最后更新已过 N 分钟就开始处理”这种判定也会跟着偏。这正是不用时间或大小、而用 4.2 的 done / manifest 来判定完成的理由
  • 变更通知也会漏掉。 对共享文件夹的监视不要只依赖事件通知,与定期的目录枚举配合使用会更稳定。这部分内容整理在 FileSystemWatcher 实务指南 - 漏报与重复的应对 中

4.2. 用 done / manifest 明确标示完整性

除了数据本体之外,用另一个文件明确标示“什么已经完成”,可以让接收方更稳定。 在异构系统集成中尤其有效。

生成 data.tmp公开为 data.csv创建 data.done / manifest.json接收方检测到 done / manifest验证文件名、大小、哈希

希望放入 manifest 中的字段大致有这些:

  • 目标文件名
  • 大小
  • 哈希
  • 记录数
  • 集成 ID / idempotency key
  • 生成时间

顺序也很重要。 如果比本体公开更早放置 done,那它就不是完成通知,而是事故预告。

4.3. 接收方以原子方式获取 claim

如果多个 worker 都在查看同一个 incoming,那么“读取之前先把它移到自己名下”是最容易理解的做法。 只有把文件从 incoming 重命名到 processing/<worker>/ 成功的 worker 才会进行处理。

processingincomingworker2worker1processingincomingworker2worker1先成功的一方获得所有权找到 a.csv找到 a.csvrename a.csvrename a.csv

在运维层面,把目录也分开,会更便于追踪。

publishclaim成功失败tempincomingprocessingarchiveerror

用于 claim 的 rename 操作,前提同样是要在同一文件系统上进行。

4.4. 若依赖 lock file,就把它做成 lease

如果要使用 lock file,就不要只做成一个空文件,而要做成 带有有效期的所有权信息。 不知道是谁获取的 lock,之后必然会引发纠纷。

lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

要点:

  • 创建要以原子方式进行
  • 把停止更新作为 stale 判定的依据
  • 删除原则上只由 创建者本人 进行
  • 预先假定会有解锁遗漏的情况,制定好恢复流程

lock file 终究只是 用于协作的一张号牌。 想靠这一张号牌就保证完全的一致性,基本上都会很吃力。

4.5. 以 idempotency 为前提

互斥控制固然重要,但在实际运维中,“偶尔重复送达”“中途重新执行”这类情况是无法归零的。 最终起作用的,是 即使同一份输入再吃一次也不会坏掉 的设计。

是否输入 + idempotency key是否已处理不重复执行,视为成功执行处理记录到已处理台账

例如,为每个接收文件配上集成 ID,并记录到已处理台账中。 即使互斥控制曾被打破一次,只要保证结果不会被重复计算,运维就会轻松很多。

5. 伪代码(节选)

下面出现的 MakeTempPathSameDirectory、TryClaimBundleByRename,都是为了展示顺序而设的 虚构函数名。真正可以运行的实现,在开头介绍的那套示例中。

这段伪代码的实现(库、两个 worker 的 claim 竞争演示、单元测试) - komurasoft-blog-samples (GitHub)

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 想更新同一个逻辑状态,不要在文件集成上硬撑,也可以考虑数据库或队列
  • 操作系统级别的锁在同一应用群、同一前提下是有效的,但不能替代交接协议

最后一项其实也是一种撤退判断。 确实存在一些问题,用文件来处理会相当痛苦。

7. 总结

文件集成的互斥控制,重点不在于调用锁函数,而在于决定状态迁移——这就是本文的核心主张。用名称或目录来表达“生成中 / 已公开 / 处理中 / 已处理”,避免 Exists -> Create 的两段式检查、直接写入最终文件名、等待大小稳定、共享文件的相互更新,以及对锁 API 的过度信赖。在此基础上,再组合使用 temp -> close -> rename / replace、done / manifest、claim rename、lease 与 idempotency,就能相当有效地避免共享文件夹集成中的问题。

在文件集成中,诀窍在于不要把“可以读到”与“可以读取”混为一谈。只要把这两者区分开,那些只在深夜才会出现的故障就会大幅减少。

8. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

文件集成的互斥控制只靠锁 API 就够了吗?
多数情况下并不够。Linux 的 flock 是 advisory lock,不遵守约定的一方照样可以写入;Windows 的 byte-range lock 在内存映射文件中会被忽略。操作系统级别的锁在同一应用群、同一前提下是有效的,但应当作为辅助手段来使用,基本做法是把 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表