秒杀系统的设计

之前写过一篇关于 促销系统的设计 中提到了秒杀/直减/聚划算,但在实际工作中,并没有真的做过秒杀系统,所以假想了一个简单的秒杀系统来”解解馋“,促销思路依旧顺延之前的文章设计。

分析

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

通常的秒杀是大量的用户抢购少量的商品,类似这样的需求只需要简单的进行库存缓存,就能在实际创建订单前过滤大量的流量。

但但但是,只是这样的话好像没什么挑战力呀!稍微加大一下难度,假设我们的秒杀是像抢小米手机一样,100 万人抢 10 万台手机呢?小米抢购时的排队是一种方法(虽然体验不太好),后续将会按照这种思路进行我们的秒杀设计。

提到小米就不得不说一下,其让我知道了什么是「运气也是实力的一部分!」

前端限流大法 : random(0, 1) ? axios.post : wait(30, '抢完啦!')

下面开始从一些代码的细节进行分析,原则上是对原有业务逻辑尽可能小的改动。另外后文中没有什么服务熔断,多级缓存等高级的玩法,只是比较简单的业务设计。

开始

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

// 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', // 秒杀期间变体图片
    }
}

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

# PromotionVariantObserver.php

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
    );
  }
}

下单

已有的下单接口,接收到变体信息后,并不知道当前变体列表哪些参与了促销,这里的判断操作是需要大量的数据库查询操作的。

所以此处为秒杀编写一个新的 api ,前端检测到当前变体处于秒杀促销时则切换到秒杀下单 api 。

当然依旧使用原有的下单 api ,前端传递一个标识也是没有问题的。

需要解释的一点时,下单通常分为两步

第一步是 「结账( checkout )」生成一个结账订单,用户可以为结账订单选择地址、优惠卷、支付方式 等。

第二步是 「确认 ( confirm )」,此时订单将变成确认状态,对库存进行锁定,且用户可以进行支付。通常如果在规定时间内没有支付,则取消该订单,并解锁库存。

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

# CheckoutController.php

/**
 * @param Request $request
 * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response
 * @throws StockException
 */
public function snapUpCheckout(Request $request)
{
    $variantId = $request->input('variant_id');
    $quantity = $request->input('quantity', 1);

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

    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) {
        throw new StockException('库存不足');

    } finally {
        optional($lock)->release();
    }

    CheckoutOrder::dispatch([
        'user_id' => \Auth::id(),
        'variant_id' => $variantId,
        'quantity' => $quantity
    ]);

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

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

如果 php-fpm 架构承受不住流量冲击,可以考虑 Swoole 或者 API 网关来进行流量过滤。

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

客户端通知

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

后端

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

# Job/CheckoutOrder.php

// ...

public function handle()
{
  // 创建结账订单
  // ...

  // 通知客户端. websocket 编程本身就是以事件为导向的,和 laravel 的 event 非常契合。
  event(new OrderChecked($this->data->user_id));
}

// ...
# Event/OrderChecked.php

class OrderChecked implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    private $userId;

    /**
     * Create a new event instance.
     *
     * @param $userId
     */
    public function __construct($userId)
    {
        $this->userId = $userId;
    }

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

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

前端

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

// 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 {
        // await post -> snap-up-checkout
        this.toCheckout()
      } catch (error) {
        // 秒杀失败
      }
    },
    toCheckout () {
      // 建立 websocket 连接
      const echo = new Echo({
        broadcaster: 'socket.io',
        host: 'http://api.e-commerce.test:6001',
        auth: {
          headers: {
            Authorization: 'Bearer ' + this.store.auth.token
          }
        }
      })

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

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

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

# BroadcastServiceProvider.php

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

// 认证路由添加 api 前缀,与上面的修改对应
"authEndpoint": "/api/broadcasting/auth"

库存解锁

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

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

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

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

总结

上图为秒杀系统的逻辑总结。至此整个秒杀流程就结束了,总的来说代码量不多,逻辑也较为简单。

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

有问题或者有更好的思路欢迎留言讨论呀~

本帖由系统于 2个月前 自动加精
Max
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 21

:+1:

2个月前 评论

干货啊

2个月前 评论

好完整的解决方案,感觉分享 :+1:

2个月前 评论

:+1:每次看你的文章都感觉茅塞顿开 :sweat_smile:

2个月前 评论

:+1: :+1: :+1: :+1:

2个月前 评论

$lock = \Cache::lock('snap_up:' . $variantId);这块是获取锁吗?那怎么加锁 呢?有没有学习的链接文档呢

2个月前 评论
tryIt: 秒杀加锁可以参考redis的setnx方法 2周前
qingyan233

:smiley:

2个月前 评论
guanhui07

队列术

2个月前 评论
allen9009

666,顺便再介绍一下supervisor就更完美了

1个月前 评论
  if ($lock->get()) {
    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
    );

    $lock->release();
  }

  // 分发一个创建结账订单的任务,对于数量不大的秒杀我们可以把这里换成 dispatchNow 同步执行
  CheckoutOrder::dispatch([
    'user_id' => \Auth::id(),
    'variant_id' => $variantId,
    'quantity' => $quantity
  ]);

有个疑问,为什么$lock->get()没进去,下面依然要分发一个创建结算订单的任务呢?
以及被大量过滤掉的用户是什么意思?

1个月前 评论
Max

@terranc
lock->get() 是一个阻塞操作,没进入的用户会阻塞并等待解锁。所以每个请求用户都会进入到这个 lock 中。

lock 的作用是防止高并发的时候出现超卖的问题,但是很抱歉,上面的代码中我把 promotionVariant 从缓存中取出写在了锁的外面,刚刚已经调整了过来。


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

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) {

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

} finally {
    optional($lock)->release();
}

这是正确的代码。

1个月前 评论

@Max $lock->get() 这个不阻塞吧,没拿到锁就直接返回false了

1个月前 评论
Max: 5.8 的话 是阻塞的,我测试了一下 1个月前
ab0029: @Max 难倒我用的假的,我用的是Redis驱动,需要阻塞的话,应该是调用$lock->block(秒)才对啊 1个月前
ab0029: @Max 我用的是5.7,不过刚对比了下5.8的,只是释放锁的代码有差别而已,get操作没区别 1个月前
Max: @ab0029 block 好像只是最多阻塞的时间,和等待还是跳过没有关系。 1个月前
Max

@ab0029

$lock = \Cache::lock('test');

    if ($lock->get()) {
        sleep(5);
        $lock->release();
    }

这是我的测试代码

1个月前 评论

@Max

Route::get('/test', function() {
    $lock = \Cache::lock('foo', 20);
    if ($lock->get()) {
        // 异步解锁
        return '操作中...';
    }
    return '没得锁...';
});

打开两个浏览器,一个会得到【操作中】,一个是【没得锁】,直到超时了,或者异步的队列解锁了

1个月前 评论

file
@Max 同时发起,不会阻塞,直接响应的

1个月前 评论
Max

@ab0029 是我的问题,我的环境(valte)好像只支持一次一条请求。

1个月前 评论
ab0029: :ok_hand: 1个月前
Max: @ab0029 感谢指正,已修改 1个月前
Jourdon

好东西

1个月前 评论

可以 有完整的吗 整个demo

1个月前 评论

只是为了防止超卖,加锁还不如直接使用redis lua脚本吧?

3周前 评论
Max: 防止超卖这个 用 nginx lua 脚本 或者 api 网关都比较合适呀 3周前

你好 我想问一下 异步创建mysql订单后 再进行地址添加 和优惠方式计算是吧 最后才是支付 这样理解可以吗

2周前 评论
Max: 是的。 准确点是,用户确认订单后,再进行支付 2周前

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!