秒殺解決方案

秒殺系統(tǒng)的特點/難點

1. 訪問量突然增大

突然增加的訪問量可能導致原有商城系統(tǒng)響應不過來而崩潰

解決方案:將秒殺活動獨立部署在另外的機器上面

2. 帶寬問題

假如商品頁面的大小為1M,這時有10000個用戶并發(fā),那消耗的帶寬就是10G,遠遠超過平時的帶寬

解決方案:提前將商品頁面緩存在CDN中,可以自己搭建或者直接購買第三方平臺的

自己搭建CND可以參考這里:nginx + squid 實現(xiàn)CDN加速

3. 有大部分的請求不會生成訂單

既然是秒殺,就意味著不是所有的請求都能成功下單,可以直接在接入層過濾掉大部分的請求

解決方案:在接入層(nginx)做漏桶限流,減輕應用層(PHP、MySQL)的流量壓力

4. 請求負載大
  1. 使用隊列,將所有請求放入隊列中,由另一個腳本按照順序一個一個的處理
  2. 負載均衡,使用nginx反向代理實現(xiàn)負載均衡,將請求分發(fā)到不同的機器上,平攤流量
  3. 接入層限流 + 配置中心限流實現(xiàn)過載保護,用nginx實現(xiàn)限流,攔截大部分請求,降低服務器壓力,保護服務不被擊潰
5. 超賣問題

一旦存在并發(fā),就很有可能會產(chǎn)生超賣問題,而且這個問題很嚴重,必須要解決。

解決方案:

  1. MySQL悲觀鎖

    使用MySQL的鎖機制,在查詢庫存時加排它鎖,阻止其他事務對這條數(shù)據(jù)進行加鎖或者修改

    優(yōu)點:使用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樂觀鎖

    樂觀鎖其實就是不加鎖實現(xiàn)鎖的效果。MySQL的樂觀鎖就是MVCC機制,借助version版本號進行控制

    優(yōu)點:因為不涉及到鎖數(shù)據(jù),所以它的并發(fā)量會比加悲觀鎖強一些

    缺點:雖然不鎖數(shù)據(jù),但是還是基于MySQL來實現(xiàn)的,這就意味著他要受到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 + 隊列

    將請求序列化存入隊列,由另一個腳本排著隊挨個執(zhí)行

    優(yōu)點:降低了MySQL的壓力

    缺點:這種方式每次只處理一個請求,反而降低了程序的并發(fā)量

  4. PHP + Redis分布式鎖

    Redis分布式鎖就是線程鎖,通過鎖線程來實現(xiàn),同時只允許一個線程執(zhí)行,其它線程進入等待狀態(tài)

    優(yōu)點:既降低了MySQL壓力,又比隊列的方式并發(fā)性更高

    缺點:因為線程需要排隊等待,所以并發(fā)量級也不是特別的高

    示例:

    $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監(jiān)控,采用事務打斷的方式實現(xiàn)

    優(yōu)點:并沒有鎖定任何資源,多線程可以并行,所以比以上幾種性能要更好,并發(fā)量級更大

    缺點:這是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樂觀鎖(最優(yōu)方案)

    這種方案是最優(yōu)方案,直接繞過應用層,在接入層實現(xiàn)限流和防止超賣的操作,只消耗很少的服務器性能,但是可抗并發(fā)量級特別大,性能上遠超上述幾種方案。

    邏輯分析:先使用 Nginx+Lua 漏桶算法過濾掉大部分請求,再使用Lua連接Redis,使用Redis樂觀鎖的方式控制庫存。假設只有10個秒殺商品,那這里就過濾掉其他,只保留10個請求進入應用層(PHP和MySQL),應用層不需要進行其他操作,直接操作數(shù)據(jù)庫就可以

    實操演示:

    • 安裝 LuaJIT

      選擇 LuaJIT 而不是標準 Lua 的原因:

      1. LuaJIT 的運行速度比標準 Lua 快數(shù)十倍,可以說是一個 Lua 的高效率版本
      2. LuaJIT 被設計成全兼容標準Lua 5.1, 因此 LuaJIT 代碼的語法和標準 Lua 的語法沒多大區(qū)別

      官網(wǎng)下載地址:https://luajit.org/download.html

      PS:本次使用的不是官網(wǎng)的,是 OpenResty 的,因為使用官網(wǎng)版本啟動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 環(huán)境變量

      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,提供函數(shù)和宏處理一些基本任務,減輕第三方模塊開發(fā)的代碼量。

      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現(xiàn)有的模塊,復制configure arguments:后邊的內(nèi)容
      nginx -V
      # 進去nginx安裝包目錄,重新編譯,加上剛才解壓的兩個目錄
      ./configure 上邊configure arguments:后邊的內(nèi)容... --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數(shù)據(jù),方便返回給前端

      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’的靜態(tài)聲明出現(xiàn)在非靜態(tài)聲明之后
       {
       ^
      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所在的目錄執(zhí)行查找字符串命令
      [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);
      # 發(fā)現(xiàn)只有l(wèi)ua_cjson.c 文件中包含上面字符串,所以編輯 lua_cjson.c
      [root@localhost lua-cjson-2.1.0]# vi lua_cjson.c
         直接搜索 luaL_setfuncs,去掉此方法的 static 關鍵字
         
      # 繼續(xù)編譯就成功了
      [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數(shù)據(jù)
      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"
      -- 每秒立即處理的請求數(shù)
      local rate = 50
      -- 漏桶的最大容量
      local capacity = 1000
      -- 限制請求在每秒 rate 次以下并且并發(fā)請求每秒 capacity 次
      -- 也就是延遲處理每秒 rate 次以上 capacity 次以內(nèi)的請求
      -- 每秒超過 rate+capacity 次的請求會直接 reject 拒絕掉
      -- my_limit_req_store 為共享內(nèi)存區(qū)域名稱
      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
      
      -------------------------- 實現(xiàn)redis樂觀鎖 -------------------------------------
      -- 設置關閉redis的函數(shù),在redis使用完后調(diào)用它
      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");
      -- 創(chuàng)建一個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
      -- 獲取請求參數(shù)
      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))
      -- 實現(xiàn)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
      -- 搶購成功,進入下單流程
      -- 注意:這行代碼前面不能執(zhí)行 ngx.say()
      ngx.exec("/create_order")
      
    • 在 nginx.conf 中的配置

      ...
      http {
         ...
         # 設置共享內(nèi)存區(qū)域,大小為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;
              }
          }
          ...
      }
      
    • 壓測

      可以發(fā)現(xiàn),前十個是成功下單的,從第十一個開始就會返回沒搶到的信息

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容