来了解一下分布式锁以及Redis分布式锁demo(单机)
为什么要使用分布锁?
什么是锁?
在单机多线程环境中,我们经常遇到多个线程访问同一个共享资源(这里需要注意的是:在很多地方,这种资源会称为临界资源,但在今天这篇文章中,我们统一称之为共享资源)的情况。为了维护数据的一致性,我们需要某种机制来保证只有满足某个条件的线程才能访问资源,不满足条件的线程只能等待,在下一轮竞争中重新满足条件时才能访问资源。
这个机制指的是,为了实现分布式互斥,在某个地方做个标记,这个标记每个线程都能看到,到标记不存在时可以设置该标记,当标记被设置后,其他线程只能等待拥有该标记的线程执行完成,并释放该标记后,才能去设置该标记和访问共享资源。这里的标记,就是我们常说的锁。
也就是说,锁是实现多线程同时访问同一共享资源,保证同一时刻只有一个线程可访问共享资源所做的一种标记。
与普通锁不同的是,分布式锁是指分布式环境下,系统部署在多个机器中,实现多进程分布式互斥的一种锁。为了保证多个进程能看到锁,锁被存在公共存储(比如Redis、Memcache、数据库等三方存储中),以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性。
可以再看看下面这篇文章
了解一下三种分布式锁:关系型数据库分布式锁、缓存分布式锁、zookeeper分布式锁
那什么场景下需要使用分布式锁呢?
比如,现在某电商要售卖某大牌吹风机(以下简称“吹风机”),库存只有2个,但有5个来自不同地区的用户{A,B,C,D,E}几乎同时下单,那么这2个吹风机到底会花落谁家呢?
你可能会想,这还不简单,谁先提交订单请求,谁就购买成功呗。但实际业务中,为了高并发地接受大量用户订单请求,很少有电商网站真正实施这么简单的措施。
此外,对于订单的优先级,不同电商往往采取不同的策略,比如有些电商根据下单时间判断谁可以购买成功,而有些电商则是根据付款时间来判断。但,无论采用什么样的规则去判断谁能购买成功,都必须要保证吹风机售出时,数据库中更新的库存是正确的。为了便于理解,我在下面的讲述中,以下单时间作为购买成功的判断依据。
我们能想到的最简单方案就是,给吹风机的库存数加一个锁。当有一个用户提交订单后,后台服务器给库存数加一个锁,根据该用户的订单修改库存。而其他用户必须等到锁释放以后,才能重新获取库存数,继续购买。
在这里,吹风机的库存就是共享资源,不同的购买者对应着多个进程,后台服务器对共享资源加的锁就是告诉其他进程“关键重地,非请勿入”。
但问题就这样解决了吗?当然没这么简单。
想象一下,用户A想买1个吹风机,用户B想买2个吹风机。在理想状态下,用户A网速好先买走了1个,库存还剩下1个,此时应该提示用户B库存不足,用户B购买失败。但实际情况是,用户A和用户B同时获取到商品库存还剩2个,用户A买走1个,在用户A更新库存之前,用户B又买走了2个,此时用户B更新库存,商品还剩0个。这时,电商就头大了,总共2个吹风机,却卖出去了3个。
不难看出,如果只使用单机锁将会出现不可预知的后果。因此,在高并发场景下,为了保证临界资源同一时间只能被一个进程使用,从而确保数据的一致性,我们就需要引入分布式锁了。
此外,在大规模分布式系统中,单个机器的线程锁无法管控多个机器对同一资源的访问,这时使用分布式锁,就可以把整个集群当作一个应用一样去处理,实用性和扩展性更好。
分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占 时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。
占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用 完了,再调用 del 指令释放茅坑。
// 这里的冒号:就是一个普通的字符,没特别含义,它可以是任意其它字符,不要误解 > setnx lock:codehole true OK ... do something critical ...
> del lock:codehole
(integer) 1
但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样 就会陷入死锁,锁永远得不到释放。
于是我们在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也 可以保证 5 秒之后锁会自动释放。
> setnx lock:codehole true OK
> expire lock:codehole 5 ...
do something critical ...
> del lock:codehole
(integer) 1
但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因 为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。
这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可 以一起执行就不会出现问题。也许你会想到用 Redis 事务来解决。但是这里不行,因为 expire是依赖于 setnx 的执行结果的,如果 setnx 没抢到锁,expire 是不应该执行的。事务里没有 if- else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。
为了解决这个疑难,Redis 开源社区涌现了一堆分布式锁的 library,专门用来解决这个问 题。实现方法极为复杂,小白用户一般要费很大的精力才可以搞懂。如果你需要使用分布式锁, 意味着你不能仅仅使用 Jedis 或者 redis-py 就行了,还得引入分布式锁的 library。
为了治理这个乱象,Redis 2.8 版本中作者加入了 set 指令的扩展参数,使得 setnx 和expire 指令可以一起执行,彻底解决了分布式锁的乱象。从此以后所有的第三方分布式锁library 可以休息了。
> set lock:codehole true ex 5 nx
OK ...
do something critical ...
> del lock:codehole
上面这个指令就是 setnx 和 expire 组合在一起的原子指令,它就是分布式锁的 奥义所在。
超时问题
如果在加锁和释放锁之间的逻辑执行的太长,超出了超时限制,怎么破?
也就是说第一个线程持有的锁过期了但临界区的逻辑还没有执行完,这个时候第二个线程就提前重新持有了这把锁,导致每个请求执行临界区代码时不能严格的串行执行。
Redis 的分布式锁不能解决超时问题,建议分布式锁不要用于较长时间的任务。
set
现在官方建议直接使用 set 来实现锁。我们可以使用 set 命令来替代 setnx,就是下面这个样子
if (Redis::set("my:lock", 1, "nx", "ex", 10)) { ... do something Redis::del("my:lock") }
上面的代码把 my:lock 设置为 1,当且仅当这个 lock 不存在的时候,设置完成之后设置过期时间为 10。
获取锁的机制是对了,但是删除锁的机制直接使用 del 是不对的。因为有可能导致误删别人的锁的情况。
比如,这个锁我上了 10s,但是我处理的时间比 10s 更长,到了 10s,这个锁自动过期了,被别人取走了,并且对它重新上锁了。那么这个时候,我再调用 Redis::del 就是删除别人建立的锁了。
官方对解锁的命令也有建议,建议使用 lua 脚本,先进行 get,再进行 del
$token = rand(1, 100000);//可替换为 UUID function lock() { return Redis::set("my:lock", $token, "nx", "ex", 10); } function unlock() { $script = ` if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end ` return Redis::eval($script, "my:lock", $token) } if (lock()) { // do something unlock(); }
这里的 token 是一个随机数,当 lock 的时候,往 redis 的 my:lock 中存的是这个 token,unlock 的时候,先 get 一下 lock 中的 token,如果和我要删除的 token 是一致的,说明这个锁是之前我 set 的,否则的话,说明这个锁已经过期,是别人 set 的,我就不应该对它进行任何操作。
比如:有三个线程,线程A、线程B、线程C,首先线程A在执行,执行的过程中超时了,然后导致最终没有释放锁,因为只要超时后面的代码没执行,意味着后面的delete没执行,不过不用担心,锁有超时时间,锁到期之后释放;然后线程B看没有锁,线程B开始执行,但是线程B在执行的时候,执行到中间了,线程A突然连接上了,因为刚才线程A超时现在又可以继续执行了,那么线程A走完后面的代码流程删除锁,那么这个时候线程A删除的肯定是线程B的锁,现在出现的问题是线程B还在执行,其实已经没有锁了;线程C一看没有锁,那么线程C进来了,这个时候同一时间有两个线程在操作同一个资源,那么现在还是会出现线程不安全的问题。那么这个时候只要把锁加一个唯一标识,比如uuid、一个比较大的随机数等等标识,确保每个线程都有自己的唯一Key,就不会出现此类问题了。
所以:不要再使用 setnx,直接使用 set 进行锁实现。
需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行,会先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功
php demo
/** * 领取优惠券接口,限制只能领取一张券 * @param Request $request * @return array */ public function receiveCoupon(Request $request, $sendFlag = 0, $times = 0) { $rules = array( 'authkey' => 'required|string', 'timestamp' => 'required|integer', 'sign' => 'required|string', 'user_id' => 'required_without_all:user_id,phone|integer', 'phone' => 'required_without_all:user_id,phone|integer', 'id' => 'required_without_all:id,key|integer',//优惠券配置id 'key' => 'required_without_all:id,key|string',//优惠券配置触发关键词 'type' => 'integer',//业务类型11:二手车,12:养护套餐,13:贷款,14:保险,15:线下店,16:新车,17:合伙人, 18:平台服务费 TODO //'car_id' => 'string', 'order_d' => 'string',//发放订单号 'value' => 'numeric',//外部传入金额 'start_time' => 'string',//用来补发券 //'enforce_effect' => 'boolean',//强制生效 'package_id' => 'integer',//礼包id 'is_db' => 'integer',// ); $is_db = $request->input('is_db', 0); $validation = Validator::make(Input::all(), $rules); if ($validation->fails()) { return Response::json(['status' => 101, 'errmsg' => $validation->errors()->first()]); } $params['user_id'] = $request->input('user_id'); $phone = $request->input('phone'); $params['activity_id'] = $request->input('id'); $params['authkey'] = $request->input('authkey'); $params['key'] = $request->input('key', ''); $params['car_id'] = $request->input('car_id', ''); $params['value'] = $request->input('value', ''); $params['order_id'] = $request->input('order_id', ''); $params['start_time'] = $request->input('start_time', ''); $params['enforce_effect'] = $request->input('enforce_effect', false); $params['package_id'] = $request->input('package_id', 0); //获取活动配置id $params['activity_id'] = $this->entity->getConfigId($params['activity_id'], $params['key']); //获取用户信息 $user_info = $this->entity->getUserInfo($params['user_id'], $phone); $params['user_id'] = $user_info['id']; $params['phone'] = $user_info['phone']; $params['register_time'] = $user_info['register_time']; $params['ip'] = $request->ip(); $params['route'] = $request->path(); //获取优惠券配置信息 $config_info = $this->entity->getConfigInfo($params['activity_id']); $received_num_key_lock = sprintf(self::COUPON_ENTITY_COUNT . '_lock', $params['activity_id']); echo $received_num_key_lock;die; $token = rand(1, 9000000); // 解锁 $script = ' if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end '; $redis = Redis::connection('lock'); if ($redis->set($received_num_key_lock, $token, "nx", "ex", 8)) { try { //验证是否发券 $this->entity->checkValid($params, $config_info, $sendFlag); //整合优惠券实体入库数据 $coupon_entity = $this->entity->conformCouponData($params, $config_info, $sendFlag);//发券数据整合 //优惠券实体数据入队列【0】 或者 数据库【1】 $this->entity->entityDataPush($coupon_entity, $is_db); } catch (ApiException $apiException) { $res = $redis->eval($script, 1, $received_num_key_lock, $token); // 如果释放成功 if ($res) { throw new ApiException(json_decode($apiException->getMessage(), true)); } else { // 锁被提前释放 // TODO 内容需要回滚 } } catch (\Exception $exception) { $res = $redis->eval($script, 1, $received_num_key_lock, $token); if ($res) { // ...... } else { // TODO 内容需要回滚 } } $res = $redis->eval($script, 1, $received_num_key_lock, $token); // 如果释放成功 if ($res) { // ...... } else { // 锁被提前释放 // TODO 内容需要回滚 } return Response::json(['status' => 0, 'errmsg' => 'ok']); } else { $times++; if ($times <= 5) { Log::info('Grabbing coupons failed: ' . json_encode($params) . ", try times: " . $times); $this->receiveCoupon($request, $sendFlag, $times); } else { return Response::json(['status' => 301, 'errmsg' => 'Grabbing coupons failed']); } } }