在分布式解决方案中使用 MongoDB 进行独占锁
介绍了如何在分布式解决方案中设计一种锁机制,以允许服务器组件之一获得独占访问或进行领导者选举。
引言
在我团队开发各种大型分布式和云系统过程中,我们遇到了许多需要为共享资源的独占访问提供某种标志,或者确保分布式解决方案中只有一个进程(也可称为工作进程、服务器、服务)能够执行特定工作流或扮演唯一角色的情况。这种标志需要一种锁定功能,该功能能够确保该标志持有者在整个系统中的唯一性,同时也能确保在当前锁资源持有者崩溃时能够释放并重新分配锁。
这种标志需要一种锁定功能,它能确保在整个系统中该标志持有者的唯一性,同时也能确保在当前被锁定资源的持有者崩溃时能够释放锁并重新分配。
下面提出的解决方案涉及使用 MongoDB NoSQL 数据库作为此代码示例中用于提供原子锁功能的提供者。
背景
让我们考虑以下场景:你在 Azure Cloud 中将业务逻辑处理服务托管为 Worker Role。Worker Role 可以扩展到任意数量的该角色的实例。所有 Worker Role 的实例都将从某个消息队列获取并处理其作业。但是,文章的重点来了,其中一个实例需要承担一个独特角色,例如负责激活系统中需要运行的计划任务。在任何时候,系统中都应该只有一个这样的服务,因为相同的计划任务不应该在同一时间被执行多次。
一种选择是系统中有单独的调度器组件,它只负责运行计划任务,但选择这条路自然需要额外的 IT 资源来部署、监控、提供冗余和扩展这个额外的组件。
我们的团队得出的结论是采用一种创建分布式机制的方法,该机制能够为前面提到的服务实例中的一个实例授予唯一的使用权,并赋予它作为系统调度器的独占权。这种机制需要提供一种类似锁的行为,以确保系统中不会有第二个实例同时充当调度器。这种解决方案的一个明显要求是为调度器组件提供冗余,以便在当前担任调度器角色的实例发生任何情况(如崩溃或严重资源拥塞)时,其健康的同类实例能够接替其角色,成为调度器。
此时,需要选择一个共享资源或锁服务,它将成为做出“是否有人持有锁以及谁持有锁”这个决策的真相来源。正如我们所理解的,分布式锁解决方案的一部分需要服务轮询锁的可用性,以检查它们是否可以获取锁并继续执行与调度器相关的任务。由于 MongoDB 数据库已经集成到解决方案中,并且鉴于其良好的性能基准和对文档级别原子性的支持,我们决定使用它作为锁。另一种考虑的选项是使用 MS SQL Server 存储过程配合 update 命令来提供同类的原子锁定功能,但由于我们不需要任何关系数据库特定的功能,并且考虑到关系数据库与 No SQL 产品相比性能较低,我们决定坚持使用 MongoDB。当然,其他 No SQL 数据库,如 Redis,也可以用于此角色,前提是它们为写入操作提供强一致性。
MongoDB 被选择的另一个原因,但最终对我们来说没有用处,是它对 TTL 索引的支持——也就是说,一旦用作 TTL 索引的文档的日期类型字段中的值过期,MongoDB 就会删除该记录。这可以作为一种机制,在当前调度器崩溃时释放锁,以便其他客户端可以获得对锁的访问。问题在于 MongoDB 不保证在索引过期时立即删除文档,而是在某个时间点基于内部后台数据刷新策略删除。系统设计无法接受任何延迟的锁释放,因此我们不得不实现自己的锁释放机制。
请注意,争夺某个共享资源的独占锁并不是提供系统范围共识的唯一方法,以确保只有一组对等方中的一个能够执行某些操作或拥有独占角色。存在许多已知的、更完善的达到系统共识的方法——一个例子就是 MongoDB 副本集本身用于选择主节点的选举式方法。
鉴于我们没有特定的服务选择逻辑或偏好标准的要求,我们对一个能够随机产生调度器角色的持有者的简单解决方案感到满意。
解决方案的顶层概览
该解决方案具有以下功能特性:
- 每个想要获取锁的客户端都必须被唯一标识,这样一旦获取锁,就可以标记为属于该特定客户端/进程/服务。
在本文附带的 DistributedLockingTestClient 项目中,您可以看到 `var uniqueId = Guid.NewGuid().ToString();`,它为运行的客户端应用程序实例创建了一个唯一的标识符。您可以看到 **IExclusiveGlobalLock** 接口的所有方法都期望获取 `string clientIdentifier` 作为调用的参数。
- 每个锁的持有时间是有限的。如果客户端想持有更长时间,它应该在锁过期前定期续期。这种机制类似于许多缓存实现中使用的滑动过期方法。
为此,**IExclusiveGlobalLock** 接口提供了 **ProlongLock** 方法,用于进一步延长锁的持续时间。考虑到它是由当前持有锁的客户端调用,并且在锁过期时间之前执行——它将更新锁文档的 **LockAcquireTime** 日期字段——并为锁提供额外的延长时间。
- 未持有锁的客户端应定期轮询锁。如果锁未被任何人持有或已过期,客户端可以请求锁并创建带有其客户端 ID 的 MongoDB 锁记录。
如上图所示,创建的 MongoDB 文档包含当前锁定客户端的 ID(存储在 **LockingProcessId** 字段中)以及获取锁的时间。
在底层——所有请求锁的客户端实际上都在尝试插入一个具有相同 ID(示例代码中为“1”)的文档,并且鉴于 MongoDB 集合中文档的 ID 必须是唯一的——没有两个并发插入同一个锁的请求能够成功。只有当生成的锁文档被移除后,新的锁文档才能被插入。
代码详细解析
该解决方案的代码实现基于两个主要接口:
一个是 **IExclusiveGlobalMongoBasedLock**,它基本上提供了对所需基本独占锁相关功能的低级访问。
public interface IExclusiveGlobalLock { /// Try to get exclusive access to the locked resource bool TryGetLock(string clientIdentifier); /// Extend the lock the client is currenlty holding void ProlongLock(string clientIdentifier); /// Release lock held by the client void ReleaseLock(string clientIdentifier); /// Returns the last tine when lock was acquired or extended DateTime? LastAquiredLockTime {get;} /// Returns lock duration int LockDurationTimeInMilliSeconds { get; } }
解决方案的核心是 **ExclusiveGlobalMongoBasedLock** 类,它是 **IExclusiveGlobalMongoBasedLock** 接口的 MongoDB 特定实现。它使用 MongoClient 库将 `ExclusiveLockStorageModel` 类实例插入到 MongoDB 中,该实例被转换为代表此独占锁的 BSON 文档。
//Try to insert lock record into MongoDB - if no error returned -we got the lock _collection.Insert(new ExclusiveLockStorageModel() { LockId = _lockId, LockAcquireTime = _lastAquiredLockTime.Value, LockingProcessId = clientIdentifier });
如果没有返回错误,代码会假定锁已成功获取,并进入锁保持模式。
考虑到大多数情况下开发人员将希望实现某种异步任务或基于线程的解决方案来进行锁轮询和锁续期机制,我还创建了 **IExclusiveGlobalLockEngine** 接口,它为应用程序面向的抽象层提供了签名,该层将隐藏锁获取过程。
该接口在 **ExclusiveGlobalMongoBasedLockEngine** 类中实现,该类由作为示例代码中客户端的控制台应用程序直接创建。
public interface IExclusiveGlobalLockEngine { /// Start periodic attempts to acquire lock void StartCheckingLock(string clientIdentifier, Action onLockAcquired, Action<string> onLockLost); /// Stop the process started in StartCheckingLock method and release lock if is currently held by the client void StopCheckingOrReleaseLock(string clientIdentifier); } </string>
当使用 **ExclusiveGlobalMongoBasedLockEngine** 来获取锁定功能时,您的客户端代码会变得非常简洁。主要部分是附加代码注释中解释的许多配置参数、客户端实例的唯一标识符,以及两个回调:一个用于响应客户端获取锁,另一个用于响应锁丢失。
//constructor IExclusiveGlobalLockEngine lockEngine = new ExclusiveGlobalMongoBasedLockEngine( mongoDbConnectionString, "TestLockDb", "Lock", lockDurationInMills, lockCheckFrequency); //start asynchronous process of trying to get the lock lockEngine.StartCheckingLock(uniqueId, () => { Console.WriteLine( "Lock Acquired"); .... }, (reason) => { ... });
使用代码
为了运行代码,请使用 Ms Visual Studio 2012 或更高版本打开 **DistributedLocking** 解决方案。然后,您需要右键单击解决方案并选择“启用 NuGet 包还原”。这样,解决方案中使用的唯一 NuGet 包 **mongocsharpdriver** 将会被下载并部署到项目中。
本文包含的示例应用程序需要运行一个 MongoDB 数据库实例,该实例在本地机器上的默认 27017 端口上运行。
如果您已经在其他位置安装了它,您可以转到 DistributedLockingTestClient 项目中的 Program.cs 文件并修改 mongodb 连接字符串。
在包含请求锁的客户端代码的 **DistributedLockingTestClient** 项目中,有一个 RunMe.bat 文件。我建议在项目成功编译后,从项目的 bin 文件夹中运行该文件。该批处理文件将创建两个客户端控制台应用程序实例,每个实例都会自动获得一个唯一的 ID,并且很容易看出其中一个客户端获取了独占锁,而另一个仍在尝试获取。
运行批处理文件后,您应该会看到以下内容: