为什么要有分布式锁?
模仿一个秒杀接口:
商品表:
单机状况下,用 Jmeter 发送 1000 个申请过去:
因为加了 sychronized 进行办法同步,后果失常。
当初模仿集群环境,还是用下面的接口,但启动两个服务,别离是 8080 和 8081 端口,用 nginx 负载平衡到两个 tomcat,用 Jmeter 发送 1000 个申请到 nginx:
发现库存并没有 -1000,并且管制的库存量打印有反复。
论断:
咱们在零碎中批改已有数据时,须要先读取,而后进行批改保留,此时很容易遇到并发问题。因为批改和保留不是原子操作,在并发场景下,局部对数据的操作可能会失落。在单服务器零碎咱们罕用本地锁来防止并发带来的问题,然而,当服务采纳集群形式部署时,本地锁无奈在多个服务器之间失效,这时候保证数据的一致性就须要分布式锁来实现。
MySql 分布式锁
基于数据库的分布式锁, 罕用的一种形式是应用表的惟一束缚个性。当往数据库中胜利插入一条数据时, 代表只获取到锁。将这条数据从数据库中删除,则开释锁。
CREATE TABLE `database_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` varchar(1024) NOT NULL DEFAULT ""COMMENT' 资源 ',
`lock_id` varchar(1024) NOT NULL DEFAULT ""COMMENT' 惟一锁编码 ',`count` int(11) NOT NULL DEFAULT '0' COMMENT '锁的次数,可重入锁',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_lock_id` (`lock_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
当咱们想要取得锁时,能够插入一条数据INSERT INTO database_lock(resource,lock_id,count) VALUES ("resource","lock_id",1);
留神:在表 database_lock 中,lock_id 字段做了唯一性束缚,能够是机器的 mac 地址 + 线程编号, 这样如果有多个申请同时提交到数据库的话,数据库能够保障只有一个操作能够胜利(其它的会报错:ERROR 1062 (23000): Duplicate entry‘1’for key‘uiq_lock_id’),那么咱们就能够认为操作胜利的那个申请取得了锁。
当须要开释锁的时,能够删除这条数据:DELETE FROM database_lock where method_name ='resource' and cust_id = 'lock_id'
可重入锁:UPDATE database_lock SET count = count + 1 WHERE method_name ='resource' AND cust_id = 'lock_id'
伪代码:
public void test(){
String resource = "resource";
String lock_id = "lock_id";
if(!checkReentrantLock(resource,lock_id)){lock(resource,lock_id);// 加锁
}else{reentrantLock(resource,lock_id); // 可重入锁 +1
}
// 业务解决
unlock(resource,lock_id);// 开释锁
}
这种实现形式十分的简略,然而须要留神以下几点:
- 这种锁没有生效工夫,一旦开释锁的操作失败就会导致锁记录始终在数据库中,其它线程无奈取得锁。这个缺点也很好解决,比方能够做一个定时工作去定时清理。
- 这种锁的可靠性依赖于数据库。倡议设置备库,防止单点,进一步提高可靠性。
- 这种锁是非阻塞的,因为插入数据失败之后会间接报错,想要取得锁就须要再次操作。如果须要阻塞式的,能够弄个 for 循环、while 循环之类的,直至 INSERT 胜利再返回。
- 这种锁也是非可重入的,因为同一个线程在没有开释锁之前无奈再次取得锁,因为数据库中曾经存在同一份记录了。想要实现可重入锁,能够在数据库中增加一些字段,比方取得锁的主机信息、线程信息等,那么在再次取得锁的时候能够先查问数据,如果以后的主机信息和线程信息等能被查到的话,能够间接把锁调配给它。
Redis 分布式锁
Redis 锁次要利用 Redis 的 setnx 命令。
- 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回胜利,否则返回失败。KEY 是锁的惟一标识,个别按业务来决定命名。
- 解锁命令:DEL key,通过删除键值对开释锁,以便其余线程能够通过 SETNX 命令来获取锁。
- 锁超时:EXPIRE key timeout, 设置 key 的超时工夫,以保障即便锁没有被显式开释,锁也能够在肯定工夫后主动开释,防止资源被永远锁住。
if (setnx(key, 1) == 1){expire(key, 30)
try {//TODO 业务逻辑} finally {del(key)
}
}
应用 SpingBoot 集成 Redis 后应用分布式锁:
能够看到打印的日志不再有反复的库存量,最小的库存量与数据库中的统一。
Redis 分布式锁可能存在一些问题:
1. 设置过期工夫
A 客户端获取锁胜利,然而在开释锁之前解体了,此时该客户端实际上曾经失去了对公共资源的操作权,但却没有方法申请解锁(删除 Key-Value 键值对),那么,它就会始终持有这个锁,而其它客户端永远无奈取得锁。
在加锁时为锁设置过期工夫,当过期工夫达到,Redis 会主动删除对应的 Key-Value,从而防止死锁。
2.SETNX 和 EXPIRE 非原子性
如果 SETNX 胜利,在设置锁超时工夫之前,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时工夫变成死锁。Redis 2.8 之后 Redis 反对 nx 和 ex 操作是同一原子操作。
3. 锁误会除
如果线程 A 胜利获取到了锁,并且设置了过期工夫 30 秒,但线程 A 执行工夫超过了 30 秒,锁过期主动开释,此时线程 B 获取到了锁;随后 A 执行实现,线程 A 应用 DEL 命令来开释锁,但此时线程 B 加的锁还没有执行实现,线程 A 理论开释的线程 B 加的锁。
通过在 value 中设置以后线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是以后线程持有。可生成一个 UUID 标识以后线程
4. 超时解锁导致并发
如果线程 A 胜利获取锁并设置过期工夫 30 秒,但线程 A 执行工夫超过了 30 秒,锁过期主动开释,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
个别有两种形式解决该问题:
将过期工夫设置足够长,确保代码逻辑在锁开释之前可能执行实现。
为获取锁的线程减少守护线程,为将要过期但未开释的锁减少无效工夫。
更好的办法是是应用 Redission,WatchDog 机制会为将要过期但未开释的锁减少无效工夫。
5.redis 主从复制
A 客户端在 Redis 的 master 节点上拿到了锁,然而这个加锁的 key 还没有同步到 slave 节点,master 故障,产生故障转移,一个 slave 节点降级为 master 节点,B 客户端也能够获取同个 key 的锁,但客户端 A 也曾经拿到锁了,这就导致多个客户端都拿到锁。
应用 RedLock
- 首先生成多个 Redis 集群的 Rlock,并将其结构成 RedLock。
- 如果循环加锁的过程中加锁失败,那么须要判断加锁失败的次数是否超出了最大值,这里的最大值是依据集群的个数,比方三个那么只容许失败一个,五个的话只容许失败两个,要保障少数胜利。
- 加锁的过程中须要判断是否加锁超时,有可能咱们设置加锁只能用 3ms,第一个集群加锁曾经耗费了 3ms 了。那么也算加锁失败。
- 2,3 步外面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在申请一次解锁。
能够看见 RedLock 基本原理是利用多个 Redis 集群,用少数的集群加锁胜利,缩小 Redis 某个集群出故障,造成分布式锁呈现问题的概率。
ZooKeeper 分布式锁
1. 多个客户端创立一个锁节点下的一个接一个的长期程序节点
2. 如果本人是第一个长期程序节点,那么这个客户端加锁胜利;如果本人不是第一个节点,就对本人上一个节点加监听器
3. 当某个客户端监听到上一个节点开释锁,本人就排到后面去了,此时继续执行步骤 2,相当于是一个排队机制。
应用 Curator 框架进行加锁和开释锁
参考:
再有人问你分布式锁,这篇文章扔给他
分布式锁的实现之 redis 篇
七张图彻底讲清楚 ZooKeeper 分布式锁的实现原理
Demo:
https://github.com/WillLiaowh…