博客园  :: 首页  :: 新随笔  :: 管理

3.2.4 分布式锁(redis/mysql)

Posted on 2023-05-14 23:43  wsg_blog  阅读(78)  评论(0)    收藏  举报

Linux c/c++ 高性能后端

锁与分布式数据库

分布式锁本质上是解决了分布式事务中隔离性问题

在分布式系统中,一个应用部署在多台机器当中,某些场景下,为了保证数据据一致性,要求同一时刻,同一任务只在一个节点上运行,即保证某个行为在同一时刻只能被一个线程执行;在单机单进程多线程环境,通过锁很容易做到,比如mutex、spinlock、信号量等;而在多机多进程环境中,此时就需要分布式锁来解决了

锁与无锁回顾

互斥锁(公平锁)、自旋锁(非公平锁):实现本次分布式系统的两个锁机制

// 互斥锁的使用:
pthread_mutex_init(&mutex, NULL);
pthread_mutex_lock(&mutex);
// ....
pthread_mutex_unlock(&mutex);
pthread_mutex_destroy(&mutex);

读写锁:主要用于数据库中行锁
信号量IPC:同步类型的锁,pipe、FIFO、信号量、信号、消息队列、共享内存、socket
条件变量
无锁:原子变量、内存屏障

分布式系统

上图为基本实现,其中lock为锁资源R1-R6;S1-S4为不同的机器上的进程,通过网络获取lock资源;用于对数据库DB的读写操作等等
上述分布式锁,常见的实现方式有:基于mysql,基于缓存redis

分布式系统关注点

  • 互斥性:同时只允许一个持锁对象(S1-S4)进入临界资源;其他待持锁对象要么等待,要么轮询检测是否能获取锁;
  • 锁超时:允许持锁对象持锁最长时间;如果持锁对象宕机,需外力解除锁定,方便其他持锁对象获取锁;
  • 高可用:锁存储位置若宕机,可能引发整个系统不可用;应有备份存储位置和切换备份存储的机制,从而确保服务可用;
  • 容错性:若存储位置宕机,恰好锁丢失的话,是否能正确处理,raft一致性算法;

分布式锁的类型

  • 重入锁和非重入锁:是否允许持锁对象再次获取锁;S1是个多线程环境,S1中的某个线程获取了R1锁,是否允许S1的其他线程获取R1锁
  • 公平锁和非公平锁:如果同时争夺锁是否获取锁的几率一样?公平锁(互斥锁)通常通过阻塞队列排队来实现;非公平锁(自旋锁)通常通过不断尝试获取锁来实现;

数据库分布式锁的一般实现逻辑

  • 锁是一种资源,需要存储;要保证可用性,避免锁失效
  • 加锁对象和解锁对象必须为同一个
  • 互斥语义;将锁打标记
  • 加锁解锁行为是网络通信;需要锁超时
  • 怎么获取持有锁对象释放锁?
    • 主动探寻,非公平锁
    • 被动通知:广播(非公平锁),排队单独通知(公平锁)
  • 是否允许同一持锁对象多次加锁? 重入锁/非重入锁

MySQL实现分布式锁

主要利用MySQL唯一键的唯一性约束来实现互斥性
MySQL实现分布式锁是通过"创建锁表格"来实现,表中每条数据就是一个锁
相比用c/c++自己来实现的分布式系统,MySQL分布式数据库性能是否能满足使用场景是一个比较值得关注的点

表结构

DROP TABLE IF EXISTS `dislock`;
CREATE TABLE `dislock` ( 
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',  #非空唯一索引
`lock_type` varchar(64) NOT NULL COMMENT '锁类型',             #自旋锁还是互斥锁,重入锁还是非重入锁
`owner_id` varchar(255) NOT NULL COMMENT '持锁对象',           #记录R1-R4标识
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,  #记录时间,解决锁超时问题
PRIMARY KEY (`id`),
UNIQUE KEY `idx_lock_type` (`lock_type`)     #唯一键互斥
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT
CHARSET=utf8 COMMENT='分布式锁表';

加锁与解锁

解锁关键点:怎么确保不释放别人的锁? owner_id

INSERT INTO dislock (`lock_type`, `owner_id`) VALUES ('act_lock', 'ad2daf3');  #往dislock表中插入一行,即为加锁
DELETE FROM dislock WHERE `lock_type` = 'act_lock' AND `owner_id` = 'ad2daf3';  #删除自己加的(owner_id)锁

主动探寻加解锁代码

// 分布式锁的使用案例
while (1) {
    CLock my_lock;
    bool flag = dlm->Lock("foo", 100000, my_lock);
    if (flag) {
        printf("获取成功, Acquired by client name:%s, res:%s, vttl:%d\n",
               my_lock.m_val, my_lock.m_resource, my_lock.m_validityTime);
        // do resource job
        sleep(80);
        dlm->Unlock(my_lock);
        // do other job
        sleep(2);
        return;
    } else {
        printf("获取失败, lock not acquired, name:%s\n", my_lock.m_val);
        sleep(rand() % 3);
    }
}

怎么实现锁超时检测?超进程,定时器定时检测这张表,拿当前时间减去update_time,如果超过最大持锁时间,删除那一行(释放锁)
怎么实现重入锁?dislock表里加一行count 为什么要实现重入锁:分布式业务上需要嵌套锁,即临界资源上还要加锁 count++,释放锁count--;

总结

可用性依赖数据库,若数据库是单点,挂掉将导致业务系统不可用;
还需额外实现锁失效的问题;解锁失败,其他线程将无法获得锁;
MySQL适合中小级别数据存储,分布式存储在速度和配置上还需要比较复杂的优化,分布式数据库推荐TiDB


redis实现非公平锁(自旋锁)

redis 内存数据库、数据结构数据库、kv数据库
redis操作的数据都是在内存中,速度比较快;提供了丰富的数据结构list、hash、string、set、zset等等;通过k来操作value(list、hash、string...)来增删改查
那么如何实现redis的分布式锁呢?

mysql加锁通过sql表来实现的,redis是nosql数据库,通过相关命令来加减锁

redis分布式锁流程

  • 尝试获取锁
    • 获取锁成功,操作临界资源,操作结束尝试释放锁;
    • 获取锁失败,订阅解锁信息;
  • 尝试释放锁
    • 释放锁成功,广播解锁信息;
    • 释放锁失败(说明此时持有锁对象不是自己),获得锁失效时间;

加解锁等基础命令

set act_lock 123456 NX //加锁,NX: not exist,如果不存在k; set k v:k(act_lock) v(123456);如果k不存在返回OK加锁成功,如果存在返回nil加锁失败
del ack_lock  //解锁,删除k为ack_lock的锁,返回(integer) 1代表成功
set ack_lock 123456 NX EX 10  //定时加锁10秒,EX: expire 到期,终止;成功返回OK
ttl ack_lock  //查询锁剩余时间,ttl: time to life 锁的生命周期
get ack_lock  //查询锁是否存在,不存在返回nil,存在返回value值,分布式redis中value的值为加锁进程的进程号

原子性问题

分布式中的原子性问题

以解锁为例:首先先获取key,判断val是否为自己家的锁,如果是再进行解锁;1.get act_lock 2.val == uuid 3.del act_lock;要实现1、2、3的原子性问题,否则数据会出问题,怎么解决?
Redis 的原子性保证机制
在 Redis 中,Lua 脚本是实现原子性操作的核心机制之一。其原理主要基于 Redis 的单线程模型和 Lua 脚本的执行方式,确保脚本内的多个命令作为一个整体执行;

单线程模型:Redis 使用单线程处理命令(6.0+ 版本引入多线程仅用于网络 I/O,核心逻辑仍单线程)。
天然隔离性:同一时间只有一个命令被执行,Lua 脚本执行期间,其他客户端命令必须等待。
Lua 脚本的原子性:脚本整体执行:Redis 将整个 Lua 脚本作为一个命令执行,脚本中的所有 Redis 操作(如 GET/SET/DEL)会被连续执行,中间不会插入其他客户端的命令。
无并发冲突:类似数据库的“事务隔离”,脚本内的逻辑看不到外部中间状态。

--[[
    KEYS[1]    lock_name
    KEYS[2]    uuid
  ]]
local uuid = redis.call("get", KEYS[1])
if uuid == KEYS[2] then
    redis.call("del", KEYS[1])
end

redis加锁

--[[
KEYS[1] lock_name
KEYS[2] lock_channel_name
ARGV[1] lock_time (ms)
ARGV[2] uuid
]]
if redis.call('exists', KEYS[1]) == 0 then
   redis.call('hset', KEYS[1], ARGV[2], 1)
   redis.call('pexpire', KEYS[1], ARGV[1])
   return
end
-- 若支持锁重入,将注释去掉
-- if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
--    redis.call('hincrby', KEYS[1], ARGV[2], 1)
--    redis.call('pexpire', KEYS[1], ARGV[1])
--    return
-- end
redis.call("subscribe", KEYS[2])
return redis.call('pttl', KEYS[1])

redis解锁

--[[
    KEYS[1] lock_name
    KEYS[2] uuid
]]
local uuid = redis.call("get", KEYS[1])
if uuid == KEYS[2] then
    redis.call("del", KEYS[1])
end
--[[
    KEYS[1] lock_name
    KEYS[2] lock_channel_name
    ARGV[1] 0 sign of unlock
    ARGV[2] lock_expire_time
    ARGV[3] uuid
]]
if redis.call('exists', KEYS[1]) == 0 then
    redis.call('publish', KEYS[2], ARGV[1])
    return 1
end
if redis.call('hexists', KEYS[1], ARGV[3]) == 0
then
    return
end
-- 若支持锁重入,将注释去掉
-- local cnt = redis.call('hincrby', KEYS[1], ARGV[3], -1)
-- if cnt > 0 then
--     redis.call('pexpire', KEYS[1], ARGV[2])
--     return 0
-- else
      redis.call('del', KEYS[1])
      redis.call('publish', KEYS[2], ARGV[1])
      return 1
-- end

总结
可用性依赖 redis 的可用性;
容错性很差,redis 采用的异步复制,数据可能丢失;
效率最高的一种分布式锁;
最安全的 redis 分布式锁:https://github.com/jacket-code/red
lock-cpp.git;
http://redis.cn/topics/distlock.html