惊魂一刻:PDF没章,服务器却说“签完了”?

今天直接来复盘一个最近在生产环境遇到的“灵异事件”。这事儿要是没经历过,你绝对想不到:明明用户手里的PDF文件空空如也、没有电子签章,但后端的签章服务器日志却信誓旦旦地显示“签章成功”

起初,我们以为是阿里云NAS出了Bug,或者是网络延迟导致数据没同步。甚至怀疑过是不是PDF渲染库的问题。

但最后查下来,锅不在NAS,也不在网络,而在我们自己的架构设计上——多个节点同时对着同一个PDF文件进行“读 - 改 - 写”操作,导致了严重的版本竞争和覆盖

简单说就是:几个服务器操作同一份文件,结果把NAS搞蒙了

来,咱们像讲故事一样,把这个坑怎么挖的、怎么填的,给大家捋一捋。

🕵️‍♂️ 案发现场:薛定谔的电子签章

事情是这样的。我们的电子合同系统为了应对高并发,部署了多台签章服务节点(Node A, Node B, Node C...),它们都挂载了同一块阿里云NAS,共享存储PDF文件。

某天,客服突然接到投诉:“我刚签完字,下载下来的合同怎么是白的?红色的章呢?这合同有效吗?”

开发小哥赶紧去查后台日志。结果让人更懵了:

  • 用户侧:下载的PDF文件,确实没有签章域,也没红章,仿佛从来没被处理过。
  • 服务端:日志里明明白白写着 [INFO] Sign Task ID: 8899, Node: B, Status: SUCCESS, Time: 14:00:05。而且不只一条,Node A、Node C 也都记录了针对该文件的“成功”日志。

这就尴尬了。三个服务器都说“我盖好章了”,但文件上却一个章都没有。这难道就是传说中的“量子态签章”?

🔍 抽丝剥茧:谁抹掉了我的章?

既然日志显示成功,说明每个节点在自己的内存里确实完成了盖章逻辑。问题一定出在**“写回文件”**这个动作上。

我们还原了一下当时的执行时序,发现了一个致命的竞态条件(Race Condition):

假设有一个PDF文件 contract.pdf,初始状态是无章
此时,由于某种业务重试机制或负载均衡的抖动,Node ANode B 几乎同时拿到了这个任务,开始处理。

时间轴如下

  1. 14:00:00.100 - Node A 从NAS读取 contract.pdf(此时是无章版本 v0)。

  2. 14:00:00.105 - Node B 也从NAS读取 contract.pdf(此时也是无章版本 v0)。
    注意:此时两个节点内存里拿到的都是旧文件。

  3. 14:00:00.500 - Node A 在内存中处理完毕,生成了带A章的文件内容。

  4. 14:00:00.505 - Node B 在内存中也处理完毕,生成了带B章的文件内容。

  5. 14:00:00.600 - Node A带A章的文件写回NAS,覆盖了原文件。此时NAS上是v1版本(有A章)。

  6. 14:00:00.605 - Node B带B章的文件写回NAS。
    关键点来了! Node B 是基于它刚才读取的 v0 版本(无章)修改的,它并不知道 Node A 已经更新了文件。所以,Node B 写入的内容,本质上还是基于 v0 的,只是多了B的签名数据。

    但是,更糟糕的情况发生了:
    如果我们的代码逻辑是“如果已签章则跳过”,或者PDF库在合并签名时有特殊逻辑,可能会出现:

    • Node B 写入时,直接覆盖了 Node A 的写入。
    • 或者,因为两个进程同时操作,文件锁机制缺失,导致写入数据交错,文件损坏。
    • 最典型的“丢章”场景:Node B 读取的是无章版,它处理完后写回。如果它的逻辑是“全量覆盖”,那么 Node A 辛苦盖上的章,瞬间就被 Node B 的旧版本基础数据给覆盖掉了!

    甚至在某些极端情况下,如果两个节点都在尝试“追加”签名,但底层实现是“重写整个文件流”,后写入的那个节点会完全覆盖先写入的节点。如果后写入的节点因为某种原因(比如读取时机极早,或者逻辑判断失误)并没有真正包含有效的签章流,或者它覆盖时出现了截断,最终文件就可能变回“无章”状态,或者变成一个损坏的PDF。

在我们的案例中,实际情况是:Node B 后写入,它基于旧版本(无章)生成的新文件覆盖了 Node A 刚写好的有章文件。而由于并发冲突,Node B 的写入可能并未正确包含它自己声称的章(比如异常被吞掉,或者逻辑分支错误),最终导致文件回退到了无章状态,或者变成了一个“看似成功实则空壳”的文件。

结论:这不是缓存延迟问题,这是典型的“丢失更新”(Lost Update)

💥 核心原因:分布式环境下的“读 - 改 - 写”陷阱

在单机多线程环境下,我们可能会用synchronizedReentrantLock来保护共享资源。
但在分布式多节点环境下,JVM级别的锁失效了!Node A 的锁锁不住 Node B。

当多个节点对同一个文件执行 Read -> Modify -> Write 流程时,如果没有分布式锁控制,必然会发生:

  1. 脏读:多个节点读到同一个旧版本。
  2. 覆盖写:后提交的写入会无条件覆盖先提交的写入,导致先提交的数据丢失。

这就好比三个人同时在黑板上擦掉旧字写新字,都不看别人写了啥。最后黑板上留下的,往往是最后那个人的字迹,而且如果那个人手里拿的是旧草稿,那前面两个人写的就全白费了。

🛠️ 解决方案:给文件加上“分布式锁”

找到病因,治病就简单了。我们必须保证:同一时刻,只有一个节点能对同一个PDF文件进行写操作

方案一:分布式锁(标准答案)✅

这是最稳妥的方案。在读取文件之前,先去抢一把锁。

  • 技术选型:Redis(Redisson)、Zookeeper、Etcd 等。
  • 逻辑流程
    1. 尝试获取锁 lock:pdf:{fileId}(设置过期时间,防止死锁)。
    2. 如果获取成功:
      • 从NAS读取文件。
      • 内存中盖章。
      • 写回NAS。
      • 释放锁。
    3. 如果获取失败:
      • 等待重试,或者直接返回“处理中”,让前端轮询。
// 伪代码示例 (使用 Redisson)
RLock lock = redissonClient.getLock("lock:pdf:" + fileId);
if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
    try {
        // 1. 再次检查文件状态(双重检查锁)
        if (!isSigned(fileId)) {
            // 2. 读取 -> 修改 -> 写入
            signAndSave(fileId);
        }
    } finally {
        lock.unlock();
    }
} else {
    // 获取锁失败,说明有其他节点正在处理
    log.warn("文件正在被其他节点处理,稍后重试");
}

方案二:文件级原子操作(特定场景)

如果业务允许,可以将“临时文件 + 原子重命名”作为策略:

  1. 节点A读取原文件,处理后写入 contract.pdf.tmp.A
  2. 尝试将 contract.pdf.tmp.A 重命名为 contract.pdf
    注意:在NFS上,重命名的原子性也需要谨慎验证,且无法解决“基于旧版本修改”的逻辑错误,只能保证写入过程不交错。此方案不如分布式锁稳妥。

方案三:架构优化——避免共享写

从根本上解决问题:不要让多个节点去写同一个文件

  • 按节点拆分:每个节点只负责写属于自己的临时文件,最后由一个单独的聚合服务(单节点)负责合并和最终落盘。
  • 队列化处理:将签章任务放入消息队列(Kafka/RocketMQ),消费者设置为单线程或单实例消费,确保同一文件的任务串行执行。

💡 避坑指南 & 总结

这次踩坑给我们上了一课:在分布式系统中,共享存储(Shared Storage)

阿里云NAS提供了共享存储的能力,但它不提供文件级的分布式锁或版本控制功能。把并发控制的希望寄托在文件系统上,是极其危险的。

posted @ 2026-03-13 17:18  changlong2022  阅读(71)  评论(0)    收藏  举报