redis分布式锁实现原理-以秒杀活动一人一单问题为例

一、秒杀活动一人一单问题的定义:

比如商品代金券,100元的代金券,在活动期间,仅需80元即可购买,这样的优惠力度是非常大的,为了防止黄牛无限制地购买,我们要限制一个用户id针对一个代金券只能下一单,为了防止同一用户同时使用多个手机同时下单,我们需要使用synchronized锁,对用户id+商品id的字符串上锁,在获取到锁之后才可以进行订单下单的业务,判断用户是否下过单,没有下过单就插入新订单,否则报错。如果没有这个锁,就会出现同一个用户id多个线程并发过来,查询数据库,发现都不存在订单,然后这些线程都能下单,显然违背了一人一单的原则。
在这里插入图片描述

二、为什么要使用分布式锁

在服务的实际部署过程中,往往是集群式的部署,例如将服务启动在多个服务器上,这样就部署了多个tomcat,而每个tomcat都有自己的JVM,如下图,在服务器A的tomcat内部,有两个线程,这两个线程的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A的锁对象却不是同一个,因此无法保证所有线程获取锁都能达到互斥的效果,本质原因是synchronized锁是Java提供的一种内置锁,在单个JVM进程中提供线程之间的锁定机制,控制多线程并发。只适用于单机环境下的并发控制。因此我们需要使用一种分布式锁,满足分布式系统或集群模式下的进程都可以使用。
在这里插入图片描述

三、redis实现分布式锁

redis是一种键值对的非关系型数据库(如果没有学习过redis请先预习)
1.互斥性
我们使用最简单的setnx命令就可以实现一个简易的分布式锁,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,通过key的唯一性就实现了锁的互斥性;
例如setnx order:userid:5:productid:1 value;
key值中包含有用户id和商品id,可以唯一地对一个用户对某一商品进行秒杀下单时锁住。
这里value的取值我们先不做讨论,之后会进行说明。
2.防止死锁
考虑这样一个情况,在获取锁之后,服务器宕机了,代码将永远无法走到释放锁的流程中,因此我们可以给锁设置一个自动释放的机制,即利用redis中的EXPIRE命令,给key添加一个过期时间,到时自动删除;
将1和2组合起来可以使用命令:

SET order:userid:5:productid:1 value NX EX 10; //NX代表互斥,EX 10代表过期时间为10秒。

3.释放锁的误删问题
如果业务正常执行完毕,我们要把锁给释放掉,也就是从redis中删除这个键值对,看起来可以直接使用命令del key;简单,快速,又方便,直觉上是非常可行的,实际上又会产生问题,我们考虑这样一个场景:线程1在获取锁之后执行业务,执行业务时被阻塞了很久,甚至锁已经超时自动释放了,这时线程2看到锁被释放,他就拿到锁,开始执行业务,恰好线程2开始执行业务时,线程1又恢复了,执行完业务后去执行了释放锁的代码,而这个锁其实已经属于线程2了,线程1将线程2的锁给错误地释放了!这时又来了一个线程3,他看见有锁可以拿,又开始执行业务,这样,线程2和线程3又出现了并发问题,他们理应串行执行。
(实际上在真实业务场景下,不应该出现锁提前释放,之后会在Redisson中讲解,这里只需了解如何解决锁的误删问题)
那么如何解决锁的误删问题呢?
很简单,只需要在删除时判断一下这个锁是不是自己的不就行了。在1中的命令中setnx order:userid:5:productid:1 value;value的取值没有说明,实际上,value值就其实是用来防止误删的,那么该用什么值来唯一的识别一个线程呢?
(1)线程id——很显然,分布式集群环节下,不同的服务器上有着相同的线程id,再正常不过了,只靠线程id来识别锁,很容易误判。
(2)随机字符串+线程id——如果这个随机字符串是全局唯一的,就再好不过了,这里再加上线程id,就更加保险了,比较好的随机字符串生成方法有UUID、雪花算法等(请自行了解);
我们只需要在线程获取锁时,生成一个UUID,再加上线程id,作为锁的value,在释放锁时,再判断锁的value是否与该线程的UUID+线程id相同,相同就可以释放,这样就避免了锁的误删问题。
相关代码实现如下:

//随机字符串UUID 作为线程的static final常量
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
//获取锁的代码
@Override
public boolean tryLock(long timeoutSec) {
   // 获取线程标示
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 获取锁  KEY_PREFIX+name是锁的键 一般是业务名 ;threadId是线程全局唯一标识
   Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
   return Boolean.TRUE.equals(success);
}
//释放锁的代码
public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的线程标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标示是否一致
    if(threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

4.删除锁时的原子性问题

首先我们来看看java多线程并发中原子性操作定义
所谓原子操作,就是“不可中断的一个或一系列操作”,是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何线程的切换。
举一个最简单的例子,我们有一个变量i,执行语句i++;这个语句包含了三个jvm指令:到内存中取i的值放入寄存器,寄存器的值增加1,寄存器的值存到内存中。这三个操作都是不可再分的,具备原子性,是线程安全的,然而三个原子操作组合起来,形成的i++语句就不具有原子性了,比如先读后写,就有可能在读之后,其实这个变量被修改了,出现读和写数据不一致的情况。
最经典的例子就是两个线程各执行100次i++指令,最终i的结果在2-200之间都是有可能的,如果不理解可以自行搜索。

回到分布式锁的问题来,3中锁的误删问题其实还没有全部解决,考虑下图这样一个场景:线程1执行完业务后,准备释放锁了,当他执行到判断锁的标识语句,并且返回true,即锁是线程1自己的,而恰好没有执行delete key代码时,线程1被阻塞了(例如jvm自动进行垃圾回收,使线程1暂时失去CPU执行权),不巧的是,线程1拿回CPU执行的权力时,锁已经超时释放并且被线程2所获取,此时再去执行删除锁的命令,就把线程2的锁给删除了,此时线程3拿到锁,也开始执行业务了,因此再次发生了误删的问题。
在这里插入图片描述
这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁三个动作,实际上并不是原子性的,换句话说,是可以被线程调度机制打断的,只要解决了线程调度机制打断的问题,就可以避免删锁时的原子性问题。
解决方案:Lua脚本
redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,可以确保多条命令执行时的原子性。
基本语法可以参考:https://www.runoob.com/lua/lua-tutorial.html
我们编写一个删除锁的简易Lua脚本,KEYS[1]和AGRV[1]两个参数可以通过java传值

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

Java中使用Lua脚本,修改删除锁的代码:

//创建一个加载lua脚本的类 
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        //lua脚本的位置  项目的resources文件夹下
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        //指定lua脚本返回值的类型
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
//释放锁的代码
public void unlock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}

5.该分布式锁依然存在的问题

在上述几个小节的描述中,我们循序渐进的讲述了redis分布式锁的实现原理,包括:
(1)利用set nx满足互斥性
(2)利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
(3)利用唯一的线程标识防止锁的误删
(4)利用lua脚本保证删除锁操作的原子性

但是,基于setnx实现的分布式锁仍然存在下面的问题:
(1)重入问题
重入问题是指获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的主要意义是防止死锁,也就是同一个线程可以多次获取同一把锁,当前我们使用set nx实现的分布式锁,同一个线程只能获取一次。
(2)不可重试
目前的分布式锁只能尝试获取一次,获取失败就返回false,获取成功就返回true;
我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁,比如在一段时间内反复尝试。
(3)超时释放
我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果业务阻塞时间超长,锁超时释放了,其他线程拿到了锁,依然有安全隐患(即我们要确保锁是因为业务执行完而释放,不是因为超时自动释放)。
(4)主从一致性
如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
下一篇文章我将更新Redisson的原理讲解,它是一个在Redis的基础上实现的Java驻内存数据网格,不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现,其分布式锁解决了这四个依然存在的问题。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值