秒杀系统的特点/难点
1. 访问量突然增大
突然增加的访问量可能导致原有商城系统响应不过来而崩溃
解决方案:将秒杀活动独立部署在另外的机器上面
2. 带宽问题
假如商品页面的大小为1M,这时有10000个用户并发,那消耗的带宽就是10G,远远超过平时的带宽
解决方案:提前将商品页面缓存在CDN中,可以自己搭建或者直接购买第三方平台的
自己搭建CND可以参考这里:nginx + squid 实现CDN加速
3. 有大部分的请求不会生成订单
既然是秒杀,就意味着不是所有的请求都能成功下单,可以直接在接入层过滤掉大部分的请求
解决方案:在接入层(nginx)做漏桶限流,减轻应用层(PHP、MySQL)的流量压力
4. 请求负载大
- 使用队列,将所有请求放入队列中,由另一个脚本按照顺序一个一个的处理
- 负载均衡,使用nginx反向代理实现负载均衡,将请求分发到不同的机器上,平摊流量
- 接入层限流 + 配置中心限流实现过载保护,用nginx实现限流,拦截大部分请求,降低服务器压力,保护服务不被击溃
5. 超卖问题
一旦存在并发,就很有可能会产生超卖问题,而且这个问题很严重,必须要解决。
解决方案:
-
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(); }
-
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 '库存不足,抢购失败'; }
-
PHP + 队列
将请求序列化存入队列,由另一个脚本排着队挨个执行
优点:降低了MySQL的压力
缺点:这种方式每次只处理一个请求,反而降低了程序的并发量
-
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 '抢购失败'; }
-
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 '抢购失败'; }
-
Nginx结合Lua做漏桶限流 + Redis乐观锁(最优方案)
这种方案是最优方案,直接绕过应用层,在接入层实现限流和防止超卖的操作,只消耗很少的服务器性能,但是可抗并发量级特别大,性能上远超上述几种方案。
逻辑分析:先使用 Nginx+Lua 漏桶算法过滤掉大部分请求,再使用Lua连接Redis,使用Redis乐观锁的方式控制库存。假设只有10个秒杀商品,那这里就过滤掉其他,只保留10个请求进入应用层(PHP和MySQL),应用层不需要进行其他操作,直接操作数据库就可以
实操演示:
-
安装 LuaJIT
选择 LuaJIT 而不是标准 Lua 的原因:
- LuaJIT 的运行速度比标准 Lua 快数十倍,可以说是一个 Lua 的高效率版本
- 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; } } ... }
-
压测
可以发现,前十个是成功下单的,从第十一个开始就会返回没抢到的信息
-