前言

在很多分布式系统中都存在并发场景,存在并发就会存在竞争,多线程去竞争资源的时候系统会变的“不稳定”,一般遇到这种问题很容易想到使用synchronized加锁,但是synchronized有其固有的局限性:

  • 无法做到细粒度的锁控制
  • 只适合单机的情况(分布式系统下难以满足)
  • 只是解决多线程问题的一种方法

redis分布式锁

使用redis分布式锁的好处显而易见:

  • 支持分布式
  • 可以实现细粒度的控制
  • 实现多台机器(多个进程)对同一个数据操作的互斥

直接上代码吧:

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
/**
 * @author lihang
 * @date 2018/3/28.
 * @description
 */
@Slf4j
public class Main {

    /*关闭订单的分布式锁key**/
    private final static String TASK_LOCK = "TASK_LOCK";

    /**
     * 通过setnx、getSet两个原子操作实现分布式锁
     * SETNX命令:将key设置值为value,如果key不存在,设置成功,这种情况下等同SET命令;如果key存在,什么也不做,SETNX是“SET if Not eXists”的简写。
     * GETSET:自动将key对应到value并且返回原来key对应的value。(如果key存在但对应的value不是字符串,返回错误)
     */
    public void lockTask() {
        long lockTimeout = 5000;
        Long setnxResult = RedisShardedPoolUtil.setnx(TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
        if (setnxResult != null) {
            //如果返回值是1,代表设置成功,获取锁
            //该线程可以执行业务代码
            //具体业务代码……
        } else {
            //未获取到锁,继续判断,判断时间戳,看是否可以重置并获取到锁
            //这里的时间判断防止了死锁的发生(比如一个线程获得了锁但是因为一些原因出了问题,那其他线程将一直等待)
            String currentValue = RedisShardedPoolUtil.get(TASK_LOCK);
            //如果锁过期
            if (!StringUtils.isEmpty(currentValue) && System.currentTimeMillis() > Long.parseLong(currentValue)) {
                //获取上一个锁的时间
                String oldValue = RedisShardedPoolUtil.getSet(TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
                //多线程访问,oldValue.equals(currentValue)保证了只有一个线程获得锁(因为getSet是原子操作)
                if (StringUtils.isEmpty(oldValue) || (!StringUtils.isEmpty(oldValue) && StringUtils.equals(oldValue, currentValue))) {
                    //该线程可以执行业务代码
                    //具体业务代码……
                } else {
                    log.info("没有获取到分布式锁:{}", TASK_LOCK);
                }
            } else {
                log.info("没有获取到分布式锁:{}", TASK_LOCK);
            }
        }
        log.info("任务执行完毕");
    }
}

核心就是借助redis的原子操作命令setnx、getSet,这里原子操作的含义泛指只有一个线程会成功执行该操作,期间其他线程是执行失败的。其中RedisShardedPoolUtil是个工具类,如下:

/**
 * @author lihang
 * @date 2018/3/28.
 * @description
 */
@Slf4j
public class RedisShardedPoolUtil {

    /**
     * 原子操作,如果key不存在则设置成功
     * @param key
     * @param value
     * @return
     */
    public static Long setnx(String key, String value) {
        ShardedJedis jedis = null;
        Long result = null;
        try {
            jedis = RedisShardedPool.getJedis();
            result = jedis.setnx(key, value);
        } catch (Exception e) {
            log.error("setnx key:{} value:{} error", key, value, e);
            RedisShardedPool.returnBrokenResource(jedis);
            return result;
        }
        RedisShardedPool.retureResource(jedis);
        return result;
    }

    /**
     * 原子操作,将key对应到value并且返回原来key对应的value
     * @param key
     * @param value
     * @return
     */
    public static String getSet(String key, String value) {
        ShardedJedis jedis = null;
        String result = null;
        try {
            jedis = RedisShardedPool.getJedis();
            result = jedis.getSet(key, value);
        } catch (Exception e) {
            log.error("setnx key:{} value:{} error", key, value, e);
            RedisShardedPool.returnBrokenResource(jedis);
            return result;
        }
        RedisShardedPool.retureResource(jedis);
        return result;
    }

   //省略其他方法……其中RedisShardedPool是配置的jedis连接池资源
}

整体流程描述如下:

  1. 多个线程竞争分布式锁,只有一个线程成功执行setnx指令,其他线程执行失败(返回0)
  2. 锁的value为 当前时间+超时时间
  3. 获得锁的线程去执行任务
  4. 执行完任务释放锁(直接删除对于的key)

这里面之所以设置超时时间是因为任务执行是不确定的,线程获取锁之后由于种种原因如果一直持有锁的话会产生死锁,因此锁需要设置超时执行的时间,时间不宜设置过长(其他线程饥饿),也不宜设置过短(任务来不及执行完),超时时间最好根据业务需求动态设置。因此线程在setnx命令执行失败之后继续判断当前锁是否超时,对于已经超时的操作我们认为当前现在有资格执行任务。
更好的做法是:

  1. 在执行任务之前给对应的key通过expire命令添加超时时间,保证锁一定会释放
  2. 在任务执行完毕之后手动删除key,保证锁及时被释放。

总结

这种分布式锁本质上还是一种悲观锁的策略,效率低于乐观锁,关于乐观锁的方案有空再研究。悲观锁适用于竞争激烈且数据一致性要求不高的场景,锁保证了并发情况的安全性但一定程度上降低了系统吞吐量,因此没有完美的方案,只有最适合的场景。