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

本文详细解释了秒杀活动中防止一人多单问题的分布式锁原理,使用Redis的setnx和过期时间实现互斥性和故障释放,通过UUID和线程ID解决锁的误删,并讨论了lua脚本确保删除操作原子性。后续提到Redisson扩展了解决重入、重试、超时和主从一致性问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

比如商品代金券,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常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现,其分布式锁解决了这四个依然存在的问题。

<think>好的,我现在需要解释分布式锁是什么。用户可能对分布式系统有定了解,但需要基础的概念介绍。首先,我应该从分布式系统的背景入手,说明在多个服务或节点协作时,如何保证资源访问的互斥性。 分布式锁的定义是关键,需要简明扼要。然后要讲清楚它的核心作用:协调多个节点对共享资源的访问,防止冲突和数据不致。接下来,可能需要对比机锁和分布式锁的不同,突出分布式环境的复杂性,比如网络延迟、节点故障等问题。 然后,应该介绍分布式锁的常见应用场景,比如秒杀系统、库存扣减等,让用户明白实际用途。接下来,可能需要说明实现分布式锁的几种方式,比如基于数据库Redis、ZooKeeper等,简要说明各自的优缺点,比如Redis的RedLock算法,ZooKeeper的顺序节点和临时节点机制。 还要提到实现分布式锁时需要注意的关键点,比如锁的超时时间、避免死锁、保证原子性操作等。此外,CAP理论中的致性和可用性之间的权衡可能也需要提及,因为不同的实现方式可能在这点上有所不同。 用户可能需要了解为什么需要分布式锁,而不仅仅是机锁,所以需要强调分布式系统本身的特性,比如节点独立、网络不可靠等。同时,可以举个简子,比如多个服务实同时修改同数据库记录,如果没有锁可能导致数据错误,而分布式锁可以解决这个问题。 最后,总结分布式锁的重要性,以及选择合适实现方式时需要考虑的因素。确保语言通俗易懂,避免过多技术术语,但关键概念要准确。需要检查是否符合之前的系统指令,比如数学公式是否正确使用$符号,结构是否清晰,分点说明是否合理。可能需要用中文子或类比来帮助理解,比如比喻成钥匙,只有拿到钥匙才能操作资源。</think>分布式锁分布式系统中协调多个节点/服务对共享资源进行互斥访问的机制。以下是分步说明: 1. **核心作用** -分布式环境下,确保同时刻只有个节点能访问关键资源(如数据库记录、文件等) - 类比:相当于多协作时的"会议室预定系统",同时间段只允许个团队使用 2. **与机锁的区别** - 机锁:通过内存变量实现(如Java的`synchronized`) - 分布式锁:需要跨网络通信,需处理网络延迟、节点宕机等问题 - 关键挑战:满足$CAP$理论中的致性(Consistency)与可用性(Availability)的平衡 3. **典型应用场景** - 电商秒杀:库存扣减操作 - 文件系统:避免并发写入冲突 - 定时任务:防止多个节点重复执行 4. **实现方式对比** | 实现方式 | 原理 | 优点 | 缺点 | |---------------|-----------------------------|----------------------|----------------------| | 数据库 | 利用唯索引/版本号 | 实现 | 性能低,无自动释放 | | Redis | SETNX命令+过期时间 | 高性能 | 需处理锁续期问题 | | ZooKeeper | 创建有序临时节点 | 可靠性高 | 部署复杂度较高 | 5. **关键特性要求** - **互斥性**:$P(\text{锁被持有}) = 1$时,其他请求必须等待 - **避免死锁**:自动释放机制(如设置TTL) - **容错性**:至少半数节点存活时仍可用 - **可重入性**:同节点可多次获取锁(需记录持有者信息) 6. **典型实现Redis)** ```lua -- 原子化获取锁 if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 else return 0 end ``` 此脚本满足: - 通过`SETNX`保证原子性 - 使用`PEXPIRE`设置毫秒级过期时间 - 返回1表示成功获取锁 7. **注意事项** - 时钟同步问题:不同节点时间不致可能导致锁提前释放 - 锁续期机制:需实现类似"看门狗"的定期续期程序 - 网络分区风险:可能产生脑裂现象(split-brain) 总结:分布式锁是构建可靠分布式系统的关键基础设施,选择方案时需要根据具体场景在性能、可靠性和复杂度之间权衡。建议优先考虑成熟方案如Redisson(Redis)、Curator(ZooKeeper)等经过验证的库。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值