分布式锁
1 什么是分布式锁?
在讨论分布式锁之前,我们先假设一个业务场景:
1.1 业务场景
在电商系统中,用户购买商品需要扣减库存,一般扣库存有两种方式:
那么,我们一般为了用户体验分布式定时任务,会采用下单减库存分布式定时任务,为了解决下单减库存的缺陷,会创建一个定时任务,定时去清理超时未支付的订单。
这个定时任务主要包含以下步骤:
查询超时未支付的订单,获取订单中的商品信息。修改未支付订单的状态,改为取消。恢复订单中商品扣减的库存。
如果我们给订单服务搭建一个 100 个节点的超时订单检查服务集群,那么就会同时有 100 个定时任务触发并执行,设想一下这样的场景:
因为任务的并发执行,出现了线程安全问题,商品库存被增加多次。
为什么需要分布式锁
对于线程安全问题,传统的方法是给对线程操作的资源代码加锁。
理想状态下,加了锁以后,在当前订单服务执行时,其他订单需要等待当前服务完成业务后才能执行,这样就避免了线程安全的问题。实际上这样并不能解决问题。
1.2.1 线程锁
我们通常使用的 synchronized 和 Lock 都是线程锁,对同一个 JVM 进程内的多个线程有效。因为锁的本质是在内存中存放一个标记,记录获取锁的线程是谁,这个标记对每个线程都可见。
因此,锁生效的前提是:
互斥:锁的标记只有一个线程可以获取。
共享:标记对所有线程可见。
然而我们启动了多个订单服务,就是多个 JVM,内存中的锁显然是不共享的。为了解决这个问题,能够保证各个订单服务能够共享内存的锁,分布式锁就派上用场了。
1.2.2 分布式锁
分布式锁将锁的标记变为进程可见,保证这个任务同一时刻只能被多个进程中的某一个执行,那么这就是一个分布式锁。
分布式锁有多种实现方式,基本原理类似,只要满足如下要求即可:
常见的实现方案包括:基于数据库实现,基于 Redis 实现,基于 Zookeeper 实现。
2 Redis 实现分布式锁2.1 基本实现
我们先关注其中的两个必要条件:
1) Redis 本身就是基于 JVM 之外的,因此满足多进程可见的要求。
2) 互斥,互斥是说只有一个进程能获取锁标记,这个我们可以基于 Redis 的 setnx 指令来实现。setnx 是 set when not exist 的意思。当多次执行 setnx 命令时,只有第一次执行能成功,返回1,其余均返回0。
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> SETNX lock 001
(integer) 1
127.0.0.1:6379> get lock
"001"
127.0.0.1:6379> SETNX lock 002
(integer) 0
127.0.0.1:6379> get lock
"001"
多个进程对同一个 key 进行 setnx 操作,只有一个会成功,满足了互斥的需求。
3) 释放锁
释放锁其实只需要把锁的 key 删除即可,使用 del 指令。不过还需要思考一个问题,如果我们的服务器突然宕机,那么这个锁是不是就永远无法删除了那?
为了避免服务器宕机引起的锁无法释放的问题,我们可以再获取锁的时候,给锁加一个有效时间,超时自动释放,避免了锁永远不释放的问题。
SETNX 指令没有设置时间的功能,因此需要使用 set 指令,然后结合 set 的 NX 和 PX 参数来完成。
EX:过期时长,单位是秒。PX:过期时长,单位是毫秒。NX:等同与 SETNX。
127.0.0.1:6379> set lock 001 NX EX 30
OK
127.0.0.1:6379> set lock 002 NX EX 30
nil (第二次执行失败)
127.0.0.1:6379> ttl lock
(integer) 12
127.0.0.1:6379> get lock
"001"
127.0.0.1:6379>
步骤:
2.2 互斥性
上面的版本中,会有一定的安全问题。
问题出现了,B 和 C 同时获取到了锁,违反了互斥性。其实问题就是当前线程删除了其他线程的锁。
那么如何判断当前获取的锁是不是自己的锁那?
可以在 set 锁时,存入当前线程的唯一标识,删除之前判断一下这个标识是不是自己的,如果不是自己的,就不要删除。
2.3 重入性
如果我们在获取锁以后,执行代码的过程中,再次尝试获取锁,执行 setnx 肯定会失败,因为锁已经存在了。这样可能会导致死锁,这样的锁就是不可重入的。
重入锁
可重入锁,也叫所递归锁,指的是在同一个线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。同一个线程再次进入到同步代码块时,可以使用自己已获取到的锁。
实现:
因此,存储在锁中的信息就必须包含:key,线程标识,可重入次数,需要使用 hash 结构。
假设我们设置的锁的 key 为 lock, hashKey 为当前线程的 id:“threadID”,锁自动释放的时间为 20 秒。
获取锁的步骤:
判断 lock 是否存在 EXISTS lock存在,说明有获取获取锁了,下面判断是不是自己的锁HEXISTS lock threadId HINCRBY lock threadId 1 不存在,说明可以获取锁, HSET key threadId 1 。设置锁的自动释放时间, EXPIRE lock 20 。
释放锁的步骤:
判断当前线程 id 作为 hashkey 是否存在: HEXISTS lock threadId 。HINCRBY lock threadId -1判断重入次数是否为0:DEL Lock EXPIRE lock 202.4 Lua 脚本
上面探讨的实现方案都需要多行 redis 命令才能实现,这时我们就需要考虑原子性的问题,如果不能保证原子性,整个过程的问题还是很大的。
Redis 中使用 Lua 脚本来保证原子性。
执行 Lua 脚本
EVAL script numkeys key [key ...] arg [arg ...]
summary: Execute a lua script server side
since: 2.6.0
缓存 Lua 脚本
SCRIPT LOAD script
summary: Load the specified lua script into the script cache.
since: 2.6.0
将一段脚本缓存起来,生成一个 SHA1 值并返回,作为脚本字典的 key,方便下次使用,参数 script 就是脚本内容或者地址。
127.0.0.1:6379>
127.0.0.1:6379> SCRIPT LOAD "return 'hello world!'"
"absd9sd9fsdjdkfjs9ds0d0r1klj1209i"
127.0.0.1:6379>
此处返回的 absd9sd9fsdjdkfjs9ds0d0r1klj1209i 就是脚本缓存后得到 sha1 值。
执行缓存脚本
EVALSHA sha1 numkeys key[key ...] arg[arg ...]
summary: Execute a lua script server side
since: 2.6.0
与 EVAL 类似,执行一段脚本,区别是通过脚本的 sha1 值,去脚本缓存中查找,然后执行。
Lua 基本语法
限时特惠:本站每日持续更新海量设计资源,一年会员只需29.9元,全站资源免费下载
站长微信:ziyuanshu688