|
| 1 | +## 20.2019-05-12-秒杀系统的设计 |
| 2 | + |
| 3 | +之前写过一篇关于 [促销系统的设计](https://learnku.com/articles/21623#d802a5) 中提到了秒杀/直减/聚划算,但在实际工作中,并没有真的做过秒杀系统,所以假想了一个简单的秒杀系统来”解解馋“,促销思路依旧顺延之前的文章设计。 |
| 4 | + |
| 5 | +## 分析 |
| 6 | + |
| 7 | +秒杀时大量的流量涌入,秒杀开始前频繁刷新查询,如果大量的流量瞬间冲击到数据库的话,非常容易造成数据库的崩溃。所以秒杀的主要工作就是对流量进行层层筛选最后让尽可能**少**且**平缓**的流量进入到数据库。 |
| 8 | + |
| 9 | +通常的秒杀是大量的用户抢购少量的商品,类似这样的需求只需要简单的进行库存缓存,就能在实际创建订单前过滤大量的流量。 |
| 10 | + |
| 11 | +但但但是,只是这样的话好像没什么挑战力呀!稍微加大一下难度,假设我们的秒杀是像抢小米手机一样,100 万人抢 10 万台手机呢?小米抢购时的排队是一种方法(虽然体验不太好),后续将会按照这种思路进行我们的秒杀设计。 |
| 12 | + |
| 13 | +> 提到小米就不得不说一下,其让我知道了什么是「运气也是实力的一部分!」 |
| 14 | +> |
| 15 | +> 前端限流大法 : random(0, 1) ? axios.post : wait(30, '抢完啦!') |
| 16 | +
|
| 17 | +下面开始从一些代码的细节进行分析,原则上是对原有业务逻辑尽可能小的改动。**另外后文中没有什么服务熔断,多级缓存等高级的玩法,只是比较简单的业务设计。** |
| 18 | + |
| 19 | +## 开始 |
| 20 | + |
| 21 | +运营人员在后台将一个变体添加到秒杀促销,并设置秒杀的库存/秒杀折扣率/开始时间和结束时间等,我们能够得到类似这样的数据。 |
| 22 | + |
| 23 | +```php |
| 24 | +// promotion_variant (促销和变体表「sku」的一个中间表) |
| 25 | +{ |
| 26 | + 'id': 1, |
| 27 | + 'variant_id': 1, |
| 28 | + 'promotion_id': 1, |
| 29 | + 'promotion_type': 'snap_up', |
| 30 | + 'discount_rate': 0.5, |
| 31 | + 'stock': 100, // 秒杀库存 |
| 32 | + 'sold': 0, // 秒杀销量 |
| 33 | + 'quantity_limit': 1, // 限购 |
| 34 | + 'enabled': 1, |
| 35 | + 'product_id': 1, |
| 36 | + 'rest': { |
| 37 | + variant_name: 'xxx', // 秒杀期间变体名称 |
| 38 | + image: 'xxx', // 秒杀期间变体图片 |
| 39 | + } |
| 40 | +} |
| 41 | +``` |
| 42 | + |
| 43 | +首先便是在秒杀促销创建成功后将促销的信息进行缓存 |
| 44 | + |
| 45 | +```php |
| 46 | +# PromotionVariantObserver.php |
| 47 | + |
| 48 | +public function saved(PromotionVariant $promotionVariant) |
| 49 | +{ |
| 50 | + if ($promotionVariant->promotion_type === PromotionType::SNAP_UP) { |
| 51 | + $seconds = $promotionVariant->ended_at->getTimestamp() - time(); |
| 52 | + |
| 53 | + \Cache::put( |
| 54 | + "promotion_variants:$promotionVariant->id", |
| 55 | + $promotionVariant, |
| 56 | + $seconds |
| 57 | + ); |
| 58 | + } |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | + |
| 63 | + |
| 64 | +## 下单 |
| 65 | + |
| 66 | +已有的下单接口,接收到变体信息后,并不知道当前变体列表哪些参与了促销,这里的判断操作是需要大量的数据库查询操作的。 |
| 67 | + |
| 68 | +所以此处为秒杀编写一个新的 api ,前端检测到当前变体处于秒杀促销时则切换到秒杀下单 api 。 |
| 69 | + |
| 70 | +> 当然依旧使用原有的下单 api ,前端传递一个标识也是没有问题的。 |
| 71 | +
|
| 72 | +需要解释的一点时,下单通常分为两步 |
| 73 | + |
| 74 | +第一步是 「结账( checkout )」生成一个结账订单,用户可以为结账订单选择地址、优惠卷、支付方式 等。 |
| 75 | + |
| 76 | +第二步是 「确认 ( confirm )」,此时订单将变成确认状态,对库存进行锁定,且用户可以进行支付。通常如果在规定时间内没有支付,则取消该订单,并解锁库存。 |
| 77 | + |
| 78 | +所以在第一步时就会对用户进行过滤和排队处理,防止后续的选择地址、优惠卷等操作对数据库进行冲击。 |
| 79 | + |
| 80 | +```php |
| 81 | +# CheckoutController.php |
| 82 | + |
| 83 | +public function snapUpCheckout(Request $request) |
| 84 | +{ |
| 85 | + $variantId = $request->input('variant_id'); |
| 86 | + $quantity = $request->input('quantity', 1); |
| 87 | + $promotionVariant = \Cache::get('promotion_variants:' . $variantId); |
| 88 | + |
| 89 | + $lock = \Cache::lock('snap_up:' . $variantId); |
| 90 | + |
| 91 | + // 大量的用户会在这个环节被过滤掉。 |
| 92 | + if ($lock->get()) { |
| 93 | + if ($promotionVariant->quantity < $quantity) { |
| 94 | + |
| 95 | + $lock->release(); |
| 96 | + |
| 97 | + throw new StockException('库存不足'); |
| 98 | + } |
| 99 | + |
| 100 | + $promotionVariant->quantity -= $quantity; |
| 101 | + |
| 102 | + $seconds = $promotionVariant->ended_at->getTimestamp() - time(); |
| 103 | + \Cache::put( |
| 104 | + "promotion_variants:$promotionVariant->id", |
| 105 | + $promotionVariant, |
| 106 | + $seconds |
| 107 | + ); |
| 108 | + |
| 109 | + $lock->release(); |
| 110 | + } |
| 111 | + |
| 112 | + // 分发一个创建结账订单的任务,对于数量不大的秒杀我们可以把这里换成 dispatchNow 同步执行 |
| 113 | + CheckoutOrder::dispatch([ |
| 114 | + 'user_id' => \Auth::id(), |
| 115 | + 'variant_id' => $variantId, |
| 116 | + 'quantity' => $quantity |
| 117 | + ]); |
| 118 | + |
| 119 | + return response('库存已锁定,结账订单创建中'); |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +可以看到在秒杀结账 api 中,并没有涉及到数据库的操作。并且通过 dispatch 将创建订单的任务分发到队列,用户按照进入队列的先后顺序进行对应时间的排队等待。 |
| 124 | + |
| 125 | +现在的问题是,订单创建成功后如何通知客户端呢? |
| 126 | + |
| 127 | +## 客户端通知 |
| 128 | + |
| 129 | +这里的方案无非就是轮询或者 websocket, 这里选择对服务器性能消耗较小的 websocket ,且使用 laravel 提供的 laravel-echo ( laravel-echo-server ) 。 当用户秒杀成功后,前端和后端建立 websocket 链接,后端结账订单创建成功后通知前端可以进行下一步操作。 |
| 130 | + |
| 131 | +#### 后端 |
| 132 | + |
| 133 | +后端接下来要做的就是在 「CheckoutOrder」Job 中的订单创建成功后,向 websocket 对应的频道中发送一个 「OrderChecked 」事件,来表明结账订单已经创建完成,用户可以进行下一步操作。 |
| 134 | + |
| 135 | +```php |
| 136 | +# Job/CheckoutOrder.php |
| 137 | + |
| 138 | +// ... |
| 139 | + |
| 140 | +public function handle() |
| 141 | +{ |
| 142 | + // 创建结账订单 |
| 143 | + // ... |
| 144 | + |
| 145 | + // 通知客户端. websocket 编程本身就是以事件为导向的,和 laravel 的 event 非常契合。 |
| 146 | + event(new OrderChecked($this->data->user_id)); |
| 147 | +} |
| 148 | + |
| 149 | +// ... |
| 150 | +``` |
| 151 | + |
| 152 | + |
| 153 | + |
| 154 | +```php |
| 155 | +# Event/OrderChecked.php |
| 156 | + |
| 157 | +class OrderChecked implements ShouldBroadcast |
| 158 | +{ |
| 159 | + use Dispatchable, InteractsWithSockets, SerializesModels; |
| 160 | + |
| 161 | + private $userId; |
| 162 | + |
| 163 | + /** |
| 164 | + * Create a new event instance. |
| 165 | + * |
| 166 | + * @param $userId |
| 167 | + */ |
| 168 | + public function __construct($userId) |
| 169 | + { |
| 170 | + $this->userId = $userId; |
| 171 | + } |
| 172 | + |
| 173 | + /** |
| 174 | + * App.User.{id} 是 laravel 初始化时,默认的私有频道,直接使用即可 |
| 175 | + * @return \Illuminate\Broadcasting\Channel|array |
| 176 | + */ |
| 177 | + public function broadcastOn() |
| 178 | + { |
| 179 | + return new PrivateChannel('App.User.' . $this->userId); |
| 180 | + } |
| 181 | +} |
| 182 | +``` |
| 183 | + |
| 184 | +假设当前抢购的用户 id 是 1,总结一下上面的代码就是向 websocket 的私有频道「App.User.1」 推送一个 「OrderChecked」 事件。 |
| 185 | + |
| 186 | +#### 前端 |
| 187 | + |
| 188 | +下面的代码是使用 vue-cli 工具初始化的默认项目。 |
| 189 | + |
| 190 | +```javascript |
| 191 | +// views/products/show.vue |
| 192 | + |
| 193 | +<script> |
| 194 | + |
| 195 | +import Echo from 'laravel-echo' |
| 196 | +import io from 'socket.io-client' |
| 197 | +window.io = io |
| 198 | + |
| 199 | +export default { |
| 200 | + name: 'App', |
| 201 | + methods: { |
| 202 | + async snapUpCheckout () { |
| 203 | + try { |
| 204 | + // await post -> snap-up-checkout |
| 205 | + this.toCheckout() |
| 206 | + } catch (error) { |
| 207 | + // 秒杀失败 |
| 208 | + } |
| 209 | + }, |
| 210 | + toCheckout () { |
| 211 | + // 建立 websocket 连接 |
| 212 | + const echo = new Echo({ |
| 213 | + broadcaster: 'socket.io', |
| 214 | + host: 'http://api.e-commerce.test:6001', |
| 215 | + auth: { |
| 216 | + headers: { |
| 217 | + Authorization: 'Bearer ' + this.store.auth.token |
| 218 | + } |
| 219 | + } |
| 220 | + }) |
| 221 | + |
| 222 | + // 监听私有频道 App.User.{id} 的 OrderChecked 事件 |
| 223 | + echo.private('App.User.' + this.store.user.id).listen('OrderChecked', (e) => { |
| 224 | + // redirect to checkou page |
| 225 | + }) |
| 226 | + } |
| 227 | + } |
| 228 | +} |
| 229 | +</script> |
| 230 | +``` |
| 231 | + |
| 232 | + |
| 233 | + |
| 234 | +laravel-echo 使用时需要注意的一点,由于使用了私有频道,所以 laravel-echo 默认会向服务端api `/broadcasting/auth` 发送一条 post 请求进行身份验证。 但是由于采用了前后端分类而不是 blade 模板,所以我们并不能方便的获取 csrf token 和 session 来进行一些必要的认证。 |
| 235 | + |
| 236 | +因此需要稍微修改一下 broadcast 和 laravel-echo-server 的配置 |
| 237 | + |
| 238 | +```php |
| 239 | +# BroadcastServiceProvider.php |
| 240 | + |
| 241 | +public function boot() |
| 242 | +{ |
| 243 | + // 将认证路由改为 /api/broadcasting/auth 从而避免 csrf 验证 |
| 244 | + // 添加中间件 auth:api (jwt 使用 api.auth) 进行身份验证,避免访问 session ,并使 Auth::user() 生效。 |
| 245 | + Broadcast::routes(["prefix" => "api", "middleware" => ["auth:api"]]); |
| 246 | + |
| 247 | + require base_path('routes/channels.php'); |
| 248 | +} |
| 249 | +``` |
| 250 | + |
| 251 | +```json |
| 252 | +// laravel-echo-server.json |
| 253 | + |
| 254 | +// 认证路由添加 api 前缀,与上面的修改对应 |
| 255 | +"authEndpoint": "/api/broadcasting/auth" |
| 256 | +``` |
| 257 | + |
| 258 | + |
| 259 | + |
| 260 | +#### 库存解锁 |
| 261 | + |
| 262 | +在已经为该订单锁定”库存“的情况下,用户如果断开 websocket 连接或者长时间离开时需要将库存解锁,防止库存无意义占用。 |
| 263 | + |
| 264 | +> 这里的库存指的是**缓存库存**,而非数据库库存。这是因为此时订单即使创建成功也是结账状态(未选择地址,支付方式等),在个人中心也是不可见的。只有当用户确认订单后,才会将数据库库存锁定。 |
| 265 | +
|
| 266 | +所以此处的理想实现是,用户断开 websocket 连接后,将该订单锁定的库存归还。且结账订单创建后再创建一个延时队列对长时间未操作的订单进行库存归还。 |
| 267 | + |
| 268 | +但但但是,laravel-echo 是一个广播系统,并没有提供客户端断开连接事件的回调,有些方法可以实现 laravel 监听的客户端事件,比如在 laravel-echo-server 添加 hook 通知 laravel,但是需要修改 laravel-echo-server 的实现,这里就不细说了,重点还是提供秒杀思路。 |
| 269 | + |
| 270 | +## 总结 |
| 271 | + |
| 272 | + |
| 273 | + |
| 274 | +上图为秒杀系统的逻辑总结。至此整个秒杀流程就结束了,总的来说代码量不多,逻辑也较为简单。 |
| 275 | + |
| 276 | +从图中可以看出,整个流程中,只有在 queue 中才会和 mysql 交互,通过 queue 的**限流**从而最大限度的适应了 mysql 的承受能力。在 mysql 性能足够的情况下,通过大量的 queue 同时消费订单,用户是完全感知不到排队的过程的。 |
| 277 | + |
| 278 | +有问题或者有更好的思路欢迎留言讨论呀~ |
| 279 | + |
| 280 | +引用地址: https://learnku.com/articles/28245 |
0 commit comments