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

· · 文件集成, 互斥控制, 设计, Windows 开发

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

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

目录

  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.csvcounter.json 的设计,基本上会是最后写入的人「获胜」。 一旦开始把文件集成当作简易数据库来用,就会在这里吃苦头。

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

也有人会选择 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 文件名。

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

要点:

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

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

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. 伪代码(节选)

5.1. 典型的失败模式

var lockPath = finalPath + ".lock";

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, "");
    using var writer = OpenForWrite(finalPath); // 直接写入最终文件名
    WritePayload(writer);

    File.Delete(lockPath);
}

存在三个问题:

  • ExistsWriteAllText 是两个独立操作
  • 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 / replacedone / manifest、claim rename、lease 与 idempotency,就能相当有效地防止共享文件夹集成中的事故。

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

8. 参考资料

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

常见问题

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

文件集成的互斥控制只靠锁 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表