Java教程:如何深入理解Redis分布式锁?

相信很多同学都听说过分布式锁,但也仅仅停留在概念的理解上,这篇文章会从分布式锁的应用场景讲起,从实现的角度上深度剖析redis如何实现分布式锁。

一、超卖问题

我们先来看超卖的概念: 当宝贝库存接近0时,如果多个买家同时付款购买此宝贝,或者店铺后台在架数量大于仓库实际数量,将会出现超卖现象。超卖现象本质上就是买到了比仓库中数量更多的宝贝。

本文主要解决超卖问题的第一种,同时多人购买宝贝时,造成超卖。

测试代码

那么超卖问题是如何产生的呢?我们准备一段代码进行测试:

@Autowired private StringRedisTemplate stringRedisTemplate; /** * 第一种实现,进程内就存在线程安全问题 * 可以只启动一个进程测试 */ @RequestMapping(“/deduct_stock1”) public void deductStock1(){ String stock = stringRedisTemplate.opsForValue().get(“stock”); int stockNum = Integer.parseInt(stock); if(stockNum > 0){ //设置库存减1 int realStock = stockNum – 1; stringRedisTemplate.opsForValue().set(“stock”,realStock + “”); System.out.println(“设置库存” + realStock); }else{ System.out.println(“库存不足”); } }

这段代码中,使用redis先获取库存数量(当然实际场景中不会只保存一个全局库存数,应该根据每一个商品单元(sku)保存一份库存数)。

String stock = stringRedisTemplate.opsForValue().get(“stock”); int stockNum = Integer.parseInt(stock);

接下来,判断库存数是否大于0:

如果大于0,将库存数减一,通过set命令,写回redis这里没有使用redis的decrement命令,因为此命令在redis单线程模型下是线程安全的,而为了可以模拟线程不安全的情况将其拆成三步操作 //设置库存减1 int realStock = stockNum – 1; stringRedisTemplate.opsForValue().set(“stock”,realStock + “”); System.out.println(“设置库存” + realStock);如果小于等于0,提示库存不足JMeter测试

通过JMeter进行并发测试,看下会不会出现超卖的问题:

1.启动tomcat

这种情况下,只需要启动一个tomcat就会出现超卖。我们先启动一个tomcat在8080端口上。

Java教程:如何深入理解Redis分布式锁?
2.下载JMeter

Apache JMeter是Apache组织开发的基于Java的压力测试工具。 从官网上下载即可:

https://jmeter.apache.org/download_jmeter.cgi 下载完之后解压,运行bin目录下的jmeter.bat,显示如下界面:
Java教程:如何深入理解Redis分布式锁?

如果嫌字体太小,可以选择放大:

Java教程:如何深入理解Redis分布式锁?
3.配置JMeter

在Test Plan上点击右键,创建线程组(Thread Group)

Java教程:如何深入理解Redis分布式锁?

配置一下具体参数:

Java教程:如何深入理解Redis分布式锁?
Number of Threads 同时并发线程数Ramp-Up Period(in-seconds) 代表隔多长时间执行,0代表同时并发。假设线程数为100, 估计的点击率为每秒10次, 那么估计的理想ramp-up period 就是 100/10 = 10 秒Loop Count 循环次数

这里给出500是为了直接测试并发500抢,看看能不能正好把500个货物抢完。

添加Http请求:

Java教程:如何深入理解Redis分布式锁?

添加请求URL:

Java教程:如何深入理解Redis分布式锁?

添加聚合结果,用来显示整体的运行情况:

Java教程:如何深入理解Redis分布式锁?

到此为止JMeter的配置结束。

4.设置库存量

启动redis-server,使用redis-client连接:

Java教程:如何深入理解Redis分布式锁?

把库存数设置为500。

5.开始测试

点击运行按钮,启动测试:

Java教程:如何深入理解Redis分布式锁?

首先我们看到聚合报告里输出的结果:

Java教程:如何深入理解Redis分布式锁?

错误率0%,样本数500,证明500个请求都已经执行,但是发现控制台输出如下:

Java教程:如何深入理解Redis分布式锁?

很显然,一份商品都被卖了多次,这显然是不合理的。

原因分析

现在我们只启动了一个tomcat,在单jvm进程的情况下,tomcat会使用线程池接收请求:

Java教程:如何深入理解Redis分布式锁?

而由于每个线程可能同时获取到库存量,所以库存量在两个线程中显示的都是500,然后两个线程就继续进行扣减库存操作,得出499写回redis中,在这个过程中,显然存在线程安全的问题。同一个商品被卖出了2份,超卖问题就出现了。

二、加锁优化synchronized锁

要保证单jvm中线程安全,最简单直接的方式就是添加synchronized关键字,那么这样行不行呢,我们来做一个测试:

/** * 第二种实现,使用synchronized加锁 * 可以只启动一个进程测试 */ @RequestMapping(“/deduct_stock2”) public void deductStock2(){ synchronized (this){ String stock = stringRedisTemplate.opsForValue().get(“stock”); int stockNum = Integer.parseInt(stock); if(stockNum > 0){ //设置库存减1 int realStock = stockNum – 1; stringRedisTemplate.opsForValue().set(“stock”,realStock + “”); System.out.println(“设置库存” + realStock); }else{ System.out.println(“库存不足”); } } }

在进行扣减库存前,先通过synchronized关键字,对资源加锁,这样就只有一个线程能进入到扣减库存的代码块中。来测试一下:

重置库存set stock 500修改接口地址
Java教程:如何深入理解Redis分布式锁?
测试
Java教程:如何深入理解Redis分布式锁?

可以看到,库存被扣减为0,并且没有出现超卖的情况(设置了500库存,并且500个人抢,正好抢完)。 但是这种方案显然是不行的,在生产环境上如果部署多个tomcat实例,那么就会出现如下情况:

Java教程:如何深入理解Redis分布式锁?

多个进程无法共享jvm内存中的锁,所以会出现多把锁,这种情况下也会出现超卖问题。

三、分布式锁的实现多Tomcat实例下的超卖演示

接下来我们演示一下如何在多个Tomcat情况下,演示超卖的问题:

1.启动两个tomcat服务

在IDEA中配置两个spring boot的启动项,使用vm参数指定不同的端口号

-Dserver.port=8080
Java教程:如何深入理解Redis分布式锁?
Java教程:如何深入理解Redis分布式锁?
2.配置nginx

编写

~/nginx_redis/conf/nginx.conf如下: user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main $remote_addr$remote_user [$time_local] “$request $status $body_bytes_sent$http_referer $http_user_agent” “$http_x_forwarded_for; upstream redislock{ server 192.168.226.1:8080 weight=1; server 192.168.226.1:8081 weight=1; } server { listen 80; server_name localhost; location /{ root html; proxy_pass http://redislock; } } access_log /var/log/nginx/access.log main; sendfile on; #tcp_nopush on; keepalive_timeout 65; #gzip on; include /etc/nginx/conf.d/*.conf; }

192.168.226.1这是我宿主机的IP

准备一个虚拟机(也可以使用windows下的nginx),使用docker启动nginx:

docker pull nginx docker run -di -p 10085:80 –name nginx-redis-hc -v ~/nginx_redis/html:/usr/share/nginx/html -v ~/nginx_redis/conf/nginx.conf:/etc/nginx/nginx.conf -v ~/nginx_redis/logs:/var/log/nginx nginx

在宿主机下使用虚拟机的IP地址:10085访问nginx,如果出现如下页面就代表成功:

Java教程:如何深入理解Redis分布式锁?
3.测试

修改接口地址为nginx:

Java教程:如何深入理解Redis分布式锁?

运行查看两个tomcat的控制台:

tomcat1
Java教程:如何深入理解Redis分布式锁?
tomcat2
Java教程:如何深入理解Redis分布式锁?

没有将库存清空,证明存在超卖问题。

手动实现分布式锁

使用redis手动实现分布式锁,需要用到命令setnx。先来介绍一下setnx:

SETNX key value[]

可用版本: >= 1.0.0

时间复杂度: O(1)

只在键 key 不存在的情况下, 将键 key 的值设置为 value

若键 key 已经存在, 则 SETNX 命令不做任何动作。

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

返回值

命令在设置成功时返回 1 , 设置失败时返回 0

代码示例redis> EXISTS job # job 不存在 # job 不存在 (integer) 0 redis> SETNX job “programmer” # job 设置成功 (integer) 1 redis> SETNX job “code-farmer” # 尝试覆盖 job ,失败 (integer) 0 redis> GET job # 没有被覆盖

使用redis构建分布式锁流程如下:

Java教程:如何深入理解Redis分布式锁?

image.png

线程1申请锁(setnx),拿到了锁。线程2申请锁,由于线程1已经拥有了锁,setnx返回0失败,这一步用户操作会失败。线程1执行扣减库存操作并释放锁。线程2再次申请锁,获取到锁并执行扣减库存,然后释放锁。

注意这里线程没有拿到锁,如果不尝试while(true)重新获取锁,这个操作就直接失败了。

代码实现/** * 第三种实现,使用redis中的setIfAbsent(setnx命令)实现分布式锁 */ @RequestMapping(“/deduct_stock3”) public void deductStock3(){ //在获取到锁的时候,给锁分配一个id String opId = UUID.randomUUID().toString(); Boolean stockLock = stringRedisTemplate .opsForValue().setIfAbsent(“stockLock”, opId, Duration.ofSeconds(30); if(stockLock){ try{ String stock = stringRedisTemplate.opsForValue().get(“stock”); int stockNum = Integer.parseInt(stock); if(stockNum > 0){ //设置库存减1 int realStock = stockNum – 1; stringRedisTemplate.opsForValue().set(“stock”,realStock + “”); System.out.println(“设置库存” + realStock); }else{ System.out.println(“库存不足”); } }catch(Exception e){ e.printStackTrace(); }finally { if(opId.equals(stringRedisTemplate .opsForValue().get(“stockLock”))){ stringRedisTemplate.delete(“stockLock”); } } } }

测试略过,这里有几个知识点需要说明

setIfAbsent设置超时

如果setIfAbsent不设置超时时间,假设线程执行业务代码时间时死锁或者其他原因导致长时间不释放,那么会影响其他线程获取到锁,这个时候整体业务就会出现不可用。

Boolean stockLock = stringRedisTemplate .opsForValue().setIfAbsent(“stockLock”, opId, Duration.ofSeconds(30);

设置超时时间为30秒,该时间一般大于业务执行的最大时间。

每次获取到锁,设置唯一ID

考虑这样的场景

Java教程:如何深入理解Redis分布式锁?
线程1获取锁扣减库存,但是由于操作不当,长时间卡住,这样会触发超时时间锁被释放。线程2获取到锁,扣减库存。线程1的代码抛出异常,执行finally释放锁,但是释放的是进程B的锁。 解决方案就是在加锁前生成UUID,释放的时候校验UUID是否正确,如果不正确,说明加锁线程不是当前线程。使用Redisson实现分布式锁

setnx虽好,但是实现起来毕竟太过麻烦,一不小心就可能陷入并发编程的陷阱中,那么有没有更加简单的实现方式呢?答案就是redisson

Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。【Redis官方推荐】 Redisson在基于NIO的Netty框架上,充分的利用了Redis键值数据库提供的一系列优势,在Java实用工具包中常用接口的基础上,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。

总而言之,redisson提供了一系列较为完善的工具类,其中就包含了分布式锁。用redisson实现分布式锁的流程极为简单。

引入依赖 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.14.0</version> </dependency>创建Redisson实例 @Bean public RedissonClient redisson(){ // 1. Create config object Config config = new Config(); config.useSingleServer().setAddress(“redis://127.0.0.1:6379”); // config.useClusterServers() // // use “rediss://” for SSL connection // .addNodeAddress(“redis://127.0.0.1:7181”); return Redisson.create(config); }编写分布式锁代码 @Autowired private RedissonClient redissonClient; /** * 第四种实现,使用redisson实现 */ @RequestMapping(“/deduct_stock4”) public void deductStock4(){ RLock lock = redissonClient.getLock(“redisson:stockLock”); try{ //加锁 lock.lock(); String stock = stringRedisTemplate.opsForValue().get(“stock”); int stockNum = Integer.parseInt(stock); if(stockNum > 0){ //设置库存减1 int realStock = stockNum – 1; stringRedisTemplate.opsForValue().set(“stock”,realStock + “”); System.out.println(“设置库存” + realStock); }else{ System.out.println(“库存不足”); } }catch(Exception e){ e.printStackTrace(); }finally { lock.unlock(); } }

其中加锁代码基本与进程内加锁一致,就不再详细解读,读者自行实践即可。

Redisson分布式锁原理

Redisson分布式锁的主要原理非常简单,利用了lua脚本的原子性。 在分布式环境下产生并发问题的主要原因是三个操作并不是原子操作:

获取库存扣减库存写入库存 那么如果我们把三个操作合并为一个操作,在默认单线程的Redis中运行,是不会产生并发问题的。源码如下: <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { internalLockLeaseTime = unit.toMillis(leaseTime); return evalWriteAsync(getName(), LongCodec.INSTANCE, command, “if (redis.call(exists, KEYS[1]) == 0) then “ + “redis.call(hincrby, 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.singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); }

这一段源码中,redisson利用了lua脚本的原子性,校验key是否存在,如果不存在就创建key并利用incrby加一操作(这步操作主要是为了实现可重入性)。redisson实现的分布式锁具备如下特性:

锁失效锁续租

执行时间长的锁快要到期时会自动续租

可重入操作原子性锁续租原理

使用如下代码进行测试锁续租的情况

@Test void test() throws InterruptedException { RLock testlock1111 = redissonClient.getLock(“testlock”); testlock1111.lock(); try{ Thread thread = new Thread(() -> { while(true){ Long testlock = redisTemplate.getExpire(“testlock”); System.out.println(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + ” ttl:” + testlock); try { Thread.sleep(1000L); } catch (InterruptedException e) { e.printStackTrace(); } } }); thread.start(); thread.join(); }finally { if(testlock1111.isHeldByCurrentThread()){ testlock1111.unlock(); } } }

我们会发现,每隔10秒会自动续租一次,保证锁不被释放。

Java教程:如何深入理解Redis分布式锁?

那么这种续租的行为是如何实现的呢?考虑这种情况:如果线程加锁之后,进程宕机,线程无法执行解锁代码,那么这个锁就无法得到释放(注意,不是加锁线程不允许乱解锁),为了避免这种情况的发生,锁都会设置一个过期时间。比如使用lock无参命令会默认设置30秒的过期时间。那么30秒之后呢?如果线程还在工作,自动释放依然会产生线程安全的问题。所以Redisson使用了watch dog看门狗机制来实现自动续租。

核心代码及注释:

private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { RFuture<Long> ttlRemainingFuture; //lock()无参方法leaseTime为-1,所以进else分值 if (leaseTime > 0) { ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } else { //通过lua脚本加锁 ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); } CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> { // 异步方法,等到加锁成功会回调,第一次加锁ttlRemaining为空,leaseTime为-1 if (ttlRemaining == null) { if (leaseTime > 0) { internalLockLeaseTime = unit.toMillis(leaseTime); } else { //设置延时任务 scheduleExpirationRenewal(threadId); } } return ttlRemaining; }); return new CompletableFutureWrapper<>(f); }

接下来分析scheduleExpirationRenewal的过程:

private void renewExpiration() { ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee == null) { return; } //创建一个延迟任务 Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ent == null) { return; } Long threadId = ent.getFirstThreadId(); if (threadId == null) { return; } //执行lua脚本进行续租 CompletionStage<Boolean> future = renewExpirationAsync(threadId); future.whenComplete((res, e) -> { if (e != null) { log.error(“Cant update lock “ + getRawName() + ” expiration”, e); EXPIRATION_RENEWAL_MAP.remove(getEntryName()); return; } //执行lua续租,锁还在就续租,锁不在返回false就取消续租的行为 if (res) { // reschedule itself renewExpiration(); } else { cancelExpirationRenewal(null); } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); //internalLockLeaseTime默认值30,所以每10秒会续租一次,续租到30秒 ee.setTimeout(task); }

其中,renewExpirationAsync执行的lua脚本如下:

protected CompletionStage<Boolean> renewExpirationAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, “if (redis.call(hexists, KEYS[1], ARGV[2]) == 1) then “ + “redis.call(pexpire, KEYS[1], ARGV[1]); “ + “return 1; “ + “end; “ + “return 0;”, Collections.singletonList(getRawName()), internalLockLeaseTime, getLockName(threadId)); }

判断hash中是否存在锁,如果存在就设置过期时间为30秒,返回1。如果不存在就返回0。

总结

本文介绍了超卖问题产生的原因:操作不具备原子性,同时提出了集中解决思路。

synchronized锁,无法保证多实例下的线程安全setnx手动实现,坑很多、代码较为复杂redisson实现,能够保证多实例下线程安全,代码简单可靠

免责声明:文章内容来自互联网,本站仅作为分享,不对其真实性负责,如有侵权等情况,请与本站联系删除。
转载请注明出处:Java教程:如何深入理解Redis分布式锁? https://www.dachanpin.com/a/cyfx/11537.html

(0)
上一篇 2023-05-12 03:23:33
下一篇 2023-05-12 03:24:36

相关推荐

  • “梦想照进现实”创业者分享会在济南应用技术学校成功举行

            2016年12月19日,济南应用技术职业中等专业学校热闹非凡,“梦想照进现实”创业者故事分享会在这里举行,此次讲座请来了费小林、张超、姚伟等几位成功的创业者,他们分享自己的创业经验,帮助同学们更好的在毕业后找到目标。     &nbs…

    创业分享 2023-05-26
    5300
  • 毛大庆 把创业文学化是一种幼稚-创客故事-就业频道-中工网

    分享到: 毛大庆 1969年生于北京,北京大学区域经济学博士后。2002年到2009年,任新加坡凯德置地中国控股集团北京地区常务副总经理。2009年到2015年1月,任万科集团高级副总裁、北京万科总经理。2015年1月起,任万科北京区域首席执行官,并兼任北京公司董事长。   ID:xjbmaker   2015年3月,万科北京区域首席执行官毛大庆突然辞职了。…

    2023-06-01
    5600
  • 博士快递哥分享创业经验 称被同学渐渐疏远

    谭超:“快递哥”不是我的全部,也不是我的终极目标。我的理想是做个高校老师,目标是产学研结合。快递这块业务,我打算培养一个创新社团,交给他们管理,当然,我会经常给他们提供指导。 谭超 每日热搜 谭超,男,1984年5月生,山东烟台龙口人,烟台大学快递网点老板,同时还是吉林延边大学历史系博二研究生。 华商报:你的快递站点已经有一定规模,业务做大做强靠的什么? 华…

    创业分享 2023-05-27
    5100
  • 当面临男人出轨时,不要给小三面子

     一个人只要是想出轨第三者不一定要有多出色有多美一样会轻易变心不是你太差而是第三者有你没有的新鲜感,济南侦探 当面临男人出轨时,不要给小三面子 悲剧的结束是死亡,而喜剧的终结往往是婚姻。有人常说女性的心是难以捉摸的,但是当她爱上一个人后,却变得清澈透明。令一个爱与你的女孩离开你时,这便是你伤她最深之时。所以说,女子的出轨率往往低于男人,故…

    创业分享 2023-05-12
    4300
  • 快讯:沪指横盘震荡创业板指回落 次新股板块小幅跳水

      中金在线讯 8月30日消息,午后大盘表现波澜不惊,呈现横盘震荡走势,次新股板块出现小幅跳水,创业板指也涨幅收窄。   盘面上,钢铁行业、煤炭采选、有色金属等板块涨幅靠前,高送转、次新股、银行、ST概念等板块跌幅靠前。   中金在线声明:中金在线登载此文出于传递更多信息之目的,并不意味着赞同其观点或证实其描述。文章内容仅供参考,不构成投资建议。投资者据此操…

    创业分享 2023-05-22
    5800

发表回复

登录后才能评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信