1.不允许使用库存创建队列
因为库存如果是10w难道要创建一个10w长度的队列吗
2.不允许对整个业务过程加锁
可能业务执行时间很长 导致锁粒度太大 影响并发量
3.如果业务时间大于锁时间 会造成锁失效
需要实现锁续期
4.需要解决“超买”问题
对于秒杀除了库存并发问题 还有单用户购买问题限制 例如同一个不能多生成多笔相同订单
5.“超卖”问题
主要解决库存不能为负数或订单总量库存大于销售库存
6.需要支持连接池下也能正常使用
7.获取锁自动重试
8.代码异常出错导致未释放锁 应自动释放锁 不应产生长期死锁
9.采用lua脚本hash方式实现 提高性能 以及 天然原子性操作
以上问题都将得到解决 并未采用redis乐观锁方式 如果你对redis乐观锁感兴趣可自行搜索了解 这里仅列相关命令作为简单演示
watch test
multi
set test 123
get test
exec 如果失败返回null 成功返回数组
redisObject = $redisObject;
self::$self = $self;
}
return self::$self;
}
public function getReqId($key, $count, $stock = -1)
{
$clientId = $this->getClientId();
$this->lock('listQueTimeOut:' . $key);
$this->listQueTimeOut('list:req:lock:' . $key . ':lock:lua');
$this->unlock('listQueTimeOut:' . $key);
//生成请求标识
$reqId = (string)md5(uniqid(md5(microtime(true)), true)) . ':' . $clientId;
$reqTimeOut = $this->reqTimeOut;
$script = << 0 and reqListCount == 0)
--if(stock > 0 and (redis.call('exists', key) ~= 1 or keyStock <= 0) and reqListCount == 0)
then
--更新库存
redis.call('set', key,stock)
end
--如果mysql传过来的为0 那么久将队列与库存全部重置! 小于0 不做任何操作 标识不更改库存
if(stock == 0)
then
--更新redis库存为0
redis.call('set', key,stock)
--直接删除队列
redis.call('del', listReqKey)
return 0
end
--判断库存是否充足
if(redis.call('DECRBY', key,value) < 0)
then
--库存不足加回去
redis.call('INCRBY', key,value)
return 0
else
--再次校验 如果小于0返回失败 并 重置redis中的数量为0
--[[local checkStock = redis.call('get', key)
if(checkStock == nil or checkStock == '') then
redis.call('set', key,0)
return 0
else
if(tonumber(checkStock) < 0) then
redis.call('set', key,0)
return 0
end
end]]
--往队列中放入标识
redis.call('ZADD', listReqKey,tonumber(redis.call('time')[1])+reqTimeOut,reqId)
--库存充足
return reqId
end
LUA;
return $this->retrunClass($count, $this->execLuaScript($script, [$key, $count, $stock, $reqId, $reqTimeOut]), $key);
}
/**
* 获取客户端id
* @return mixed
*
*/
public function getClientId()
{
return $this->redisObject->client('id');
}
/**
* 上锁
* @param string $name 锁名字
* @param int $expire 锁有效期 秒 / 最大续期时间/程序最大运行时长 默认1小时
* @param int $retryTimes 重试次数
* @param int $sleep 重试休息微秒
* @return mixed
*/
public function lock(string $name, int $expire = self::LockTimeOut, int $retryTimes = 10, $sleep = 10000)
{
$clientId = $this->getClientId();
$oj8k = false;
$retryTimes = max($retryTimes, 1);
$key = self::REDIS_LOCK_KEY_PREFIX . $name;
while ($retryTimes-- > 0) {
$kVal = microtime(true) + $expire;
$kVal = (string)$kVal . ':' . $clientId;
$oj8k = $this->getLock($key, $expire, $kVal);//上锁
if ($oj8k) {
$this->lockedNames[$key] = $kVal;
break;
}
usleep($sleep);
}
return $oj8k;
}
/**
* 获取锁
* @param $key
* @param $expire
* @param $value
* @return mixed
*/
private function getLock($key, $expire, $value)
{
$valueR = $this->redisObject->GET($key);
if (!empty($valueR)) {
$clientId = explode(':', $valueR)[1];
if ($this->getClientIsConn((int)$clientId) == false) {
$this->redisObject->del($key);
} else {
$ttlKey = $this->redisObject->ttl($key);
if ($ttlKey > 0) {
$this->redisObject->expire($key, self::LockTimeOut);
}
}
}
$script = <<execLuaScript($script, [$key, $value, $expire]);
}
/**
* 获取指定客户端id是否存在
* @param $clientId
* @return bool
*/
public function getClientIsConn($clientId)
{
if ((int)$this->redisObject->rawCommand('CLIENT', 'TRACKING', 'off', 'REDIRECT', (int)$clientId) == 1) {
return true;
} else {
return false;
}
}
/**
* 执行lua脚本
* @param string $script
* @param array $params
* @param int $keyNum
* @return mixed
*/
private function execLuaScript($script, array $params, $keyNum = 1)
{
$hash = $this->redisObject->script('load', $script);
return $this->redisObject->evalSha($hash, $params, $keyNum);
//return $this->redisObject->eval($script,$params, $keyNum);
}
/**
* 获取指定时间内过期的队列并进行链接活跃校验以及清理断开的链接
* @param $key
*/
public function listQueTimeOut($key)
{
$reqTimeOutList = $this->redisObject->ZRANGEBYSCORE($key, 0, time());
foreach ($reqTimeOutList as $item) {
$clientId = explode(':', $item)[1];
if ($this->getClientIsConn((int)$clientId) == false) {
$this->redisObject->ZREM($key, $item);
} else {
//对值过期但是连接依然存在的情况处理 可能因为连接池或被重复分配id 异常未回收可能会被断开没问题
//【可能过期需要续期【业务超期执行中】、可能连接池复用(能被复用回去应该是正常执行结束 问题不大)、可能重复分配相同id(一般redis实例重启可能会出现重复)TODO:这种特殊情况一般可以考虑做个最大续期计数避免无限续期】
//业务超时进行续期 避免每次都在这里处理一次
$this->redisObject->ZADD($key, time() + $this->reqTimeOut, $item);
}
}
}
//设置连接名称
//获取链接名称
/**
* 解锁
* @param string $name
* @return mixed
*/
public function unlock(string $name)
{
$script = <<lockedNames[$key])) {
$val = $this->lockedNames[$key];
return $this->execLuaScript($script, [$key, $val]);
}
return false;
}
/**
* @param $count
* @param $reqId
* @param $key string 标识 count购买的数量 stock更新库 更新库存频率自行上锁控制
* @return RedisLock|__anonymous@8648|null
* 获取请求id >0 成功就满足 不成功就是库存不足
* 可自动更新库存
*
*/
private function retrunClass($count, $reqId, $key)
{
if (empty($reqId)) {
return null;
}
$key = $key . ':lock:lua';
return new class($this->redisObject, $count, $reqId, $key) extends RedisLock {
private $redisObject;//redis对象
private $count;//购买的数量
private $reqId;//请求id
private $key;//标识
public function __construct($redisObject, $count, $reqId, $key)
{
$this->redisObject = $redisObject;
$this->count = $count;
$this->reqId = $reqId;
$this->key = $key;
}
/**
* 执行lua脚本
* @param string $script
* @param array $params
* @param int $keyNum
* @return mixed
*/
private function execLuaScript($script, array $params, $keyNum = 1)
{
$hash = $this->redisObject->script('load', $script);
return $this->redisObject->evalSha($hash, $params, $keyNum);
}
//回收请求
public function recoveryReqId()
{
return $this->redisObject->zRem("list:req:lock:" . $this->key, $this->reqId);
}
//回收库存并自动回收请求【安全回收库存】
/**
* 失败返回0 成功返回当前库存数量
*/
public function recoveryStock()
{
//如果在redis更新库存的时候回滚了 会造成多的 那么怎么解决?
//答:通过lua脚本 判断是否存在请求 存在 在回滚 否则不进行回滚 上面lua脚本在redis库存为0的时候 队列为空的时候 会更新库存 并清空队列 那么这里必须保证 存在队列
//1.如果该请求被更新库存的时候清除了 那么表示库存已经标准了 那么回滚不在有意义 所以不用回滚库存
//2.如果存在请求 进行回滚 表示需要回滚当前库存 因为这一波库存属于该请求的 它才可以被回滚
//结论:某一个请求回滚库存时判断当前redis库存是否属于自己的 才有权利回滚库存 如果该请求超时 那么也不允许其进行更新库存了因为被超时剔除了
//就算多次调用 也只会回滚一次保证安全
$script = <<execLuaScript($script, [$this->key, $this->reqId, $this->count]);
}
};
}
public function releasBatchReq(array $reqObj)
{
foreach ($reqObj as $v) {
if (gettype($v) == 'object') {
if (method_exists($v, 'recoveryReqId')) {
$v->recoveryReqId();
}
}
}
}
//手动释放请求 根据请求id [不建议使用 该方法]
// public function releaseReqList($key,$reqId){
// $this->redisObject->zRem("list:req:lock:".$key,$reqId);
// }
//批量回收请求
public function releasBatchStock(array $reqObj)
{
foreach ($reqObj as $v) {
if (gettype($v) == 'object') {
if (method_exists($v, 'recoveryStock')) {
$v->recoveryStock();
}
}
}
}
//批量回收库存 会自动回收库存 【安全回收库存】
/**
* 获取锁并执行
* @param callable $func
* @param string $name
* @param int $expire
* @param int $retryTimes
* @param int $sleep
* @return bool
* @throws \Exception
*/
public function run(callable $func, string $name, int $expire = 5, int $retryTimes = 10, $sleep = 10000)
{
if ($this->lock($name, $expire, $retryTimes, $sleep)) {
try {
call_user_func($func);
} catch (\Exception $e) {
throw $e;
} finally {
$this->unlock($name);
}
return true;
} else {
return false;
}
}
/**
*
*/
private function __clone()
{
}
}
/**
* 用法示例
*/
//$redis_p = Cache::store('redis')->handler();
//$redisLock = \RedisLock::getInstance($redis_p);
//
//$pdo = new PDO('mysql:host=127.0.0.1;dbname=testredis', 'testredis', 'S3hCHcpJZHxFe8AH');
//
//
//
//$goodsId = $_GET['goodsId'];//产品id
//$key = 'goods:'.$goodsId;
//$count = $_GET['count'];//购买量
//
//
//
设置库存 判断库存锁 坐等超时即可 不用解锁 限制更新频率
//$oj8k = $redisLock->lock($key, 5,10);
//$number=-1;
//if ($oj8k) {
// //允许设置库存 进行获取库存
// $sql="select `number` from storage where goodsId={$goodsId} limit 1";
//
// $res = $pdo->query($sql)->fetch();
// $number = $res['number'];
//}
//
//
获取请求id
//$reqid = $redisLock->getReqId($key,$count,$number);
//if(empty($reqid)){
// exit('库存不足');
//}
//----------------------业务代码-------------------------
//查看库存
//$sql="select `number` from storage where goodsId={$goodsId} limit 1";
//$res = $pdo->query($sql)->fetch();
//$number = $res['number'];
//if($number>0)
//{
//
//$createTime = date('Y-m-d H:i:s');
// $sql ="insert into `order` VALUES ('',$number,'{$createTime}')";
// $order_id = $pdo->query($sql);
// if($order_id)
// {
// $sql="update storage set `number`=`number`-$count WHERE goodsId={$goodsId}";
// if($pdo->query($sql)){
// var_dump($reqid->recoveryReqId());//手动回收请求
// }else{
//
// var_dump($reqid->recoveryStock());//手动回收库存
// }
// }
//
//
//
//
// // var_dump($reqid->recoveryStock());//手动回收库存
// // var_dump($reqid->recoveryReqId());//手动回收请求
//
//
// //批量回收请求
// // $reqid->releasBatchReq([$reqid]);
//
// //批量回库存
// // $reqid->releasBatchStock([$reqid]);
//
//
//
//
//
//
//
//
// echo 'done';
//}
1.对于标品库存和非标品的重量库存,他们单位是不一致的 【数量单位、重量单位】,比如重量库存可能存在浮点数。那么需要针对浮点数移位变为整数,目前是不支持浮点数的,自己转以下计算即可 。edis对浮点数处理较为麻烦 而且目前我们线上项目认为没必要所以使用时转换单位即可
2.对于加锁可使用 lock和unlock方法,对于使用时尽量保证能解锁 这样更可以降低死锁概率和时间,特殊情况 例如代码错误导致未走到unlock方法 也不要紧,会自动根据连接池或单次进程自动销毁锁,死锁时间由连接池或gc时间维持,如果未放回连接池 可能会被gc回收或连接池会进行断开,是不会产生长期死锁,当然尽量保证自己的代码健壮性,减少代码出错才是长久 正确的处理方式。
3.如果是用使用防超卖 尽量保证正常回收请求 这样库存可能在更改后也会更快速更新到redis中 当然如果你使用的监听binlog自动更新到redis方式 那么也要注意不能随意更改redis库存 避免产生超卖问题 除非可以保证队列此时是空的。这里的代码分享 可以避免过多加锁查库 不会用锁针对整个逻辑 可做到比较细粒度的控制超卖问题
4.如果涉及到秒杀防超卖和超买还要考虑对下单接口根据用户上锁 通过 lock和unlock方法实现超买问题 因为除了要控制库存不为负数 订单总量库存不超过库存阈值 还要考虑单用户秒杀并发可能会涉及一个用户单个商品出现多笔订单 导致未正常控制住单用户限制下单购买的商品数量。
也就是 对某用户进行上锁 例如下单请求 对用户id进行锁定 必须上一个请求结束 才能继续下一个请求 避免整个过程存在并发查询该用户已购买数导致判断失误造成【超买】
5.我们线上一直在用 当然如果你使用不放心建议做好测试 如果发现有什么遗漏的bug问题 欢迎一起探讨
6.对于redis连接时间限制 我得建议是尽量不要做限制或者时间更长一些
如果有什么不完善的地方 希望提出一起讨论