分布式锁方案选择

阅读说明:

  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

相关推荐
爱勇宝8 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒13 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码14 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo14 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志15 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..15 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects15 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰15 小时前
一些基础和问题解决
后端
陈随易15 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN15 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp