分布式锁设计

1. 引言

在分布式系统中,多个客户端可能同时访问和操作共享资源。为了防止数据竞争和不一致,分布式锁是一个常见的解决方案。Redis 提供了强大的功能来实现高效且可靠的分布式锁。本文将通过 C++ 和 Redis(通过 hredis 库)实现一个简单的分布式锁。

image-20250403122841554

2. 项目背景

  • 分布式锁:它是一种机制,用于保证在分布式系统中,某一时刻只有一个客户端能够执行某些共享资源的操作。
  • 使用 Redis 作为锁存储:Redis 被用作集中式存储,可以确保锁的状态在所有参与者之间同步。

3. 设计思路

分布式锁的核心思想是:

  1. 加锁:客户端通过设置一个 Redis 键来获取锁。通过 Redis 的原子操作,确保只有一个客户端能够成功设置该键。
  2. 持有者标识符:每个客户端在加锁时生成一个唯一的标识符(UUID),该标识符用来标识锁的持有者。
  3. 超时机制:锁会在一定时间后自动释放(过期),防止因程序异常导致的死锁。
  4. 解锁:只有锁的持有者才能释放锁,这通过 Redis Lua 脚本来保证。

4. 代码实现步骤

4.1 生成全局唯一标识符 (UUID)

使用 Boost UUID 库生成一个全局唯一的标识符(UUID)。这个标识符会被用作锁的持有者标识符。它确保每个客户端在加锁时拥有唯一的标识,从而能够确保锁的唯一性。

代码:

  1. std::string generateUUID() {
  2. boost::uuids::uuid uuid = boost::uuids::random_generator()();
  3. return to_string(uuid);
  4. }

4.2 尝试加锁(acquireLock 函数)

客户端通过 Redis 的 SET 命令尝试加锁。该命令的参数如下:

  • NX:确保只有当键不存在时才能成功设置(即,只有一个客户端能够成功设置锁)。
  • EX:设置一个超时时间,锁会在超时后自动释放,避免死锁。

如果加锁成功,返回一个唯一标识符。如果加锁失败,则会在指定的超时时间内多次尝试。

代码如下:

  1. // 尝试获取锁,返回锁的唯一标识符(UUID),如果获取失败则返回空字符串
  2. std::string acquireLock(redisContext* context, const std::string& lockName, int lockTimeout, int acquireTimeout) {
  3. std::string identifier = generateUUID();
  4. std::string lockKey = "lock:" + lockName;
  5. auto endTime = std::chrono::steady_clock::now() + std::chrono::seconds(acquireTimeout);
  6. while (std::chrono::steady_clock::now() < endTime) {
  7. // 使用 SET 命令尝试加锁:SET lockKey identifier NX EX lockTimeout
  8. redisReply* reply = (redisReply*)redisCommand(context, "SET %s %s NX EX %d", lockKey.c_str(), identifier.c_str(), lockTimeout);
  9. if (reply != nullptr) {
  10. // 判断返回结果是否为 OK
  11. if (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK") {
  12. freeReplyObject(reply);
  13. return identifier;
  14. }
  15. freeReplyObject(reply);
  16. }
  17. // 暂停 1 毫秒后重试,防止忙等待
  18. std::this_thread::sleep_for(std::chrono::milliseconds(1));
  19. }
  20. return "";
  21. }

函数参数说明

redisContext* context
这是一个指向 Redis 连接上下文的指针,用于与 Redis 服务器通信。通过这个上下文,你可以发送命令和接收响应。

const std::string& lockName
这是你想要加锁的资源名称。例如,如果你需要对某个资源加锁,可以用 "my_resource",函数内部会把它拼接成 "lock:my_resource" 作为 Redis 中的 key。

int lockTimeout
这是锁的有效期,单位是秒。设置这个值的目的是防止因程序异常或崩溃而导致的死锁。当锁达到这个超时时间后,Redis 会自动删除这个 key,从而释放锁。

int acquireTimeout
这是获取锁的最大等待时间,单位也是秒。如果在这个时间内没有成功获取到锁,函数就会停止尝试,并返回空字符串。这样可以避免程序无限等待。

Redis 命令解释

acquireLock 函数中,使用的 Redis 命令格式是:

  1. "SET %s %s NX EX %d"

这个命令实际上是一个格式化字符串,参数会被填入以下位置:

  1. SET
    Redis 的基本命令,用于设置一个 key 的值。
  2. %s(第一个 %s)
    代表锁的 key(例如 "lock:my_resource")。
  3. %s(第二个 %s)
    代表锁的持有者标识符,也就是通过 generateUUID() 生成的 UUID。
  4. NX
    表示 “Not eXists”,意思是“只有当 key 不存在时才进行设置”。这可以保证如果其他客户端已经设置了这个 key(即已经有锁了),那么当前客户端就不会覆盖原来的锁。
  5. EX %d
    EX 参数用于指定 key 的过期时间,%d 表示锁的有效期(lockTimeout),单位为秒。这样即使客户端因某些原因没有正常释放锁,锁也会在指定时间后自动失效。

构造锁的 Redis 键

  1. std::string lockKey = "lock:" + lockName;
  • 构造出 Redis 键,格式为 lock:lockName,用于存储锁的状态。

设置获取锁的截止时间

  1. auto endTime = std::chrono::steady_clock::now() + std::chrono::seconds(acquireTimeout);
  • 设置一个截止时间 endTime,表示最多尝试获取锁的时间,单位为秒。当前时间加上 acquireTimeout 秒即为截止时间。

尝试获取锁

  1. while (std::chrono::steady_clock::now() < endTime) {
  • 通过一个 while 循环,不断尝试获取锁,直到超时或成功获取锁。

使用 Redis 的 SET 命令尝试加锁

  1. redisReply* reply = (redisReply*)redisCommand(context, "SET %s %s NX EX %d", lockKey.c_str(), identifier.c_str(), lockTimeout);
  • 通过 Redis 的 SET 命令来尝试获取锁,命令格式为:
    • SET lockKey identifier NX EX lockTimeout
      • NX:只有当 lockKey 不存在时才会设置成功(即实现了锁的功能,防止其他客户端重入)。
      • EX lockTimeout:设置锁的过期时间为 lockTimeout 秒,防止锁永远占用。
    • 如果锁成功获取,Redis 会返回 OK

检查返回结果

  1. if (reply != nullptr) {
  2. if (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK") {
  3. freeReplyObject(reply);
  4. return identifier;
  5. }
  6. freeReplyObject(reply);
  7. }
  • 如果 Redis 返回的 reply 不为空,检查返回值类型是否为 REDIS_REPLY_STATUS,并且返回的字符串是否是 OK,表示锁成功获取。
  • 如果获取锁成功,释放 redisReply 对象,并返回生成的唯一标识符 identifier,表示锁已经成功获得。

暂停并重试

  1. std::this_thread::sleep_for(std::chrono::milliseconds(1));
  • 如果获取锁失败,则通过 std::this_thread::sleep_for 暂停 1 毫秒,避免忙等待,提高 CPU 的利用率。

超时返回空字符串

  1. return "";
  • 如果在指定的 acquireTimeout 时间内没有成功获取锁,函数返回空字符串,表示获取锁失败。

4.3 释放锁(releaseLock 函数)

释放锁的操作使用 Redis Lua 脚本,确保只有持有锁的客户端才能释放锁。脚本通过判断当前锁的持有者是否与传入的标识符一致来决定是否删除锁。

Lua 脚本:

  1. if redis.call('get', KEYS[1]) == ARGV[1] then
  2. return redis.call('del', KEYS[1])
  3. else
  4. return 0
  5. end
  • KEYS[1]:锁的 key(例如 lock:my_resource)。
  • ARGV[1]:客户端在加锁时生成的唯一标识符。
  • 如果当前锁的值(标识符)与传入的标识符一致,删除该锁。

Lua 脚本的作用是:

  1. redis.call('get', KEYS[1]):从 Redis 获取 lockKey 对应的值。
  2. if redis.call('get', KEYS[1]) == ARGV[1]:检查获取到的值是否与传入的 identifier 相同,只有标识符匹配时才能删除锁。
  3. return redis.call('del', KEYS[1]):如果匹配,执行删除操作,释放锁。
  4. else return 0:如果标识符不匹配,返回 0,表示没有成功释放锁。

代码:

  1. // 释放锁,只有锁的持有者才能释放,返回是否成功
  2. bool releaseLock(redisContext* context, const std::string& lockName, const std::string& identifier) {
  3. std::string lockKey = "lock:" + lockName;
  4. // Lua 脚本:判断锁标识是否匹配,匹配则删除锁
  5. const char* luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then \
  6. return redis.call('del', KEYS[1]) \
  7. else \
  8. return 0 \
  9. end";
  10. // 调用 EVAL 命令执行 Lua 脚本,第一个参数为脚本,后面依次为 key 的数量、key 以及对应的参数
  11. redisReply* reply = (redisReply*)redisCommand(context, "EVAL %s 1 %s %s", luaScript, lockKey.c_str(), identifier.c_str());
  12. bool success = false;
  13. if (reply != nullptr) {
  14. // 当返回整数值为 1 时,表示成功删除了锁
  15. if (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1) {
  16. success = true;
  17. }
  18. freeReplyObject(reply);
  19. }
  20. return success;
  21. }

函数参数说明

  • redisContext* context:指向 Redis 连接上下文的指针,用于执行 Redis 命令。
  • const std::string& lockName:锁的名称,用于生成 Redis 键的名称。
  • const std::string& identifier:标识符,用于标识哪个客户端持有锁。

函数返回一个布尔值,通常表示释放锁操作是否成功。

执行 Lua 脚本

  1. redisReply* reply = (redisReply*)redisCommand(context, "EVAL %s 1 %s %s", luaScript, lockKey.c_str(), identifier.c_str());
  • 使用 redisCommand 函数执行 Redis 的 EVAL 命令,传入脚本、键的数量(在这里是 1,因为只有一个 lockKey),然后依次传入 lockKeyidentifier
  • redisCommand 会返回一个 redisReply 指针,表示命令的返回结果。

处理返回结果

  1. cpp复制bool success = false;
  2. if (reply != nullptr) {
  3. // 当返回整数值为 1 时,表示成功删除了锁
  4. if (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1) {
  5. success = true;
  6. }
  7. freeReplyObject(reply);
  8. }
  • 如果返回的 redisReply 不为空,表示 Redis 执行了命令。
  • 检查返回值的类型是否是整数(REDIS_REPLY_INTEGER),并且它的值是否是 1。如果是 1,表示删除锁成功,将 success 设置为 true
  • 释放 redisReply 对象,防止内存泄漏。

4.4 主函数(main 函数)

主函数执行以下操作:

  1. 创建 Redis 客户端并连接到 Redis 服务器。
  2. 尝试加锁,若成功获取锁,则执行临界区代码。
  3. 在临界区代码执行完后,释放锁。

代码:

  1. int main() {
  2. // 连接到 Redis 服务器(根据实际情况修改主机和端口)
  3. redisContext* context = redisConnect("127.0.0.1", 6379);
  4. if (context == nullptr || context->err) {
  5. if (context) {
  6. std::cerr << "连接错误: " << context->errstr << std::endl;
  7. redisFree(context);
  8. } else {
  9. std::cerr << "无法分配 redis context" << std::endl;
  10. }
  11. return 1;
  12. }
  13. // 尝试获取锁(锁有效期 10 秒,获取超时时间 5 秒)
  14. std::string lockId = acquireLock(context, "my_resource", 10, 5);
  15. if (!lockId.empty()) {
  16. std::cout << "子进程 " << GetCurrentProcessId() << " 成功获取锁,锁 ID: " << lockId << std::endl;
  17. // 执行需要保护的临界区代码
  18. std::this_thread::sleep_for(std::chrono::seconds(2));
  19. // 释放锁
  20. if (releaseLock(context, "my_resource", lockId)) {
  21. std::cout << "成功释放锁" << std::endl;
  22. } else {
  23. std::cout << "释放锁失败" << std::endl;
  24. }
  25. } else {
  26. std::cout << "获取锁失败" << std::endl;
  27. }
  28. // 释放 Redis 连接
  29. redisFree(context);
  30. return 0;
  31. }

5. 封装为单例类操作

类声明如下

  1. #include <string>
  2. #include <hiredis.h>
  3. class DistLock
  4. {
  5. public:
  6. static DistLock& Inst();
  7. ~DistLock();
  8. std::string acquireLock(redisContext* context, const std::string& lockName,
  9. int lockTimeout, int acquireTimeout);
  10. bool releaseLock(redisContext* context, const std::string& lockName,
  11. const std::string& identifier);
  12. private:
  13. DistLock() = default;
  14. };

类定义如下

  1. #include <iostream>
  2. #include <string>
  3. #include <chrono>
  4. #include <thread>
  5. #include <cstdlib>
  6. #include <boost/uuid/uuid.hpp>
  7. #include <boost/uuid/uuid_generators.hpp>
  8. #include <boost/uuid/uuid_io.hpp>
  9. #include <hiredis.h>
  10. DistLock& DistLock::Inst() {
  11. static DistLock lock;
  12. return lock;
  13. }
  14. DistLock::~DistLock() {
  15. }
  16. // 尝试获取锁,返回锁的唯一标识符(UUID),如果获取失败则返回空字符串
  17. std::string DistLock::acquireLock(redisContext* context, const std::string& lockName,
  18. int lockTimeout, int acquireTimeout) {
  19. std::string identifier = generateUUID();
  20. std::string lockKey = "lock:" + lockName;
  21. auto endTime = std::chrono::steady_clock::now() + std::chrono::seconds(acquireTimeout);
  22. while (std::chrono::steady_clock::now() < endTime) {
  23. // 使用 SET 命令尝试加锁:SET lockKey identifier NX EX lockTimeout
  24. redisReply* reply = (redisReply*)redisCommand(context, "SET %s %s NX EX %d",
  25. lockKey.c_str(), identifier.c_str(), lockTimeout);
  26. if (reply != nullptr) {
  27. // 判断返回结果是否为 OK
  28. if (reply->type == REDIS_REPLY_STATUS && std::string(reply->str) == "OK") {
  29. freeReplyObject(reply);
  30. return identifier;
  31. }
  32. freeReplyObject(reply);
  33. }
  34. // 暂停 1 毫秒后重试,防止忙等待
  35. std::this_thread::sleep_for(std::chrono::milliseconds(1));
  36. }
  37. return "";
  38. }
  39. // 释放锁,只有锁的持有者才能释放,返回是否成功
  40. bool DistLock::releaseLock(redisContext* context, const std::string& lockName,
  41. const std::string& identifier) {
  42. std::string lockKey = "lock:" + lockName;
  43. // Lua 脚本:判断锁标识是否匹配,匹配则删除锁
  44. const char* luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then \
  45. return redis.call('del', KEYS[1]) \
  46. else \
  47. return 0 \
  48. end";
  49. // 调用 EVAL 命令执行 Lua 脚本,第一个参数为脚本,后面依次为 key 的数量、key 以及对应的参数
  50. redisReply* reply = (redisReply*)redisCommand(context, "EVAL %s 1 %s %s",
  51. luaScript, lockKey.c_str(), identifier.c_str());
  52. bool success = false;
  53. if (reply != nullptr) {
  54. // 当返回整数值为 1 时,表示成功删除了锁
  55. if (reply->type == REDIS_REPLY_INTEGER && reply->integer == 1) {
  56. success = true;
  57. }
  58. freeReplyObject(reply);
  59. }
  60. return success;
  61. }

测试用例

  1. int TestDisLock() {
  2. // 连接到 Redis 服务器(根据实际情况修改主机和端口)
  3. redisContext* context = redisConnect("81.68.86.146", 6380);
  4. if (context == nullptr || context->err) {
  5. if (context) {
  6. std::cerr << "连接错误: " << context->errstr << std::endl;
  7. redisFree(context);
  8. }
  9. else {
  10. std::cerr << "无法分配 redis context" << std::endl;
  11. }
  12. return 1;
  13. }
  14. std::string redis_password = "123456";
  15. redisReply* r = (redisReply*)redisCommand(context, "AUTH %s", redis_password.c_str());
  16. if (r->type == REDIS_REPLY_ERROR) {
  17. printf("Redis认证失败!\n");
  18. }
  19. else {
  20. printf("Redis认证成功!\n");
  21. }
  22. // 尝试获取锁(锁有效期 10 秒,获取超时时间 5 秒)
  23. std::string lockId = DistLock::Inst().acquireLock(context, "my_resource", 10, 5);
  24. if (!lockId.empty()) {
  25. std::cout << "子进程 " << GetCurrentProcessId() << " 成功获取锁,锁 ID: " << lockId << std::endl;
  26. // 执行需要保护的临界区代码
  27. std::this_thread::sleep_for(std::chrono::seconds(2));
  28. // 释放锁
  29. if (DistLock::Inst().releaseLock(context, "my_resource", lockId)) {
  30. std::cout << "子进程 " << GetCurrentProcessId() << " 成功释放锁" << std::endl;
  31. }
  32. else {
  33. std::cout << "子进程 " << GetCurrentProcessId() << " 释放锁失败" << std::endl;
  34. }
  35. }
  36. else {
  37. std::cout << "子进程 " << GetCurrentProcessId() << " 获取锁失败" << std::endl;
  38. }
  39. // 释放 Redis 连接
  40. redisFree(context);
  41. }

6. 多进程测试

我们可以创建另一个项目,调用之前生成好的distribute.exe

  1. #include <windows.h>
  2. #include <iostream>
  3. #include <vector>
  4. int main() {
  5. const int numProcesses = 5; // 需要启动 5 个子进程
  6. std::vector<PROCESS_INFORMATION> procInfos;
  7. for (int i = 0; i < numProcesses; i++) {
  8. STARTUPINFO si = { 0 };
  9. si.cb = sizeof(si);
  10. PROCESS_INFORMATION pi = { 0 };
  11. // 这里假设 ChildTest.exe 在当前目录下
  12. if (CreateProcess(TEXT("DistributeLock.exe"), // 应用程序名
  13. NULL, // 命令行参数
  14. NULL, // 进程句柄不可继承
  15. NULL, // 线程句柄不可继承
  16. FALSE, // 不继承句柄
  17. 0, // 没有特殊创建标志
  18. NULL, // 使用父进程的环境
  19. NULL, // 使用父进程的当前目录
  20. &si, // 指向 STARTUPINFO 结构体的指针
  21. &pi)) // 指向 PROCESS_INFORMATION 结构体的指针
  22. {
  23. std::cout << "成功创建子进程, PID: " << pi.dwProcessId << std::endl;
  24. procInfos.push_back(pi);
  25. }
  26. else {
  27. std::cerr << "创建子进程失败: " << GetLastError() << std::endl;
  28. }
  29. }
  30. // 等待所有子进程结束
  31. for (auto& pi : procInfos) {
  32. WaitForSingleObject(pi.hProcess, INFINITE);
  33. CloseHandle(pi.hProcess);
  34. CloseHandle(pi.hThread);
  35. }
  36. std::cout << "所有子进程已结束" << std::endl;
  37. system("pause");
  38. return 0;
  39. }

测试效果

image-20250331215038920

7. 总结

  • 分布式锁:使用 Redis 和 Boost UUID 实现了一个简单的分布式锁,确保多个客户端可以同步地访问共享资源。
  • 加锁:通过 Redis 的 SET 命令,使用 NXEX 参数确保只有一个客户端可以成功加锁。
  • 解锁:通过 Lua 脚本确保只有锁的持有者能够释放锁,避免其他客户端误释放锁。
  • 持有者标识符:每个客户端在加锁时生成一个唯一的标识符(UUID),它作为锁的持有者标识。
热门评论

热门文章

  1. 解密定时器的实现细节

    喜欢(566) 浏览(3401)
  2. C++ 类的继承封装和多态

    喜欢(588) 浏览(4794)
  3. Linux环境搭建和编码

    喜欢(594) 浏览(11669)
  4. windows环境搭建和vscode配置

    喜欢(587) 浏览(2659)
  5. slice介绍和使用

    喜欢(521) 浏览(2441)

最新评论

  1. golang 函数介绍 secondtonone1:函数是go中的一等公民,作为新兴语言,go摒弃了面向对象的一些糟粕,采取接口方式编程,而接口方式编程都是基于函数的,参数为interface,进而达到泛型作用,比如sort排序,只需要传入的参数满足sort所需interface的规定即可,需实现Len, Swap, Less三个方法,只要实现了这三个方法都可以用来做sort排序的参数。
  2. 聊天项目(13) 重置密码功能 Doraemon:万一一个用户多个邮箱呢 有可能的
  3. 堆排序 secondtonone1:堆排序非常实用,定时器就是这个原理制作的。
  4. asio多线程模式IOThreadPool secondtonone1:这么优秀吗
  5. interface应用 secondtonone1:interface是万能类型,但是使用时要转换为实际类型来使用。interface丰富了go的多态特性,也降低了传统面向对象语言的耦合性。

个人公众号

个人微信