秒杀解决方案

秒杀系统的特点/难点

1. 访问量突然增大

突然增加的访问量可能导致原有商城系统响应不过来而崩溃

解决方案:将秒杀活动独立部署在另外的机器上面

2. 带宽问题

假如商品页面的大小为1M,这时有10000个用户并发,那消耗的带宽就是10G,远远超过平时的带宽

解决方案:提前将商品页面缓存在CDN中,可以自己搭建或者直接购买第三方平台的

自己搭建CND可以参考这里:nginx + squid 实现CDN加速

3. 有大部分的请求不会生成订单

既然是秒杀,就意味着不是所有的请求都能成功下单,可以直接在接入层过滤掉大部分的请求

解决方案:在接入层(nginx)做漏桶限流,减轻应用层(PHP、MySQL)的流量压力

4. 请求负载大
  1. 使用队列,将所有请求放入队列中,由另一个脚本按照顺序一个一个的处理
  2. 负载均衡,使用nginx反向代理实现负载均衡,将请求分发到不同的机器上,平摊流量
  3. 接入层限流 + 配置中心限流实现过载保护,用nginx实现限流,拦截大部分请求,降低服务器压力,保护服务不被击溃
5. 超卖问题

一旦存在并发,就很有可能会产生超卖问题,而且这个问题很严重,必须要解决。

解决方案:

  1. MySQL悲观锁

    使用MySQL的锁机制,在查询库存时加排它锁,阻止其他事务对这条数据进行加锁或者修改

    优点:使用MySQL事务锁机制,准确度高

    缺点:比较耗性能,对MySQL的压力比较大

    示例:

    DB::beginTransaction();
    try {
        $stock = Skill::query()->where('id', $id)->lockForUpdate()->value('stock');
        if ($stock > 0) {
            Skill::query()->where('id', $id)->decrement('stock');
            echo '抢购成功';
        } else {
            echo '库存不足,抢购失败';
        }
        DB::commit();
    } catch (\Exception $e) {
        echo $e->getMessage();
        DB::rollBack();
    }
    
  2. MySQL乐观锁

    乐观锁其实就是不加锁实现锁的效果。MySQL的乐观锁就是MVCC机制,借助version版本号进行控制

    优点:因为不涉及到锁数据,所以它的并发量会比加悲观锁强一些

    缺点:虽然不锁数据,但是还是基于MySQL来实现的,这就意味着他要受到MySQL抗压瓶颈的影响

    示例:

    $info = Skill::query()->where('id', $id)->first(['stock', 'version']);
    if ($info->stock > 0) {
     $skill = Skill::query()->where(['id' => $id, 'version' => $info->version])->update(['stock' => $info->stock - 1, 'version' => $info->version + 1]);
     echo '抢购成功';
    } else {
     echo '库存不足,抢购失败';
    }
    
  3. PHP + 队列

    将请求序列化存入队列,由另一个脚本排着队挨个执行

    优点:降低了MySQL的压力

    缺点:这种方式每次只处理一个请求,反而降低了程序的并发量

  4. PHP + Redis分布式锁

    Redis分布式锁就是线程锁,通过锁线程来实现,同时只允许一个线程执行,其它线程进入等待状态

    优点:既降低了MySQL压力,又比队列的方式并发性更高

    缺点:因为线程需要排队等待,所以并发量级也不是特别的高

    示例:

    $key = "test:lock:".$id;
    $uuid = Uuid::uuid1()->getHex();
    try {
        $ret = Redis::set($key, $uuid, 'EX', 10, 'NX');
        if (!$ret) {
            usleep(10);
            return $this->test($id);
        }
        $stock = Skill::query()->where('id', $id)->value('stock');
        if ($stock > 0) {
            Skill::query()->where('id', $id)->decrement('stock');
            $msg = '抢购成功';
        } else {
            $msg = '库存不足,抢购失败';
        }
        if (Redis::get($key) == $uuid) {
            Redis::del($key);
        }
        return $msg;
    } catch (\Exception $exception) {
        return '抢购失败';
    }
    
  5. PHP + Redis乐观锁

    Redis的乐观锁就是借助Redis事务和watch监控,采用事务打断的方式实现

    优点:并没有锁定任何资源,多线程可以并行,所以比以上几种性能要更好,并发量级更大

    缺点:这是PHP层面的控制,而PHP也是有性能瓶颈的

    示例:

    $key = 'stock:'.$id;
    Redis::watch($key);
    $stock = Redis::get($key);
    if (is_null($stock)) {
     return '没有商品';
    }
    if ($stock == 0) {
     return '库存不足';
    }
    Redis::multi();
    Redis::decr($key);
    $res = Redis::exec();
    if ($res) {
     Skill::query()->where('id', $id)->decrement('stock');
     return '抢购成功';
    } else {
     return '抢购失败';
    }
    
  6. Nginx结合Lua做漏桶限流 + Redis乐观锁(最优方案)

    这种方案是最优方案,直接绕过应用层,在接入层实现限流和防止超卖的操作,只消耗很少的服务器性能,但是可抗并发量级特别大,性能上远超上述几种方案。

    逻辑分析:先使用 Nginx+Lua 漏桶算法过滤掉大部分请求,再使用Lua连接Redis,使用Redis乐观锁的方式控制库存。假设只有10个秒杀商品,那这里就过滤掉其他,只保留10个请求进入应用层(PHP和MySQL),应用层不需要进行其他操作,直接操作数据库就可以

    实操演示:

    • 安装 LuaJIT

      选择 LuaJIT 而不是标准 Lua 的原因:

      1. LuaJIT 的运行速度比标准 Lua 快数十倍,可以说是一个 Lua 的高效率版本
      2. LuaJIT 被设计成全兼容标准Lua 5.1, 因此 LuaJIT 代码的语法和标准 Lua 的语法没多大区别

      官网下载地址:https://luajit.org/download.html

      PS:本次使用的不是官网的,是 OpenResty 的,因为使用官网版本启动Nginx时会有个警告,让使用 OpenResty 的,虽然不影响使用,但是强迫症还是改了它。

      # 安装依赖
      yum install readline-devel
      # 下载安装包
      wget https://github.com/openresty/luajit2/archive/refs/tags/v2.1-20210510.tar.gz
      tar -zxvf luajit2-2.1-20210510.tar.gz
      cd luajit2-2.1-20210510
      make && make install
      

      配置 LuaJIT 环境变量

      vi /etc/profile
      
      export LUAJIT_LIB=/usr/local/lib
      export LUAJIT_INC=/usr/local/include/luajit-2.1
      
      source /etc/profile
      

      测试 Lua 脚本

      [root@localhost ~]# vi test.lua
        print("Hello World!")
      [root@localhost ~]# lua test.lua 
      Hello World!
      
    • 安装 ngx_devel_kit 和 lua-nginx-module

      ngx_devel_kit 简称NDK,提供函数和宏处理一些基本任务,减轻第三方模块开发的代码量。

      lua-nginx-module 是Nginx的Lua模块

      wget https://github.com/simpl/ngx_devel_kit/archive/v0.3.1.tar.gz
      tar -zxvf ngx_devel_kit-0.3.1.tar.gz
      # 这里选择v0.10.9rc7这个版本,其他版本在nginx启动时都会有各种坑
      wget https://github.com/openresty/lua-nginx-module/archive/v0.10.9rc7.tar.gz
      tar -zxvf lua-nginx-module-0.10.9rc7.tar.gz
      

      将解压好的文件夹加载到Nginx的模块中,Nginx如何安装就不讲了,这里安装好的版本是 nginx-1.20.1

      # 查看nginx现有的模块,复制configure arguments:后边的内容
      nginx -V
      # 进去nginx安装包目录,重新编译,加上刚才解压的两个目录
      ./configure 上边configure arguments:后边的内容... --add-module=/root/ngx_devel_kit-0.3.1 --add-module=/root/lua-nginx-module-0.10.9rc7
      make && make install
      echo "/usr/local/lib" >> /etc/ld.so.conf
      ldconfig
      

      修改Nginx配置

      vi nginx.conf
      
      server {
              listen  80;
              
              ...
              
             # 加入这段测试代码
              location /lua {
                  set $test "hello,world";
                  content_by_lua '
                      ngx.header.content_type="text/plain"
                      ngx.say(ngx.var.test)
                  ';
              }
      }
      

      重启Nginx后进行访问测试

      [root@localhost conf]# curl 127.0.0.1/lua
      hello,world
      [root@localhost conf]#
      
    • 下载需要用到的模块

      lua-resty-limit-traffic:限流模块

      lua-resty-redis:操作redis模块

      lua-cjson:在lua中操作json数据,方便返回给前端

      mkdir /usr/local/nginx/lua
      cd /usr/local/nginx/lua
      git clone https://github.com/openresty/lua-resty-limit-traffic.git
      git clone https://github.com/openresty/lua-resty-redis.git
      wget https://kyne.com.au/~mark/software/download/lua-cjson-2.1.0.tar.gz
      tar -zxvf lua-cjson-2.1.0.tar.gz
      cd lua-cjson-2.1.0/
      make && make install
      

      编译cjson报错:

      [root@localhost lua-cjson-2.1.0]# make && make install
      cc -c -O3 -Wall -pedantic -DNDEBUG  -I/usr/local/include -fpic -o lua_cjson.o lua_cjson.c
      lua_cjson.c:43:17: 致命错误:lua.h:没有那个文件或目录
       #include <lua.h>
                       ^
      编译中断。
      make: *** [lua_cjson.o] 错误 1
      

      解决:

      [root@localhost lua-cjson-2.1.0]# find / -name lua.h
      /usr/local/include/luajit-2.1/lua.h
      [root@localhost lua-cjson-2.1.0]# vi Makefile
         将 LUA_INCLUDE_DIR =   $(PREFIX)/include
         修改为 LUA_INCLUDE_DIR = /usr/local/include/luajit-2.1
         
       [root@localhost lua-cjson-2.1.0]# make && make install
      

      仍然报错:

      [root@localhost lua-cjson-2.1.0]# make && make install
      cc -c -O3 -Wall -pedantic -DNDEBUG  -I/usr/local/include/luajit-2.1/ -fpic -o lua_cjson.o lua_cjson.c
      lua_cjson.c:1299:1: 错误:对‘luaL_setfuncs’的静态声明出现在非静态声明之后
       {
       ^
      In file included from lua_cjson.c:44:0:
      /usr/local/include/luajit-2.1/lauxlib.h:88:18: 附注:‘luaL_setfuncs’的上一个声明在此
       LUALIB_API void (luaL_setfuncs) (lua_State *L, const luaL_Reg *l, int nup);
                        ^
      make: *** [lua_cjson.o] 错误 1
      

      解决:

      # 直接在Makefile所在的目录执行查找字符串命令
      [root@localhost lua-cjson-2.1.0]# find . -type f -name "*.*" | xargs grep "luaL_setfuncs"
      ./lua_cjson.c: * luaL_setfuncs() is used to create a module table where the functions have
      ./lua_cjson.c:static void luaL_setfuncs (lua_State *l, const luaL_Reg *reg, int nup)
      ./lua_cjson.c:    luaL_setfuncs(l, reg, 1);
      # 发现只有lua_cjson.c 文件中包含上面字符串,所以编辑 lua_cjson.c
      [root@localhost lua-cjson-2.1.0]# vi lua_cjson.c
         直接搜索 luaL_setfuncs,去掉此方法的 static 关键字
         
      # 继续编译就成功了
      [root@localhost lua-cjson-2.1.0]# make && make install
      cc -c -O3 -Wall -pedantic -DNDEBUG  -I/usr/local/include/luajit-2.1/ -fpic -o lua_cjson.o lua_cjson.c
      cc -c -O3 -Wall -pedantic -DNDEBUG  -I/usr/local/include/luajit-2.1/ -fpic -o strbuf.o strbuf.c
      cc -c -O3 -Wall -pedantic -DNDEBUG  -I/usr/local/include/luajit-2.1/ -fpic -o fpconv.o fpconv.c
      cc  -shared -o cjson.so lua_cjson.o strbuf.o fpconv.o
      mkdir -p //usr/local/lib/lua/5.1
      cp cjson.so //usr/local/lib/lua/5.1
      chmod 755 //usr/local/lib/lua/5.1/cjson.so
      
    • 完整的 Lua 脚本示例

      vi /usr/local/nginx/lua/seckill.lua

      -------------------------- 定义json -------------------------------------
      -- 引入 cjson 模块,操作json数据
      local cjson = require "cjson"
      local cjson_req = cjson.new()
      local ret_object = {["code"] = 999, ["msg"] = "很遗憾,手慢了,没抢到"}
      ret_json = cjson_req.encode(ret_object)
      
      -------------------------- 漏桶限流 -------------------------------------
      -- 引入 nginx-lua 限流模块
      local limit_req = require "resty.limit.req"
      -- 每秒立即处理的请求数
      local rate = 50
      -- 漏桶的最大容量
      local capacity = 1000
      -- 限制请求在每秒 rate 次以下并且并发请求每秒 capacity 次
      -- 也就是延迟处理每秒 rate 次以上 capacity 次以内的请求
      -- 每秒超过 rate+capacity 次的请求会直接 reject 拒绝掉
      -- my_limit_req_store 为共享内存区域名称
      local lim, err = limit_req.new("my_limit_req_store", rate, capacity)
      if not lim then
          ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
          return ngx.exit(500)
      end
      -- 每个请求,都获取客户端的IP来作为限制的 key
      local key = ngx.var.binary_remote_addr
      -- 获取每个请求的等待时长,这个时长是通过 resty.limit.req 模块计算出来的
      local delay, err = lim:incoming(key, true)
      if (delay < 0 or delay == nil) then
          return ngx.exit(500)
      end
      -- 大于 capacity 以外的就溢出
      if not delay then
          if err == "rejected" then
              return ngx.exit(500)
          end
          ngx.log(ngx.ERR, "failed to limit req: ", err)
          return ngx.exit(500)
      end
      -- 如果等待时长超过10s,直接返回超时
      if (delay > 10) then
          ngx.say(ret_json)
          return
      end
      
      -------------------------- 实现redis乐观锁 -------------------------------------
      -- 设置关闭redis的函数,在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.log(ngx.ERR, "close redis error: ", err)
              return
          end
      end
      -- 引入 redis 模块
      local redis = require("resty.redis");
      -- 创建一个redis对象实例
      local redis_instance = redis:new()
      -- 设置超时时间,单位毫秒
      redis_instance:set_timeout(1000)
      -- 建立连接
      local host = "192.168.241.111"
      local port = 6379
      local pass = "root"
      -- 尝试连接到redis服务器正在侦听的远程主机和端口
      local ok, err = redis_instance:connect(host, port)
      if not ok then
          ngx.log(ngx.ERR, "connect redis error: ", err)
          return close_redis(redis_instance);
      end
      -- Redis身份验证
      local auth, err = redis_instance:auth(pass);
      if not auth then
          ngx.log(ngx.ERR, "redis failed to authenticate: ", err)
          return close_redis(redis_instance);
      end
      -- 获取请求参数
      local request_method = ngx.var.request_method
      local args, param
      if request_method == "GET" then
          args = ngx.req.get_uri_args()
      elseif request_method == "POST" then
          ngx.req.read_body()
          args = ngx.req.get_post_args()
      end
      -- 可通过 args["user_id"] 获取请求的用户id,进行身份等逻辑判断,此处略过
      
      -- 从redis中取出当前请求商品sku的库存
      local redis_key = "sku:"..args["sku_id"]..":stock"
      local stock = tonumber(redis_instance:get(redis_key))
      -- 实现redis乐观锁
      if (stock > 0) then
          redis_instance:watch(redis_key)
          redis_instance:multi()
          redis_instance:decr(redis_key)
          local ans = redis_instance:exec()
          if (tostring(ans) == "userdata: NULL") then
              return ngx.say(ret_json)
          end
      else
          return ngx.say(ret_json)
      end
      -- 抢购成功,进入下单流程
      -- 注意:这行代码前面不能执行 ngx.say()
      ngx.exec("/create_order")
      
    • 在 nginx.conf 中的配置

      ...
      http {
         ...
         # 设置共享内存区域,大小为100M
         lua_shared_dict my_limit_req_store 100m;
         # 设置Lua扩展库的搜索路径(';;' 表示默认路径)
          lua_package_path "/usr/local/nginx/lua/lua-resty-limit-traffic/lib/?.lua;;/usr/local/nginx/lua/lua-resty-redis-master/lib/?.lua;;";
          
          server {
             listen       80;
             ...
             # 限流及控制库存
             location /seckill {
                 # 可有可无
                  default_type 'application/x-javascript;charset=utf-8';
                  # 引入lua脚本
                  content_by_lua_file /usr/local/nginx/lua/seckill.lua;
              }
              # 下订单
              location /create_order {
                 # 只允许本地访问
                 allow   127.0.0.1;
                 deny    all;
                 # 反向代理到真实下单的接口
                 proxy_pass   http://192.168.241.150/api/create_order;
              }
          }
          ...
      }
      
    • 压测

      可以发现,前十个是成功下单的,从第十一个开始就会返回没抢到的信息

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,907评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,546评论 1 289
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,705评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,624评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,940评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,371评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,672评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,396评论 0 195
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,069评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,350评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,876评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,243评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,847评论 3 231
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,004评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,755评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,378评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,266评论 2 259

推荐阅读更多精彩内容