玖叶教程网

前端编程开发入门

IT技术栈:程序员面试宝典之系统架构,如何设计一个分布式锁系统

概述

所谓分布式锁,就是在分布式网络环境中对本地锁机制的升级,制造分布式环境下的临界区。保证操作的原子性。 一句话概之就是保证多台服务器在执行某一段代码时保证只有一台服务器执行。

  • 为什么需要分布式锁呢 ?

单机多线程环境是JVM锁就搞定了,但是现在的微服务架构是跨多进程的,需要保证进程级别的互斥性,所以需要分布式锁保证,既然是进程级别就需要依赖外部独立的系统

在常用缓存读取不到再从DB读取数据,完了之后把结果set到缓存的时候,对于一些大事务DB操作负载很重,要是不限制,完全并发。则会导致DB负载过大,导致数据库连接数被打满,进而引起 RT 明显增大,进而出现各种稳定性问题。所以必须加以限制保证查询DB 和set 缓存的原子性,只有一个线程能操作成功,其余线程等待该线程操作完毕之后,直接从缓存读取即可,此举会大大提高业务稳定性和性能。

  • 分布式锁是什么类型的锁?

分布式锁是在分布式场景中实现互斥类型的锁。

分布式:运行的节点可能在不同的机器或者网段当中,节点间通过socket进行通信

互斥类型:同一时刻只允许一个执行体进入临界资源

  • 分布式锁解决了什么问题?

解决分布式事务中的隔离性问题。在分布式场景中,同时只允许一个节点执行某类任务。

  • 分布式锁的存储

分布式锁本身也是一种资源,在分布式场景中,通过网络交互的方式,不同机器的实体都可以访问锁资源。可以将锁资源保存在MySQL后者Redis中。

  • 分布式锁的行为

分布式锁的行为分别加锁和解锁。加锁和解锁本质上是一次网络交互行为,某个实例加锁成功,其他实例便加锁失败。并且,加锁和解锁的对象必须是同一个,除了因为网络异常而造成的锁超时情况。

实现分布式锁,需要考虑的几点问题

  • 互斥性

同时只允许一个持锁对象进入临界资源;其他待持锁对象要么等待,要么轮询检测是否能获取锁。需要记录持有锁对象(加锁对象和解锁对象必须为同一个)方便判定锁被谁占有了。加锁的时候需要打上该对象的标记,解锁的时候取消标记。

  • 锁超时,要释放

允许持锁对象持锁最长时间;如果持锁对象宕机,需要外力解除锁定,方便其他持锁对象获取锁。

在单进程的多线程场景下,资源和行为是同生共死的关系,程序宕机会自动释放所有资源和行为。而在分布式场景中有比较大的差别,锁资源和行为是分离的,通过网络交互操作锁,要考虑到锁资源宕机和行为实体宕机的情况如何释放资源和解除行为。

比如行为实体宕机了,如何释放锁?如果不能释放锁则其他的实体将一直等待;所以,需要锁超时机制,设置操作时长的最大值,超时释放锁。再比如锁资源宕机的情况。

  • 保证上锁和解锁都是同一个客户端(进程)

例如A进程上的锁,必须由A进程去释放,保证A的业务已经正常结束,如果是B进程去释放了A进程的锁,有可能A进程还在执行任务,锁被释放,相关变量(数据)可能会被污染

Mysql实现分布式锁

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='分布式锁表';
  • id,不同类型的锁 主键id不断自增。
  • lock_type,锁的类型用来描述不同业务类型的锁。实现互斥。
  • owner_id,持锁对象,允许谁来解锁,其他对象不能解锁;另一个作用是避免重复加锁。
  • update_time,具体操作锁的时间,主要用于解决锁超时问题。
  • 唯一索引是指一列中不存在重复字段的行,即字段唯一。
  • 主键是非空唯一索引。

根据唯一索引的约束实现互斥,即lock_type在一个表中不会出现两个相同的lock_type。唯一键是确保字段在表中是唯一的。

加锁

假设S1加锁成功,也就是往表dislock中成功插入一条记录。其中:act_lock是具体的锁,ad2daf3是S1的id。

INSERT INTO dislock (`lock_type`, `owner_id`) VALUES ('act_lock', 'ad2daf3');

解锁

假设S1解锁,也就是从表中删除对应的记录。要表明解什么锁lock_type,谁来解owner_id,如果id不一致解锁失败。

DELETE FROM dislock WHERE `lock_type` = 'act_lock' AND `owner_id` = 'ad2daf3';

Mysql在解锁之后没有主动通知的功能。其他申请锁的实例只能在获取锁失败之后,休眠一会再主动轮询,看是否能加锁。

// 连续分布式锁的使用案例
while (1) {
    CLock my_lock;
    bool flag = dlm->ContinueLock("foo", 14000, 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(10);
    } else {
        printf("获取失败, lock not acquired, name:%s\n", my_lock.m_val);
        sleep(rand() % 3);
    }
}

锁超时

锁超时是在MySQL中有超时进程,利用定时器实现定时检测表,用当前时间减去update_time,如果超过最大持锁时间,就主动删除这条记录(释放锁)。

实现重入锁

在表结构中加一个count字段,加锁count加一,解锁count减一;当count等于0时删除数据(解锁)。

优点:

1)简单易用:使用MySQL作为分布式锁的实现方式相对简单,无需引入额外的组件或服务。

2)数据持久性:MySQL作为关系型数据库,具备数据持久性的特点,分布式锁的状态可以被持久化存储,即使系统重启也能保持锁的状态。

3)可靠性:MySQL提供了ACID事务特性,可以确保分布式锁的可靠性和一致性。

缺点:

1)效率不高,需要另起一个线程检测锁超时;并且MySQL是一种关系型数据库,对于高并发的场景可能存在性能瓶颈,因为每次获取或释放锁都需要与数据库进行交互。

2)锁释放不能主动通知,只能通过主动探寻解决;

3)还需额外实现锁失效的问题,解锁失败,其他线程将无法获得锁;

4)单点故障:如果使用单个MySQL实例作为分布式锁的中心节点,当该节点发生故障时,整个分布式锁系统将失效。

Redis实现分布式锁

1)内存数据库:Redis主要将数据存储在内存中,因此它可以被称为内存数据库。相比传统的磁盘存储数据库,Redis在读取和写入数据时具有更快的速度和更低的延迟。

2)数据结构数据库:Redis不仅仅是简单的键值对存储,它还提供了多种数据结构的支持,例如字符串、哈希表、列表、集合和有序集合等。这使得开发人员可以根据实际需求选择合适的数据结构,并在应用程序中使用这些数据结构来实现更复杂的功能。

3)键值数据库:Redis以键值对的形式存储数据。每个键都是唯一的,并且与一个值关联。这使得Redis非常适合用作分布式缓存、会话存储和数据存储等场景,其中快速查找和存储数据是关键。

加锁

redis的set命令中,可以把key存放锁的类型,value存放持锁对象;为了实现锁的互斥性,使用NX参数(NX就是not exist的缩写)。

zxm@ubuntu:~$ redis-cli
// key:act_lock   uuid:111  NX 表示只有当key不存在时,该命令执行成功,否则失败
127.0.0.1:6379> set act_lock 111 NX 
OK
// 因为key:act_lock 已经存在,所以加锁失败
127.0.0.1:6379> set act_lock 222 NX
(nil)
// 解锁act_lock
127.0.0.1:6379> del act_lock
(integer) 1
// 因为key:act_lock 已经解除,所以加锁成功
127.0.0.1:6379> set act_lock 222 NX
OK

真正加锁操作一般不会使用上面的,因为需要考虑重入锁。可以考虑使用hash的数据结构。

--[[
  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 Lua 脚本片段,根据给定的键(KEYS)和参数(ARGV),检查键是否存在,如果不存在则设置字段的初始值为 1,并为键设置过期时间。

这段代码的功能如下:

1)使用 Redis 的 exists 命令检查键 KEYS[1] 是否存在。如果结果为 0,表示键不存在。

2)如果键不存在,则使用 Redis 的 hset 命令将键 KEYS[1] 中的字段 ARGV[2] 的值设置为 1。

3)接着,使用 Redis 的 pexpire 命令为键 KEYS[1] 设置过期时间,过期时间由参数 ARGV[1] 指定。pexpire 命令以毫秒为单位设置过期时间。

4)最后,代码执行结束,函数返回。

解锁

先用get命令获取持锁对象的value,与自己对比。如果相等才调用del解锁。

get act_lock
if (val == uuid)
{
	/ 解锁
	del act_lock ;
}
原子操作
--[[
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的set命令中有EX和PX参数,设置超时时间。
EX就是Expire的缩写,这个以秒为单位。
PX是pExpire,这个是以毫秒为单位。

set act_lock 123 NX EX 10
# 或者
set act_lock 123 NX PX 10000

总结

redis是效率最高的分布式锁;etcd是完备性最高的分布式锁;MySQl是效率最低的、最不完备的分布式锁。

三者比较:

1)完备性:etcd > redis >mysql

2)性能: redis > etcd > mysql

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言