41讲如何设计更优的分布式锁 - 图141讲如何设计更优的分布式锁

你好,我是刘超。

41讲如何设计更优的分布式锁 - 图2从这⼀讲开始,我们就正式进⼊最后⼀个模块的学习了,综合性实战的内容来⾃我亲身经历过的⼀些案例,其中⽤到的知识点会相对综合,现在是时候跟我⼀起调动下前⾯所学了!

去年双⼗⼀,我们的游戏商城也搞了⼀波活动,那时候我就发现在数据库操作⽇志中,出现最多的⼀个异常就是Interrupted
Exception了,⼏乎所有的异常都是来⾃⼀个校验订单幂等性的SQL。

因为校验订单幂等性是提交订单业务中第⼀个操作数据库的,所以幂等性校验也就承受了⽐较⼤的请求量,再加上我们还是基于⼀个数据库表来实现幂等性校验的,所以出现了⼀些请求事务超时,事务被中断的情况。其实基于数据库实现的幂等性校验就是⼀种分布式锁的实现。

那什么是分布式锁呢,它⼜是⽤来解决哪些问题的呢?

在JVM中,在多线程并发的情况下,我们可以使⽤同步锁或Lock锁,保证在同⼀时间内,只能有⼀个线程修改共享变量或执
⾏代码块。但现在我们的服务基本都是基于分布式集群来实现部署的,对于⼀些共享资源,例如我们之前讨论过的库存,在分布式环境下使⽤Java锁的⽅式就失去作⽤了。

这时,我们就需要实现分布式锁来保证共享资源的原⼦性。除此之外,分布式锁也经常⽤来避免分布式中的不同节点执⾏重复性的⼯作,例如⼀个定时发短信的任务,在分布式集群中,我们只需要保证⼀个服务节点发送短信即可,⼀定要避免多个节点重复发送短信给同⼀个⽤户。

因为数据库实现⼀个分布式锁⽐较简单易懂,直接基于数据库实现就⾏了,不需要再引⼊第三⽅中间件,所以这是很多分布式业务实现分布式锁的⾸选。但是数据库实现的分布式锁在⼀定程度上,存在性能瓶颈。

接下来我们⼀起了解下如何使⽤数据库实现分布式锁,其性能瓶颈到底在哪,有没有其它实现⽅式可以优化分布式锁。

数据库实现分布式锁

⾸先,我们应该创建⼀个锁表,通过创建和查询数据来保证⼀个数据的原⼦性:



CREATE TABLE order (
id int(11) NOT NULL AUTO_INCREMENT,
order_no int(11) DEFAULT NULL,
pay_money decimal(10, 2) DEFAULT NULL,
status int(4) DEFAULT NULL,
create_date datetime(0) DEFAULT NULL,
delete_flag int(4) DEFAULT NULL, PRIMARY KEY (id) USING BTREE,
INDEX idx_status(status) USING BTREE, INDEX idx_order(order_no) USING BTREE
) ENGINE = InnoDB

其次,如果是校验订单的幂等性,就要先查询该记录是否存在数据库中,查询的时候要防⽌幻读,如果不存在,就插⼊到数据库,否则,放弃操作。



select id from order where order_no= ‘xxxx’ for update

最后注意下,除了查询时防⽌幻读,我们还需要保证查询和插⼊是在同⼀个事务中,因此我们需要申明事务,具体的实现代码如下:



@Transactional
public int addOrderRecord(Order order) { if(orderDao.selectOrderRecord(order)==null){
int result = orderDao.addOrderRecord(order); if(result>0){
return 1;
}
}
return 0;
}

到这,我们订单幂等性校验的分布式锁就实现了。我想你应该能发现为什么这种⽅式会存在性能瓶颈了。我们在第34讲中讲过,在RR事务级别,select的for update操作是基于间隙锁gap lock实现的,这是⼀种悲观锁的实现⽅式,所以存在阻塞问题。

因此在⾼并发情况下,当有⼤量的请求进来时,⼤部分的请求都会进⾏排队等待。为了保证数据库的稳定性,事务的超时时间往往⼜设置得很⼩,所以就会出现⼤量事务被中断的情况。

除了阻塞等待之外,因为订单没有删除操作,所以这张锁表的数据将会逐渐累积,我们需要设置另外⼀个线程,隔⼀段时间就去删除该表中的过期订单,这就增加了业务的复杂度。

除了这种幂等性校验的分布式锁,有⼀些单纯基于数据库实现的分布式锁代码块或对象,是需要在锁释放时,删除或修改数据
的。如果在获取锁之后,锁⼀直没有获得释放,即数据没有被删除或修改,这将会引发死锁问题。

Zookeeper实现分布式锁

除了数据库实现分布式锁的⽅式以外,我们还可以基于Zookeeper实现。Zookeeper是⼀种提供“分布式服务协调“的中⼼化服务,正是Zookeeper的以下两个特性,分布式应⽤程序才可以基于它实现分布式锁功能。

顺序临时节点:Zookeeper提供⼀个多层级的节点命名空间(节点称为Znode),每个节点都⽤⼀个以斜杠(/)分隔的路径来表示,⽽且每个节点都有⽗节点(根节点除外),⾮常类似于⽂件系统。

节点类型可以分为持久节点(PERSISTENT )、临时节点(EPHEMERAL),每个节点还能被标记为有序性
(SEQUENTIAL),⼀旦节点被标记为有序性,那么整个节点就具有顺序⾃增的特点。⼀般我们可以组合这⼏类节点来创建我们所需要的节点,例如,创建⼀个持久节点作为⽗节点,在⽗节点下⾯创建临时节点,并标记该临时节点为有序性。

Watch机制:Zookeeper还提供了另外⼀个重要的特性,Watcher(事件监听器)。ZooKeeper允许⽤户在指定节点上注册⼀些Watcher,并且在⼀些特定事件触发的时候,ZooKeeper服务端会将事件通知给⽤户。

我们熟悉了Zookeeper的这两个特性之后,就可以看看Zookeeper是如何实现分布式锁的了。

⾸先,我们需要建⽴⼀个⽗节点,节点类型为持久节点(PERSISTENT) ,每当需要访问共享资源时,就会在⽗节点下建⽴相应的顺序⼦节点,节点类型为临时节点(EPHEMERAL),且标记为有序性(SEQUENTIAL),并且以临时节点名称+⽗节点名称+顺序号组成特定的名字。

在建⽴⼦节点后,对⽗节点下⾯的所有以临时节点名称name开头的⼦节点进⾏排序,判断刚刚建⽴的⼦节点顺序号是否是最
⼩的节点,如果是最⼩节点,则获得锁。

如果不是最⼩节点,则阻塞等待锁,并且获得该节点的上⼀顺序节点,为其注册监听事件,等待节点对应的操作获得锁。当调⽤完共享资源后,删除该节点,关闭zk,进⽽可以触发监听事件,释放该锁。

41讲如何设计更优的分布式锁 - 图3

以上实现的分布式锁是严格按照顺序访问的并发锁。⼀般我们还可以直接引⽤Curator框架来实现Zookeeper分布式锁,代码如
下:



InterProcessMutex lock = new InterProcessMutex(client, lockPath); if ( lock.acquire(maxWait, waitUnit) )
{
try
{
// do some work inside of the critical section here
}
finally
{
lock.release();
}
}

Zookeeper实现的分布式锁,例如相对数据库实现,有很多优点。Zookeeper是集群实现,可以避免单点问题,且能保证每次
操作都可以有效地释放锁,这是因为⼀旦应⽤服务挂掉了,临时节点会因为session连接断开⽽⾃动删除掉。

由于频繁地创建和删除结点,加上⼤量的Watch事件,对Zookeeper集群来说,压⼒⾮常⼤。且从性能上来说,其与接下来我要讲的Redis实现的分布式锁相⽐,还是存在⼀定的差距。

Redis实现分布式锁

相对于前两种实现⽅式,基于Redis实现的分布式锁是最为复杂的,但性能是最佳的。

⼤部分开发⼈员利⽤Redis实现分布式锁的⽅式,都是使⽤SETNX+EXPIRE组合来实现,在Redis 2.6.12版本之前,具体实现代码如下:



public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

Long result = jedis.setnx(lockKey, requestId);//设置锁if (result == 1) {//获取锁成功
// 若在这⾥程序突然崩溃,则⽆法设置过期时间,将发⽣死锁
jedis.expire(lockKey, expireTime);//通过过期时间删除锁return true;
}
return false;
}

这种⽅式实现的分布式锁,是通过setnx()⽅法设置锁,如果lockKey存在,则返回失败,否则返回成功。设置成功之后,为了能在完成同步代码之后成功释放锁,⽅法中还需要使⽤expire()⽅法给lockKey值设置⼀个过期时间,确认key值删除,避免出现锁⽆法释放,导致下⼀个线程⽆法获取到锁,即死锁问题。

如果程序在设置过期时间之前、设置锁之后出现崩溃,此时如果lockKey没有设置过期时间,将会出现死锁问题。在 Redis 2.6.12版本后SETNX增加了过期时间参数:

|

private static final String LOCK_SUCCESS = “OK”; private static final String SET_IF_NOT_EXIST = “NX”; private static final String SET_WITH_EXPIRE_TIME = “PX”;

/*
- 尝试获取分布式锁
- @param jedis Redis客户端
- @param lockKey 锁
- @param requestId 请求标识
- @param expireTime 超期时间
- @return 是否获取成功
/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime)


String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);


if (LOCK_SUCCESS.equals(result)) { return true;
}
return false;


} | | —- | | 41讲如何设计更优的分布式锁 - 图4 | | |

我们也可以通过Lua脚本来实现锁的设置和过期时间的原⼦性,再通过jedis.eval()⽅法运⾏该脚本:



// 加锁脚本
private static final String SCRIPT_LOCK = “if redis.call(‘setnx’, KEYS[1], ARGV[1]) == 1 then redis.call(‘
// 解锁脚本
private static final String SCRIPT_UNLOCK = “if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.ca
41讲如何设计更优的分布式锁 - 图5

虽然SETNX⽅法保证了设置锁和过期时间的原⼦性,但如果我们设置的过期时间⽐较短,⽽执⾏业务时间⽐较⻓,就会存在锁代码块失效的问题。我们需要将过期时间设置得⾜够⻓,来保证以上问题不会出现。

这个⽅案是⽬前最优的分布式锁⽅案,但如果是在Redis集群环境下,依然存在问题。由于Redis集群数据同步到各个节点时是异步的,如果在Master节点获取到锁后,在没有同步到其它节点时,Master节点崩溃了,此时新的Master节点依然可以获取锁,所以多个应⽤服务可以同时获取到锁。

Redlock算法
Redisson由Redis官⽅推出,它是⼀个在Redis的基础上实现的Java驻内存数据⽹格(In-Memory Data Grid)。它不仅提供了
⼀系列的分布式的Java常⽤对象,还提供了许多分布式服务。Redisson是基于netty通信框架实现的,所以⽀持⾮阻塞通信, 性能相对于我们熟悉的Jedis会好⼀些。

Redisson中实现了Redis分布式锁,且⽀持单点模式和集群模式。在集群模式下,Redisson使⽤了Redlock算法,避免在
Master节点崩溃切换到另外⼀个Master时,多个应⽤同时获得锁。我们可以通过⼀个应⽤服务获取分布式锁的流程,了解下
Redlock算法的实现:

在不同的节点上使⽤单个实例获取锁的⽅式去获得锁,且每次获取锁都有超时时间,如果请求超时,则认为该节点不可⽤。当应⽤服务成功获取锁的Redis节点超过半数(N/2+1,N为节点数)时,并且获取锁消耗的实际时间不超过锁的过期时间,则获 取锁成功。

⼀旦获取锁成功,就会重新计算释放锁的时间,该时间是由原来释放锁的时间减去获取锁所消耗的时间;⽽如果获取锁失败, 客户端依然会释放获取锁成功的节点。

具体的代码实现如下:

  1. ⾸先引⼊jar包:



org.redisson
redisson
3.8.2
  1. 实现Redisson的配置⽂件:


@Bean
public RedissonClient redissonClient() { Config config = new Config(); config.useClusterServers()
.setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
.addNodeAddress(“redis://127.0.0.1:7000).setPassword(“1”)
.addNodeAddress(“redis://127.0.0.1:7001”).setPassword(“1”)
.addNodeAddress(“redis://127.0.0.1:7002”)
.setPassword(“1”); return Redisson.create(config);
}
  1. 获取锁操作:


long waitTimeout = 10; long leaseTime = 1;
RLock lock1 = redissonClient1.getLock(“lock1”); RLock lock2 = redissonClient2.getLock(“lock2”); RLock lock3 = redissonClient3.getLock(“lock3”);

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在⼤部分节点上加锁成功就算成功,且设置总超时时间以及单个节点超时时间
redLock.trylock(waitTimeout,leaseTime,TimeUnit.SECONDS);

redLock.unlock();

总结

实现分布式锁的⽅式有很多,有最简单的数据库实现,还有Zookeeper多节点实现和缓存实现。我们可以分别对这三种实现⽅式进⾏性能压测,可以发现在同样的服务器配置下,Redis的性能是最好的,Zookeeper次之,数据库最差。

从实现⽅式和可靠性来说,Zookeeper的实现⽅式简单,且基于分布式集群,可以避免单点问题,具有⽐较⾼的可靠性。因此,在对业务性能要求不是特别⾼的场景中,我建议使⽤Zookeeper实现的分布式锁。

思考题

我们知道Redis分布式锁在集群环境下会出现不同应⽤服务同时获得锁的可能,⽽Redisson中的Redlock算法很好地解决了这个问题。那Redisson实现的分布式锁是不是就⼀定不会出现同时获得锁的可能呢?

期待在留⾔区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。

41讲如何设计更优的分布式锁 - 图6

  1. 精选留⾔ <br />![](https://cdn.nlark.com/yuque/0/2022/png/1852637/1646315653444-e86fa6cc-fa76-4d81-816f-1831e1aae558.png#)a、<br />不⼀定,因为如果集群中有5个redis,abcde,如果发⽣⽹络分区,abc在⼀个分区,de在⼀个分区,客户端A向abc申请锁成功<br />,在c节点master异步同步slave的时候,master宕机了,slave接替,然后c的slave⼜和de在⼀个分区⾥,这时候如果客户端B 来申请锁,也就可以成功了。<br />zk锁也会出现问题,如果客户端A申请zk锁成功,这时候客户端A和zk不在⼀个分区⾥,zk就会把临时节点删除,然后如果客户端B再去申请,也就可以申请成功<br />2019-08-24 01:53<br />作者回复<br />对的,这种情况也是可能发⽣的,前提是c节点在宕机之前没有持久化锁。

第⼆zk锁的问题,如果连接session已经断开,客户端的锁是会释放的,不会存在同时获取锁的情况。
2019-08-27 09:34

41讲如何设计更优的分布式锁 - 图7-W.LI-
⽼师好!基于数据库的实现,我现在项⽬中直接不开事务,select后插⼊(oeder_no做唯⼀约束)。try_catch 异常,重试3次。如果查到了返回成功保证密等。这么做会有问题么?
课后题:万⼀收到的N/2+1节点全部挂了肯定会有问题。不知道,从新选为master节点的算法不知,如果会选择没有收到的节点 做master也会有问题。

2019-08-24 21:45
作者回复
没有问题。

问题的答案:redis实现的分布式锁,都是有⼀个过期时间,如果⼀旦服务A出现stop the world的情况,有可能锁过期了,⽽此时服务A中仍然存在持有锁,此时另外⼀个服务B⼜获取了锁,这个时候存在两个服务同时获取锁的可能。
2019-08-26 10:15

41讲如何设计更优的分布式锁 - 图8我已经设置了昵称
41讲如何设计更优的分布式锁 - 图9不太懂redission机制,每个节点各⾃去获取锁。超过⼀半以上获取成功就算成功。那是不是还有这么⼀步:这些⼀半以上的机
器获取了以后,是否还要决定谁真正拿到锁,才能真正执⾏这个任务
2019-08-25 22:01
作者回复
都会设置锁对象
2019-08-26 09:36

41讲如何设计更优的分布式锁 - 图10godtrue
我们的导⼊功能就是⽤的redis分布式锁,防⽌多个业务操作⼈员同时导⼊,超时时间⼀般为五分钟。出现⽹络分区只能⼆选⼀要A或者C,不过互联⽹企业基本都会选择A。
2019-09-13 22:04
41讲如何设计更优的分布式锁 - 图11⽊刻
⽼师你好,我尝试了下第⼀个,模拟并发情况下发现会有概率抛数据库异常: Deadlock found when trying to get lock; try rest arting transaction
https://github.com/mygodmele/DbLock.git
2019-09-11 15:31
作者回复
运⾏了代码,并没有出现死锁问题,麻烦贴出数据库脚本
2019-09-15 17:05

41讲如何设计更优的分布式锁 - 图12K
⽼师好,课后问题还是没听懂,⾸先我理解redis集群可能同时获取锁,是因为锁时间超时了,别的线程也能拿到,是这个原因
。Redlock 算法是怎样解决这个问题的呢?
2019-09-08 15:57
作者回复
RedLock算法是会去每⼀个节点获取锁,正常情况下,别的线程⽆法同时获取锁的。

2019-09-11 09:55

41讲如何设计更优的分布式锁 - 图13知⾏合⼀
⽼师,想问个问题,redis集群已经分了槽,客户端写⼊根据算法应该写⼊⼀个节点啊,为啥要多个节点同时枷锁?
2019-09-05 16:16
作者回复
写⼊⼀个单点只实现了⾼可⽤,没有实现集群式分布式锁。单点的问题会存在单个节点挂了的情况下,不同应⽤服务同时获取锁的可能。
2019-09-07 11:34

41讲如何设计更优的分布式锁 - 图14Jxin
1.锁超时,也会出现多个任务同时持有锁进⾏。
2.解决⽅式,守护线程续航锁持有时间。
3.弊端,浪费线程,开销太⼤。
4.根据业务情况设置合理的超时时间是最棒的。

5.集群环境还会导致事务失效(同时提交多个key,多个key在不同节点)挺蛋疼。
2019-09-04 00:06
41讲如何设计更优的分布式锁 - 图15再续啸傲
Redisson的“看⻔狗”watch机制,解决了业务执⾏时间⻓于锁过期时间的问题。但是为每⼀个获取锁的线程设置监听线程,会不会在⾼并发的场景下耗费过多资源呢?
2019-09-03 17:51
作者回复
应该是⼀个线程监听,具体需要看源码实现。
2019-09-07 11:39

41讲如何设计更优的分布式锁 - 图16zero
⽤etcd实现锁,是不是更好呢
2019-08-28 19:03

41讲如何设计更优的分布式锁 - 图17 rong
41讲如何设计更优的分布式锁 - 图18 ⽼师,使⽤select for update防⽌幻读那⾥,直接把order_no设置成唯⼀索引,事务⾥⾯只有⼀条insert语句就可以吧?如果之前有,插⼊不成功,没有的话,插⼊成功
2019-08-27 22:57
作者回复
是的,唯⼀索引可以实现该功能。
2019-08-28 09:39

41讲如何设计更优的分布式锁 - 图19-W.LI-
谢谢⽼师!STW问题之前都没想到,不过正常情况STP时间⽐较短的吧,除⾮是CMS下的超⼤⽼年代,或者代码不合理。G1分
segment回收STW应该不会⻓吧。项⽬中数据库锁和redis锁⽤的⽐较多,不过超时时间都是随意设置10,20S。正常⼀般⼏⼗
ms就能就能完成的。请问redis锁超时时间设置多少⽐较合理呢?项⽬中⼤部分情况锁冲突概率⽐较⼩。电商项⽬,商家余额这种冲突概率很⼤的适合⽤zk锁是么?
2019-08-26 22:24
作者回复
是的,根据⾃⼰的需求设定。zk锁则没有超时时间问题。
2019-08-30 10:05

41讲如何设计更优的分布式锁 - 图20我已经设置了昵称
数据库实现,select for update是为了放置幻读?是为了同时两个线程⾛到同⼀⾏查询代码,然后插⼊两遍的意思吗?那后⾯的把查询和插⼊放同⼀个事务⾥⾯的作⽤是什么?请⽼师指点下,这边还是不太懂
2019-08-26 07:22
作者回复
是的,这是⼀个间隙锁,可以防⽌两个事务插⼊相同订单号的数据。将查询和插⼊作为⼀个事务,是保证在查询没有订单时,然后才能插⼊数据。
2019-08-26 09:34

41讲如何设计更优的分布式锁 - 图21明天更美好
我对redisson不是很了解,只是之前看过⼀些别的帖⼦,好像底层也是有⽤lua脚本的。如果对于原⽣的还好些,但是有些公司
⾃研的分布式缓存是不⽀持lua的。这时候恐怕就不适⽤了
2019-08-25 22:27
41讲如何设计更优的分布式锁 - 图22许童童
分布式锁这⼀块确实没有实践过,跟着⽼师⼀起学习。
2019-08-24 14:41