在实际工作中,并没有真的做过秒杀系统,所以假想了一个简单的秒杀系统来 “解解馋”
以下写的是一些关键点以及部分代码, 具体的表设计,表结构根据自己需要来定.
分析
秒杀时大量的流量涌入,秒杀开始前频繁刷新查询,如果大量的流量瞬间冲击到数据库的话,非常容易造成数据库的崩溃。所以秒杀的主要工作就是对流量进行层层筛选最后让尽可能少且平缓的流量进入到数据库。
开始
在后台将一个变体添加到秒杀促销,并设置秒杀的库存 / 秒杀折扣率 / 开始时间和结束时间等,我们能够得到类似这样的数据。
变体: 变体(又称父/子关系)是彼此关联的一组商品
变体 (variant
) 即 sku (Stock Keeping Unit 最小存货单位)
, 下文将统称为变体.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| { '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', } }
|
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(); });
|
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(); });
|
首先便是在秒杀促销创建成功后将促销的信息进行缓存
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)」
, 此时订单变成确认状态, 对库存进行锁定, 且用户可以进行支付.通常如果在规定时间内没有支付,则取消该订单, 并解锁库存.
所以在第一步时就会对用户进行过滤和排队处理,防止后续的选择地址、优惠卷等操作对数据库进行冲击。
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 extends BaseController { public function snapUpCheckout(Request $request) { $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」
事件,来表明结账订单已经创建完成,用户可以进行下一步操作。
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;
public function __construct($data) { $this->data = $data; }
public function handle() {
event(new OrderCheckedEvent($this->data->user_id)); } }
|
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;
public function __construct($user_id) { $this->user_id = $user_id; }
public function broadcastOn() { return new PrivateChannel('App.User.' . $this->user_id); } }
|
假设当前抢购的用户 id
是 1
,总结一下上面的代码就是向 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 token
和 session
来进行一些必要的认证。
因此需要稍微修改一下 broadcast
和 laravel-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 {
public function boot() { Broadcast::routes([ 'prefix' => 'api', 'middleware' => ['auth:api'] ]);
require base_path('routes/channels.php'); } }
|
1 2
| "authEndpoint": "/api/broadcasting/auth"
|
库存解锁
在已经为该订单锁定” 库存 “的情况下,用户如果断开 websocket 连接或者长时间离开时需要将库存解锁,防止库存无意义占用。
这里的库存指的是缓存库存,而非数据库库存。这是因为此时订单即使创建成功也是结账状态(未选择地址,支付方式等),在个人中心也是不可见的。只有当用户确认订单后,才会将数据库库存锁定。
所以此处的理想实现是,用户断开 websocket
连接后,将该订单锁定的库存归还。且结账订单创建后再创建一个延时队列对长时间未操作的订单进行库存归还。
但但但是,laravel-echo
是一个广播系统,并没有提供客户端断开连接事件的回调,有些方法可以实现 laravel
监听的客户端事件,比如在 laravel-echo-server
添加 hook
通知 laravel
,但是需要修改 laravel-echo-server
的实现,这里就不细说了,重点还是提供秒杀思路。
总结
上图为秒杀系统的逻辑总结。
从图中可以看出,整个流程中,只有在 queue
中才会和 mysql 交互,通过 queue
的限流从而最大限度的适应了 mysql
的承受能力。在 mysql
性能足够的情况下,通过大量的 queue
同时消费订单,用户是完全感知不到排队的过程的。
参考