redis实现分布式锁防坑指南

分布式锁一般有三种实现方式:

  • 数据库乐观锁
  • 基于Redis的分布式锁
  • 基于ZooKeeper的分布式锁

保证分布式锁可靠性的四个条件

  • 互斥性。在任意时刻,只有一个客户端能持有锁
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁
  • 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了

加锁的正确姿势

public class RedisLock {

  private static final String LOCK_SUCCESS = "OK";

  /** 当key不存在时,进行set操作;若key已经存在,则不做任何操作 */
  private static final String SET_IF_NOT_EXIST = "NX";

  /** 给key加一个过期的设置 */
  private static final String SET_WITH_EXPIRE_TIME = "PX";

  @Autowired
  @Qualifier("redisClient")
  protected static JedisCommands jedis;

  /**
   * @param lockKey 锁, key是唯一的, 所以可以用来当锁
   * @param requestId 请求标识, 知道是哪个请求加的锁, 在解锁的时候才能有依据, 满足可靠性条件四: 解铃还须系铃人
   * @param expireTime 过期时间
   * @return 是否获取成功
   */
  public static boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
  
    String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
    
    if (LOCK_SUCCESS.equals(result)) {
      return true;
    }
    
    return false;
  }
}

set()方法的两种结果:

  • 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端
  • 已有锁存在,不做任何操作

满足可靠性的条件:

  • NX参数, 保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性
  • 过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁
  • 将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端
  • 暂时只考虑Redis单机部署的场景,不考虑容错性
加锁错误示例1, jedis.setnx()和jedis.expire()组合加锁
  /**
   * 错误示例, 使用jedis.setnx()和jedis.expire()组合加锁
   * 两条Redis命令,不具有原子性
   * 如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间, 将会发生死锁
   *
   * 错误原因: 低版本的jedis并不支持多参数的set()方法
   *
   * @param lockKey
   * @param requestId
   * @param expireTime
   */
  public static void tryGetDistributedLock(String lockKey, String requestId, int expireTime) {

    /** setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间 */
    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
      /** 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁 */
      jedis.expire(lockKey, expireTime);
    }
  }
加锁错误示例2
  /**
   * 使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间
   *
   * @param lockKey
   * @param expire
   * @return
   */
  public static boolean tryGetDistributedLock(String lockKey, int expire) {

    String expireTime = String.valueOf(System.currentTimeMillis() + expire);

    // 1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expireTime) == 1) {
      return true;
    }

    // 2. 如果锁已经存在则获取锁的过期时间,和当前时间比较
    String curExpireTime = jedis.get(lockKey);
    if (StringUtils.isNotBlank(curExpireTime) && 
    	Long.parseLong(curExpireTime) < System.currentTimeMillis()) {
        
      // 3. 如果锁已经过期,获取上一个锁的过期时间,并设置现在锁的过期时间,返回加锁成功
      String oldExpireTime = jedis.getSet(lockKey, expireTime);
      if (StringUtils.isBlank(oldExpireTime) && StringUtils.equals(oldExpireTime, curExpireTime)) {
          // 考虑多线程并发的情况,只有一个线程的设置值oldExpireTime和当前值curExpireTime相同,它才有权利加锁
          return true;
      }
    }

    // 其他情况,一律返回加锁失败 (锁未过期, 加锁失败)
    return false;
  }

代码问题

  1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步
  2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖 – 存在并发问题
  3. 锁不具备拥有者标识,即任何客户端都可以解锁

解锁的正确姿势

public class RedisLock {

    private static final Long RELEASE_SUCCESS = 1L;

    public boolean releaseDistributedLock(String lockKey, String requestId) {

        String luaScript = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        
        // eval()方法是将Lua代码交给Redis服务端执行, 可以确保原子性
        // 参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId
        // 获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
        Object result = jedis.eval(luaScript, 
        	Collections.singleton(lockKey), 
        	Collections.singleton(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }

        return false;
    }
}
解锁错误示例1

不先判断锁的拥有者而直接解锁
导致任何客户端都可以随时进行解锁

public void releaseDistributedLock(String lockKey) {
    jedis.del(lockKey);
}
解锁错误示例2
  public void releaseDistributedLock(String lockKey, String requestId) {

    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
      // 若在此时,这把锁突然不是这个客户端的,则会误解锁 -- 并发问题
      jedis.del(lockKey);
    }
  }

客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了
此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了

建议

如果项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值