提示:可跳过背景信息,直接跳到标题三阅读
一. 分布式锁使用场景
在服务器后端程序开发中,分布式锁主要用于多台机器的多个进程/线程的并发执行问题(处理同一数据)。比如同时用户下单时多个并发请求,进行扣减同一商品库存操作。
并发执行伪代码
----------
//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)
一般的,有以下两种方案:
- 排队机制(异步消息队列方案)。将并发的请求顺序入消息队列,然后开起一个单独进程,逐个消费队列内容。
并发执行伪代码
----------
pushMes(list,'商品1扣减库存');
return '商品购买中'
----------
单进程异步去消费队列
---------
while(PopMes(list))
{
//1.获取商品库存数量
$num = getNum($pruduct_id);
//2.库存相关逻辑
if ($num < 10) {
//商品购买失败
return false;
}
//3.扣减库存
setNum($num - 1);
//通知购买情况
notify();
}
---------
- 争抢锁机制。多个请求同时争抢一个分布式的锁,拿到锁的请求执行完成后释放锁,未拿到锁的请求循环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个请求 100并发
ab -n 2000 -c 100 -k http://127.0.0.1/test.php (linux或mac命令行执行)
测试结果正确,一共成功执行2000个请求,库存只减到10。
- 1000个请求 100并发
ab -n 1000 -c 100 -k http://127.0.0.1/test.php (linux或mac命令行执行)
测试结果正确,一共成功执行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/年,非新用户可以注册新账号或者续费也有优惠。没有云服务器的同学可以趁着打折去来一台