资讯动态

Laravel 高并发秒杀系统实战:Redis 原子扣减与防超卖完整解析

发布时间:2026/10/20 21:17:25 来源:尧图企业网站定制
搞秒杀题做了无数遍真正敢说自己完整实现过一个 Laravel 秒杀的人其实不多。很多人把能跑通当成做完了结果上线一压测直接被打穿超卖、雪崩、队列积压、重复下单每个坑都能让你加班到天亮。我基于 Laravel 从零搭过一个完整的秒杀 Demo这一篇把设计思路、表结构、Redis 扣减、异步下单、超时回补全部串起来讲代码可以直接抄踩坑记录也一并给到适合正在做电商活动、限时抢购或准备面试相关岗位的人收藏着看。先给一个结论秒杀系统绝大多数情况下不能用常规 CRUD 思路做。比如商品库存 100 件1 万个用户在同一秒点进来数据库那条 UPDATE 语句就是最大的瓶颈行锁等待能把数据库拖死响应时间从几十毫秒涨到几秒甚至直接超时。所以秒杀本质上是在考你两件事怎么把写压力挡在数据库前面以及怎么保证削峰之后数据最终不出错。1. 秒杀系统整体设计与技术选型1.1 秒杀场景到底在挑战什么秒杀和普通下单最大的区别在于流量密度。普通购物下单是均匀分布的数据库完全扛得住秒杀则是把一周的流量压缩到几秒钟内释放而且绝大多数请求都是无效请求——真正抢到的人只有 100 个剩下的 9900 个都注定失败。这些请求如果在应用层直接查库再写库会带来三个直接问题数据库连接瞬间耗光。PHP-FPM 模式下每个请求至少占用一个数据库连接几千并发打过来连接池立刻满后续请求全部排队。行锁激烈竞争。所有请求对同一条商品库存记录执行 UPDATE 时数据库层会强制串行锁等待时间远大于实际执行时间CPU 被打满。超卖无法避免。如果先 SELECT 查库存再 UPDATE两个并发请求同时读到库存为 1然后都执行扣减库存就变成 -1 了。所以秒杀的第一步不是写代码而是认清一个事实绝对不能让数据库直接面对瞬时流量。我们需要在数据库前加一层过滤器让绝大多数请求在 Redis 层就被终结掉只有货真价实抢到的少量请求才落到数据库。1.2 为什么选 Laravel Redis 这套组合选 Laravel 不是因为它在高并发场景下性能拔尖而是因为它开发效率高而且内置了很多和秒杀配套的组件数据库事务、队列系统、缓存系统、RateLimiter 限流器这些在原生 PHP 里都要自己造轮子。Redis 在整个链路里承担三个角色库存容器用 Redis String 存剩余库存配合 Lua 脚本做原子扣减扛住高并发。限流计数器记录用户/IP 的请求频次。队列存储Laravel 的 Queue 可以基于 Redis 驱动秒杀请求入队后由后台 Worker 慢慢消费。数据库只做最终落库承担的工作量很小这就是削峰填谷的思路。你可以把 Redis 想象成一个高速闸机只有持有有效票的人才能通过数据库则是检票后的记录台只需要服务极少量的通过者。1.3 整体流程用户端四个接口完整链路通常拆成四个接口而不是一个下单接口打天下。获取秒杀商品详情普通查询可以缓存商品信息数据库库存不展示展示 Redis 里的实时库存。预扣下单接口用户点击抢购先在 Redis 里做原子扣减扣成功就把任务丢进队列立刻返回排队中。查询订单结果前端轮询返回订单状态排队中、成功、失败、库存不足。支付接口秒杀通常要求下单 X 分钟内支付超时订单自动取消回补库存。这个流程的好处是用户点击后几十毫秒内就能拿到响应不会因为排队等待而卡死页面。真正创建订单的耗时段移到了后台异步完成用户感知不到。2. 数据模型与表结构设计2.1 核心表拆解秒杀项目不能把所有字段塞在一张订单表里。参考最常见的电商抢购场景我的 Demo 里拆了四张表用户表users存用户基础信息秒杀场景下不需要额外字段。商品表products通用商品信息价格、名称、描述。秒杀活动表flash_sales这是秒杀专属表记录活动开始时间、结束时间、秒杀价格、活动总库存、已售数量。订单表orders包含用户 ID、秒杀活动 ID、商品 ID、订单状态待支付、已支付、已取消、创建时间、支付时间。为什么单独拆出flash_sales而不是直接在商品表上加库存因为同一个商品可能同时存在多个活动普通售卖和秒杀售卖走的是不同价格策略库存也需要分开管理。比如手机库存 1000 台日常销售占 900 台618 秒杀只放出 100 台这两部分库存必须隔离否则秒杀用户把普通用户的库存买走了会造成线上线下多渠道库存对不上。2.2 为什么必须加库存流水表大多数入门 Demo 只有上面四张表但我强烈建议再加一张stock_flows库存流水表。它的作用相当于会计凭证每次扣减库存、回补库存都记一条流水。Schema::create(stock_flows, function (Blueprint $table) { $table-id(); $table-unsignedBigInteger(flash_sale_id); $table-unsignedBigInteger(user_id); $table-unsignedBigInteger(order_id)-nullable(); $table-integer(change_amount)-comment(正数回补负数扣减); $table-string(type)-comment(order, cancel, expire); $table-timestamps(); });有了流水表才能支持对账。万一 Redis 和数据库库存出现不一致可以通过流水表重放完整过程定位到底是哪一步丢了。我在实际项目里遇到过 Redis 库存明明是 0数据库已售数量却是 80查流水才发现是队列消费失败丢了一部分任务。2.3 目录结构与基础准备我的 Demo 目录结构大致如下app/ ├── Http/Controllers/Api/FlashSaleController.php ├── Jobs/CreateFlashSaleOrder.php ├── Services/FlashSaleService.php ├── Models/FlashSale.php ├── Order.php └── StockFlow.php基础环境要求PHP 8.1Laravel 9/10 或 11 都可以。Redis 5.0因为要用到 Lua 脚本和 Stream 相关能力。MySQL 5.7需要支持事务和行锁。队列 Worker 用 supervisor 守护php artisan queue:work redis --tries3。3. 核心链路实现Redis 原子预扣、限流与防重3.1 用 Lua 脚本保证扣库存的原子性Redis 的单个命令是原子的但多人并发场景下你需要的是检查库存、扣减、标记用户这一串连续操作都变成原子。常见错误是把三步拆成三个独立 Redis 调用比如先 GET 库存再 DECR中间还有一段 PHP 业务逻辑。这种写法在并发下必然出错两个请求同时 GET 到库存 1然后都执行 DECR库存变成 -1。正确做法是把整段逻辑写进 Lua 脚本Redis 执行 Lua 时是单线程的不会被打断。我的 Redis 预扣脚本如下-- keys[1] 库存key -- keys[2] 已抢用户set key -- argv[1] 用户id -- argv[2] 每个用户限购数量 if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 0 end local stock tonumber(redis.call(get, KEYS[1])) if not stock then return -1 end if stock tonumber(ARGV[2]) then return -2 end redis.call(decrby, KEYS[1], ARGV[2]) redis.call(sadd, KEYS[2], ARGV[1]) redis.call(expire, KEYS[2], 86400) return 1脚本返回值含义0该用户已经抢过直接判定重复。-1活动不存在或库存 key 未初始化。-2库存不足。1预扣成功把用户 ID 记入已抢集合防止同一用户二次下单。在 Laravel 中调用这段脚本public function tryDeduct(FlashSale $sale, int $userId): int { $script file_get_contents(resource_path(lua/deduct_stock.lua)); return Redis::eval( $script, 2, $this-stockKey($sale-id), $this-userSetKey($sale-id), $userId, $sale-limit_per_user ); }注意库存 key 必须是 String 类型不能是其他类型。Lua 脚本里redis.call的类型错误会导致整个脚本报错压测时会暴露得很惨。3.2 限流挡掉重复请求和脚本刷单即使有了 Lua 扣减也不能让用户无限点击抢购按钮。前端可以做防抖但后端必须继续限流因为脚本刷单绕得过前端。Laravel 自带 RateLimiter可以针对用户和 IP 做双重限流。我在FlashSaleController的构造方法里定义限流规则RateLimiter::for(flash_sale, function ($job) { return Limit::perMinute(10)-by(optional($job-user)-id ?: $job-ip()); }); RateLimiter::for(flash_sale_ip, function ($job) { return Limit::perMinute(30)-by($job-ip()); });路由里应用限流中间件Route::middleware([throttle:flash_sale, throttle:flash_sale_ip]) -post(/api/flash-sale/{id}/buy, [FlashSaleController::class, buy]);每个用户每分钟最多 10 次抢购请求每个 IP 最多 30 次超出直接返回 429。这个阈值你可以按实际活动调整但思路是一样的单用户限频 单 IP 限频双保险。限流实现原理也很简单底层就是 Redis 计数器检查当前时间窗口内已经请求了多少次。Laravel 14 分钟窗口跟秒杀这种瞬时流量场景不是特别匹配实际压测时感觉时间窗口可以缩短到 5 秒或 10 秒效果更明显。3.3 防重复下单的第三种机制除了 Lua 脚本里的用户 Set还需要考虑另一种重复用户在队列处理完成前疯狂点击请求已经进了队列但 Redis 预扣已经成功了。这种请求会被 Lua 脚本拦截住因为用户 ID 已经进了 Set。但如果服务在 Redis 扣减成功之后、Set 写入之前挂了会出现极端情况。为了避免这个问题我在 Service 层加了一个分布式锁用 Laravel 内置的 Cache 锁即可$lock Cache::lock(flash_sale_user_ . $sale-id . _ . $userId, 5); if (!$lock-get()) { return 处理中请勿重复提交; } try { $result $this-tryDeduct($sale, $userId); // ... } finally { $lock-release(); }分布式锁和 Lua 脚本的原子检查是两个不同层面的防线锁解决的是同一时间窗口内的并发重复提交Set 解决的是跨时间段的重放。两手都要抓。3.4 库存预热与活动开关秒杀开始前需要把库存从数据库同步到 Redis。活动开关用 Redis 的过期 key 实现最简单public function prepare(FlashSale $sale) { Redis::setex($this-stockKey($sale-id), $sale-duration, $sale-total_stock); Redis::del($this-userSetKey($sale-id)); }活动结束后检查 Redis 里活动 key 是否存在不存在就直接返回活动已结束。注意不要用数据库的end_time判断数据库查询在高并发下也有压力用 Redis key 过期做开关是零成本。4. 异步下单、超时回补与订单结果查询4.1 为什么必须异步创建订单如果所有流量都走数据库事务里创建订单每个请求至少需要 20 至 50 毫秒的数据库操作时间1000 并发就意味着数据库要持续承担 20 秒以上的写压力。而用户并不在乎订单是 10 毫秒创建还是 500 毫秒创建只在乎自己是否抢到。所以正确的做法是Redis 预扣成功后立刻把任务丢进 Laravel Queue用户收到的响应永远是排队中。订单由后台 Worker 慢慢创建数据库压力被均匀分散不会出现瞬时峰值。4.2 队列 Job 的核心逻辑我定义了一个CreateFlashSaleOrderJob这是整个秒杀系统里最容易出错的地方代码结构如下public function handle() { $sale FlashSale::find($this-saleId); $user User::find($this-userId); DB::transaction(function () use ($sale, $user) { // 再次检查活动状态 if ($sale-status ! active) { $this-releaseStock($sale, $this-userId, order_cancelled); return; } // 创建订单 $order Order::create([ user_id $user-id, flash_sale_id $ale-id, product_id $sale-product_id, price $sale-seckill_price, status pending_payment, expire_at now()-addMinutes(15), ]); // 扣减数据库库存必须带条件 $affected FlashSale::where(id, $sale-id) -where(stock, , 1) -decrement(stock); if ($affected 0) { throw new \Exception(数据库库存不足); } // 记录库存流水 StockFlow::create([ flash_sale_id $sale-id, user_id $user-id, order_id $order-id, change_amount -1, type order, ]); }); // 发送通知或消息给用户 } public function releaseStock(...) { Redis::incr($this-stockKey($saleId)); Redis::srem($this-userSetKey($saleId), $userId); }几个关键点DB::transaction保证订单、库存、流水三个操作要么全成功要么全回滚。where(stock, , 1)-decrement(stock)是带条件的原子扣减即使最后一步到了数据库也要防止超卖。这一行代码是防超卖的兜底措施。如果数据库库存不足Job 会抛异常队列的tries参数控制重试次数releaseStock会被触发把 Redis 库存和用户 Set 回补保证总库存不丢失。4.3 超时未支付自动取消与库存回补秒杀订单一般要求 15 分钟内支付超时取消订单并回补库存。我用 Laravel 的Queue::later延迟任务实现Queue::later(now()-addMinutes(15), new CancelExpiredOrder($order-id));延迟任务到达时先检查订单状态如果仍是pending_payment则更新为cancelled同时回补库存。为了避免和用户手动取消、支付完成三种状态冲突必须用原子更新条件$order Order::where(id, $orderId) -where(status, pending_payment) -update([status cancelled]); if ($order) { // 回补 Redis 库存和数据库库存 }这样就能保证如果用户刚好在第 15 分钟支付了cancel任务因为where(status, pending_payment)条件不满足而什么也不做不会出现支付成功但订单被取消的脏数据。4.4 前端轮询接口返回订单状态前端不能靠 WebSocket 推送给所有用户秒杀场景用户量太大一般用轮询。查询接口直接查 Redis 和数据库public function orderStatus($orderId) { $order Order::find($orderId); if (!$order) { return [status queueing]; } return [ status $order-status, order_id $order-id, price $order-price, ]; }轮询间隔建议 2 秒一次最多轮询 30 次。前端拿到success后跳转支付页拿到fail则提示手慢了下次再来。5. 常见问题与线上排查实录5.1 超卖问题压测过了但库存还是超了为什么我在刚写完 Demo 时做过一次压测1000 个并发线程抢 100 件库存压测报告显示成交订单 103 件超卖 3 件。排查后发现根因不在 Redis 扣减而在数据库兜底那一层。当时数据库扣减写的是$sale-decrement(stock);这种写法不判断库存是否大于 0。虽然 Redis 已经拦截了大部分请求但队列消费是并行的多个 Worker 同时把任务从队列里取出来执行decrement数据库层仍然可能并发扣成负数。修复方式就是前面讲过的带条件更新FlashSale::where(id, $sale-id)-where(stock, , 1)-decrement(stock);经验任何导致库存变更的 SQL 都必须满足两个条件第一是带库存数量条件第二是在事务内执行。只靠应用层判断絕對不够。5.2 队列积压严重用户等待时间过长秒杀开始时如果队列消费速度跟不上入队速度用户会一直停在排队中。排查这类问题先看两个指标队列长度和 Worker 数量。队列长度可以通过 RedisLLEN命令查看redis-cli LLEN queues:default如果长度持续上涨说明 Worker 消费能力不足。最简单的扩容方式是在 supervisor 配置里增加 process 数量比如从 4 个 Worker 改成 16 个。更优的方案是改成 Laravel Horizon它更专业按策略自动调度 Worker还能查看每个队列的吞吐量。消费速度上也可以做些优化比如不要在 Job 里请求外部 API、不要做耗时的图片处理把能异步的都往后推Job 里只保留订单创建的核心逻辑。5.3 Redis 和数据库库存不一致这是令很多人头疼的问题。表现是 Redis 里显示还有库存但点进去已经下单成功了数据库卖完了反过来也有。我的排查思路是以流水表为准重建数据。写一个审计脚本扫stock_flows表汇总所有负数变更扣减的数量对比flash_sales.stock的初始库存和当前库存。如果两者对不上就把差异行的订单列出来逐单核实状态。这类问题最常出现在 Job 重试场景数据库事务成功但 Job 抛异常或者反过来数据库事务回滚了但releaseStock没有执行。建立流水表后这些问题都能靠对账发现。5.4 压测时 Redis 连接数被打满我第一次压测时只是简单用了 Laravel 默认的Redis::门面每个请求都新建连接导致压测时 Redis 连接数爆炸大量请求报连接池耗尽。优化方案开启 Redis 连接复用Laravel 的 Redis 配置默认是短连接改成persistent长连接状态。把库存扣减逻辑下沉到一个 Service不要散落在 Controller 各处这样 Redis 调用次数容易控制。请求量大时还可以在前端做流量拦截比如按钮置灰、CDN 层做动态请求排队。这点往往被忽略但实际效果非常显著。还有一个经验压测时不要只测单个接口。秒杀链路里的详情接口、下单接口、查询接口、支付回调接口要一并压否则很容易出现单接口没事全链路一压就崩的窘境。最后再分享一点实战感受秒杀 Demo 整个做下来我最大的体会是不是代码炫技而是每一层都要想清楚崩了怎么办。Redis 挂了怎么办数据库挂了怎么办队列积压了怎么办用户重复支付怎么办——这些看似边缘的问题才是区别初学者和资深开发者的分水岭。还有一个很容易被忽略的点日志。秒杀系统中 Redis 扣减、队列投递、数据库落单、库存回补这几个关键节点一定要打日志而且要带上user_id、sale_id、order_id及时间戳。出问题时你就知道定位快一天和定位三天的差距就是打日志时多写了一个 ID 的差距。如果你也是第一次碰秒杀类项目建议先把 Demo 跑通然后做三件扩展第一把库存逻辑单独抽成服务并做单元测试第二用 Redis 做流量染色观察完整链路第三试着把下单接口改成异步消息推送比如 WebSocket 或第三方推送体验一下真实电商项目的感受。这三件事做完你对高并发系统的理解会又上一个台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑