0

0

秒杀系统的设计

步履不停

步履不停

发布时间:2019-06-25 15:06:33

|

4020人浏览过

|

来源于php中文网

原创

秒杀系统的设计

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

分析

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

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

但但但是,只是这样的话好像没什么挑战力呀!稍微加大一下难度,假设我们的秒杀是像抢小米手机一样,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 将创建订单的任务分发到队列,用户按照进入队列的先后顺序进行对应时间的排队等待。

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

CA.LA
CA.LA

第一款时尚产品在线设计平台,服装设计系统

下载

客户端通知

这里的方案无非就是轮询或者 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

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 的实现,这里就不细说了,重点还是提供秒杀思路。

总结

Snipaste_2019-06-25_15-05-56.png

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

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

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

更多Laravel相关技术文章,请访问Laravel教程栏目进行学习!

相关专题

更多
Golang gRPC 服务开发与Protobuf实战
Golang gRPC 服务开发与Protobuf实战

本专题系统讲解 Golang 在 gRPC 服务开发中的完整实践,涵盖 Protobuf 定义与代码生成、gRPC 服务端与客户端实现、流式 RPC(Unary/Server/Client/Bidirectional)、错误处理、拦截器、中间件以及与 HTTP/REST 的对接方案。通过实际案例,帮助学习者掌握 使用 Go 构建高性能、强类型、可扩展的 RPC 服务体系,适用于微服务与内部系统通信场景。

8

2026.01.15

公务员递补名单公布时间 公务员递补要求
公务员递补名单公布时间 公务员递补要求

公务员递补名单公布时间不固定,通常在面试前,由招录单位(如国家知识产权局、海关等)发布,依据是原入围考生放弃资格,会按笔试成绩从高到低递补,递补考生需按公告要求限时确认并提交材料,及时参加面试/体检等后续环节。要求核心是按招录单位公告及时响应、提交材料(确认书、资格复审材料)并准时参加面试。

44

2026.01.15

公务员调剂条件 2026调剂公告时间
公务员调剂条件 2026调剂公告时间

(一)符合拟调剂职位所要求的资格条件。 (二)公共科目笔试成绩同时达到拟调剂职位和原报考职位的合格分数线,且考试类别相同。 拟调剂职位设置了专业科目笔试条件的,专业科目笔试成绩还须同时达到合格分数线,且考试类别相同。 (三)未进入原报考职位面试人员名单。

58

2026.01.15

国考成绩查询入口 国考分数公布时间2026
国考成绩查询入口 国考分数公布时间2026

笔试成绩查询入口已开通,考生可登录国家公务员局中央机关及其直属机构2026年度考试录用公务员专题网站http://bm.scs.gov.cn/pp/gkweb/core/web/ui/business/examResult/written_result.html,查询笔试成绩和合格分数线,点击“笔试成绩查询”按钮,凭借身份证及准考证进行查询。

11

2026.01.15

Java 桌面应用开发(JavaFX 实战)
Java 桌面应用开发(JavaFX 实战)

本专题系统讲解 Java 在桌面应用开发领域的实战应用,重点围绕 JavaFX 框架,涵盖界面布局、控件使用、事件处理、FXML、样式美化(CSS)、多线程与UI响应优化,以及桌面应用的打包与发布。通过完整示例项目,帮助学习者掌握 使用 Java 构建现代化、跨平台桌面应用程序的核心能力。

65

2026.01.14

php与html混编教程大全
php与html混编教程大全

本专题整合了php和html混编相关教程,阅读专题下面的文章了解更多详细内容。

36

2026.01.13

PHP 高性能
PHP 高性能

本专题整合了PHP高性能相关教程大全,阅读专题下面的文章了解更多详细内容。

75

2026.01.13

MySQL数据库报错常见问题及解决方法大全
MySQL数据库报错常见问题及解决方法大全

本专题整合了MySQL数据库报错常见问题及解决方法,阅读专题下面的文章了解更多详细内容。

21

2026.01.13

PHP 文件上传
PHP 文件上传

本专题整合了PHP实现文件上传相关教程,阅读专题下面的文章了解更多详细内容。

35

2026.01.13

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
Rust 教程
Rust 教程

共28课时 | 4.4万人学习

PostgreSQL 教程
PostgreSQL 教程

共48课时 | 7.2万人学习

Git 教程
Git 教程

共21课时 | 2.7万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号