ThinkPHP6 秒杀

秒杀,在这几年业务、面试中的比较常见的,商城、红包、数字藏品是秒杀的主要业务场景。应对的方案有很多,行锁、队列消费等。

使用 Lua 来进行秒杀的实现,原子性是特别重要的一个点,这是我们传统在业务中多次请求 Redis 所很难达到的。

脚本的原子性: Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。

具体的思路可以见代码注释。

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
<?php
namespace app\controller;

use app\BaseController;

class Seckill extends BaseController
{
/**
* 商品ID,测试需要
*
* @var int
*/
protected $id;

/**
* Redis 实例
*
* @var \Predis\Client
*/
protected $redis;

/**
* 库存 缓存key
*
* @var string
*/
protected $stock_key;

/**
* 中签记录 缓存key
*
* @var string
*/
protected $stock_get_key;

/**
* 配置信息,测试需要
*
* @var array
*/
protected $config = [
'start_time' => '2022-05-10 22:00:00', // 开始时间
'end_time' => '2022-05-11 22:00:00', // 结束时间
];

public function __construct()
{
$this->redis = new \Predis\Client('tcp://IP:端口', [
'parameters' => [
'password' => '密码',
],
]);

$this->id = (int) input('id', 1);

if($this->id < 1) {
$this->id = 1;
}

$this->stock_key = sprintf('%s:stock:%d', 'Seckill', $this->id);

$this->stock_get_key = sprintf('%s:stock_get:%d', 'Seckill', $this->id);
}

/**
* 秒杀
*/
public function index()
{
// 随机生成 UID
$uid = random_int(1, 10000 * 10000);

if(time() < strtotime($this->config['start_time'])) {
return '秒杀未开启';
}

if(time() > strtotime($this->config['end_time'])) {
return '秒杀已结束';
}

// 有 10% 的几率可以走正常的流程
// if(random_int(1, 100) > 10) {
// return '重试';
// }

/**
* 秒杀 LUA 脚本
* -- 目前不考虑同一商品可以中多次情况
*
* 1. 判断是否存在中签记录,如果存在则结束
* 2. 未中签过则减少商品库存(返回值低于 0 则说明已售罄)
* 3. 追加中签记录
*/
$eval = <<<LUA
-- 获取用户是否存在记录
local has_get = redis.call('hexists', KEYS[2], KEYS[3])

if(has_get == 1)
then
-- 存在参与记录则无法重复参与
return -1
end

-- 先减少库存
local stock = redis.call('decr', KEYS[1])

if(stock < 0)
then
-- 已无库存
return -2
end

-- 增加参与记录
local result = redis.call('hsetnx', KEYS[2], KEYS[3], redis.call('time')[1])

if(result == 1)
then
-- 插入成功
return 1
else
-- 插入失败
redis.call('incr', KEYS[1])
return 0
end

-- 异常兜底
return -99
LUA;

// 执行 LUA 脚本,正常返回值是 return 的值
try {
$value = (int) $this->redis->eval($eval, 3, $this->stock_key, $this->stock_get_key, $uid);
} catch (\Exception $e) {
return '系统异常';
}

if($value === -1) {
return '无法重复参与';
} else if($value === -2) {
return '已售罄';
} else if($value < 0) {
return '系统异常';
} else if($value === 0) {
return '重试';
}

// 业务流程
// 入库等等。

return '领取成功';
}

/**
* 初始化商品
*/
public function init()
{
// 库存数
$value = pow(2, 16);

// 初始化库存数
$this->redis->setnx($this->stock_key, $value);

return '1';
}
}

阿里云8核16G(ecs.c7.2xlarge)单机(已开启opache)压测结果,redis 是最低配的 256M。

如果只保留 10% 用户可以走正常流程,那就单机可以达到每秒处理 30000 - 40000 个请求,但实际业务我们不可以让单机承受那么多请求,我们要增加服务器数量和提升 Redis 配置来减少请求的耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./wrk -t400 -c800 -d10m --latency https://seckill.hongfs.cn/seckill
Running 10m test @ https://seckill.hongfs.cn/seckill
400 threads and 800 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 251.53ms 260.34ms 2.00s 87.01%
Req/Sec 12.20 6.07 40.00 55.81%
Latency Distribution
50% 143.23ms
75% 270.70ms
90% 603.53ms
99% 1.30s
2437045 requests in 10.00m, 543.53MB read
Socket errors: connect 0, read 0, write 0, timeout 3634
Requests/sec: 4061.05
Transfer/sec: 0.91MB
往上