秒杀解决方案

秒杀系统的特点/难点

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 
                       ^
      编译中断。
      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;
              }
          }
          ...
      }
      
    • 压测

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

你可能感兴趣的:(秒杀解决方案)