微服务系列(五)解读分布式锁

微服务系列(五)解读分布式锁

首先,锁是一个熟悉的字眼,在单机应用中,我们常常使用J.U.C等并发工具类来控制多线程读写问题,也会使用ReentrantLock/ReentrantReadWriteLock或是synchronized关键字来给方法或代码块加锁,从而达到同样的目的。

我理解的锁

说到锁,我能想到这样几个关键字:临界区、共享变量、并发问题

从抽象的角度去考虑,锁就是一个能给什么东西加锁和解锁的东西,至于给什么加锁,我们并不关心,于是在jdk源码中存在了这样一个接口java.util.concurrent.locks.Lock

那么在生活中,存在这样一些锁,比如自行车防盗的锁、防盗门的锁,这就是锁的实现,当然了,在这之上还可以有更多的实现,比如全自动锁还是手动锁等。

而在计算机的世界里,就需要这样的一把锁,能帮助我们解决一个通用的资源共享问题。首先我们知道,计算机是cpu、显卡、主板等硬件组成的,其上运行的操作系统,我们之所以能轻松的控制和调度它就是因为这个”全自动的“操作系统,它帮助我们调度cpu、资源分配,让我们不需要关心这些细节。

而操作系统中存在线程的概念,引用维基百科给的定义:

In computer science, a thread of execution is the smallest sequence of programmed instructions that can be managed independently by a scheduler, which is typically a part of the operating system.

线程是操作系统能够进行运算调度的最小单位。

简单的理解就是一个线程的运行,就是一个cpu的调度,并且为了充分利用cpu的资源,一个cpu会同时运行多个线程(按时间片分配调度)。

既然多个线程是可能同时运行的,如果他们的运算需要满足某种先后关系,那么如何保证他们的运算正确性呢?

这又类似了cpu与主存/cache面临的问题,cpu每次计算都会拿cache中的值,而cache中的值和主存中的值并不是每时每刻都保持一致,如果主存中存在一个需要多个cpu运算的变量,那么就必须保证在其中一个cpu运算的时候拿到的是主存中最新的值,并且在另一个cpu获取它并修改它之前将计算后的值回写到主存。

当然,硬件层面的一致性问题被很好的解决了(从总线锁到缓存锁的优化过程,可以看到,锁的概念从计算机最初的发展起就诞生了),这样才会让我们只关心软件层面上的一致性问题。

所以,锁住的东西就是”共享变量“(操作系统中称之为”临界区“,锁则对应了操作系统中的“信号量“)。

分布式锁

分布式锁也是锁,那么必然和我们常常使用的java锁存在很多相同之处,首先,我们需要弄清楚,java锁和分布式锁的联系和区别,再来了解分布式锁是如何使用和构建的。

举一个最简单的例子,应用A的JVM内存中存在一个库存变量x,当应用A初始化时x初始化值为2000,当其他用户调用“库存扣减”接口时,将会提交一个库存扣减的任务给业务线程池(总线程数量大于1),那么当多个用户同时调用“库存扣减”的接口时,就可能存在多个线程同时对库存变量x进行操作。

public class Test {

    /**
     * JVM中的库存变量
     */
    private int a = 2000;

    /**
     * 业务线程池
     */
    private final ThreadPoolExecutor executor = new ThreadPoolExecutor(50, 100,
            0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>());

    public Future<Integer> mock(int num){
        return executor.submit(()->decr(num));
    }

    public synchronized int decr(int num){
        a = a - num;
        return a;
    }

    public static void main(String[] args) {
        //初始化应用
        Test test = new Test();
        List<Future<Integer>> futures = new ArrayList<>();
        //模拟多个用户同时调用接口
        for(int i = 0; i < 1000; i++){
            futures.add(test.mock(1));
        }
        //保证所有请求均处理完成
        for(int i = 0; i < 1000; i++){
            try {
                futures.get(i).get();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        }
        System.out.println("如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=" + test.a + "]");
    }

}

这个程序的5次运行结果如下:

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1007]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1008]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1003]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1011]

这里由于运气不错,有一次调用满足了我们的预期。

这就是一个很常见的并发安全的场景,由于decr()操作并非原子的,decr操作本质上是先读a的值,然后计算a-num的值,并重新赋值给a,最后将a返回;这个过程中,可能存在另一个线程修改了a的值,导致结果不符合预期,比如当a=1800时,线程A读到a的值是1800,然后计算a-1为1799,此时还没有重新赋值给a,线程B读到a的值是1800,然后计算a-1为1799,此时线程A重新赋值给a,a此时的值为1799,线程B此时也计算结束,将1799重新赋值给a,最终导致两次decr后,结果依然是1799。

为了让程序向我们期望的方向进行,我们使用锁来改造一下,为decr方法加上synchronized关键字:

public synchronized int decr(int num){
    a = a - num;
    return a;
}

再次运行5次程序:

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

如果1000个用户调用完,没有出现异常,那么期望值[a=1000],但实际结果为[a=1000]

实际上,锁帮助我们达到了这样一个目的,保证了decr内的多个操作聚合成了一个原子操作,也就是在这个原子操作执行过程中,不允许其他的原子操作执行,原子操作之间只能存在先后关系。

根据这个场景,我们可以类比的思考,这个公共的变量可以是一个公共的区域,但不限定为一个变量,可以叫它为资源或是动态数据;decr操作可以是一次业务操作,而业务操作包含了读、写等操作的集合;

于是随着分布式架构的到来,再次出现了过去单机资源共享的问题。

把这个例子这样替换一下:

应用A的JVM内存中存在一个库存变量x,当应用A初始化时x初始化值为2000,当其他用户调用“库存扣减”接口时,将会提交一个库存扣减的任务给业务线程池(总线程数量大于1),那么当多个用户同时调用“库存扣减”的接口时,就可能存在多个线程同时对库存变量x进行操作。

应用A变成了一个分布式系统:由节点A1、A2…组成

JVM内存变成了一个存储系统,假设它是一个数据库系统mysql:由节点M1、M2…组成

库存变量变成了存储系统中的一条记录:表示某个商品的库存值

再假设这个存储系统无法为我们保证decr的原子性,例如我们使用了先查询后更新的方式来进行decr操作

那么这里的多线程就会存在于分布式系统A的多个节点A1、A2上,那么jdk提供的锁如synchronized关键字将会不再有能力保证原子性(仅在同一JVM中生效)

于是分布式锁诞生了…

随之而来的是这样几个问题:

  1. synchronized在同一JVM中生效是因为它利用了JVM中的资源,而多个线程间通过同一JVM中的资源来控制他们的执行顺序,那么分布式系统中也需要一个公共资源来控制线程间的执行顺序,哪里存在一个这样的公共资源呢?
  2. jdk为我们实现了单机下的方便、健壮的锁,如今分布式环境下必须由我们自己实现一个健壮的分布式锁,需要考虑哪些问题?

对于问题1的解决,我们实际上只需要找到一个具备存储能力、提供读写的系统,在分布式架构中,还需要解决单点问题,那么我们的目标就是找到一个具备存储能力、提供读写、并且解决了单点问题的系统:

数据库系统oracle/mysql/sqlserver…、缓存系统redis/mongo/memcache…、分布式协调系统zookeeper

那么对于问题2,我们需要考虑这样几个问题:

  1. 分布式架构中,分布式锁应该是比较通用的工具,所以对于锁的操作,代价不应该太大,响应应该及时
  2. 作为分布式系统中分布式锁的公共资源,组件不应该过重,应该尽可能与业务系统的组件分离

很显然数据库系统就会显得非常臃肿,而且数据库系统一般作为业务系统的核心组件,不应该存储锁信息,并且如果为了分离,而单独部署新的数据库系统,就会显得非常重,运维工作也会变得困难很多,并且对于锁信息的存储,由于需要频繁的读写,更适合放置于缓存系统,而不是每次写都落盘,如果使用数据库系统,其速度也会慢很多。

所以一般而言,我们会选择缓存系统或分布式协调系统zookeeper,个人认为缓存系统更适合作为锁信息的存储系统,zookeeper是具有强一致性的,生产环境中如果需要扩容,将会让分布式锁变的很重,频繁操作,对zookeeper的压力非常大,并且对于使用锁的一方也会出现各种问题(获取锁超时、请求处理能力变弱等),另外,缓存系统一般会提供数据淘汰机制,这也会更加适合我们实现锁超时的效果。

基于缓存系统实现的分布式锁

接下来,以redis为例,实现一个健壮的分布式锁。

  • 基于setNx、getSet命令实现分布式锁

功能:分布式场景下的加锁、解锁、判断锁超时、防止死锁

基本思路:value存储锁超时时间,自旋获取锁,当setNx命令返回1或getSet命令返回值小于当前时间戳(即锁超时)则获取到锁

优势:实现简单,功能齐全

弊端:分布式环境下需要保证时钟一致,否则可能导致锁提前超时或锁延迟超时,如果时钟时差较大,会造成死锁或锁失效。

code:

package com.xcxcxcxcx.distribute.lock;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * 基于setNx和getSet命令实现
 * @author XCXCXCXCX
 * @since 1.0
 */
public class DistributedLock implements Lock{

    private final String key;

    /**
     * 锁超时时间
     */
    private final Duration timeout;

    private volatile Thread owner;

    private final StringRedisTemplate redisTemplate;

    public DistributedLock(String key, StringRedisTemplate redisTemplate) {
        this(key, Duration.ofSeconds(60), redisTemplate);
    }

    public DistributedLock(String key, Duration timeout, StringRedisTemplate redisTemplate) {
        this.key = key;
        this.timeout = timeout;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public void lock() {
        for(;;){
            if(tryLock()){
                break;
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        for(;;){
            if(tryLock()){
                break;
            }
            if (Thread.interrupted()){
                throw new InterruptedException();
            }
        }
    }

    @Override
    public boolean tryLock() {
        long dead = buildTimeoutTimestamp();
        if(setNx(String.valueOf(dead))){
            //获取到锁
            setOwner(Thread.currentThread());
            return true;
        }
        //检测锁超时
        long timeout = get();
        long now = System.currentTimeMillis();
        long newDead = buildTimeoutTimestamp();
        if(timeout > 0 && timeout < now && getSet(String.valueOf(newDead)) < now){
            //获取到锁
            setOwner(Thread.currentThread());
            return true;
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        long end = System.currentTimeMillis() + unit.toMillis(time);
        for(;;){
            if(tryLock()){
                return true;
            }
            if(System.currentTimeMillis() > end){
                return false;
            }
            if(Thread.interrupted()){
                throw new InterruptedException("Thread is interrupted, tryLock end.");
            }
        }
    }

    @Override
    public void unlock() {
        if(Thread.currentThread() == owner){
            setOwner(null);
            del();
        }else{
            throw new IllegalMonitorStateException("Only threads holding the lock can unlock.");
        }
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("Currently Condition is unsupported.");
    }

    private void setOwner(Thread owner) {
        this.owner = owner;
    }

    private boolean setNx(String value){
        return redisTemplate.opsForValue().setIfAbsent(key, value);
    }

    private long get(){
        String result = redisTemplate.opsForValue().get(key);
        return Long.parseLong(result == null ? "0" : result);
    }

    private long getSet(String value){
        String result = redisTemplate.opsForValue().getAndSet(key, value);
        return Long.parseLong(result == null ? "0" : result);
    }

    private void del(){
        redisTemplate.delete(key);
    }

    private long buildTimeoutTimestamp(){
        return System.currentTimeMillis() + timeout.toMillis();
    }

}
  • 基于lua脚本实现分布式锁

功能:分布式场景下的加锁、解锁、判断锁超时、防止死锁

基本思路:value不需要存储信息,自旋获取锁,通过lua脚本将setNx与expire命令合并为原子操作,锁的超时由redis控制。

优势:实现相对复杂,需要考虑集群环境下的api支持不同

弊端:如果lua脚本运行过程中失败,可能造成死锁,可以在value中存储超时信息,进一步防止这种情况的发生。

code:

package com.xcxcxcxcx.distribute.lock;

import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.connection.jedis.JedisClusterConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * 使用lua脚本,基于setNx和expire命令实现
 * @author XCXCXCXCX
 * @since 1.0
 */
public class DistributedLockV2 implements Lock{

    private static final String EXCLUSIVE_VALUE = "1";

    private final String key;

    /**
     * 锁超时时间
     */
    private final Duration timeout;

    private volatile Thread owner;

    private final StringRedisTemplate redisTemplate;

    private final String sha;

    private static final String LUA = "local flag = redis.call(\"setnx\", KEYS[1],ARGV[1]) " +
            "if(flag == 1) then " +
            "flag = redis.call(\"expire\", KEYS[1], ARGV[2]) end " +
            "return flag";

    public DistributedLockV2(String key, StringRedisTemplate redisTemplate) {
        this(key,Duration.ofSeconds(60),redisTemplate);
    }

    public DistributedLockV2(String key, Duration timeout, StringRedisTemplate redisTemplate) {
        this.key = key;
        this.timeout = timeout;
        this.redisTemplate = redisTemplate;
        this.sha = initScript();
    }

    @Override
    public void lock() {
        for(;;){
            if(tryLock()){
                break;
            }
        }
    }


    @Override
    public void lockInterruptibly() throws InterruptedException {
        for(;;){
            if(tryLock()){
                break;
            }
            if (Thread.interrupted()){
                throw new InterruptedException();
            }
        }
    }


    @Override
    public boolean tryLock() {
        if(setNxAndExpire(EXCLUSIVE_VALUE, timeout.getSeconds())){
            //获取到锁
            setOwner(Thread.currentThread());
            return true;
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        long end = System.currentTimeMillis() + unit.toMillis(time);
        for(;;){
            if(tryLock()){
                return true;
            }
            if(System.currentTimeMillis() > end){
                return false;
            }
            if(Thread.interrupted()){
                throw new InterruptedException("Thread is interrupted, tryLock end.");
            }
        }
    }


    @Override
    public void unlock() {
        if(Thread.currentThread() == owner){
            setOwner(null);
            del();
        }else{
            throw new IllegalMonitorStateException("Only threads holding the lock can unlock.");
        }
    }


    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("Currently Condition is unsupported.");
    }

    /**
     * lua脚本保证原子操作
     * @param value
     * @param expire
     * @return
     */
    private boolean setNxAndExpire(String value, long expire){
        return redisTemplate.execute(new RedisCallback<Boolean>() {
            @Override
            public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
                long returnVal = 0;
                if(connection instanceof JedisClusterConnection){
                    List<byte[]> args = new ArrayList<byte[]>();
                    args.add(value.getBytes());
                    args.add(String.valueOf(expire).getBytes());
                    returnVal = ((JedisClusterConnection) connection).execute(LUA, key.getBytes(), args);
                }else{
                    byte[][] keysAndArgs = new byte[3][];
                    keysAndArgs[0] = key.getBytes();
                    keysAndArgs[1] = value.getBytes();
                    keysAndArgs[2] = String.valueOf(expire).getBytes();
                    returnVal = connection.scriptingCommands().evalSha(sha, ReturnType.INTEGER, 1, keysAndArgs);
                }
                return returnVal == 1 ? Boolean.TRUE : Boolean.FALSE;
            }
        });
    }

    private String initScript(){
        return redisTemplate.execute(new RedisCallback<String>() {
            @Override
            public String doInRedis(RedisConnection connection) throws DataAccessException {

                if(connection instanceof JedisClusterConnection){
                    return null;
                }
                return connection.scriptingCommands()
                        .scriptLoad((LUA).getBytes());
            }
        });
    }

    private void del(){
        redisTemplate.delete(key);
    }

    public void setOwner(Thread owner) {
        this.owner = owner;
    }
}

测试:

  1. 在controller注入lock,并暴露一个加锁并休眠的接口
@GetMapping("/lock")
public void lock(@RequestParam("howLong") long howLong){
    long start = System.currentTimeMillis();
    try {
        System.out.println(Thread.currentThread().getName() + "尝试获取锁");
        lock.lock();
        System.out.println(Thread.currentThread().getName() + "持有锁, 获取锁耗时" + (System.currentTimeMillis() - start) + "ms");
        Thread.sleep(howLong);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }finally {
        lock.unlock();
        long end = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName() + "释放锁");
        System.out.println(Thread.currentThread().getName() + "加锁时间" + (end - start) + "ms");
    }
}
  1. 在浏览器窗口分别访问

http://localhost:52599/lock?howLong=99999999

http://localhost:52599/lock?howLong=1000

http://localhost:52599/lock?howLong=1000

观察日志信息

http-nio-auto-1-exec-1尝试获取锁
http-nio-auto-1-exec-1持有锁, 获取锁耗时8ms
http-nio-auto-1-exec-2尝试获取锁
http-nio-auto-1-exec-3尝试获取锁
http-nio-auto-1-exec-4尝试获取锁
http-nio-auto-1-exec-3持有锁, 获取锁耗时31652ms
http-nio-auto-1-exec-3释放锁
http-nio-auto-1-exec-3加锁时间32652ms
http-nio-auto-1-exec-2持有锁, 获取锁耗时58878ms
http-nio-auto-1-exec-2释放锁
http-nio-auto-1-exec-2加锁时间59879ms
http-nio-auto-1-exec-4持有锁, 获取锁耗时27213ms
http-nio-auto-1-exec-4释放锁
http-nio-auto-1-exec-4加锁时间28214ms

代码已上传至Github==>传送门

你可能感兴趣的:(源码分析,微服务,分布式相关)