redis 分布式阻塞锁的实现(非争抢、同步队列机制)

提示:可跳过背景信息,直接跳到标题三阅读

一. 分布式锁使用场景

在服务器后端程序开发中,分布式锁主要用于多台机器的多个进程/线程的并发执行问题(处理同一数据)。比如同时用户下单时多个并发请求,进行扣减同一商品库存操作。

并发执行伪代码
----------
//1.获取商品库存数量
$num = getNum($pruduct_id)
//2.库存相关逻辑
if ($num < 10) {
   //商品购买失败
   return false;
}
//3.扣减库存
setNum($num - 1);
----------

上边伪代码在并发执行的时候,先getNum、再setNum,这并非一个原子操作,会出现同时获取到的库存数量都满足要求,然后都进行减库存的情况。

二. 并发问题解决方案

本质上的解决思路是,把多个异步并发执行的请求变为同步按顺序执行。

1. 在数据库层面处理

加锁将查询和修改两条语句合为一个原子操作,比如mysql的select ... for update语句。

2. 在应用程序层面处理(php/java/go)

一般的,有以下两种方案:

  1. 排队机制(异步消息队列方案)。将并发的请求顺序入消息队列,然后开起一个单独进程,逐个消费队列内容。
并发执行伪代码
----------
pushMes(list,'商品1扣减库存');
return '商品购买中'
----------

单进程异步去消费队列
---------
while(PopMes(list))
{
   //1.获取商品库存数量
   $num = getNum($pruduct_id);
   //2.库存相关逻辑
   if ($num < 10) {
      //商品购买失败
      return false;
   }
   //3.扣减库存
   setNum($num - 1);
   
   //通知购买情况
   notify();
}
---------
  1. 争抢锁机制。多个请求同时争抢一个分布式的锁,拿到锁的请求执行完成后释放锁,未拿到锁的请求循环sleep一段时间,去等待锁释放、争抢锁。
并发执行伪代码
----------
times = 0;
while(times < 10) {
   //获取锁
   if (getLock()) {
      //1.获取商品库存数量
      $num = getNum($pruduct_id);
      //2.库存相关逻辑
      if ($num < 10) {
         //商品购买失败
         return false;
      }
      //3.扣减库存
      setNum($num - 1);
      
      //释放锁
      releaseLock();
      return true;
   } else {
      times = times + 1;
      //等待一段时间
      sleep(0.01);
   }
}
----------

三. 本文新方案,分布式非争抢阻塞锁(同步队列机制)

1. 概念解读

  • 首先锁是分布式的
  • 阻塞锁指的是,不能拿到锁的时候,会阻塞程序的执行直至拿到锁
  • 非争抢指的是,等待拿锁的过程是不用争抢的,通过同步队列实现(相对异步消息队列而言)

2. 实现原理

  • 分布式:创建一个redis队列来存储一个key,作为一个可用锁。
  • 阻塞非争抢拿锁:通过redis的brpop命令来阻塞获取一个锁
  • 释放锁:拿到锁执行完对应业务后,将锁资源存入redis队列


BRPOP 是一个阻塞的列表弹出原语。 它是 RPOP的阻塞版本,因为这个命令会在给定list无法弹出任何元素的时候阻塞连接。关于redis brpop的非争抢和阻塞特性的实现,在后边的文章分析。

3. 代码实现(php)

注: 下面代码仅为事例代码,具体应用还要考虑其他问题。比如加锁后程序异常退出,释放锁失效的问题。

<?php

   $redis = new Redis();
   $redis->connect('127.0.0.1', 6379);

   //商品id
   $pruduct_id = 1;
   //锁的名称
   $lock_key = 'lock_' . $pruduct_id;
   //产品库存在redis中存储的key
   $store_key = 'product_' . $pruduct_id;
   //初始设置商品库存为2000
   $redis->setnx($store_key, 2000);
   //获取锁最多阻塞10s
   $lock = getLock($lock_key, 10);
   //记录请求数量
   $redis->incr('request_num');
   if ($lock) {
      $num = $redis->get($store_key);
      if (is_numeric($num) && $num > 10) {
         //减库存
         $num--;
         $redis->set($store_key, $num);
      }
      //释放锁资源
      releaseLock($lock_key);
   }

   /**
   * 阻塞非争抢获取一个锁
   * @param string $key 锁的名称
   * @param string $timeout 最大阻塞时间(秒),超过时间将不再等待拿锁
   * @return bool 获取锁成功/失败
   */
   function getLock($key = 'lock1', $timeout) 
   {
      global $redis;
      //第一次请求, 锁标识不存在的情况,直接拿到锁
      $lock =  $redis->setnx($key, 1);
      if (!$lock) {
        //非第一次请求,阻塞等待拿到锁
        $lock = $redis->brpop($key . '_list', $timeout);
      }
     return (bool)$lock;
   }

   /**
   * 争抢获取一个锁(使用setnx实现 拿不到锁最多重试100次)
   * @param string $key 锁的名称
   * @param string $timeout 最大阻塞时间(秒),超过时间将不再等待拿锁
   * @return bool 获取锁成功/失败
   */
   function getLock2($key = 'lock1') 
   {
      global $redis;
      $lock =  $redis->setnx($key, 1);
      if (!$lock) {
        for ($i=0; $i < 100; $i++) {
          //记录拿锁重试次数
          $redis->incr('retry');
          usleep(1);
          if ($redis->setnx($key, 1)) {
            return true;
          }
        }
        //记录拿锁失败次数
        $redis->incr('get_lock_fail');
      }
     return (bool)$lock;
   }

   /**
     * 释放锁
     * @param string $key 锁的名称
     * @return bool 释放锁成功/失败
     */
   function releaseLock($key = 'lock1')
   {
      global $redis;
      //返回可用资源到队列
      $ret = $redis->rpush($key . '_list', 'lock_item1');
      return $ret;
   }

   /**
     * 释放争抢锁
     * @param string $key 锁的名称
     * @return bool 释放锁成功/失败
     */
   function releaseLock2($key = 'lock1')
   {
      global $redis;
      //删除锁
      $ret = $redis->del($key);
      return $ret;
   }

4. 测试

(1)正确性测试

使用ab测试工具,模拟并发请求


测试结果正确,一共成功执行2000个请求,库存只减到10。


测试结果正确,一共成功执行1000个请求,库存扣减到1000。

(2)和redis争抢锁对比测试

提示:示例代码中的getLock2、releaseLock2即为争抢锁例子

  • 效率对比
    两种加锁方式,分别ab测试2000个请求 100个并发, php-fpm开启50个进程
    ab -n 2000 -c 100 -k http://127.0.0.1/test.php (linux或mac命令行执行)
  • 争抢锁执行结果
    将初始库存改为3000
    ab -n 2000 -c 100 -k http://127.0.0.1/test.php (linux或mac命令行执行)

四. 最后

本文是提供了一个新的思路,不完善的地方欢迎在评论区讨论。

五. 广告

云服务器练手推荐

3月份腾讯云在打折促销,新用户1核2G云服务器99/年,非新用户可以注册新账号或者续费也有优惠。没有云服务器的同学可以趁着打折去来一台

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容