今天我们来聊一聊分布式锁的那些事.
相信大家对锁已经不陌生了, 我们在多线程环境中, 如果需要对同一个资源进行操作, 为了避免数据不一致, 我们需要在操作共享资源之前进行加锁操作. 在计算机科学中, 锁 (lock) 或互斥 (mutex) 是一种同步机制, 用于在有许多执行线程的环境中强制对资源的访问限制.
比如你去相亲, 发现你和一大哥同时和一个女的相亲, 那怎么行呢..., 搞不好还要被揍一顿.
那什么是分布式锁呢. 当多个客户端需要争抢锁时, 我们就需要分布式锁. 这把锁不能是某个客户端本地的锁, 否则的话, 其它客户端是无法访问的. 所以分布式锁是需要存储在共享存储系统中的, 比如 Redis,Zookeeper 等, 可以被多个客户端共享访问和获取. 今天我们就来看一下如何使用 Redis 来实现分布式锁.
一, 前言
在正式开始之前, 我们先来了解两个 Redis 的命令:
SETNX key value
这个命名的含义是, 当 key 存在时, 不做任何赋值操作; 当 key 不存在时, 就创建 key, 并赋值成 value, 即(不存在即设置).
SET key value [EX seconds | PX milliseconds] NX
SET 后加 NX 选项, 就和 SETNX 命令类似了, 也实现不存在即设置的功能. 此外, 这个命令在执行时, 可以通过 EX 或者 PX 设置键值对的过期时间.
二, 正文
开始之前, 我们先引入一个场景:
假设要给某个商品举行秒杀活动, 我们事先把库存数据 100 已经存入到了 Redis 中, 我们现在需要来进行库存扣减.
如图所示, 我们假设有 1000 个客户端来进行库存扣减操作, 那我们该如何做, 才能保证库存扣减顺序一致且不会超扣呢.
我们首先想到的就是加锁, 在进行库存扣减之前, 我们先拿到锁, 然后进行扣减, 最后再释放锁. 在 Redis 中我们创建一个 key 来代表一个锁变量, 然后对应的值来表示锁变量的值. 我们来看一下如何进行加锁.
假设 1000 个客户端同时进行加锁请求. 因为 Redis 使用单线程来处理请求, 所以 Redis 会串行执行他们的请求操作. 假设 Redis 先处理客户端 2 的请求, 读取 lock_key 的值, 发现 lock_key 为 0, 所以客户端 2 就把 lock_key 的 value 设置成 1, 表示已经进行了加锁操作. 如果此时客户端 3 被处理, 发现 lock_key 的值已经为 1 了, 所以就返回加锁失败的信息.
当拿到锁的客户端 2 处理完共享资源后, 就要进行释放锁的操作, 释放锁很简单, 就是将 lock_key 重新设置为 0.
由于加锁操作包含了三个操作(读取锁变量, 判断锁变量的值以及把锁变量的值设置成 1), 而这三个操作在执行的过程中需要保证原子性. 那怎么保证原子性呢?
我们可以使用 SETNX 命令来实现加锁操作, SETNX 命令表示 key 不存在时就创建, key 存在时就不做任何赋值操作, 当加锁时候, 我们执行
SETNX lock_key 1
对于释放锁操作来说, 我们可以使用 DEL 命令来删除锁变量. 比如客户端 2 进行加锁, 执行 SETNX lock_key 1, 如果 lock_key 不存在, 则会创建 lock_key, 返回加锁成功, 此时客户端 2 可以进行共享资源的访问. 如果这时客户端 1 来发起请求加锁操作, 而此时 lock_key 已经存在, SETNX lock_key 1 不做任何赋值操作操作, 返回加锁失败, 所以客户端 1 加锁失败. 当客户端 2 执行完共享资源访问后, 执行 DEL 命令来释放锁. 此时当有其它客户端再来访问时, lock_key 已经不存在了, 就可以进行正常的加锁操作了. 所以, 我们可以使用 SETNX 和 DEL 命令组合来进行加锁和释放锁的操作.
不过这里有两个问题:
1. 当某个客户端执行完 SETNX 命令, 加锁后, 此时发生了异常, 结果一直没有执行 DEL 操作命令来释放锁. 因此, 这个客户端一直占用着这个锁, 其它客户端无法拿到锁.
解决这个问题, 一个有效的方法就是, 给锁变量设置一个过期时间. 这样一来, 即使持有锁的客户端发生了异常, 无法主动的释放锁, Redis 也会根据锁变量的过期时间把它删除. 其它客户端在锁变量过期后, 就可以重新进行加锁操作了.
2. 如果客户端 1 执行了 SETNX 命令加锁后. 如果此时客户端 2 执行 DEL 命令删除锁, 这时, 客户端 A 的锁就被误释放了. 这是我们不能接受的.
为了解决这个问题, 我们需要能区分来自不同客户端的锁操作. 我们该如何做呢? 我们可以给每个客户端生成一个唯一值, 在进行加锁时, 我们把锁变量赋值成这个唯一值. 这样在释放锁的时候, 客户端需要判断, 当前锁变量的值是否和自己的唯一标识相等, 在相等的情况下, 才能释放锁.
下面来看一下如何在 Redis 中进行实现. 我们可以使用 SET 加 EX/PX 和 NX 选项, 来进行加锁操作.
SET lock_key uuid NX PX 100
其中 lock_key 是锁变量, uuid 表示客户端的唯一标识, PX 100 表示 100ms 过期. 由于我们在释放锁时需要对比客户端的标识和锁变量的值是否一致, 这包含了多个操作, 为了保证原子性, 我们需要使用 lua 脚本, 下面是 lua 脚本的实现.
- if Redis.call("get",KEYS[1]) == ARGV[1] then
- return Redis.call("del",KEYS[1])
- else
- return 0
- end
其中 KEY[1]表示 lock_key,ARGV[1]表示当前客户端的唯一标识, 这两个值是我们在执行 lua 脚本时作为参数传入的. 下面我们来看一下完整的代码实现.
- import Redis
- import traceback
- import uuid
- import time
- class Inventory(object):
- def __init__(self):
- pool = Redis.ConnectionPool(host='localhost', port=6379, decode_responses=True)
- client = Redis.StrictRedis(connection_pool=pool, max_connections=20)
- self.client=client
- self.uuid=str(uuid.uuid1())
- print(self.uuid)
- self.key="lock_key"
- self.inventory_key="inventory"
- def unlock(self):
- unlock_script="" \
- "if redis.call(\"get\",KEYS[1]) == ARGV[1] then" \
- "return redis.call(\"del\",KEYS[1])" \
- "else" \
- "return 0" \
- "end"
- try:
- unlock_cmd=self.client.register_script(unlock_script)
- result=unlock_cmd(keys=[self.key],args=[self.uuid])
- if result==1:
- print("释放成功")
- else:
- print("释放出错")
- except:
- print(traceback.format_exc())
- def lock(self):
- try:
- while True:
- result=self.client.set(self.key,self.uuid,px=100,nx=True)
- print(result)
- if result==1:
- break
- print("sleep 1s")
- time.sleep(1)
- print("加锁成功")
- return True
- except:
- print(traceback.format_exc())
- def inventory(self):
- if self.lock():
- print("库存扣减")
- self.client.decr(self.inventory_key)
- print("扣减完成")
- self.unlock()
- inv=Inventory()
- inv.inventory()
到此, 我们就把 Redis 实现分布式锁就聊完了. 既然都读到了这里, 不妨给个「三连」吧, 你的三连就是我最大的动力.
三, 后记
更多硬核知识, 请关注公众号[程序员学长] . 回复[资料] 可以获得上百本电子书资料
我们下期见.
来源: https://www.cnblogs.com/laohanshuibi/p/15164807.html