秒杀系统

在实际工作中,并没有真的做过秒杀系统,所以假想了一个简单的秒杀系统来 “解解馋”
以下写的是一些关键点以及部分代码, 具体的表设计,表结构根据自己需要来定.

分析

秒杀时大量的流量涌入,秒杀开始前频繁刷新查询,如果大量的流量瞬间冲击到数据库的话,非常容易造成数据库的崩溃。所以秒杀的主要工作就是对流量进行层层筛选最后让尽可能少且平缓的流量进入到数据库。

开始

在后台将一个变体添加到秒杀促销,并设置秒杀的库存 / 秒杀折扣率 / 开始时间和结束时间等,我们能够得到类似这样的数据。

变体: 变体(又称父/子关系)是彼此关联的一组商品
变体 (variant) 即 sku (Stock Keeping Unit 最小存货单位), 下文将统称为变体.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// promotion_variant (促销和变体表「sku」的一个中间表)
{
'id': 1,
'variant_id': 1,
'promotion_id': 1,
'promotion_type': 'snap_up',
'discount_rate': 0.5,
'stock': 100, // 秒杀库存
'sold': 0, // 秒杀销量
'quantity_limit': 1, // 限购
'enabled': 1,
'product_id': 1,
'rest': {
variant_name: 'xxx', // 秒杀期间变体名称
image: 'xxx', // 秒杀期间变体图片
}
}
  • tab_promotions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Schema::create('tab_promotions', function (Blueprint $table) {
$table->increments('id');
$table->string('code');

$table->string('name')->nullable();
$table->string('description')->nullable();
$table->string('cover')->nullable()->comment('促销封面');
$table->string('asset_url')->nullable()->comment('促销详情链接')

$table->integer('position')->default(0)->comment('权重');
$table->string('type')->comment('优惠卷/满减促销/品牌促销/秒杀/拼团/通用.');

$table->json('config')->nullable()->comment('配置');

$table->timestamp('began_at')->nullable()->comment('促销开始时间');
$table->timestamp('ended_at')->nullable()->comment('促销结束时间');
$table->timestamps();
$table->softDeletes();
});
  • tab_promotion_variants
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Schema::create('tab_promotion_variants', function (Blueprint $table) {
$table->increments('id');

$table->unsignedInteger('variant_id')->index();
$table->unsignedInteger('promotion_id')->index();

$table->decimal('discount_rate')->nullable()->comment('折扣率, 值为0.3表示打7折');
$table->unsignedInteger('stock')->nullable()->comment('促销库存');
$table->unsignedInteger('sold')->default(0)->comment('销售数量');
$table->unsignedInteger('quantity_limit')->nullable()->comment('购买数量限制');
$table->boolean('enabled')->default(1)->comment('启用');

// 冗余
$table->unsignedInteger('product_id');
$table->string('promotion_type')->comment('冗余promotion表type');
$table->json('rest')->nullable()->comment('冗余');

$table->timestamps();
});

首先便是在秒杀促销创建成功后将促销的信息进行缓存

  • PromotionVariantObserver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace App\Observers;

use Illuminate\Support\Facades\Cache;

class PromotionVariantObserver
{
public function saved(PromotionVariant $promotionVariant)
{
if ($promotionVariant->promotion_type === PromotionType::SNAP_UP) {
$seconds = $promotionVariant->ended_at->getTimestamp() - time();

Cache::put(
"promotion_variants:$promotionVariant->id",
$promotionVariant,
$seconds
);
}
}
}

下单

下单通常分为两步

  • 第一步是 「结账 (checkout)」 生成一个结账订单, 用户可以为结账订单选择地址、优惠券、支付方式等
  • 第二步是 「确认 (confirm)」 , 此时订单变成确认状态, 对库存进行锁定, 且用户可以进行支付.通常如果在规定时间内没有支付,则取消该订单, 并解锁库存.

所以在第一步时就会对用户进行过滤和排队处理,防止后续的选择地址、优惠卷等操作对数据库进行冲击。

  • CheckoutController
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
<?php

namespace App\Api\Controller;

use App\Api\Controller as BaseController;
use App\Jobs\CheckoutOrderJob;
use Collective\Annotations\Routing\Annotations\Annotations\Controller;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\{Auth, Request, Cache};

/**
* Class CheckoutController
* @package App\Api\Controller
* @Controller(prefix="/api/checkout")
*/
class CheckoutController extends BaseController
{
public function snapUpCheckout(Request $request)
{
// 商品ID
$variantId = $request->input('variant_id');
// 商品购买数量
$quantity = $request->input('quantity', 1);

// 加锁防止超卖
$lock = Cache::lock('snap_up:' . $variantId);

try {
// 未获取锁的消费者将阻塞在这里
$lock->block(10);

// 商品库存量
$promotionVariant = Cache::get('promotion_variants' . $variantId);

if ($promotionVariant->quantity < $quantity) {
$lock->release();

throw new StockException('库存不足');
}

// 减少库存
$promotionVariant->quantity -= $quantity;

// 缓存时间
$seconds = $promotionVariant->ended_at->getTimestamp() - time();

Cache::put(
"promotion_variants:$promotionVariant->id",
$promotionVariant,
$seconds
);

} catch (LockTimeoutException $e) {
return response('库存不足');
} finally {
optional($lock)->release();
}

// 派发任务
CheckoutOrderJob::dispatch([
'user_id' => Auth::id(),
'variant_id' => $variantId,
'quantity' => $quantity
]);

return response('结账订单创建中');
}
}

可以看到在秒杀结账 api 中,并没有涉及到数据库的操作。并且通过 dispatch 将创建订单的任务分发到队列,用户按照进入队列的先后顺序进行对应时间的排队等待。

现在的问题是,订单创建成功后如何通知客户端呢?

客户端通知

这里的方案无非就是轮询或者 websocket, 这里选择对服务器性能消耗较小的 websocket ,且使用 laravel 提供的 laravel-echo (laravel-echo-server) 。 当用户秒杀成功后,前端和后端建立 websocket 链接,后端结账订单创建成功后通知前端可以进行下一步操作。

后端

后端接下来要做的就是在 「CheckoutOrder」 Job 中的订单创建成功后,向 websocket 对应的频道中发送一个 「OrderChecked」 事件,来表明结账订单已经创建完成,用户可以进行下一步操作。

  • Job/CheckoutOrderJob
1
php artisan make:job CheckoutOrderJob
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
<?php

namespace App\Jobs;

use App\Events\OrderCheckedEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class CheckoutOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

private $data;

/**
* Create a new job instance.
*
* @return void
*/
public function __construct($data)
{
$this->data = $data;
}

/**
* Execute the job.
*
* @return void
*/
public function handle()
{
// 创建结账订单

// 通知客户端
event(new OrderCheckedEvent($this->data->user_id));
}
}
  • Event/OrderCheckedEvent
1
php artisan make:event OrderCheckedEvent
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
<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class OrderCheckedEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;

private $user_id;

/**
* OrderCheckedEvent constructor.
* @param $user_id
*/
public function __construct($user_id)
{
$this->user_id = $user_id;
}

/**
* 获取事件应广播的频道。
* App.User.{id} 是 Laravel初始化时, 默认的私有频道, 直接使用即可
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PrivateChannel('App.User.' . $this->user_id);
}
}

假设当前抢购的用户 id1,总结一下上面的代码就是向 websocket 的私有频道「App.User.1」 推送一个 「OrderChecked」 事件。

前端

下面的代码是使用 vue-cli 工具初始化的默认项目。

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
// views/products/show.vue
<script>
import Echo from 'laravel-echo'
import io from 'socket.io-client'
window.io = io

export default {
name: 'App',
methods: {
async snapUpCheckout () {
try {
this.toCheckout()
} catch (error) {
// 秒杀失败
}
},
toCheckout () {
// 建立websock连接
const echo = new Echo({
broadcaster: 'socket.io',
host: 'http://laravel8.test:6001',
auth: {
headers: {
Authorization: 'Bearer ' + this.store.auth.token
}
}
})

// 监听私有频道 App.User.{id} 的OrderCheckedEvent事件
echo.private('App.User.' + this.store.user.id).listen('OrderCheckedEvent', (e) => {
// rediect to checkout page
})
}
}
}
</script>

laravel-echo 使用时需要注意的一点,由于使用了私有频道,所以 laravel-echo 默认会向服务端 api /broadcasting/auth 发送一条 post 请求进行身份验证。 但是由于采用了前后端分离而不是 blade 模板,所以我们并不能方便的获取 csrf tokensession 来进行一些必要的认证。

因此需要稍微修改一下 broadcastlaravel-echo-server 的配置

  • Providers/BroadcastServiceProvider
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
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Broadcast;

class BroadcastServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
// 将认证路由改为 /api/broadcasting/auth 从而避免csrf验证
// 添加中间件 auth:api (jwt 使用 api.auth) 进行身份验证, 避免访问session, 并使Auth::user()生效
Broadcast::routes([
'prefix' => 'api',
'middleware' => ['auth:api']
]);

require base_path('routes/channels.php');
}
}
  • laravel-echo-server.json
1
2
// 认证路由添加 api 前缀,与上面的修改对应
"authEndpoint": "/api/broadcasting/auth"

库存解锁

在已经为该订单锁定” 库存 “的情况下,用户如果断开 websocket 连接或者长时间离开时需要将库存解锁,防止库存无意义占用。

这里的库存指的是缓存库存,而非数据库库存。这是因为此时订单即使创建成功也是结账状态(未选择地址,支付方式等),在个人中心也是不可见的。只有当用户确认订单后,才会将数据库库存锁定。

所以此处的理想实现是,用户断开 websocket 连接后,将该订单锁定的库存归还。且结账订单创建后再创建一个延时队列对长时间未操作的订单进行库存归还。

但但但是,laravel-echo 是一个广播系统,并没有提供客户端断开连接事件的回调,有些方法可以实现 laravel 监听的客户端事件,比如在 laravel-echo-server 添加 hook 通知 laravel,但是需要修改 laravel-echo-server 的实现,这里就不细说了,重点还是提供秒杀思路。

总结

seckill

上图为秒杀系统的逻辑总结。
从图中可以看出,整个流程中,只有在 queue 中才会和 mysql 交互,通过 queue 的限流从而最大限度的适应了 mysql 的承受能力。在 mysql 性能足够的情况下,通过大量的 queue 同时消费订单,用户是完全感知不到排队的过程的。

参考

Powered by Hexo and Hexo-theme-hiker

Copyright © 2017 - 2023 Keep It Simple And Stupid All Rights Reserved.

访客数 : | 访问量 :