分布式锁的使用场景
- 库存超卖
-
- 重复下单
- 司机重复抢单
实现方式
总的来说,可以使用redis或者zookeeper来实现分布式,以下分别就两种方式的不同实现方法进行介绍。
第一种:使用redis的setnx命令进行实现
redis命令:SET my:lock 随机值 NX PX 30000
加锁代码
正确加锁代码
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; /** * 尝试获取分布式锁 * @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, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; } }
我们加锁的代码就是使用jedis执行setnx命令:String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
-
lockKey表示加锁的名称。
-
requestId表示value,是一个随机值,可以作为解锁的依据,解锁时校验下该值是否和客户端传过来的值一致,一致才可以删除该锁,
requestId可以使用
UUID.randomUUID().toString()
方法生成。 -
NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
-
PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
-
expireTime,与第四个参数相呼应,代表key的过期时间。
错误加锁代码
比较常见的错误示例就是使用jedis.setnx()
和jedis.expire()
组合实现加锁,代码如下:
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) { Long result = jedis.setnx(lockKey, requestId); if (result == 1) { // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁 jedis.expire(lockKey, expireTime); } }
首先setnx()方法就是执行SET IF NOT EXIST,expire()方法就是给锁加一个过期时间,这种方式加锁存在的问题是,因为是两条redis命令,不具有原子性,setnx命令执行成功后,可能expire方法会执行失败,这样锁就没有过期时间,会发生死锁。
解锁代码
正确解锁代码
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 释放分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
这段代码使用eval()方法执行了一段lua脚本,这段lua脚本做了什么呢?
- 首先获取key对应的value,判断value和传入的requestId是否一致
- 如果一致,则删除key
- 如果不一致,则直接返回
在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。所以以上三步是一个原子性的操作,可以避免非原子性带来的一些线程安全问题。
错误解锁方式1
最常见的解锁代码就是直接使用jedis.del()
方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) { jedis.del(lockKey); }
错误解锁方式2
这个方式和正确的方式很类似,但是是分成两个命令执行的,也就是说两步操作不是原子性的,会带来一些问题,比如线程A执行完第一个命令之后,锁到期了自动释放,线程B加锁成功,这时线程A执行到第二个命令直接把线程B的锁删除了,这显然是有问题的。
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) { // 判断加锁与解锁是不是同一个客户端 if (requestId.equals(jedis.get(lockKey))) { // 若在此时,这把锁突然不是这个客户端的,则会误解锁 jedis.del(lockKey); } }
总结
- setnx命令的方式可以保证分布式锁的互斥性, 在任一时刻,只有一个客户端加锁
- 如果客户端给锁加上过期时间的话,即使客户端在持有锁期间突然崩溃而没有解锁,也不会发生死锁,锁会到期自动释放
- 存在的问题:由于加锁时间是需要程序设置的,这个过期时间可能会把握不好,如果线程A加锁成功后,代码还没有执行完,锁就到期了被自动释放,别的客户端就会获取到锁,导致程序发生问题。
第二种:使用redis的redisson类库进行实现
redisson是一款优秀的redis开源框架,对分布式锁的支持非常不错,支持可重入锁、公平锁、红锁、读写锁、信号量等,可参考:
https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
- redisson内部封装了复杂的加锁逻辑,加锁和解锁只需要一行代码就能搞定,非常简单方便
- redisson对分布式锁的支持很好,支持可重入锁、公平锁、红锁、读写锁、信号量等
- redisson可以解决上面提到的setnx存在的问题:如果线程A加锁成功后,代码还没有执行完,锁就到期了被自动释放,别的客户端就会获取到锁,导致程序发生问题
- redisson不会发生死锁
接下来我们来看下redisson加锁代码以及它的原理,
代码实现
首先引入pom
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.8.1</version> </dependency>
创建redisson客户端实例
@Value("${config.redis.host}") private String host; @Value("${config.redis.port}") private String port; @Bean public Redisson redisson() { // 此为单机模式,当然也可以使用cluster模式 Config config = new Config(); config.useSingleServer().setAddress("redis://"+host+":"+port).setDatabase(0); return (Redisson) Redisson.create(config); }
加锁代码
@Autowired private StringRedisTemplate template; @Autowired private Redisson redisson; @GetMapping("/prod_stock") public String stock(){ String lock_key = "anyLock"; RLock lock = redisson.getLock(lock_key);//第一步,获取锁 try { lock.lock();//第二步,加锁 int stock = Integer.parseInt(template.opsForValue().get("stock")); if (stock>0) { template.boundValueOps("stock").increment(-1); log.info("库存扣减成功,剩余库存:{}",stock - 1); }else { log.info("库存扣减失败,库存不足"); } } finally { lock.unlock();//第三步,释放锁 } return "hi"; }
可以看到,使用redisson加锁和解锁非常简单,只需要一行代码,加锁:lock.lock(); 解锁:lock.unlock(); 注意解锁的代码一定要放到finally里,这样即使代码发生异常也能执行解锁代码
原理
我们来解读下lock()方法的原理,看下redisson是如何做到可以避免死锁发生,以及避免任务没有执行完锁就到期自动释放, 怎么实现的可重入锁等问题。
首先来看下redisson的执行加锁和解锁的流程:
加锁机制:
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { internalLockLeaseTime = unit.toMillis(leaseTime); return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('hset', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "return redis.call('pttl', KEYS[1]);", Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); }
这段脚本首先判断key是否存在,不存在的话执行hset命令,命令执行完之后,会出现这样的一个Hash数据结构:"8743c9c0-0795-4907-87fd-6c719a6b4586:1 "代表了客户端的某一线程,1代表的是加锁次数
可重入锁机制:
key已存在会执行第二个if,表示有客户端加锁成功,继续判断线程唯一标识(UUID:线程id)是否存在,存在则表示是同一线程加锁,则执行hincrby将线程的加锁次数加1,这段逻辑就保证了可重入锁,同一个线程可以重复加锁,加一次锁,加锁次数就增加1
锁互斥机制:
当锁已经被一个线程占住时,此时有别的客户端线程来加锁,那么
锁已存在,所以第一个if不成立;不是同一个线程加的锁,所以第二个if不成立
所以会走到pttl key这个命令,返回key的剩余生命周期。而且之后会进入一个while死循环,每隔一段时间(这个时间正是lua脚本返回的锁过期时间)就进行加锁尝试;
锁自动延期机制
我们上面讨论了一种异常情况:如果线程A加锁成功后,代码还没有执行完,锁就到期了被自动释放,别的客户端就会获取到锁,导致程序发生问题。
那么redisson是怎么解决这个问题的呢?依赖的watch dog机制:
"if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; " + "end;" + "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + "return nil;" + "end; " + "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + "if (counter > 0) then " + "redis.call('pexpire', KEYS[1], ARGV[2]); " + "return 0; " + "else " + "redis.call('del', KEYS[1]); " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; "+ "end; " + "return nil;",
防死锁机制:
如果一个客户端加锁成功后,在解锁之前就发生了宕机,会不会发生死锁呢?
答案是否定的,因为
第三种方式:RedLock算法
这其实是一种算法思想,为了解决上面说的master节点宕机导致的多个客户端同时加锁成功问题 ,简单介绍一下这种算法的思想,redisson也有关于红锁相关的api,感兴趣的同学可以了解下
红锁采用主节点过半机制,即获取锁或者释放锁成功的标志为:在过半的节点上操作成功。