简单概括

什么是分布式锁

  • 分布式锁可用需要满足 3 个条件
    1. 互斥性: 任意时刻,只有一个客户端能持有锁
    2. 不会发生死锁: 即使有一个客户端在持有锁的期间因为崩溃等原因没有主动释放锁,也能保证后续其他客户端能够加锁
    3. 加锁和释放锁都必须是同一个客户端,即当前客户端不能释放其他客户端的锁

如何实现

  • 总体上使用 setnx setex
  • 避免锁无法释放,需要保持原子性
    • 使用 lua 脚本实现原子性
    • 使用命令连用的方式实现原子性

解锁需要注意什么

  • 加锁和解锁必须是同一个客户端,即当前客户端不能释放其他客户端的锁

redis 的分布式锁实现

简单版本

  1. setnx(key, value) ,如果返回 1 继续,0 返回
  2. 进行 setex(key, time, value)setnx 的键值对设置过期时间
  3. setnx 的线程需要释放该锁
  • 代码简单示范

    1. private void tryOnce() {
    2. Boolean result = false;
    3. String myValue = getRandomValue();
    4. try {
    5. // setnx
    6. result = redisTemplate.opsForValue().setIfAbsent(KEY, myValue);
    7. // setex
    8. redisTemplate.opsForValue().set(KEY, myValue, 10, TimeUnit.SECONDS);
    9. if (!result) {
    10. // 要立即返回
    11. return;
    12. } else {
    13. System.out.println("ok: " + System.currentTimeMillis());
    14. }
    15. TimeUnit.SECONDS.sleep(1);
    16. } catch (Exception e) {
    17. log.error(e.toString(), e);
    18. } finally {
    19. // 需要判断对应 key 的值是不是当前线程设置的
    20. if (result && myValue.equals(redisTemplate.opsForValue().get(KEY))) {
    21. redisTemplate.delete(KEY + serviceName);
    22. System.out.println("释放锁");
    23. }
    24. }
    25. }
    26. /**
    27. * 获取随机值,生成上可以设置为当前服务的ip
    28. *
    29. * @return {@link java.lang.String} 随机字符串
    30. */
    31. private String getRandomValue() {
    32. return UUID.randomUUID().toString().replace("-", "");
    33. }

原子性优化点一

  • setnx 后,出现下列问题,会导致 setnx 的值过期时间为 -1
    1. 当前线程所在服务宕机
    2. redis 不可用
  • 此后如果不及时处理,会导致其他服务永远拿不到锁
  • 所以就要让 setnxsetex 操作作为 原子操作

    • 方式一: lua
    • 方式二: 代码原子化

      用lua

  • Lua数据类型和redis返回值类型转换规则 | Lua数据类型 | 整数回复(Lua的数字类型会被自动转换成整数) | | —- | —- | | 字符串类型 | 字符串回复 | | table类型(数组形式) | 多行字符串回复 | | table类型(只有一个ok字段存储状态信息) | 状态回复 | | table类型(只有一个err字段存储错误信息) | 错误回复 |

  • resources 目录下新建一个 one.lua 脚本
  1. -- KEYS... , ARGS... 注意 KEYS ARGS 之间的逗号两边都有空格, 而他们各自的值是用空格分开的
  2. -- 下标从 1 开始
  3. local key = KEYS[1]
  4. local value = KEYS[2]
  5. local expired_time = ARGV[1]
  6. -- 判断结果是否包含指定的键
  7. local checkResultContainKey = function(tableName, keyName)
  8. for key, value in pairs(tableName) do
  9. -- 方式一
  10. if string.find(key, keyName) then
  11. return true
  12. end
  13. -- 方式二
  14. if key == keyName then
  15. return true
  16. end
  17. return false
  18. end
  19. end
  20. -- 开始 setnx setex 操作
  21. local result_setnx = redis.call("SETNX", key, value)
  22. if result_setnx == 1 then -- 注意 setnx 返回 1
  23. -- setnx 成功会进行 setex
  24. local result_setex = redis.call("SETEX", key, expired_time, value)
  25. if result_setnx and checkResultContainKey(result_setex, 'ok') then
  26. return true
  27. else
  28. return false
  29. end
  30. else
  31. return result_setnx
  32. end
  • 代码中读取lua 脚本

    1. /**
    2. * <h2>实现 setnx setex 原子化</h2>
    3. *
    4. * @param key 键
    5. * @param value 值
    6. * @param time 过期时间,目前限制为 seconds
    7. * @return {@link java.lang.Boolean}
    8. */
    9. private Boolean luaExpress(String key, String value, Integer time) {
    10. // 泛型表示 lua 返回值
    11. DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
    12. redisScript.setScriptSource(
    13. new ResourceScriptSource(new ClassPathResource("one.lua"))
    14. );
    15. // 也可以在这里设置返回值内容
    16. redisScript.setResultType(Boolean.class);
    17. // 封装参数
    18. List<String> keyList = new ArrayList<>();
    19. keyList.add(key);
    20. keyList.add(value);
    21. return (Boolean) redisTemplate.execute(redisScript, keyList, time);
    22. }
  • 修改原来的逻辑

    1. // 将之前代码中的 try {} 中的内容进行替换
    2. try {
    3. // setnx + setex 原子化
    4. result = luaExpress(KEY, serviceName, 30);
    5. if (!result) {
    6. // 要立即返回
    7. return;
    8. } else {
    9. System.out.println("ok: " + System.currentTimeMillis());
    10. }
    11. TimeUnit.SECONDS.sleep(1);
    12. }

命令连用

  • redis 2.6 (?不确定) 之后可以实现方法连用
  • 代码演示
  1. /**
  2. * 使用 redisConnection 中重写方法实现方法连用
  3. *
  4. * @param key 键
  5. * @param value 值
  6. * @param time 时间,目前单位规定为 seconds
  7. * @return {@link java.lang.Boolean}
  8. */
  9. private Boolean overwrite(String key, String value, Integer time) {
  10. try {
  11. Boolean result = redisTemplate.execute(new RedisCallback<Boolean>() {
  12. @Override
  13. public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
  14. return connection.set(key.getBytes(StandardCharsets.UTF_8),
  15. value.getBytes(StandardCharsets.UTF_8),
  16. Expiration.seconds(time),
  17. RedisStringCommands.SetOption.ifAbsent()
  18. );
  19. }
  20. });
  21. return result;
  22. } catch (Exception e) {
  23. log.error("出现异常: {}", e.getLocalizedMessage(), e);
  24. }
  25. return false;
  26. }
  • 逻辑修改
    1. // 将之前代码中的 try {} 中的内容进行替换
    2. try {
    3. // setnx + setex 原子化
    4. result = overwrite(KEY, myValue, 30);
    5. if (!result) {
    6. // 要立即返回
    7. return;
    8. } else {
    9. System.out.println("ok: " + System.currentTimeMillis());
    10. }
    11. TimeUnit.SECONDS.sleep(1);
    12. }

SET key value [EX seconds] [PX milliseconds] [NX|XX]

  • 原子的么?

原子性优化点二

  • 在释放锁的时候,由于 setex 设置的过期时间过短,在抢到锁的当前服务执行完毕后将要删除该锁时,该锁已经过期,此时如果别人抢占了锁,那么当前服务可能会删除到了别的服务的锁
  • 即在代码中 finally 阶段,需要进行 get 、判断是否相同、del 操作
    • 如果在 get 、判断是否相同,在进行 del 时,这时候锁过期,被其他线程抢到锁设置了一个相同 key 的锁,此时 del 就相当于释放了别人的锁
    • 所以在进行上面 finally 三步骤时,要保持 原子性
    • 可以说有进行 get + operation 的情景,即后续操作是依托于 get 的结果的情景,get 和 后续操作要保持原子性

**

用lua

  • 脚本 ```lua local key = KEYS[1] —local myValue = ARGV[1] — ARGV 传入的字符串居然会带双引号!!!!! local myValue = KEYS[2]

— 判断 key 是否为自己设置的 myValue local is_key_exist = redis.call(“GET”, key)

if is_key_exist == myValue then local deleted = redis.call(“DEL”, key) — 返回删除状态 return deleted else — 标明该 key 不是自己删除的 return false end

  1. - 读取lua脚本
  2. ```java
  3. /**
  4. * 判断目标值是否为自己设置的值
  5. *
  6. * @param key 键
  7. * @param myValue 当前设置的值
  8. * @return {@link java.lang.Boolean}
  9. */
  10. private Boolean removeIfSame(String key, String myValue) {
  11. DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
  12. redisScript.setScriptSource(
  13. new ResourceScriptSource(new ClassPathResource("removeIfSame.lua"))
  14. );
  15. redisScript.setResultType(Boolean.class);
  16. List<String> keyList = new ArrayList<>(2);
  17. keyList.add(key);
  18. keyList.add(myValue);
  19. // 这里将 key 和 myValue 当作 KEYS 传入
  20. return redisTemplate.execute(redisScript, keyList);
  21. }
  • 修改代码逻辑
    1. finally {
    2. // 判断目标值是否存在,且是否为自己设置的值
    3. // get + del 原子化
    4. if (result && removeIfSame(KEY + serviceName, serviceName)) {
    5. System.out.println("释放了自己设置的锁");
    6. }
    7. }