最新文章

后端

# 使用 CAS 和心跳为单运行器作业实现租约锁 本文档描述了一种为单运行器作业实现租约锁的模式,该模式结合使用了检查并设置 (CAS) 操作和心跳机制。此模式可用于确保在任何给定时间只有一个作业实例在运行,这是后台任务、数据处理管道和其他分布式系统的常见要求。 ## 核心概念 * **租约锁:** 一种在特定时间段(“租约”)内授予的分布式锁。锁的持有者需要定期续订租约以维持所有权。如果租约到期,另一个进程可以获取该锁。 * **检查并设置 (CAS):** 一种原子操作,它读取一个值,将其与期望值进行比较,如果匹配,则用新值更新它。这用于在没有竞争条件的情况下获取锁。在 zP1z 中,这通常通过将 `If-Match` 标头与 ETag 结合使用来实现。 * **心跳:** 锁持有者发送的周期性信号,用于表明其仍处于活动状态并正在积极工作。此机制用于续订租约。 * **单运行器作业:** 一种任务或进程,在整个分布式系统中最多只应有一个实例并发运行。 ## 锁对象 使用一个专用对象(例如,zP1z 等存储服务中的文件)来表示锁。该对象存储锁的状态。 | 属性 | 描述 | | --- | --- | | `ownerId` | 当前持有锁的作业实例的唯一标识符。 | | `leaseExpiry` | 指示租约何时到期的时间戳(例如,ISO 8601 格式)。 | | `version` | 用于 CAS 操作的版本号或 ETag。 | ## 工作流 ### 1. 获取锁 1. 作业实例 (`worker-A`) 尝试获取锁。 2. 它首先读取锁对象。主要有两种情况: a. 锁对象不存在或被视为“未锁定”(例如,`ownerId` 为 null 或 `leaseExpiry` 已过期)。`worker-A` 尝试使用自己的 `ownerId` 和新的 `leaseExpiry` 来创建/更新锁对象。此写入操作是条件性的 (CAS):只有当对象不存在或自读取后未被修改时,它才会成功。在 zP1z 中,这是通过在写入请求的 `If-Match` 标头中提供读取操作返回的 ETag 来完成的。 b. 锁由另一个工作进程 (`worker-B`) 持有,且租约尚未到期。`worker-A` 获取锁失败,应稍后回退并重试。 3. 如果 CAS 写入成功,`worker-A` 就已获取锁并可以开始工作。 ### 2. 维持锁(心跳) 1. 在 `worker-A` 运行时,它必须定期发送心跳以维持其锁。这应在 `leaseExpiry` 时间之前完成。 2. 心跳是对锁对象的一次更新操作。`worker-A` 将 `leaseExpiry` 更新为新的未来时间戳。 3. 此更新操作也**必须**是 CAS 操作。`worker-A` 必须证明它仍然是合法的持有者。它通过将更新操作的条件设置为 `ownerId` 是其自己的 ID 以及它最后一次看到的 `version`/ETag 来实现这一点。这可以防止出现以下情况:`worker-A` 的租约已经到期,另一个工作进程 (`worker-C`) 获取了锁,但 `worker-A`(由于长时间暂停,例如 GC)唤醒后错误地尝试延长租约。 ### 3. 释放锁 1. 当 `worker-A` 完成其作业时,它应显式释放锁。 2. 它通过将锁对象更新为“未锁定”状态(例如,将 `ownerId` 设置为 null)或完全删除锁对象来实现。 3. 此释放操作也应以 `ownerId` 为条件 (CAS),以确保 `worker-A` 不会意外释放已被另一个工作进程接管的锁。 ## 容错 租约机制提供了容错能力。如果持有锁的工作进程崩溃或与网络分区,它将停止发送心跳。租约最终会到期(`leaseExpiry` 将成为过去时),从而允许另一个健康的工作进程获取锁。这确保了作业不会因单个工作进程的故障而无限期停滞。 ## 使用 zP1z 的示例 让我们考虑一个使用 zP1z Blob 存储的实现。 * **锁对象:** 一个名为 `job-lock.json` 的 blob。 * **获取锁:** 1. 尝试下载 `job-lock.json`。获取其内容和 ETag。 2. 如果 blob 不存在(404 Not Found),则尝试使用带有 `If-None-Match: *` 标头的 `Put Blob` 来创建它。请求正文将是 `{"ownerId": "worker-A", "leaseExpiry": "..."}`。 3. 如果 blob 存在且其 `leaseExpiry` 已过期,则尝试使用带有 `If-Match: <ETag from download>` 标头的 `Put Blob` 来更新它。请求正文与上述相同。 4. 如果这些 `Put Blob` 操作中的任何一个因 412 Precondition Failed 而失败,则表示另一个工作进程赢得了竞争。回退并重试。 * **心跳:** 1. 在租约到期前,定期对 `job-lock.json` 调用 `Put Blob`,并使用更新后的 `leaseExpiry`。 2. 该请求**必须**包含 `If-Match: <获取/上次更新 blob 时的 ETag>` 标头,以确保您仍然是所有者。 * **释放锁:** 1. 对 `job-lock.json` 调用 `Delete Blob`,并附带 `If-Match: <ETag>` 标头,以确保您删除的是自己的锁。

当持有者死亡时,普通锁会永久死锁。租约则会过期。本文介绍如何使用“比较并交换”(compare-and-swap)获取和心跳机制来构建一个租约,以及决定其设计的故障模式。

阅读约 2 分钟 distributed-systemslockingredisgojobs
数据库

`retryWrites=false` 在 Firestore 的 MongoDB 兼容性中的陷阱 `retryWrites=false` 在 Firestore 的 MongoDB 兼容性中的陷阱 Firestore 的 MongoDB 兼容层是一个强大的工具,它允许开发者利用熟悉的 MongoDB API,同时享受 Firestore 的可扩展性和托管特性。然而,一个微妙但关键的配置细节可能会导致意想不到的数据丢失:`retryWrites` 连接字符串参数。 当连接到标准的 MongoDB 副本集时,设置 `retryWrites=true`(大多数现代驱动程序中的默认设置)是最佳实践。它使驱动程序能够自动重试因瞬时网络错误或副本集选举而失败的某些写入操作。这提供了对临时故障的恢复能力。 但对于 Firestore,情况有所不同。Firestore 的架构并非 MongoDB 副本集。尽管它提供高可用性和数据冗余,但其内部机制是不同的。至关重要的是,Firestore 的 MongoDB 兼容层*不*支持可重试写入,这与原生 MongoDB 集群的方式不同。 如果你使用 `retryWrites=true`(或保留其默认值)连接到 Firestore,驱动程序可能会尝试重试写入操作。然而,由于 Firestore 不支持 MongoDB 用于保证重试“精确一次”语义的会话逻辑,这可能导致创建重复的文档。第一次写入可能成功了,但一个瞬时错误可能导致驱动程序认为它失败了并进行重试,从而创建了第二个相同的文档。 为防止这种情况,当连接到 Firestore 的 MongoDB 兼容层时,**必须**在连接字符串中显式设置 `retryWrites=false`。 连接字符串示例: ``` mongodb://zP1pUSERzP2p:zP3pPASSWORDzP4p@zP5pPROJECT_IDzP6p.gcp.mongodb.net:27017/zP7pDATABASEzP8p?tls=true&authSource=admin&replicaSet=zP9pRS_NAMEzP10p&retryWrites=false ``` 如果不这样做,在开发和测试期间似乎一切正常,但这会埋下一颗定时炸弹,在生产环境中特定的网络条件下可能导致数据损坏。请务必检查你的连接字符串,并确保其中包含 `retryWrites=false`。

Firestore 支持 MongoDB 线路协议,但会拒绝可重试写入。如果您的驱动程序默认启用可重试写入,写入操作便会以看似随机的方式失败。以下是原因及解决方法。

阅读约 1 分钟 firestoremongodbgoidempotencydatabases
后端

将后端从 Rust 迁移到 Go,以及为何构建时间是决定性因素 我从事一个项目已有一段时间,该项目是一个 Web 应用的后端。这是一个相当标准的后端,包含 REST API、数据库和大量业务逻辑。我开始用 Rust 编写它,因为我想使用一种快速、安全且拥有出色类型系统的语言。而 Rust 兼具所有这些优点。它是一门了不起的语言。 但随着项目的发展,我开始遇到一个问题:构建时间。Rust 的构建时间是出了名的慢。随着项目越来越大,构建时间也越来越长。发展到后来,每次我做出更改,都需要花费几分钟来构建项目。这极大地扼杀了生产力。我做一个小小的改动,然后就必须等待编译器完成工作才能看到结果。这令人沮st丧。 我尝试了很多方法来加快构建时间。我使用了 `sccache`。我使用了更快的链接器。我尝试将项目拆分成更小的 crate。但这些都没有带来显著的改善。构建时间仍然太长。 所以我决定尝试一些激进的方法:我决定用 Go 重写后端。我知道 Go 的构建时间比 Rust 快得多。而且我也很好奇,用 Go 编写后端的体验与用 Rust 编写相比会如何。 重写工作花了我大约一周的时间。结果令人震惊。在运行时性能方面,Go 版本的后端与 Rust 版本一样快。但构建时间则完全是另一回事。Go 版本的后端在几秒钟内就能构建完成。几秒钟!与构建 Rust 版本所需的几分钟相比,这简直是颠覆性的改变。 构建时间的差异对我的生产力产生了巨大影响。我可以做出更改,然后几乎立即看到结果。我可以更快地迭代。我能在更短的时间内完成更多的工作。这是一次巨大的胜利。 我仍然热爱 Rust。我认为它是一门了不起的语言。我肯定会在其他项目中再次使用它。但对于这个项目,Go 是正确的选择。快速的构建时间极大地提高了我的生产力,而这正是决定性因素。 这次经历给了我一个重要的教训:在为项目选择语言时,你需要考虑所有因素。不仅仅是语言本身,还包括工具、生态系统和社区。在这个案例中,构建时间是最重要的因素。

Rust 提供了我们很少需要的安全性,以及一个我们每天都要与之斗争的编译循环。本文将通过数据,坦诚地介绍促使一个服务转向 Go 的权衡过程。

阅读约 2 分钟 gorustmigrationbuild-timedeveloper-experience