Redis实现分布式锁
一、锁用来解决什么问题?
当我们编写的应用程序存在竞争资源的问题时,需要引入锁来保证共享资源安全。
比如,在淘宝、京东等电商系统中,买家下单购买商品这个业务场景,首先需要查询相应商品的库存是否足够,只有在商品库存数量足够的前提下,才能让用户下单。下单时,我们需要在库存数量中减去下单的商品数量,并将最新的库存数量更新到数据库中。
如果不加锁,就会出现问题,假设某个商品库存只剩一件了,两个买家同时抢购这个商品,同时去读取库存,买家A读取库存是1,可以下单,买家B读取库存也是1,可以下单,就造成一件商品下单两次,这只是两个人,如果很多人同一时间抢购呢,就会造成很不好的影响。
二、synchronized这类锁和分布式锁有什么区别?既然已经有synchronized这类锁,为什么还需要Redis分布式锁呢?
1、synchronized等锁
我们知道,当运行一个Java程序时,会启动一个JVM进程来运行我们的应用程序,synchronized保证JVM进程中的多个线程访问公有资源的安全性,所以synchronized也叫JVM锁。
synchronized是针对一个实例的。
2、分布式锁
分布式架构是将应用程序部署在多个不同的JVM实例中,此时需要使用分布式锁保证多个JVM进程访问公有资源的安全性。
分布式锁是针对多个实例的。
三、Redis实现分布式锁
1、简单分布式锁
(1)、Redis如何实现分布式锁?
Redis实现分布式锁主要利用Redis的setnx命令,setnx的意思是set if not exists(如果不存在则set)。
加锁操作:setnx key value,如果key不存在,设置value,表示加锁成功,如果key已经存在,则无法设置,表示加锁失败。也就是说,这个key就是分布式锁,哪个客户端抢先给这个key赋值,谁就获取到了锁。
解锁操作:del key,通过删除value来释放锁,释放锁之后,其他客户端可以通过setnx命令进行加锁。
key可以根据业务场景设置,在下单模块中,是想锁住商品,使得只有一个客户端对商品做操作,此时key可以命名为商品编号,value可以使用uuid保证唯一,用于标识加锁的客户端。
(2)、简单分布式锁示例
public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");
/**
* 下单接口
* @param productId 商品ID
* @return
*/
public String submitOrder(String productId) throws OrderException {
// 加锁操作,key:商品编码,value:请求客户端的ID。
Boolean isLocked = jedis.setnx(PRODUCT_ID, requestId);
// 如果没有拿到锁,返回下单失败
if(!isLock){
return "Order failed!";
}
int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
if(stock > 0){// 如果库存足够
stock -= 1;// 库存减1
update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
logger.debug("库存扣减成功,当前库存为:{}", stock);
}else{
logger.debug("库存不足,扣减库存失败");
throw new OrderException("库存不足,扣减库存失败");
}
// 解锁操作:业务执行完成,删除key。
jedis.delete(PRODUCT_ID);
// 返回下单成功
return "Order success!";
}
(3)、上述简单分布式锁示例有什么问题?
上述代码有个问题,假设一个客户端获取到锁,然后执行业务代码,但是执行业务代码时,抛出了异常,方法直接退出,导致 del 指令没有被调用,锁未被释放,其他客户端就无法获取到锁,导致后续的所有下单操作都会失败,这就是分布式场景下的死锁问题,锁永远得不到释放。
2、引入try-finally代码块保证锁会被释放掉
说到这,大家可能会想到引入try...finally,将释放锁的操作放入finally块中,保证释放锁的操作肯定会被执行到。
public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");
/**
* 下单接口
* @param productId 商品ID
* @return
*/
public String submitOrder(String productId) throws OrderException {
// 加锁操作,key:商品编号,value:请求客户端的ID。
Boolean isLocked = jedis.setnx(PRODUCT_ID, requestId);
// 如果没有拿到锁,返回下单失败
if(!isLock){
return "Order failed!";
}
try{
int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
if(stock > 0){
stock -= 1;// 库存减1
update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
logger.debug("库存扣减成功,当前库存为:{}", stock);
}else{
logger.debug("库存不足,扣减库存失败");
throw new OrderException("库存不足,扣减库存失败");
}
}finally {
// 解锁操作:业务执行完成,删除key。
jedis.delete(PRODUCT_ID);
}
// 返回下单成功
return "Order success!";
}
实际上,上述代码还是无法真正解决死锁的问题。从代码逻辑来说,确实解决了死锁问题,但是生产环境是非常复杂的,代码逻辑毫无漏洞,但是不排除机器故障等问题啊。
如果客户端在成功加锁之后,执行业务代码时,还没来得及执行释放锁的代码,此时,该客户端所在的服务器宕机了。后续其他客户端进入提交订单的方法时,因之前客户端没有释放锁,这些客户端无法获取锁,导致下单失败。
3、给key设置过期时间解决死锁问题
我们给key设置一个过期时间,比如 5s,这样即使获取锁的客户端没有及时释放锁,过了 5s 之后,锁会自动释放,其他客户端可以正常获取锁。
public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");
/**
* 下单接口
* @param productId 商品ID
* @return
*/
public String submitOrder(String productId) throws OrderException {
// 加锁操作,key:商品编号,value:请求客户端的ID。
Boolean isLocked = jedis.setnx(PRODUCT_ID, requestId);
// 如果没有拿到锁,返回下单失败
if(!isLock){
return "Order failed!";
}
try{
// 给锁加上30s的过期时间
jedis.expire("ORDER_REDIS_LOCK", 30, TimeUnit.SECONDS);
int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
if(stock > 0){
stock -= 1;// 库存减1
update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
logger.debug("库存扣减成功,当前库存为:{}", stock);
}else{
logger.debug("库存不足,扣减库存失败");
throw new OrderException("库存不足,扣减库存失败");
}
}finally {
// 解锁操作:业务执行完成,删除key。
jedis.delete(PRODUCT_ID);
}
// 返回下单成
return "Order success!";
}
4、在 setnx 和 expire 之间服务器宕机,导致死锁
(1)、我们既用了finally,又用了锁超时机制,为什么还会发生死锁?
此时我们既用了finally,又用了锁超时机制,但是还是无法真正避免死锁问题的发生。还有在哪种情况下会发生死锁呢?下面我们来讲解一下。
当一个客户端执行完setnx这段代码后,正要执行expire这段代码时,该客户端所在的机器宕机了,key超时没设置上,该客户端又没有删除key,所以key会一直存在,这样的话,后续其他客户端执行setnx失败,这些客户端无法获取锁,导致下单失败。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire 是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
为了解决这个问题,Redis 2.8中作者加入了 set 指令的扩展参数,使得 setnx 和 expire 指令可以一起执行,彻底解决了分布式锁的这个问题。
Redis 2.8新增加的指令如下:
set key value [ex seconds] [px milliseconds] [nx|xx]
几个参数的含义:
ex seconds:为键设置秒级过期时间。
px milliseconds:为键设置毫秒级过期时间。
nx:键必须不存在,才可以设置成功,用于添加。
xx:与nx相反,键必须存在,才可以设置成功,用于更新。
(2)、使用Redis 2.8增加的setnx和expire合并的命令,彻底解决死锁问题。
public static final String PRODUCT_ID = "10010";// 商品编号:10010
private static Jedis jedis = new Jedis("127.0.0.1");
/**
* 下单接口
* @param productId 商品ID
* @return
*/
public String submitOrder(String productId) throws OrderException {
// 加锁操作,key:商品编号,value:请求客户端的ID。
// 并且给锁加上30s的过期时间
Boolean isLocked = jedis.set(PRODUCT_ID, requestId, "NX", "EX", 30);
// 如果没有拿到锁,返回下单失败
if(!isLock){
return "Order failed!";
}
try{
int stock = " select STOCK from PRODUCT_TABLE where PRODUCT_ID = "productId" ";// 从数据库的商品表获取库存
if(stock > 0){
stock -= 1;// 库存减1
update PRODUCT_TABLE set STOCK = stock where PRODUCT_ID = "productId";// 将最新库存更新进数据库
logger.debug("库存扣减成功,当前库存为:{}", stock);
}else{
logger.debug("库存不足,扣减库存失败");
throw new OrderException("库存不足,扣减库存失败");
}
}finally {
// 解锁操作:业务执行完成,删除key。
jedis.delete(PRODUCT_ID);
}
// 返回下单成功
return "Order success!";
}
5、可重入分布式锁
(1)、为什么需要保证可重入性?
在上面的代码中,当一个客户端获取锁之后,其他的线程再去获取锁,就会返回失败。
有一种业务场景:在提交订单的接口方法中,调用了另一个服务,而另一个服务中存在对同一个商品的加锁和解锁操作。
这样的话,提交订单的接口方法加锁成功之后,再调用另一个服务,另一个服务就不能加锁了,程序无法往下继续执行了。也就是说,目前实现的分布式锁没有可重入性。
伪代码如下:
// 提交订单的接口方法
public String submitOrder(){
// 提交订单接口方法中的加锁操作
// 调用另一个服务
// 继续
}
// 另一个服务
public void anotherService(){
// 对同一件商品进行加锁操作
}
为了当出现这种业务场景时,程序能够正常运行,我们需要多考虑一层,设计的分布式锁要具有可重入性。
(2)、什么是可重入性?
简单点来说,就是同一个线程,能够多次获取同一把锁,并且能够按照顺序进行解决操作。在分布式系统中,就是同一个客户端,能够多次获取同一把锁。
(3)、如何实现分布式锁的可重入性?
我们如何支持同一个客户端能够多次获取到锁呢?实现分布式锁的可重入有多种方式,这里只提供一个思路(要下班啦,今天就先懒懒地不写啦,后续有时间再讲!)。
value中多存储一个全局唯一的requestId,代表客户端请求标识,获取锁后将这个客户端请求标识保存在ThreadLocal中,当另一个客户端尝试获取锁的时候,将其客户端标识和ThreadLocal中的客户端标识作比较,如果相同表示同一客户端多次获取同一把锁,即实现可重入锁。