Introduce
本文提供了 Java
+ Lua
实现的基于 Redis 的分布式锁,这里的实现保证了微服务对资源的独占性
实现了锁的可入重性
。但是,对于某些情况下,该实现似乎并没有正常工作,而且暂时没有正确定位到问题。
具体,锁就是 Redis 中的一个数据,这里有两种可选的类型,其一是 string
类型,另一种是 Hash
;考虑 string
的原因是使用 string
似乎是网络上公认的分布式锁的解决方式;但是这里考虑到可入重性
,Hash
类型的 hincrby
命令可以以数值方式操作数据,并且如果该数据不存在,则会设置为 0
并进行 increment
,由于使用 string
的 set 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
中使用这些变量;另外如果你不打算传入变量,则需要指定 nKeys
为 0
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 的默认配置。所以,Service
的 decrement
代码如下
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 Count
为 1
代表每个用户只访问一次;所以,理论上,如果微服务系统的性能足够好,那么吞吐量与 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-PooledCon | Threads/Ramp-up | ThreadHash | Error%(Timeout) | Througput(req/sec) | 90 Line(ms) | Avg(ms) |
---|---|---|---|---|---|---|---|
1 | disabled | 400/20 | UUID | 22.75 | 13.5 | 10002 | |
2 | disabled | 400/40 | UUID | 33.25 | 8.1 | 10002 | 3383 |
3 | enabled | 400/40 | UUID | 0.0 | 10.0 | 86 | 86 |
4 | enabled | 400/20 | UUID | 0.0 | 15.2 | 6029 | 3137 |
5 | enabled | 400/20 | Thread.hashCode | 79 | 13.3 | 10013 | 8243 |
6 | enabled | 400/40 | Thread.hashCode | 0.0 | 10 | 97 | 91 |
7 | enabled | 400/40 | Thread.hashCode | 0.0 | 10 | 84 | 80 |
8 | enabled | 400/20 | Thread.hashCode | 69.75 | 13.4 | 10013 | 7228 |
redis 的 pool 配置如下
spring.data.redis.lettuce.pool.enabled=true
spring.data.redis.lettuce.pool.max-active=16
值得注意的是,当 Ramp-up
值为 20
时,请求的延迟曲线是线性递增的,比如测试 4
的响应延迟:
暂时未定位出具体原因,初步猜测是因为对分布式资源的竞争引起的;因为每个线程要经过两次竞争。
另外,当使用 hashCode
作为线程标识时,微服务在压力测试时出现了死锁,一个现象是,Redis 中的锁并没有像一般情况下创建,然后被销毁,反而被多次入重地调用
此时,每个
微服务在调用 redisDistributionLock
的 lock
方法后,都没有再调用 unlock
方法
这个问题的造成原因,初步猜测是因为不同 JVM
上的线程的 hashCode 冲突导致。但是随后的测试似乎
否定了这一点,在随后的两次 400/40
测试中,使用 hashCode
并没有导致这个问题。这个问题似乎与并发量有关。但是,从测试 4
与测试 8
比较来看,好像确实是使用 hashCode
导致的问题。