秒杀场景下Redis如何防止商品被超卖

本文代码见https://github.com/littlestar1998/flashsale.git

假设我们有20个产品放在在Redis的goods_count key中。

buy.php

require './vendor/autoload.php';
$redis = new Predis\Client(['host'=>'redis']);
$redis->setnx('goods_count',20);//设置库存为20,setnx表示没有此key的时候设置此key=>value如果有此key则忽略本次操作
if ( $redis->get('goods_count') > 0 ) {
    $redis->decr('goods_count');//将value -1
}

使用ab测试下来看似没有问题,但是实际业务真的是这样吗?

正常情况下,查询goods_count后还会有很多操作占用一点时间去处理。最终才会decr操作将库存-1。

我们这里使用sleep函数来模拟业务操作。

require './vendor/autoload.php';
$redis = new Predis\Client(['host'=>'redis']);
$redis->setnx('goods_count',20);//设置库存为20,setnx表示没有此key的时候设置此key=>value如果有此key则忽略本次操作
if ( $redis->get('goods_count') > 0 ) {
    sleep(1);//模拟业务处理时间
    $redis->decr('goods_count');//将value -1
}

用ab模拟50个并发模拟操作。

ab -c 50 -n 300 http://127.0.0.1:8820/buy.php
结果发生了超卖

虽然Redis是单线程原子操作,但是和业务结合起来的时候并不是原子操作。这里有个先后问题。即使没有sleep也会有这样的问题的,只是sleep将此问题放大了,业务处理时间越长并发量越大,问题越严重。

那我们该如何解决呢?

其实Redis也提供了类似mySql的事务操作,我们可以将代码修改成如下。

require './vendor/autoload.php';
$redis = new Predis\Client(['host'=>'redis']);
$redis->setnx('goods_count',20);//设置库存为20,setnx表示没有此key的时候设置此key=>value如果有此key则忽略本次操作
$stock_count = $redis->get('goods_count');
$redis->watch('goods_count');
$redis->multi();
if ( $stock_count > 0 ) {
    sleep(1);//模拟业务处理时间
    $redis->decr('goods_count');//将value -1
    if ( $redis->exec() ) {
        echo "Success";
    } else {
        echo "Fail";
    }
} else {
    echo "No stock";
}

watch命令监控key的变动,如果发生变动执行事务时直接返回false,类似乐观锁的版本号,更新时候版本号不正确将不能更新。

multi命令的作用是开启事务,告诉Redis,我下面执行的命令都是一组操作,等我提交后你再一并执行。

通过ab测试100个并发下多次测试并没有再出现问题。

如果在开启事务之后再去get操作将会得到QUEUED结果,表示已经在队列中,所以必须在开启事务之前去获取库存数量。

那么这样真的就可以了吗?

该方案还有如下几个问题。

  1. 多条Redis命令没有实现原子性。
  2. 每个用户都会产生多次Redis连接。
  3. 上面方案中使用的sleep模拟业务代码和抢购代码耦合在一起不利于维护。

对于第一、第二个问题我们可以使用Redis + Lua脚本解决,Redis 2.6+支持Lua脚本来扩展,可以将抢购的操作封装成Lua script去执行该脚本。

我看很多文章说redis + lua 可以实现事务,其实lua脚本只能实现原子性,并不能完全实现事务,(如果是输入命令出处是可以实现的,但是如果是执行时候出错是不能实现事务)所谓的原子性其实就是解决多个指令并发问题,比如上面我们先查询库存再去扣减,这样并不是原子性,其实是两个操作。如果使用lua来写,那么这两个操作就是成原子性变成一次操作。

Lua代码如下,使用decrby来扣除指定的库存。

local key_name = KEYS[1]
local decrby_count = tonumber(ARGV[1])
local goods_count = tonumber(redis.call("get",key_name))

if goods_count > 0 then
    redis.call("decrby",key_name,decrby_count)
    return tonumber(redis.call("get",key_name))
else
    return 0
end

修改后的PHP代码

require './vendor/autoload.php';
$redis = new Predis\Client(['host'=>'redis']);
$redis->setnx('goods_count',20);//设置库存为20,setnx表示没有此key的时候设置此key=>value如果有此key则忽略本次操作
$stock_count = $redis->eval(file_get_contents('flashsale.lua'),1,"goods_count","1");
if ( $stock_count > 0 ) {
    sleep(1);
} else {
    echo "No stock".PHP_EOL;
}
Server Software:        nginx/1.17.5
Server Hostname:        127.0.0.1
Server Port:            8820

Document Path:          /buy.php
Document Length:        0 bytes

Concurrency Level:      50
Time taken for tests:   18.707 seconds
Complete requests:      300
Failed requests:        251
   (Connect: 0, Receive: 0, Length: 251, Exceptions: 0)
Total transferred:      51759 bytes
HTML transferred:       2259 bytes
Requests per second:    16.04 [#/sec] (mean)
Time per request:       3117.794 [ms] (mean)
Time per request:       62.356 [ms] (mean, across all concurrent requests)
Transfer rate:          2.70 [Kbytes/sec] received

这时候QPS达到了16。

第三个问题我们可以使用消息队列解决,将抢购成功的用户push到消息队列里面消费,这样可以避免因为处理业务导致抢购服务器超时。

这里我们使用Redis的List来实现消息队列,其实不太建议这样使用,消息队列还是使用专业的软件来处理吧比如RabbitMQ、Active MQ等…这里只是模拟。

require './vendor/autoload.php';
$redis = new Predis\Client(['host'=>'redis']);
$stock_count = $redis->eval(file_get_contents('flashsale.lua'),1,"goods_count","1");
if ( $stock_count > 0 ) {
    $redis->rpush('goods_list',['email'=>'liuboserehi@gmail.com','good_id'=>88]);
} else {
    echo "No stock".PHP_EOL;
}
Server Software:        nginx/1.17.5
Server Hostname:        127.0.0.1
Server Port:            8820

Document Path:          /buy.php
Document Length:        0 bytes

Concurrency Level:      50
Time taken for tests:   9.146 seconds
Complete requests:      300
Failed requests:        281
   (Connect: 0, Receive: 0, Length: 281, Exceptions: 0)
Total transferred:      52029 bytes
HTML transferred:       2529 bytes
Requests per second:    32.80 [#/sec] (mean)
Time per request:       1524.401 [ms] (mean)
Time per request:       30.488 [ms] (mean, across all concurrent requests)
Transfer rate:          5.56 [Kbytes/sec] received

再压测一遍QPS达到了32.

redis除了使用eval执行lua外还可以使用evalsha来执行。他们之间的区别是eval执行的是lua命令,evalsha是执行一段sha的哈希值,该hash值是先Load再去执行的。可以使用evalsha再来优化下。

使用script load脚本

redis-cli script load "$(cat flashsale.lua)"

这是会返回一个hash值,将此hash值填入到evalsha函数中。

require './vendor/autoload.php';
$redis = new Predis\Client(['host'=>'redis']);
$stock_count = $redis->evalsha('64fd09add863eb1a159fcec282e2a5e7349f1970',1,"goods_count","1");
if ( $stock_count > 0 ) {
    $redis->rpush('goods_list',['email'=>'liuboserehi@gmail.com','good_id'=>88]);
} else {
    echo "No stock".PHP_EOL;
}
Server Software:        nginx/1.17.5
Server Hostname:        127.0.0.1
Server Port:            8820

Document Path:          /buy.php
Document Length:        0 bytes

Concurrency Level:      50
Time taken for tests:   8.171 seconds
Complete requests:      300
Failed requests:        281
   (Connect: 0, Receive: 0, Length: 281, Exceptions: 0)
Total transferred:      52029 bytes
HTML transferred:       2529 bytes
Requests per second:    36.71 [#/sec] (mean)
Time per request:       1361.865 [ms] (mean)
Time per request:       27.237 [ms] (mean, across all concurrent requests)
Transfer rate:          6.22 [Kbytes/sec] received

压测后的数据有稍微的提升。

以上就是秒杀架构大体内容,如果你有更好的方法可以给我留言。

此条目发表在笔记分类目录。将固定链接加入收藏夹。

发表评论

电子邮件地址不会被公开。 必填项已用*标注