Java 实现基于Redis的分布式锁

Introduce

本文提供了 Java + Lua 实现的基于 Redis 的分布式锁,这里的实现保证了微服务对资源的独占性 实现了锁的可入重性。但是,对于某些情况下,该实现似乎并没有正常工作,而且暂时没有正确定位到问题。
具体,锁就是 Redis 中的一个数据,这里有两种可选的类型,其一是 string 类型,另一种是 Hash;考虑 string 的原因是使用 string 似乎是网络上公认的分布式锁的解决方式;但是这里考虑到可入重性Hash 类型的 hincrby 命令可以以数值方式操作数据,并且如果该数据不存在,则会设置为 0 并进行 increment ,由于使用 stringset nx 不能区分每个微服务实例,所以就无法实现入重特性;所以考虑使用 Hash 实现分布式锁。
当一个微服务实例试图获取锁时,具体是在 Hash 中放入自己的线程hash Code ,并且,同一时间内,Hash 中只有一个元素;每个元素的 Hash Key 是微服务线程自己的 Hash Code ,当然也可以是其他标识内容;Hash Value 为入重标记值。

另一种方式是放入一个 UUID

需要注意的是,当 Hash 中不包含任何元素时,该数据的 Key 也将被删除。

设置 redis 上的共享资源

set inventory 100000

每个微服务线程试图对该共享资源进行递减操作。提供的 j.u.c.locks.Lock 实现类大致如下

public final class RedisDistributionLock implements Lock {
    private static final String LOCK_KEY = "lock";
    private String threadHash;
		//...
	private StringRedisTemplate t;
    private HashOperations<String, String, String> hops;
		//...
	@Override
    public void lock() {...}
    @Override
    public void unlock() {...}
	//...

Lock

Redis 支持使用 Lua Script 进行定制化操作,使用 eval 命令可以执行一段 Lua Script,并且保证执行的原子性;即,我们可以使用 Lua 定义这种类似 CAS 的操作,并同时可以保证原子性。

eval "<lua-script>" <nKeys> [<keys:space> <values:space>] 

就像环境变量一样,我们可以将变量在外部传入,并在 lua-script 中使用这些变量;另外如果你不打算传入变量,则需要指定 nKeys0

eval "return 'hello world'" 0

这里指定设置一个 tianqing 数据,要在lua中使用这些传入的变量,需要使用 KEYS ARGV 关键字,并使用 [] 指定第几个 key/value

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 tianqing wochao
#等价于
set tianqing wochao

最终获取锁的 Lua 代码如下

if((redis.call('EXISTS',KEYS[1])==0) or redis.call('hget',KEYS[1],ARGV[1])) 
    then return redis.call('hincrby',KEYS[1],ARGV[1],1) 
else return 0 end

Java 中使用RedisTemplate#execute 进行调用

private  Boolean checkLockAbsentThenSet()
{ String script = "if((redis.call('EXISTS',KEYS[1])==0) or "
    +"redis.call('hget',KEYS[1],ARGV[1])) then "
    +"return redis.call('hincrby',KEYS[1],ARGV[1],1) else return 0 end";
        return t.execute(
            new DefaultRedisScript<>(script, Boolean.class), 
            Collections.singletonList(LOCK_KEY),
            threadHash);
    }
    

注意,尽管我们使用了分布式锁,还是需要使用本地的锁来解决单个 JVM 主机上的竞态条件;因为 Controller Serivce 以及我们编写的 RedisDistributionLock 类在这里都是单例的,而 Tomcat 可以将线程池的线程最大扩容到 200,这是 SpringBoot 的默认配置。所以,Servicedecrement 代码如下

public int decrement() {
        if(distributionLock == null) {
            distributionLock = distributionLockFactory.createRedisLock();
        }
        if(lock == null){
            lock = new ReentrantLock();
        }
        lock.lock();
        distributionLock.lock();
        int inventory = Integer.parseInt(redis.opsForValue().get(INVENTORY_KEY));
        log.info("当前剩余:{}", inventory);
        if (inventory > 0) {
            redis.opsForValue().set(INVENTORY_KEY, String.valueOf(--inventory));
        } else {
            log.info("卖完了");
        }
        distributionLock.unlock();
        lock.unlock();

        return inventory;
    }

当客户端访问 /distributionlk 地址时,线程会先与本机 JVM 的线程进行 ReentrantLock 竞争,获取到本地锁的线程继续同其他 JVM 上的线程关于 RedisDistributionLock 锁进行竞争。

另外,对于 lock 方法本身,其逻辑是,如果没有获取到分布式锁,则进行 CAS 自旋;自旋代码正式上述的 checkLockAbsentThenSet 方法;当获取到锁时,返回为 true,当没有获取到锁时,返回 false;我们使用 lua 代码保证这个过程的原子性。

@Override
public void lock() {
//      threadHash = String.valueOf(Thread.currentThread().hashCode());
    threadHash = UUID.randomUUID().toString();
    log.info("==========lock invoked");
    while (true) {
        if(checkLockAbsentThenSet()){
            break;
        }
        try {
            TimeUnit.MILLISECONDS.sleep(80);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

为了对自旋频率进行控制,这里使用了 sleep,这可能会影响吞吐量;另外,对于唯一标识一个全局唯一的线程上,测试了两种方法,一种是线程对象的 hashCode 另一种是 UUID,我们需要保证线程在全局可以唯一被标识,这是防止死锁的关键,在多台 JVM 上的数百个线程,显然是有 hashCode 冲突的可能性的,而使用 UUID 则冲突可能性相对较小。

❤️‍🔥💫unlock💫❤️‍🔥

唯一注意的一点是使用 lua 保证解锁的原子性,解锁的 lua 代码如下

if(redis.call('EXISTS',KEYS[1]) and redis.call('hget',KEYS[1],ARGV[1])) 
    then if(redis.call('hincrby',KEYS[1],ARGV[1],-1) == 0) 
        then return redis.call('del',KEYS[1]) 
    end return 1 
end

Java 中使用 RedisTemplate#execute 进行调用

private  Boolean releaseLockIfPresent() {
        String script = "if(redis.call('EXISTS',KEYS[1]) and redis.call('hget',KEYS[1],ARGV[1])) "
            +"then if(redis.call('hincrby',KEYS[1],ARGV[1],-1) == 0) then return"
            +"redis.call('del',KEYS[1]) end return 1 end";
        return t.execute(
            new DefaultRedisScript<>(script, Boolean.class), 
            Collections.singletonList(LOCK_KEY),threadHash);
    }
@Override
public  void unlock() {
	log.info("==========unlock invoked");
	releaseLockIfPresent();
}

❤️‍🔥💫压力测试💫❤️‍🔥

这里的实验环境为三个相同的微服务程序,即三个 JVM 虚拟机,所以,同一时间内会有三个来自不同 JVM 的线程竞争分布式锁。这里使用的是 Jmeter 5.6.3,下载解压后去 bin 目录打开 jmeter.cmd (windows) 或者 jmeter.sh (Unix-like) ,在打开时指定测试的计划,这里使用 simple-http-request-test-plan.jmx 即可,这些默认附带的模板文件位于 bin/templates 下;

jmeter -t templates/simple-http-request-test-plan.jmx

有三个 Thread Properties 需要进行配置,其中 Number of Threads 代表同时访问的用户数量,这里记为 N T N_T NT;另一个 Ramp-up Period 用于配置用户线程的启动间隔,记为 R a m p Ramp Ramp ;所以,这两个值用于计算每秒有多少个用户访问你的微服务,具体是
v = N T R a m p    ( t h r e a d s / s e c ) v=\frac{N_T}{Ramp} ~~ (threads/sec) v=RampNT  (threads/sec)
在这里插入图片描述Loop Count1 代表每个用户只访问一次;所以,理论上,如果微服务系统的性能足够好,那么吞吐量与 v 的关系如下
T h r o u g p u t = v Througput=v Througput=v

现在,在 SpringBoot 中开启三个微服务;注意,每开一个微服务,就需要修改 springboot.properties 中的 server.port 值,以防止端口冲突,这里使用的是 81 82 83;之后,开 NGiNX 进行负载均衡,配置大致如下

http {
    upstream test {
    server localhost:81 weight=1;
    server localhost:82 weight=1;
    server localhost:83 weight=1;
    }
 	server{
	listen 80;
	server_name webtest;
	location / {
	proxy_pass http://test;
	    }
    }   
}

在测试了 8 轮后结果如下

测试Redis-PooledConThreads/Ramp-upThreadHashError%(Timeout)Througput(req/sec)90 Line(ms)Avg(ms)
1disabled400/20UUID22.7513.510002
2disabled400/40UUID33.258.1100023383
3enabled400/40UUID0.010.08686
4enabled400/20UUID0.015.260293137
5enabled400/20Thread.hashCode7913.3100138243
6enabled400/40Thread.hashCode0.0109791
7enabled400/40Thread.hashCode0.0108480
8enabled400/20Thread.hashCode69.7513.4100137228

redis 的 pool 配置如下

spring.data.redis.lettuce.pool.enabled=true
spring.data.redis.lettuce.pool.max-active=16

值得注意的是,当 Ramp-up 值为 20 时,请求的延迟曲线是线性递增的,比如测试 4 的响应延迟:

在这里插入图片描述暂时未定位出具体原因,初步猜测是因为对分布式资源的竞争引起的;因为每个线程要经过两次竞争。

另外,当使用 hashCode 作为线程标识时,微服务在压力测试时出现了死锁,一个现象是,Redis 中的锁并没有像一般情况下创建,然后被销毁,反而被多次入重地调用
在这里插入图片描述
此时,每个微服务在调用 redisDistributionLocklock 方法后,都没有再调用 unlock 方法
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述这个问题的造成原因,初步猜测是因为不同 JVM 上的线程的 hashCode 冲突导致。但是随后的测试似乎否定了这一点,在随后的两次 400/40 测试中,使用 hashCode 并没有导致这个问题。这个问题似乎与并发量有关。但是,从测试 4 与测试 8 比较来看,好像确实是使用 hashCode导致的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

天晴丶SnowCrystal

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值