每天多学一点点~
话不多说,这就开始吧…
分布式锁一般用Zookeeper(强一致性),但是Redis也可以,并且已经有比较成熟的Redisson框架。今天就来学习学习Redis锁的问题。
Redis系列文章
先看一下下面这段代码
/**
* 为了防止超卖,可以加锁,单机jvm没什么问题,但是分布式下(比如用nginx分发的话) 还是有问题
*/
@RequestMapping("/testlock")
public String deductStock() throws InterruptedException {
synchronized (this) {
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //获取库存
if (stock > 0) {
int realStock = stock - 1; //库存减1
stringRedisTemplate.opsForValue().set("stock", realStock + "");//设置最新库存
System.out.println("扣减成功,剩余库存:" + realStock + "");
} else {
System.out.println("扣减失败,库存不足");
}
}
return "结束";
}
上述代码,在单机高并发情况下,并没有什么问题,性能也还可以(jkd1.8已经对synchronized做了不少优化~),但是一般大型电商,不可能是部署一个实例,这样我们启动两个端口,8080,8010,用nginx负载模拟一下,会出现什么问题
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
upstream mytest{
server 127.0.0.1:8080 weight=1;
server 127.0.0.1:8010 weight=1;
}
sendfile on;
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://mytest;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
Nginx配置
设置Redis中库存为50,启动Nginx和jemeter,模拟500个用户,访问nginx进行分发
可以看到,两个实例都出现了相同减库存操作(比如48,47),这样就会出现超卖的现象,显然,synchronized在分布式环境下不起作用~
那么记下来我们将代码修改一下
springboot2.1.2版本,对应redis也是2.1.2,升级了setIfAbsent方法,加上了失效时间~
@RequestMapping("/testlock")
public String deductStock() throws InterruptedException {
String lockKey = "product_001";
String clientId = UUID.randomUUID().toString(); //使用uuid当作value,为finally释放锁做准备
try {
//使用setnx命令(若key不存在,则新增;存在,不做操作),并设置失效时间(因为单机测试,所以设置短一点)
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, clientId,10, TimeUnit.SECONDS);
if (!result) { //其他线程让其直接返回,保证只有一个线程 走下去
return "errorCode:当前商品正在抢购,请稍后再试";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); //获取库存
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); //设置最新库存
System.out.println("扣减成功,剩余库存:" + realStock + "");
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
// 谁加的锁,谁释放(判断当前线程生成的锁是否是自己生成的)
if (clientId.equals(stringRedisTemplate.opsForValue().get(lockKey))) {
stringRedisTemplate.delete(lockKey);
}
}
return "抢购成功";
}
上述代码,在大多数情况下(一般传统的软件公司,比如博主现在的公司)已经可以了~**不足的地方有二
用jmeter测试一下,库存500,Ramp-up设置成1s,线程数500,并发500,库存会怎样
用jmeter测试一下,库存500,Ramp-up设置成5s,线程数500,并发100,库存会怎样
综上,上述代码还不是很完美。Redisson框架就很好的解决了这个问题(redisson的lock方法当拿不到锁的时候会一致while循环等待获取锁,后面会详细讲),在介绍Redisson之前,博主先介绍一下Lua脚本~
Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:
从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。EVAL命令的格 式如下:
EVAL script numkeys key [key ...] arg [arg ...]
script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一 个Lua函数。numkeys参数用于指定键名参数的个数。键名参数 key [key …] 从EVAL的第三个参数开始算起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在 Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。
在命令的最后,那些不是键名参数的附加参数 arg [arg …] ,可以在Lua中通过全局变量ARGV数组访问, 访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。
举个例子:
eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
其中 “return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}” 是被求值的Lua脚本,数字2指定了键名参数的数量,key1和key2是键名参数,分别使用 KEYS[1] 和 KEYS[2] 访问,而最后的 first 和 second 则是附加 参数,可以通过 ARGV[1] 和 ARGV[2] 访问它们。
下面我们用jedis和redisTemplate两个方式测试下lua脚本
/**
* 用 jedis方式操作lua脚本
*/
jedis.set("qiuqiu_stock_10086", "20");
String script = " local count = redis.call('get', KEYS[1]) " +
" local a = tonumber(count) " +
" local b = tonumber(ARGV[1]) " +
" if a >= b then " +
" redis.call('set', KEYS[1], count-b) " +
//模拟语法报错回滚操作" bb == 0 " +
" return 1 " +
" end " +
" return 0 ";
Object obj = jedis.eval(script, Arrays.asList("qiuqiu_stock_10086"), Arrays.asList("10"));
System.out.println(obj);
/**
* 用 redisTemplate 测试lua 脚本
*/
redisTemplate.opsForValue().set("qiuqiu_stock_10086", 20);
RedisScript script = RedisScript.of(
" local count = redis.call('get', KEYS[1]) " +
" local a = tonumber(count) " +
" local b = tonumber(ARGV[1]) " +
" if a >= b then " +
" redis.call('set', KEYS[1], count-b) " +
//模拟语法报错回滚操作" bb == 0 " +
" return 1 " +
" end " +
" return 0 ",Long.class); //注意是Long类型,而不是Integer
List list = new ArrayList<>();
list.add("qiuqiu_stock_10086");
Object obj = redisTemplate.execute(script,new StringRedisSerializer(),new StringRedisSerializer(),list,"10");
System.out.println(obj);
注意,不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用 时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis。
Lua脚本到这里介绍完毕,各位只要知道其是原子操作的就行,具体需要用到的时候再去网上搜一下脚本怎么写。那么为何要介绍Lua脚本呢,就是为了介绍接下来的Redisson框架!
Redisson官网
Redisson框架Git中文文档
我们把上面依然会有问题的redis分布式锁的代码用redisson框架修改一下
/**
* pom依赖
*/
org.redisson
redisson
3.6.5
/**
* 在springboot启动类注入 redisson
*/
@Bean
public Redisson redisson() {
// 此为单机模式
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0).setPassword("123456");
/*config.useClusterServers()
.addNodeAddress("redis://192.168.73.130:8001")
.addNodeAddress("redis://192.168.73.131:8002")
.addNodeAddress("redis://192.168.73.132:8003")
.addNodeAddress("redis://192.168.73.130:8004")
.addNodeAddress("redis://192.168.73.131:8005")
.addNodeAddress("redis://192.168.73.132:8006");*/
return (Redisson) Redisson.create(config);
}
/**
* redisson方式 加锁
*/
@RequestMapping("/testlock")
public String deductStock() throws InterruptedException {
String lockKey = "product_001";
RLock redissonLock = redisson.getLock(lockKey); //获取锁
try {
// 加锁,实现锁续命功能
redissonLock.lock(); //加锁
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
if (stock > 0) {
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
System.out.println("扣减成功,剩余库存:" + realStock + "");
} else {
System.out.println("扣减失败,库存不足");
}
} finally {
redissonLock.unlock(); //释放锁
}
return "end";
}
同样的功能,用Redisson,只需要三步,获取锁,加锁,释放锁。
用jmeter测试一下,库存500,Ramp-up设置成1s,线程数500,并发500,库存会怎样
可以看到,用了redisson框架后,每秒并发500,依然没有剩余库存,因为redisson的lock方法当拿不到锁的时候会一致while循环等待获取锁。redisson底层都是用lua脚本实现的。
从redissonLock.lock();方法入手
加锁
>>>java.util.concurrent.locks.Lock#lock
>>>org.redisson.RedissonLock#lock()
>>>org.redisson.RedissonLock#lockInterruptibly()
>>>org.redisson.RedissonLock#lockInterruptibly(long, java.util.concurrent.TimeUnit) //加锁
>>>org.redisson.RedissonLock#tryAcquire //获取锁
>>>org.redisson.RedissonLock#tryAcquireAsync //异步获取锁
>>>org.redisson.RedissonLock#scheduleExpirationRenewal //,通过lua脚本设置锁续命,并递归调用
释放锁
>>>org.redisson.RedissonRedLock#unlock
>>>org.redisson.RedissonMultiLock#unlockInner
>>>org.redisson.api.RLockAsync#unlockAsync()
>>>org.redisson.RedissonLock#unlockAsync()
>>>org.redisson.RedissonLock#unlockAsync(long)
>>>org.redisson.RedissonLock#unlockInnerAsync //调用异步解锁
主线程执行,分线程续命,默认超时时间30s
lockInterruptibly 加锁
@Override
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
// 1.尝试获取锁
Long ttl = tryAcquire(leaseTime, unit);
// 2.获得锁成功
if (ttl == null) {
return;
}
// 3.等待锁释放,并订阅锁
long threadId = Thread.currentThread().getId();
Future future = subscribe(threadId);
get(future);
try {
// 支持重入锁 通过while循环
while (true) {
// 4.重试获取锁
ttl = tryAcquire(leaseTime, unit);
// 5.成功获得锁
if (ttl == null) {
break;
}
// 6.等待锁释放
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
// 7.取消订阅
unsubscribe(future, threadId);
}
}
tryAcquire() 获取锁
private Long tryAcquire(long leaseTime, TimeUnit unit) {
// 1.将异步执行的结果以同步的形式返回
return get(tryAcquireAsync(leaseTime, unit, Thread.currentThread().getId()));
}
private Future tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// 2.用默认的锁超时时间去获取锁
Future ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS,
TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.addListener(new FutureListener() {
@Override
public void operationComplete(Future future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// 成功获得锁
if (ttlRemaining == null) {
// 3.锁过期时间刷新任务调度
scheduleExpirationRenewal();
}
}
});
return ttlRemainingFuture;
}
Future tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId,
RedisStrictCommand command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 3.使用 EVAL 命令执行 Lua 脚本获取锁
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.
unlockInnerAsync 释放锁
protected RFuture unlockInnerAsync(long threadId) {
// 1.通过 EVAL 和 Lua 脚本执行 Redis 命令释放锁
return this.commandExecutor.evalWriteAsync(this.getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]);" +
" return 1; " +
"end;" +
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then" +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0;" +
" else redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(this.getName(), this.getChannelName()), new Object[]{LockPubSub.unlockMessage, this.internalLockLeaseTime, this.getLockName(threadId)});
}
大体博主画个图
以上是博主自己看的源码,不一定准确,还是推荐各位小伙伴们去官网学习~
Redis集群,当master挂了从节点重新选举时产生新的master,如果加的是同一把锁,会导致其他线程在洗呢master加锁成功。这样就需要用zookeeper(强一致性)加锁。但是一般这些问题偶尔发生,都是可以容忍的~如果非要强一致性,100%不出问题,那么博主这里推荐另一个Redis锁框架----Redlock框架(目前市面上的实现还是有bug的,据博主所致用的还比较少,底层实现原理和zk差不多,有兴趣的可以去看看)
世上无难事,只怕有心人,每天积累一点点,fighting!!!