秒殺系統(tǒng)的特點/難點
1. 訪問量突然增大
突然增加的訪問量可能導致原有商城系統(tǒng)響應不過來而崩潰
解決方案:將秒殺活動獨立部署在另外的機器上面
2. 帶寬問題
假如商品頁面的大小為1M,這時有10000個用戶并發(fā),那消耗的帶寬就是10G,遠遠超過平時的帶寬
解決方案:提前將商品頁面緩存在CDN中,可以自己搭建或者直接購買第三方平臺的
自己搭建CND可以參考這里:nginx + squid 實現(xiàn)CDN加速
3. 有大部分的請求不會生成訂單
既然是秒殺,就意味著不是所有的請求都能成功下單,可以直接在接入層過濾掉大部分的請求
解決方案:在接入層(nginx)做漏桶限流,減輕應用層(PHP、MySQL)的流量壓力
4. 請求負載大
- 使用隊列,將所有請求放入隊列中,由另一個腳本按照順序一個一個的處理
- 負載均衡,使用nginx反向代理實現(xiàn)負載均衡,將請求分發(fā)到不同的機器上,平攤流量
- 接入層限流 + 配置中心限流實現(xiàn)過載保護,用nginx實現(xiàn)限流,攔截大部分請求,降低服務器壓力,保護服務不被擊潰
5. 超賣問題
一旦存在并發(fā),就很有可能會產(chǎn)生超賣問題,而且這個問題很嚴重,必須要解決。
解決方案:
-
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(); } -
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 '庫存不足,搶購失敗'; } -
PHP + 隊列
將請求序列化存入隊列,由另一個腳本排著隊挨個執(zhí)行
優(yōu)點:降低了MySQL的壓力
缺點:這種方式每次只處理一個請求,反而降低了程序的并發(fā)量
-
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 '搶購失敗'; } -
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 '搶購失敗'; } -
Nginx結合Lua做漏桶限流 + Redis樂觀鎖(最優(yōu)方案)
這種方案是最優(yōu)方案,直接繞過應用層,在接入層實現(xiàn)限流和防止超賣的操作,只消耗很少的服務器性能,但是可抗并發(fā)量級特別大,性能上遠超上述幾種方案。
邏輯分析:先使用 Nginx+Lua 漏桶算法過濾掉大部分請求,再使用Lua連接Redis,使用Redis樂觀鎖的方式控制庫存。假設只有10個秒殺商品,那這里就過濾掉其他,只保留10個請求進入應用層(PHP和MySQL),應用層不需要進行其他操作,直接操作數(shù)據(jù)庫就可以
實操演示:
-
安裝 LuaJIT
選擇 LuaJIT 而不是標準 Lua 的原因:
- LuaJIT 的運行速度比標準 Lua 快數(shù)十倍,可以說是一個 Lua 的高效率版本
- 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),前十個是成功下單的,從第十一個開始就會返回沒搶到的信息
-
