Nginx+lua+redis秒杀实现

秒杀设计

秒杀特点

时间短, 并发访问量大

秒杀系统独立部署, 独立域名, 不影响现有系统业务

前端优化问题

1
2
3
4
5
秒杀页面有大量访问, 需要做限制
- 按钮置灰, 时间不到不发送请求(后端需要双重验证)
- 用户点击请求后, 按钮置灰, 防止重复提交
- 限制用户在n秒内只能提交一次
- 前端缓存前端资源, 刷新时从缓存中获取数据

带宽问题

提前购买或租借新增网络带宽 ,减轻服务器带宽压力, 并推荐租用CDN服务

超卖问题

下单数大于库存数, 导致超卖

主流解决方案

1.mysql悲观锁

即使用MySQL悲观锁实现数据的一致性和排它性

1
mysql> select * from t_goods where id = 1 for update;

但会导致MySQL性能问题, 存在大量锁等待

2.mysql乐观锁

引入MySQL版本号的概念, 实现代码层的数据一致

1
mysql> update goods set quantity = quantity - 1,version = version - 1 where id = 1 and version = {$version};

执行成功的, 代表秒杀成功, 继续执行订单操作,

SQL执行失败, 则代表秒杀失败

但由于需要在数据库中操作, 大量连接会给MySQL造成压力

3.PHP队列

引入队列, 将所有请求序列化放入队列, 依次执行

但会导致执行时间过长

4.PHP+Redis分布式锁

引入Redis分布式锁, 锁定PHP线程, 只有抢到锁的线程会执行, 其余线程空转等待

Redis特性如下:

image-20211114002141949

即setnx只会给第一个用户赋予, 返回1成功, 后续用户无法设置成功, 返回0失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

$redis = new Redis();
$redis->connect('127.0.0.1','6379');

$expire = 10;
$key = 'lock_key';
$value = time()+$expire;
$status = true;

while($status){
$lock = $redis->setnx($key, $value);
if (empty($lock)){
usleep(20);
$value = $redis->get($key);
if ($value < time()){
$redis->del($key);
}
}else{
$status = false;
secKillSuccess($redis, $key);
}
}

function secKillSuccess(Redis $redis, $key){
$redis->del($key);
echo 'kill success in ' . date(time());
}

该方案弊端在于PHP线程空转导致PHP进程激增, 直到上限

5.PHP+Redis乐观锁

打断机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

$redis = new Redis();
$redis->connect('127.0.0.1','6379');

$key = 'lock_optimistic';
$number = 10;

$redis->watch($key); // 乐观锁, 监视key变化时,回滚当前事务
$sales = $redis->get($key);

if ($sales >= $number){
exit('结束秒杀');
}

// 开启redis事务
$redis->multi();
$redis->incr($key);
$result = $redis->exec();

if ($result){
secKillSuccess();
}else{
exit('抢购失败');
}

function secKillSuccess(){
echo 'kill success in ' . date(time());
}

压测结果如下

image-20211114023236143

存在序列优先但被打断的案例, 非真实的优先排列

使用方案

nginx+lua+redis

1.前端资源静态化, 使用cdn

2.后端提交请求处理

​ 接入层使用nginx漏桶限流, nginx+lua+redis实现乐观锁, 解决超卖问题

​ 应用层只需处理设定秒杀量的请求, 利用缓存+队列+分布式+分库分表处理订单

安装nginx_lua扩展

具体实现

nginx.conf

1
2
3
4
5
6
7
8
9
http {
lua_shared_dict my_limit_req_store 100m; # 设置lua共享内存
server {
location /sec_kill{
default_type 'text/html';
content_by_lua_file /usr/local/openresty/nginx/lua_script/lua_sec_kill.lua;
}
}
}

lua_sec_kill.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
local request_method = ngx.var.request_method
local args = nil
local param = nil

-- 处理请求参数
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

local user_id = args["user_id"]

-- 关闭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.say('Redis server close failed: ', err)
end
end

-- 限流
local limit_req = require "resty.limit.req"
-- 设置速率为50个请求/每秒, 漏桶容量为1000个请求
local lim,err = limit_req.new("my_limit_req_store", 50, 1000)
if not lim then
ngx.say('Failed to instantiate a resty.limit.req object:',err)
return
end

local key = ngx.var.remote_addr
ngx.say('key is: ',key,'<br/>')
local delay,err = lim:incoming(key, true)
ngx.say('delay is: ', delay,'<br/>')

-- 1000以外的请求全部溢出拒绝掉
if not delay then
if err == 'rejected' then
return ngx.say('1000 over, spill them all!')
end
ngx.log(ngx.ERR, 'failed to limit req: ',err)
ngx.say(ngx.ERR, 'failed to limit req: ',err,'<br/>')
return ngx.exit(502)
end

if delay>10 then
ngx.say('delay time over time!','<br/>')
return
end


-- Redis中添加sku_num键(商品数量), 并添加watch_key(乐观锁用)
-- 连接Redis服务
local redis = require 'resty.redis'
local redis_instance = redis:new()

redis_instance:set_timeout(1000)
local ok,err = redis_instance:connect("192.168.253.135",6379)
if not ok then
ngx.say('failed to connection with redis',err,'<br/>')
return
end

-- 秒杀逻辑
local resp, err = redis_instance:get('sku_num')
resp = tonumber(resp)
ngx.say('goods number sku_number is : ', resp, '<br/>')
if resp>0 then
ngx.say('sec_kill success','<br/>')
redis_instance:watch('watch_key')
ngx.sleep(1)
local ok,err = redis_instance:multi();
local sku_num = resp - 1
ngx.say('goods number sku_number now is : ', sku_num, '<br/>')
redis_instance:set('sku_num', sku_num)
redis_instance:set('watch_key', 1)
ans,err = redis_instance:exec()
ngx.say('Redis response is : ', tostring(ans), '--', '<br/>')
if tostring(ans) == 'userdata: NULL' then
ngx.say('purchase failed, please earlier next', '<br/>')
return
else
ngx.say('purchase success!', '<br/>')
return
end
else
ngx.say('sec_kill failed!', '<br/>')
end

-- 执行下单
-- ngx.exec('/create_order')

查看结果

image-20220614124541176

image-20220614124518241