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/年,非新用户可以注册新账号或者续费也有优惠。没有云服务器的同学可以趁着打折去来一台

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容