加锁脚本
Redis 提供了 Hash (哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的 锁的重入次数,然后利用 lua 脚本判断逻辑。
1. if (redis.call('exists', KEYS[1]) == 0 or 2. redis.call('hexists', KEYS[1], ARGV[1]) == 1) 3. then 4. redis.call('hincrby', KEYS[1], ARGV[1], 1); 5. redis.call('expire', KEYS[1], ARGV[2]); 6. return 1; 7. else 8. return 0; 9. end
假设值为:KEYS:[lock], ARGV[uuid, expire]如果锁不存在或者这是自己的锁,就通过hincrby(不存在就新增并加1,存在就加1)获取锁或者锁次 数加1。
解锁脚本
1. -- 判断 hash set 可重入 key 的值是否等于 0 2. -- 如果为 nil 代表 自己的锁已不存在,在尝试解其他线程的锁,解锁失败 3. -- 如果为 0 代表 可重入次数被减 1 4. -- 如果为 1 代表 该可重入 key 解锁成功 5. if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then 6. return nil; 7. elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then 8. return 0; 9. else 10. redis.call('del', KEYS[1]); 11. return 1; 12. end;
这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:
1 代表解锁成功,锁被释放
0 代表可重入次数被减 1
null 代表其他线程尝试解锁,解锁失败
代码实现
由于加解锁代码量相对较多,这里可以封装成一个工具类:
具体实现:
1. public class RedisDistributeLock{ 2. 3. 4. private StringRedisTemplate redisTemplate; 5. 6. //线程局部变量,可以在线程内共享参数 7. private String lockName; 8. private String static uuid; 9. private Integer expire = 30; 10. private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>(); 11. 12. public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName) { 13. this.redisTemplate = redisTemplate; 14. this.lockName = lockName; 15. this.uuid = THREAD_LOCAL.get(); 16. if (StringUtils.isBlank(uuid)) { 17. this.uuid = UUID.randomUUID().toString(); 18. THREAD_LOCAL.set(uuid); 19. } 20. } 21. 22. public void lock() { 23. this.lock(expire); 24. } 25. 26. public void lock(Integer expire) { 27. this.expire = expire; 28. String script = "if (redis.call('exists', KEYS[1]) == 0 or" + 29. "redis.call('hexists', KEYS[1], ARGV[1]) == 1)" + 30. " then" + 31. " redis.call('hincrby', KEYS[1], ARGV[1], 1);" + 32. " redis.call('expire', KEYS[1], ARGV[2]);" + 33. " return 1;" + 34. "else" + 35. " return 0;" + 36. " end"; 37. if (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), 38. Arrays.asList(lockName), uuid, expire.toString())) { 39. try { 40. Thread.sleep(60); 41. } catch (InterruptedException e) { 42. e.printStackTrace(); 43. } 44. //没有获取到锁重试 45. lock(expire); 46. } 47. } 48. 49. public void unlock() { 50. String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 0) then" + 51. " return nil; " + 52. "elseif(redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then" + 53. " return 0; " + 54. "else" + 55. " redis.call('del', KEYS[1]);" + 56. " return 1;" + 57. "end;"; 58. //如果返回值没有使用Boolean,Spring-data-redis 进行类型转换时将会把 null 59. //转为 false,这就会影响我们逻辑判断 60. //所以返回类型只好使用 Long:null-解锁失败;0-重入次数减1;1-解锁成功。 61. Long result = this.redisTemplate.execute(new DefaultRedisScript<> 62. (script, Long.class), Arrays.asList(lockName), uuid); 63. // 如果未返回值,代表尝试解其他线程的锁 64. if (result == null) { 65. throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName: " + lockName + " with request: " + uuid); 66. } else if (result == 1) { 67. THREAD_LOCAL.remove(); 68. } 69. } 70. 71. }
使用及测试
在业务代码中使用:
1. public void checkAndLock() { 2. // 加锁,获取锁失败重试 3. RedisDistributeLock lock = new RedisDistributeLock(this.redisTemplate, 4. "lock"); 5. lock.lock(); 6. // 先查询库存是否充足 7. Stock stock = this.stockMapper.selectById(1L); 8. // 再减库存 9. if (stock != null && stock.getCount() > 0){ 10. stock.setCount(stock.getCount() - 1); 11. this.stockMapper.updateById(stock); 12. } 13. // this.testSubLock(); 14. // 释放锁 15. lock.unlock(); 16. }
测试:
测试可重入性:
自动续期
lua脚本:
1. if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then 2. redis.call('expire', KEYS[1], ARGV[2]); 3. return 1; 4. else 5. return 0; 6. end
在RedisDistributeLock中添加renewExpire方法:
1. private static final Timer TIMER = new Timer(); 2. 3. /** 4. * 开启定时器,自动续期 5. */ 6. private void renewExpire() { 7. String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then " + 8. "redis.call('expire', KEYS[1], ARGV[2]); " + 9. "return 1; " + 10. "else " + 11. "return 0; end"; 12. TIMER.schedule(new TimerTask() { 13. @Override 14. public void run() { 15. //如果uuid为空,则终止定时任务 16. if (StringUtils.isNotBlank(uuid)) { 17. redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), 18. Arrays.asList(lockName), RedisDistributeLock.this.uuid, 19. expire.toString()); 20. renewExpire(); 21. } 22. } 23. },expire * 1000 / 3); 24. }
在lock方法中使用:
在unlock方法中添加红框中的代码:
总结
特征:
1.独占排他:setnx
2.防死锁:
redis客户端程序获取到锁之后,立马宕机。给锁添加过期时间
不可重入:可重入
3.防误删:
先判断是否自己的锁才能删除
4.原子性:
加锁和过期时间之间
判断和释放锁之间
5.可重入性:hash + lua脚本
6.自动续期:Timer定时器 + lua脚本
锁操作:
1.加锁:
1.setnx:独占排他 死锁、不可重入、原子性
2.set k v ex 30 nx:独占排他、死锁 不可重入
3.hash + lua脚本:可重入锁
1.判断锁是否被占用(exists),如果没有被占用则直接获取锁(hset/hincrby)并设置过期时间(expire)
2.如果锁被占用,则判断是否当前线程占用的,如果是则重入(hincrby)并重置过期时间(expire)
3.否则获取锁失败,将来代码中重试
4.Timer定时器 + lua脚本:实现锁的自动续期
2.解锁
1.del:导致误删
2.先判断再删除同时保证原子性:lua脚本
3.hash + lua脚本:可重入
1.判断当前线程的锁是否存在,不存在则返回nil,将来抛出异常
2.存在则直接减1(hincrby -1),判断减1后的值是否为0,为0则释放锁(del),并返回1
3.不为0,则返回0
3.重试:递归 循环