基本实现
借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发 送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false)。
- 1. 多个客户端同时获取锁(setnx)
- 2. 获取成功,执行业务逻辑,执行完成释放锁(del)
- 3. 其他客户端等待重试
改造StockService方法:
1. @Service 2. public class StockService { 3. @Autowired 4. private StockMapper stockMapper; 5. @Autowired 6. private LockMapper lockMapper; 7. @Autowired 8. private StringRedisTemplate redisTemplate; 9. public void checkAndLock() { 10. // 加锁,获取锁失败重试 11. while (!this.redisTemplate.opsForValue().setIfAbsent("lock", 12. 13. "xxx")){ 14. try { 15. Thread.sleep(100); 16. } catch (InterruptedException e) { 17. e.printStackTrace(); 18. } 19. } 20. // 先查询库存是否充足 21. Stock stock = this.stockMapper.selectById(1L); 22. // 再减库存 23. if (stock != null && stock.getCount() > 0){ 24. stock.setCount(stock.getCount() - 1); 25. this.stockMapper.updateById(stock); 26. } 27. // 释放锁 28. this.redisTemplate.delete("lock"); 29. } 30. }
其中,加锁:
1. // 加锁,获取锁失败重试 2. while (!this.redisTemplate.opsForValue().setIfAbsent("lock", "xxx")){ 3. try { 4. Thread.sleep(100); 5. } catch (InterruptedException e) { 6. e.printStackTrace(); 7. } 8. }
解锁:
1. // 释放锁 2. 3. this.redisTemplate.delete("lock");
使用Jmeter压力测试如下:
查看mysql数据库:
防死锁
解决:给锁设置过期时间,自动释放锁。 设置过期时间两种方式:
1. 通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
2. 使用set指令设置过期时间:set key value ex 3 nx(既达到setnx的效果,又设置了过期时间)
压力测试肯定也没有问题。
问题:可能会释放其他服务器的锁。 场景:如果业务逻辑的执行时间是7s。执行流程如下
1. index1业务逻辑没执行完,3秒后锁被自动释放。
2. index2获取到锁,执行业务逻辑,3秒后锁被自动释放。
3. index3获取到锁,执行业务逻辑
4. index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只 执行1s就被别人释放。 最终等于没锁的情况。
解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的 锁
防误删
实现如下:
问题:删除操作缺乏原子性。 场景:
1. index1执行删除时,查询到的lock值确实和uuid相等
2. index1执行删除前,lock刚好过期时间已到,被redis自动释放
3. index2获取了lock 4. index1执行删除,此时会把index2的lock删除
解决方案:没有一个命令可以同时做到判断 + 删除,所有只能通过其他方式实现(LUA脚本)
使用lua保证删除原子性
删除LUA脚本:
1. if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', 2. KEYS[1]) else return 0 end
代码实现:
1. public void checkAndLock() { 2. // 加锁,获取锁失败重试 3. String uuid = UUID.randomUUID().toString(); 4. while (!this.redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, 5. 6. TimeUnit.SECONDS)){ 7. try { 8. Thread.sleep(50); 9. } catch (InterruptedException e) { 10. e.printStackTrace(); 11. } 12. } 13. // 先查询库存是否充足 14. Stock stock = this.stockMapper.selectById(1L); 15. // 再减库存 16. if (stock != null && stock.getCount() > 0){ 17. stock.setCount(stock.getCount() - 1); 18. this.stockMapper.updateById(stock); 19. } 20. // 释放锁 21. String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return 22. redis.call('del', KEYS[1]) else return 0 end"; 23. this.redisTemplate.execute(new DefaultRedisScript<>(script, 24. 25. Long.class), Arrays.asList("lock"), uuid); 26. }
压力测试:
可重入锁
由于上述加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加 锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代 码,可重入性就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继 续往下执行。
用一段 Java 代码解释可重入:
1. public synchronized void a() { 2. b(); 3. } 4. 5. public synchronized void b() { 6. // pass 7. }
假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。
锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己~
可重入性就可以解决这个尴尬的问题,当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释 放。 可以看到可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。
解决方案:redis + Hash