背景
在分布式系统中,一个应用部署在多台机器当中,在某些场景 下,为了保证数据一致性,要求在同一时刻,同一任务只在一 个节点上运行,即保证某个行为在同一时刻只能被一个线程执 行;在单机单进程多线程环境,通过锁很容易做到,比如 mutex、spinlock、信号量等;而在多机多进程环境中,此时就 需要分布式锁来解决了;
// 互斥锁的使用: pthread_mutex_init(&mutex, NULL); pthread_mutex_lock(&mutex); // .... pthread_mutex_unlock(&mutex); pthread_mutex_destroy(&mutex);
说明:
1、加spin_lock锁、加mutex锁不成功,会发生什么?以及与时间片到了线程切换的区别是什么?
- 加spin_lock锁不成功,会将线程放入到就绪队列中,CPU有空闲的时候就会重新调度到;
- 加mutex锁不成功,会阻塞当前线程,将当前线程放到到阻塞队列中,阻塞队列是先进先出的,当mutex锁释放的时候,会从阻塞队列中取出一个,将其置为就绪态,当CPU空闲的时候会调度到;
- 时间片到了,也是将当前线程放入到就绪队列中;
常见实现方式
- 基于数据库
- 基于缓存redis
- 基于zk/etcd
接口实现
- 加锁
- 解锁
注意事项
- 互斥性
同时只允许一个持锁对象进入临界资源;其他待持锁对象要 么等待,要么轮询检测是否能获取锁;
- 锁超时
因为在单进程中多线程是同生共死的关系,所以不用考虑超时。而在分布式场景中,因为加锁和解锁的对象是同一个,如果其中一个加锁进程宕机,如果不提供锁超时机制,其他进程就永远获取不到锁了;
- 高可用
琐存储位置若宕机,可能引发整个系统不可用;应有备份存 储位置和切换备份存储的机制,从而确保服务可用;
- 容错性
若锁存储位置宕机,恰好锁丢失的话,是否能正确处理;
类型
- 重入锁和非重入锁
是否允许持锁对象再次获取锁;
- 公平锁和非公平锁
如果同时争夺锁是否获取锁的几率一样?
公平锁通常通过排队来实现;
非公平锁通常不间断尝试获取锁来实现;
对于自旋锁这种如果获取不到锁就主动的轮询的去获取锁是否释放,所以说自旋锁对应着我们分布式锁中的非公平锁,公平锁通常我们会在lock中给它进行一个排序,即按照请求锁的顺序进行排序,没有获取到锁的也会像互斥锁那样按请求顺序放入到阻塞队列中,所以互斥锁这种对应着分布式锁中的公平锁。
说明:
1、为什么会被重复加锁?
pthread_mutex_lock(&mutex); // .... pthread_mutex_unlock(&mutex);
假设第二行里面涉及到一个函数调用,而在函数当中也涉及到相同资源的竞争,这个时候就可以在函数中加上锁,这种就是嵌套锁或者说是可重入锁
实现重点
MySQL实现非公平锁
主要利用 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 '持锁对象', `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='分布式锁表';
- 锁类型表示不同业务类型的锁,用来实现互斥性,unique_key,not null,字段唯一且非空
- update_time主要解决锁超时
加锁
加锁就是往表中insert一行,插入成功代表加锁成功,解锁就是删除一行,假设s1插入了一行并且插入成功,若此时S2也想加同样类型的锁,会插入失败,因为lock_type是unique_key,所以lock_type用来实现互斥性。
INSERT INTO dislock (`lock_type`, `owner_id`) VALUES ('act_lock', 'ad2daf3');
解锁
解锁的时候也要确保不释放别人的锁,需要带上owner_id和lock_type去判断
DELETE FROM dislock WHERE `lock_type` = 'act_lock' AND `owner_id` = 'ad2daf3';
说明
- 加锁对象和解锁对象是同一个
用owner_id来表示;
- 互斥语义
在mysql中保证同样类型的锁在mysql中只有一行数据,我们使用lock_type并且设置其为唯一索引来保证;
- 锁超时
需要通过额外实现一个超进程,超进程里面需要设置一个定时器去定时检测锁有没有超时,并且需要保证这个超进程是高可用的,它是一个计算型的高可用;详细来说就是定时检测这张表,拿当前时间减去update_time,如果超过最大持锁时间,就删除那一行(释放锁)。因为mysql中没有这种定时机制,所以需要单独实现这么一个超进程,但是redis和etcd中是存在这样一个机制的
- 怎么获取持有锁对象释放锁
因为mysql中没有被动通知机制,所以只能通过主动探寻的方式来判断锁资源是否被占有。主动探寻方式如下所示:
// 分布式锁的使用案例 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); } else { printf("获取失败, lock not acquired, name:%s\n", my_lock.m_val); sleep(rand() % 3); } }
- 可用性
mysql的可用性方案比较差,没有那种高可用的方案,TiDB的高可用不错;
- 怎么实现可重入锁
需要在表中增加一个count字段,每加锁一次,count+1,释放锁一次count-1,当count=0时,就删除这一行数据
redis实现非公平锁
redis是效率最高的一种分布式锁的解决方案,因为redis是一个内存数据库。
加锁
解锁
这里主要考虑解锁时保证获取锁、判断uuid是不是自己、释放锁这三个步骤的原子性,所以这里采用lua脚本来实现。
总结
1、关于redis中容错性的问题:
redis集群中无论是哨兵模式还是cluster集群模式,主从节点之间都是采用异步复制的方式,数据可能丢失,即锁可能丢失,这里我们采用redlock去解决,使用5个redis节点,每一个节点都是主节点,在加锁的时候保证半数以上的节点都加锁成功才进行返回。
bool CRedLock::Lock(const char *resource, const int ttl, CLock &lock) { sds val = GetUniqueLockId(); if (!val) { return false; } lock.m_resource = sdsnew(resource); lock.m_val = val; printf("Get the unique id is %s\n", val); int retryCount = m_retryCount; do { int n = 0; int startTime = (int)time(NULL) * 1000; int slen = (int)m_redisServer.size(); for (int i = 0; i < slen; i++) { if (LockInstance(m_redisServer[i], resource, val, ttl)) { n++; } } //Add 2 milliseconds to the drift to account for Redis expires //precision, which is 1 millisecond, plus 1 millisecond min drift //for small TTLs. int drift = (ttl * m_clockDriftFactor) + 2; int validityTime = ttl - ((int)time(NULL) * 1000 - startTime) - drift; printf("The resource validty time is %d, n is %d, quo is %d\n", validityTime, n, m_quoRum); if (n >= m_quoRum && validityTime > 0) { //m_quoRum指的是节点半数加1,目的是为了保证半数以上的节点都成功加锁 lock.m_validityTime = validityTime; return true; } else { Unlock(lock); //加锁成功的节点比较少或者加锁的时间超过ttl,就释放锁然后休眠一会重新加锁,默认最多执行三次 } // Wait a random delay before to retry int delay = rand() % m_retryDelay + floor(m_retryDelay / 2); usleep(delay * 1000); retryCount--; } while (retryCount > 0); //retryCount默认情况下是3 return false; }
etcd实现公平锁
待补充。。
分布式锁应用场景
分布式锁是用于协调多个分布式系统实例之间的并发访问控制机制。它可以在分布式环境下确保共享资源的互斥访问,避免数据竞争和并发冲突。
以下是一些常见的使用场景:
- 缓存同步:当多个服务节点同时操作一个共享缓存时,可以使用分布式锁来保证只有一个节点能够更新缓存,防止数据不一致。
- 数据库操作:在某些情况下,需要确保对数据库的写操作是原子性的,使用分布式锁可以避免多个请求同时修改同一条数据造成的问题。(虽然数据库基本上都是有事务的,但是事务是有隔离级别的,某些场景我们需要严格的保证数据库操作的顺序,使用数据库事务的可串行话不太好,会有小缺陷,这个时候我们考虑使用分布式锁)
- 分布式任务调度:当有多个任务调度器竞争执行同一个任务时,可以使用分布式锁来确保只有一个调度器获得执行权限,避免重复执行或者任务丢失。
- 防止重复提交:在某些场景下,用户可能会频繁提交相同的请求,通过使用分布式锁可以判断是否已经处理过相同的请求,并避免重复执行相同的业务逻辑。
总之,在任何需要保证资源访问顺序、防止并发问题或者协调多个系统实例之间操作时都可以考虑使用分布式锁。
总结:
1、什么时候考虑使用mysql、redis、etcd分布式锁?
如果项目中没有使用redis、etcd,考虑使用mysql,因为它是最不完备的。
2、C++建议使用redis类型的分布式锁