菜鸟带你手撕分布式锁-redis,看完不会算我输

1,600 阅读4分钟

背景

今天是五一,决定不出去,在家里撸代码,今天学习redis,于是准备写个基于redis的分布式锁。由于本人属于菜鸟级别,在写的过程中遇到各种问题,功夫不负有心人,终于搞定,如果发现实现有问题,欢迎指导,感谢.

在单机时代,虽然不需要分布式锁,但也面临过类似的问题,只不过在单机的情况下,如果有多个线程要同时访问某个共享资源的时候,我们可以采用线程间加锁的机制,即当某个线程获取到这个资源后,就立即对这个资源进行加锁,当使用完资源之后,再释放锁,其它线程就可以接着使用了。JAVA中已提供相关工具类。但是到了分布式系统的时代,这种线程或者进程之间的锁机制,就可能没作用了,系统可能会有多份并且部署在不同的机器上,这些资源已经不是在线程之间共享了,而是属于进程之间共享的资源。因此,为了解决这个问题,我们就必须引入「分布式锁」。

分布式锁,是指在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问。

一般分布式锁要满足一下几点要求:

  • 排他性:在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取
  • 避免死锁:这把锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)
  • 高可用:获取或释放锁的机制必须高可用且性能佳

分布式锁实现方式

目前主流的分布式锁实现主要有以下几种

  • 基于redis实现
  • 基于数据库实现
  • 基于zookeeper的实现

今天主要将基于redis实现分布式锁

redis分布式锁实现

redis分布式锁基础知识

  • 缓存过期

    缓存可以设置过期时间,redis根据时间自动进行清理。

  • setNx命令

将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
  • lua脚本

    脚本语言,用于支持redis原子操作。

熟悉以上redis知识,实现redis分布式锁比较容易了。

关注点

  • 缓存过期

    最好给加锁的key设置缓存过期时间,可以有效的防止死锁,比如某个进程加锁后没来得及释放锁,宕机,说来负责释放锁?

  • set值

    加锁时,在redis中保存在各节点中唯一的值,防止不同进程误解锁

比如serviceA已经在redis中加锁lock,一般serviceA执行时间为1秒,则设置缓存过期时间2秒,某天由于机器原因serviceA执行了3秒,那么对应的锁已经失效,此期间B去加锁,并加锁成功, serviceA执行完会释放锁,导致serviceA会将B加的锁释放,所以产生误删锁,采用唯一值,避免这种情况产生。删锁会检查值,如果加锁与解锁的值不相同则不允许解锁。

  • 加锁与失效时间必须要原子性

核心逻辑

1、利用SETNX命令加锁

    public static String set(String key, String value, long timeout) {
        Jedis jedis = getJedis();
        try {
            String ret = jedis.set(key, value, "NX", "PX", timeout);
            return ret;
        } finally {
            close(jedis);
        }
    }

2、实现阻塞加锁和非阻塞加锁

    /**
     * 阻塞加锁
     */
    public void lock() {
        if (tryLock()) {
            return;
        }
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //lock
        lock();

    }

    /**
     * 基于setNx实现非阻塞锁
     *
     * @return
     */
    public boolean tryLock() {
        String uuid = UUID.randomUUID().toString();
        String ret = JedisUtis.set(LOCK_KEY, uuid, DEFAULT_TIME_OUT);
        if ("OK".equals(ret)) {
            //lock success
            LOCAL.set(uuid);
            return true;
        }
        return false;
    }

    public boolean tryLock(long time, TimeUnit unit){
        String uuid = UUID.randomUUID().toString();
        String ret = JedisUtis.set(LOCK_KEY, uuid, unit.toMillis(time));
        if ("ok".equals(ret)) {
            LOCAL.set(uuid);
            //lock success
            return true;
        }
        return false;
    }

3、解锁

解锁的同时需要去检查值是否与加锁的值相同,不相同则不允许解锁,这里是通过ThreadLocal传加锁产生的uuid

    /**
     * unlock
     * 执行lua脚本,保证原子性
     */
    public void unlock() {
        release();
    }

    private void release() {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        JedisUtis.remove(LOCK_KEY, script, LOCAL.get());
    }

场景验证

12306售票是并发学习中经典案例,还是拿这个举例,比如有100 tickets,有多个售票窗口同时售票,怎么保证不被重复卖

/**
 * 线程不安全示例
 *
 * @author Qi.qingshan
 * @date 2020/5/1
 */
public class SaleTicket implements Runnable {

    private int tickets = 100;

    public void run() {
        for (; ; ) {
          sale();
          if (tickets < 0) break;
         }
       }
    }

    /**
     * 售票
     */
    private void sale() {
        if (tickets > 0) {
            tickets--;
            System.out.println(Thread.currentThread().getName() + " - 在售第" + (100 - tickets) + "票 :: 剩余" + (tickets));
        }
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {

        }
    }
}

测试类

/**
 * @author Qi.qingshan
 * @date 2020/5/1
 */
public class SaleTicketTest {

    BlockingDeque queue = new LinkedBlockingDeque(100);

    private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 50, 100, TimeUnit.SECONDS, queue);

    @Test
    public void testSaleTickets() throws IOException {
        SaleTicket saleTicket = new SaleTicket();
        executor.execute(new Thread(saleTicket, "售票员001"));
        executor.execute(new Thread(saleTicket, "售票员002"));
        executor.execute(new Thread(saleTicket, "售票员003"));
        executor.execute(new Thread(saleTicket, "售票员004"));
        System.in.read();
    }
}

存在重复售票情况,改用redisLock,调整核心代码

    public void run() {
        for (; ; ) {
            lock.lock();
            try {
                sale();
                if (tickets < 0) break;
            } finally {
                lock.unlock();
            }
        }
    }

执行结果如下

完整代码已上传github.com/qiqsa/distr…