秒杀业务实战

文章详细介绍了秒杀系统面临的挑战,如并发访问、带宽问题和超卖问题,并提出了一系列解决方案。包括独立部署秒杀系统、前端优化如按钮禁用和请求限制、使用CDN缓解带宽压力。在技术方案中,讨论了MySQL的悲观锁和乐观锁,以及PHP结合Redis的队列和分布式锁策略,推荐了Redis乐观锁作为有效手段。最后,文章展示了秒杀架构图及接入层解决超卖问题的实操代码,并进行了压力测试验证效果。
摘要由CSDN通过智能技术生成

1 秒杀是什么?

常见的场景比如100000人在同一秒抢一个手机。比如12:00:00抢购, 12:00:01活动就结束了

2 秒杀系统需要解决什么问题?

2.1 突然多了很多访问,可能导致原有商城瘫痪

秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。

解决方案:将秒杀系统独立部署,独立域名。

小插曲:前端如何优化:

首先要有一个展示秒杀商品的页面,在这个页面上做一个秒杀活动开始的倒计时,在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面。这里需要考虑两个问题:

秒杀前按钮是灰的,不能发送请求, 当然后端肯定也是要有判断。

产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求;

JS层面,限制用户在x秒之内只能提交一次请求;

前端缓存,当用户一直刷新页面的时候, 前端可以到浏览器里面获取缓存数据。

2.2 带宽问题

假设商品页面大小1M(主要是商品图片大小),那么10000用户并发,需要的网络带宽是:10G(1M×10000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。

解决方案:因为秒杀新增的网络带宽,必须和运营商重新购买或者租借。为了减轻网站服务器的压力,需要将秒杀商品页面缓存在CDN,同样需要和CDN服务商临时租借新增的出口带宽。

2.3 有大部分请求不会生成订单

接入层(nginx)漏桶限流。真正进入php和mysql等应用层的流量极少,大多被过滤。

2.4 超卖问题

秒杀商品的数量是有限的。

超卖问题由来:

image-20230304172951937

假设库存只剩下1件, 现在2个人同时过来抢

if(库存数量 >= 下单数){ 
	可以购买 购买成功,然后把库存数量减少 
}else{
	不能购买 
}

上面这个是会超卖的,不并发的时候是可以的, 万一并发,2个人都会抢购成功

3 秒杀难点行业主流解决方案

3.1 土豪的做法

提升配置,传说中的技术不够拿钱揍

买更多的服务器、负载均衡,不过,真没必要!

3.2 行业主流技术方案

3.2.1 mysql悲观锁

悲观锁,正如其名,它指的是对数据被外界(包括当前系统的其它事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排它性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。

演示一番,数据准备

DROP TABLE IF EXISTS employee; 
CREATE TABLE IF NOT EXISTS employee ( 
  id INTEGER NOT NULL, 
  money INTEGER, 
  VERSION INTEGER, 
  PRIMARY KEY (id) 
) 
ENGINE = INNODB; 
INSERT INTO employee VALUE (1, 0, 1); 
SELECT * FROM employee; 
SET autocommit=0

开2个客户端:

客户端1:

SET autocommit=0;
SELECT * FROM employee WHERE id = 1 FOR UPDATE;		

之后,在客户端2:

SELECT * FROM employee WHERE id = 1 FOR UPDATE;

image-20230304194525602

如上图,客户端2超时自闭了。

3.2.2 mysql乐观锁

乐观锁认为一般情况下数据不会造成冲突,所以在数据进行提交更新时才会对数据的冲突与否进行检测。如果没有冲突那就OK;如果出现冲突了,则返回错误信息并让用户决定如何去做。

乐观锁在数据库上的实现完全是逻辑的,数据库本身不提供支持,而是需要开发者自己来实现

update items set quantity=quantity-1,version=version+1 where id=100 and version=#{version};
<?php 
  $version = mysqlquery(SELECT VERSION FROM employee) 
  #这里写业务逻辑 #省略 
  mysqlquery("UPDATE employee SET money = 1, VERSION=VERSION+1 WHERE VERSION=$version")

注意,上面这个只是用来表达意思的代码,不是有效的。

成功的那个,mysql会返回更新成功, php就返回前端: 恭喜你,抢购成功

失败的那个,mysql会返回更新失败, php就返回前端: 不好意思,抢购失败

总结:乐观锁不锁数据,而是通过版本号控制,会有不同结果返回给php,把决策权交给php。

对比:乐观锁不需要锁数据,性能高于悲观锁

3.2.3 PHP+队列

将抢购请求任务序列化,大家排好队,一个一个来,不会产生多个线程间的冲突,

3.2.4 PHP+redis分布式锁,以及分布式锁主流优化方案

相当于是php线程锁,100000个抢购请求并发过来,有100000个线程,但同一时刻只会有一个线程在执行业务代码,其它线程都在死循环中等待。

redis 分布式锁与原理:

redis> EXISTS job 							# job 不存在 
(integer) 0 

redis> SETNX job "programmer" 	# job 设置成功 
(integer) 1 

redis> SETNX job "code-farmer" 	# 尝试覆盖 job ,失败 
(integer) 0 

redis> GET job 									# 没有被覆盖 
"programmer"

可见 SETNX和set是有区别的,SETNX只能1次,set可以无数次的。redis分布式锁就是利用了这点来做文章的。

分布式锁示例代码:

$expire = 10;//有效期10秒 
$key = 'lock';//key 
$value = time() + $expire;//锁的值 = Unix时间戳 + 锁的有效期 
$status = true; 
while($status) { 
  $lock = $redis->setnx($key, $value); 
  if(empty($lock)) { 
    $value = $redis->get($key); 
    if($value < time()) {
      $redis->del($key); 
    } 
  }else{
    $status = false; //下步操作.... 
  } 
}

100000个人同时进来这个代码, 始终只有1个人在执行库存等业务操作,其它的都在死循环中等待锁的释放

优化方式:设置更对的锁,比如抢购20个商品,就可以设置20个锁, 100000个人进来, 就有20个线程是在执行业务逻辑的,其它的就在等待。

3.2.5 【推荐】PHP+redis乐观锁 redis watch

<?php 
header("content-type:text/html;charset=utf-8"); 
$redis = new redis(); 
$result = $redis->connect('127.0.0.1', 6379); 

$rob_total = 10; //抢购数量 
if($mywatchkey<$rob_total){ 
  $redis->watch("mywatchkey"); 
  $mywatchkey = $redis->get("mywatchkey"); //这个mywatchkey得先在redis中初始化数据,比如0
  $redis->multi(); 
  //设置延迟,方便测试效果。 
  sleep(5); 
  //插入抢购数据 
  $redis->hSet("mywatchlist","user_id_".mt_rand(1, 9999),time()); 
  $redis->set("mywatchkey",$mywatchkey+1); 
  $rob_result = $redis->exec(); 
  if($rob_result){ 
    $mywatchlist = $redis->hGetAll("mywatchlist"); 
    echo "抢购成功!"; 
    echo "剩余数量:".($rob_total-$mywatchkey-1).""; 
    echo "用户列表:"; 
    var_dump($mywatchlist); 
  }else{ 
    echo "手气不好,再抢购!";
    exit; 
  } 
}

核心代码如下:

$redis->watch("mywatchkey"); //声明一个乐观锁 
$mywatchkey = $redis->get("mywatchkey") //获取版本号
$redis->multi(); //redis事务开始 
$redis->set("mywatchkey",$mywatchkey+1); //乐观锁的版本号+1 
$rob_result = $redis->exec();//redis事务提交

优点如下:

  1. 首先选用内存数据库来抢购速度极快。

  2. 速度快并发自然没不是问题。

  3. 使用悲观锁,会迅速增加系统资源。

  4. 比队列强的多,队列会使你的内存数据库资源瞬间爆棚。

  5. 使用乐观锁,达到综合需求。

4 秒杀架构的实现

4.1 架构图

image-20230304204657379

客户端→代理层→应用层→数据库→压力测试:

客户端 90% 静态 HTML+10% 动态 JS;配合 CDN 做好缓存工作。

接入层专注于过滤和限流。

应用层利用缓存+队列+分布式+分库分表处理好订单。

做好数据的预估,隔离,合并。

4.2 接入层解决超卖问题的实操代码

--获取get或post参数--------------------
local request_method = ngx.var.request_method
local args = nil
local param = nil
--获取参数的值
--获取秒杀下单的用户id
if "GET" == request_method then
    args = ngx.req.get_uri_args()
elseif "POST" == request_method then
    ngx.req.read_body()
    args = ngx.req.get_post_args()
end
user_id = args["user_id"]
--用户身份判断--省略
--用户能否下单--省略

--关闭redis的函数--------------------
local function close_redis(redis_instance)
    if not redis_instance then
        return
    end
    local ok,err = redis_instance:close();
    if not ok then
        ngx.say("close redis error : ",err);
    end
end

--引入cjson类--------------------
--local cjson = require "cjson"


--连接redis--------------------
local redis = require("resty.redis");
--local redis = require "redis"
-- 创建一个redis对象实例。在失败,返回nil和描述错误的字符串的情况下
local redis_instance = redis:new();
--设置后续操作的超时(以毫秒为单位)保护,包括connect方法
redis_instance:set_timeout(1000)
--建立连接
local ip = '182.17.22.130'
local port = 6379
--尝试连接到redis服务器正在侦听的远程主机和端口
local ok,err = redis_instance:connect(ip,port)
if not ok then
    ngx.say("connect redis error : ",err)
    return close_redis(redis_instance);
end


-- 加载nginx—lua限流模块
local limit_req = require "resty.limit.req"
-- 这里设置rate=50个请求/每秒,漏桶桶容量设置为1000个请求
-- 因为模块中控制粒度为毫秒级别,所以可以做到毫秒级别的平滑处理
local lim, err = limit_req.new("my_limit_req_store", 50, 1000)
if not lim then
    ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
    return ngx.exit(501)
end

local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)


-- ngx.say("计算出来的延迟时间:",delay)


if ( delay <0 or delay==nil ) then
    return ngx.exit(502)
end


-- 1000以外的就溢出,回绝掉,比如100000个人来抢购,那么100000-1000的请求直接nginx回绝
if not delay then
    if err == "rejected" then
        return ngx.say("1000以外的就溢出")
        -- return ngx.exit(502)
    end
    ngx.log(ngx.ERR, "failed to limit req: ", err)
    return ngx.exit(502)
end

-- 计算出要等很久,比如要等10秒的, 也直接不要他等了。要买家直接回家吃饭去
if ( delay >10) then
    ngx.say("抢购超时")
    return
end

--先到redis里面添加sku_num键(参与秒杀的该商品的数量)
--并到redis里面添加watch_key键(用于做乐观锁之用)
redis_instance:watch("watch_key");
redis_instance:watch("sku_num");
local sku_num, err = redis_instance:get("sku_num")
sku_num = tonumber(sku_num)
if sku_num == nil then
	return ngx.say("请先到redis中初始化秒杀商品数量")
end
ngx.say("商品剩余数量=",sku_num)

local watch_key, err = redis_instance:get("watch_key")
watch_key = tonumber(watch_key)
if watch_key == nil then
	return ngx.say("请先到redis中初始化watch_key")
end
ngx.say("当前的watch_key=",watch_key)

if (sku_num > 0) then
    -- 用来记录执行时间
    local t0 = os.time() 
    local ok, err = redis_instance:multi();
    local sku_num = tonumber(sku_num) - 1;
    ngx.say("减库存后余下sku_num=", sku_num)
    redis_instance:set("sku_num",sku_num);

    watch_key = tonumber(watch_key) + 1
	ngx.say("减库存后的watch_key=",watch_key);
    redis_instance:set("watch_key", watch_key);
    ans, err = redis_instance:exec()

    ngx.say("ans:",ans);
	ngx.say("--")
    ngx.say("tostring(ans):",tostring(ans)); -- 如果事务执行失败,则tostring(ans) == userdata: NULL
	ngx.say("--")
	ngx.say("err:",err)
    ngx.say("--")
    
    if (tostring(ans) == "userdata: NULL") then
        ngx.say("抢购失败,慢一丁点")
        return
    else
        ngx.say("抢购成功")
        local t1 = os.time()
        ngx.say("used time: ",t1-t0,"ms")
        return
        -- return ngx.exec('/create_order'); //实际业务代码,这里要到微服务中创建订单,注意这行代码前面不能执行ngx.say()
    end   
else
    ngx.say("抢购失败,手慢了")
    return
end


--[[
--每个用户限购1个,判断用户是否已经抢购过了的参考代码逻辑思路如下(具体过程略,前端缓存中也有这个类似的判断用于限制对后端的请求):

建一张用于保存已经抢购成功了的用户的redis哈希表

抢购前判断是否在该表中
local res, err = redis_instance:hmget("myhash", "user_id")
抢购成功则保存到该表
local res, err = redis_instance:hmset("myhash", "user_id", "1")
--]]

4.3 压力测试

这里使用jmeter进行压力测试。

4.3.1 创建线程组

image-20230305110949501

image-20230305110706058

我这里的本地服务器,配置不高,就测试1秒钟并发10000个请求吧

4.3.2 添加取样器——HTTP请求

image-20230305111023384

image-20230305110808779

4.3.3 添加监听器

image-20230305111417275

4.3.4 添加断言——响应断言

image-20230305111501227

image-20230305111621504

这样子,当抢购成功,响应文本会包含”抢购成功“,这些请求会被定义为成功请求,否则就是失败请求,以便于检查测试结果,具体看后面的截图,就会更加明白了

4.3.5 开始测试

  1. 先到redis中初始化库存等数据

image-20230305112421537

  1. 执行测试

image-20230305112105399

测试结果如下:

image-20230305112830713

image-20230305112937154

image-20230305113116317

image-20230305113505692

测试结果:

超卖问题已经控制住了

本地机器配置不高,吞吐量接近1000,整体ok

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值