分布式锁方案选择

阅读说明:

  1. 如果有排版格式问题,请移步www.yuque.com/mrhuang-ire... 《分布式锁简介》,选择宽屏模式效果更佳。
  2. 本文为原创文章,转发请注明出处。如果觉得文章不错,请点赞、收藏、关注一下,您的认可是我写作的动力。

分布式锁简介

日常开发中,如果遇到多线程问题,通常我们会使用java中锁来控制线程的执行。然而,随着业务发展,更多的系统采用分布式集群部署。在这种场景下,应用在多台机器上,而java中的锁都是单机有效的,不能跨服务器进行锁控制。因而,需要分布式锁,能够在多线程、多进程、分布在不同机器上的环境中,保证对共享资源的安全访问。

分布式锁主要有三种主流的实现方式:

  1. 基于数据库实现的分布式锁:采用唯一索引限制、乐观锁、悲观锁等实现;
  2. 基于分布式缓存实现的分布式锁:比如常见redis实现分布式锁;
  3. 基于分布式一致性算法实现的分布式锁:zookeeper、etcd等;

本文对三种方式做简要介绍,包含实现以及存在的问题。

数据库

基于唯一索引分布式锁

数据库表唯一索引有个特点,保证该列中的值是唯一的。如果数据库中已经有该值的一条数据,那么其他插入同值的操作就会失败,具有排他性。 表设计示例:

java 复制代码
CREATE TABLE `distributed_lock` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `unique_mutex` varchar(255) NOT NULL COMMENT '业务防重id',
  `holder_id` varchar(255) NOT NULL COMMENT '锁持有者id',
  `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `mutex_index` (`unique_mutex`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

加锁:

java 复制代码
insert into distributed_lock(unique_mutex, holder_id) values ('unique_mutex', 'holder_id');

解锁:

java 复制代码
delete from methodLock where unique_mutex='unique_mutex' and holder_id='holder_id';

这种锁有很多问题,比如:

  1. 采用主键冲突防重,在大并发情况下有可能会造成锁表现象。
  2. 没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中。

这些问题比较致命,当然还有其他的问题,不能够满足可靠性的要求,要解决这些问题比较复杂,在企业级项目中不适合用来做分布式锁。不过,唯一索引在防重方向有实际用途,请求时插入一条跟请求相关的标记数据。再次请求时,插入标记数据时,命中唯一索引限制插入失败。

乐观锁

乐观锁通过记录版本号或时间戳来实现。在获取锁前,先读取记录的版本号或时间戳,然后在修改时检查是否与之前读取的值相同,如果相同则表示没有其他事务干扰,可以执行更新操作,并且升级版本号或者更新时间戳。如果不同,则说明在这之间存在其他事务修改了记录。

乐观锁需要在原有的数据表结构上记录版本号或者修改时间戳,对数据表侵入较大。对于要求不那么高的系统,可以依赖现有的业务字段进行修改版本控制,可能存在ABA的问题。

悲观锁

悲观锁基于数据库的排他锁机制,即在获取锁时直接对数据库特定行记录进行锁定,防止其他事务修改。在实现中,可以使用数据库支持的锁语句,如 SELECT ... FOR UPDATE。当事务想要获取锁时,会阻塞其他事务对同一行记录的修改,从而实现锁的效果。

基于Redis

redis除了做缓存之外,另外一个很常用的功能就是做分布式锁了。利用SET命令,赋值前,检查key是否存在,如果key不存在,才会设置它的值,这样就能实现锁的功能。当然,这样的锁没有主动释放会出现死锁,所以还需要加上过期时间。Redis 2.6.12 之后,已经支持一条命令同时写入key和过期时间了,之前的版本需要使用lua脚本保持原子性。格式如下:

java 复制代码
SET key value [EX seconds|PX milliseconds] [NX|XX]
  • EX seconds -- Set the specified expire time, in seconds.
  • PX milliseconds -- Set the specified expire time, in milliseconds.
  • NX -- Only set the key if it does not already exist.
  • XX -- Only set the key if it already exist.

redis因为是纯内存操作,不像数据库和接下来要讲的zookeeper进行io操作,加锁速度非常快。在高并发业务中,比如c端,很适合用来做分布式锁。当然,其他内存式数据库memcached也能实现这个功能。

当然,redis做分布式锁可靠性是不足的。试想这样一种场景:

  1. 客户端 1 加锁成功,开始操作共享资源
  2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  3. 客户端 2 加锁成功,开始操作共享资源
  4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

这里存在两个严重的问题:

  1. 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端
  2. 释被别人释放:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

问题1可通过开启守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。Redisson对redis进行了增强,实现了该功能。

问题2可在客户端在加锁时,设置一个「唯一标识」进去。可以是自己的线程 ID,也可以是一个 UUID(随机且唯一)。解锁时,检查锁是否属于自己,只有自己才能释放。注意检查锁和删除锁是两个指令,需要使用lua脚本保证原子性。 JAVA伪代码如下:

java 复制代码
uuid = getUUID();
//加锁
lockResut = redisClient.setNx(key,uuid,timeOut);
if(!lockResult){
    return;
}
try{
   //执行业务逻辑
}finally{
    //解锁
    redisClient.eval(delLuaScript,keys,values)
}
//解锁的lua脚本
// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

除此之外,企业为了保证Redis锁服务的高可用,通常部署Redis是主从集群+哨兵的模式,这样就存在数据丢失的问题。当主库挂机时,会自动切换,如果主库加锁数据还没有同步到从库发生故障, 切换之后,从库上没有加锁数据,主库的加锁数据就丢失了。Redis的作者提出了RedLock的概念,核心思想就是要等加锁数据要成功写入多台机器才算成功。本人还没来得及研究RedLock,业务上也用的是Zookeeper分布式锁,这里不做深入阐述。

基于此,以其优异的性能能够满足绝大部分业务场景,可以用来做接口并发拦截。在可靠性上不足,不满足强一致性。在业务中如果搭配数据库乐观锁使用,即使redis锁出现问题也能够做到数据的一致性。

基于Zookeeper

zookeeper支持在某个节点下创建临时子节点,并且具有排他性,如果已经存在就不会创建。并且临时节点支持两种释放方式:1.主动删除临时节点;2.会话失效(例如断开连接),那么节点就会被删除。 利用这个原理,可以实现一个分布式锁,会话失效时能够主动释放锁。 加锁方式:创建临时节点 解锁方式:删除临时节点或者会话失效; 如果多个请求过来,只有一个请求能够创建节点成功,其他请求拿不到锁等待客户端轮询。

这种效率是很低的。举个现实例子,12306抢票。12,13年那会整点放票,一出来就秒光,这也造就了抢票软件市场的崛起。那个时候每到整点前就要准备好抢票,没开通抢票软件会员的还要跟机器拼手速,着实费力又不公平。现在,12306搞了候补抢票,加入候补后根据加入顺序先到先得,抢票软件的优势也没了,现在免费抢票软件已经快销声匿迹了。

官方推荐另一种分布式锁方法,搞了一个队列,每个请求过来都创建临时顺序节点,生成一个临时节点队列。按照创建时间生成节点顺序,每个顺序节点只监听上一个顺序节点的变化。这样删除节点时发生了锁释放,只唤醒后面一个临时节点。因为存在队列,并且按照顺序唤醒,提升了抢锁的效率和公平性。整体流程示例如下:

  • 首先得有一个持久节点/locks, 路径服务于某个使用场景;
  • 请求进来时首先在/locks创建临时有序节点,所有会看到在/locks下面有seq-000000000, seq-00000001 等等节点。
  • 然后判断当前创建得节点是不是/locks路径下面最小的节点,如果是,获取锁,不是,阻塞线程,同时设置监听器,监听前一个节点。
  • 获取到锁以后,开始处理业务逻辑,最后delete当前节点,表示释放锁。
  • 后一个节点就会收到通知,唤起线程,重复上面的判断。

zookeeper也存在问题。第一、客户端与zookeeper服务器维护一个Session, 依赖定时心跳来维持连接。如果发生进程GC、网络延迟异常场景,导致服务器长期收不到客户心跳,就认为Session失效,将节点删除,也即释放锁。第二、zookeeper集群间数据同步协议不是强一致性的,数据先写入Leader节点,并且Leader节点同步数据到Follower,如果出现 Follower 节点崩溃或者 Leader 进程崩溃时,有可能丢失数据。ETCD跟zookeeper功能类似,不过采用raft协议保证数据强一致性。

总结

本文介绍了三种分布式锁的实现方式,实际上只有zk分布式锁和redis分布式锁基本都能满足分布式锁的核心需求。redis内存操作,性能高于zookeeper锁,zookeeper锁可靠性高于redis。两者都无法100%的保障到锁安全可靠性,可配合数据库乐观锁来解决。

参考文献

zhuanlan.zhihu.com/p/391139899 zhuanlan.zhihu.com/p/651152250 juejin.cn/post/736825... redisson.org/ juejin.cn/post/723905... zhuanlan.zhihu.com/p/673295480 mp.weixin.qq.com/s?__biz=MzU... juejin.cn/post/684490... juejin.cn/post/712539... zhuanlan.zhihu.com/p/639756647

相关推荐
也无晴也无风雨40 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
2401_857610034 小时前
多维视角下的知识管理:Spring Boot应用
java·spring boot·后端
代码小鑫4 小时前
A027-基于Spring Boot的农事管理系统
java·开发语言·数据库·spring boot·后端·毕业设计
颜淡慕潇5 小时前
【K8S问题系列 | 9】如何监控集群CPU使用率并设置告警?
后端·云原生·容器·kubernetes·问题解决
独泪了无痕6 小时前
WebStorm 如何调试 Vue 项目
后端·webstorm
怒放吧德德7 小时前
JUC从实战到源码:JMM总得认识一下吧
java·jvm·后端
代码小鑫7 小时前
A025-基于SpringBoot的售楼管理系统的设计与实现
java·开发语言·spring boot·后端·毕业设计
前端SkyRain7 小时前
后端SpringBoot学习项目-项目基础搭建
spring boot·后端·学习
梦想画家8 小时前
理解Rust 生命周期、所有权和借用机制
开发语言·后端·rust
编程乐趣8 小时前
推荐一个.NetCore开源的CMS项目,功能强大、扩展性强、支持插件的系统!
后端