PHP拍卖网站系统毕业设计:高并发竞拍场景下的实战架构与避坑指南
毕业答辩前夜,我把电脑搬到宿舍走廊,一边盯着 Grafana 上跳动的 QPS 曲线,一边默念:「库存千万别变负数,千万别。」
如果你也在用 PHP 做拍卖系统,这篇熬夜总结或许能让你少走几个弯路。
1. 背景痛点:传统 PHP 竞拍逻辑的并发漏洞
校园网里做毕设,最容易「看起来能跑」——直到你把项目搬到公网,让 30 个同学同时点「出价」:
- 并发竞争:原生 PHP 无锁,请求同时读到同一版本库存,出现「最后一条 SQL 覆盖前者」。
- 出价幂等缺失:用户双击按钮,浏览器重发 POST,数据库里瞬间插入两条记录,价格被抬高又回退。
- 超卖:商品只剩 1 件,A、B 两人几乎同时判断
stock>0,都进入减库存逻辑,结果成交 2 笔。
这些问题在本地 XAMPP 里很难复现,可一旦放到云主机,Nginx 日志里 502 与 500 交替出现,答辩老师一句「并发下数据一致性如何保证?」就能把人问住。
2. 技术选型:Laravel + Redis 为什么胜出
我把备选方案列成一张表,在宿舍白板前纠结了一晚上:
| 方案 | 并发能力 | 事务/锁 | 生态 | 学习成本 | 结论 |
|---|---|---|---|---|---|
| 原生 PHP + MySQL | 无锁,靠手工SELECT ... FOR UPDATE | 手动,易遗漏 | 少 | 低 | 放弃 |
| ThinkPHP6 | 模型事件丰富,但锁机制仍靠 MySQL | 依赖 DB,性能瓶颈 | 国内文档多 | 中 | 可,但高并发下锁竞争激烈 |
| Laravel + Redis | 原子 Lua、事务、队列一把梭 | 支持分布式锁 | Composer 包多 | 中 | 选它 |
核心原因只有两句话:
- Redis 的单线程模型天然适合「秒杀/竞拍」类计数器,Lua 脚本保证读-判-写原子。
- Laravel 的
DB::transaction()+Redis::funnel()让我能把「数据库事务」与「分布式锁」写在同一段代码里,逻辑清晰,老师一眼能看懂。
3. 核心实现:一条出价请求的生命周期
3.1 需求建模(简化)
- 拍品表
auctions(id, title, end_time, init_price, stock, version) - 出价记录表
bids(id, auction_id, user_id, price, created_at) - 要求:库存强一致、价格单调递增、每人最多 3 次出价、支持 1 秒内 500 并发。
3.2 架构简图
3.3 关键流程(按请求顺序)
- 入口限流:Laravel 中间件
ThrottleRequests针对route('bid')做 IP 级 10 req/s。 - 参数校验:FormRequest 里用
Rule::exists('auctions','id')并校验price>0。 - Redis 分布式锁:
Redis::set("lock:auction:$auctionId", $uid, 'PX', 3000, 'NX')保证同一拍品 3 秒内只有一个请求进入核心逻辑。 - 实时出价队列:
用Redis::zAdd("auction:$auctionId:bids", $price, json_encode(['uid'=>$uid,'price'=>$price]))把价格当 score,天然升序。 - 库存与版本校验:
在 MySQL 事务内SELECT ... FOR UPDATE拿到version字段,再比较stock>0。 - 幂等写入:
先INSERT IGNORE到bids表,再用affected_rows==1判断是否为重复提交。 - 事务提交/回滚:
若全部成功,扣减stock并version+1;任一失败抛异常,Laravel 自动回滚。 - WebSocket 推送:
事务提交后,Laravel-Reverb 广播BidPlaced事件,前端实时刷新最高出价。
3.4 代码片段(Clean Code 风格)
// app/Services/BidService.php namespace App\Services; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; class BidService { /** * 执行一次出价 * @throws \Exception 业务异常 */ public function place(int $uid, int $auctionId, float $price): void { $lockKey = "lock:auction:$auctionId"; $lockVal = Redis::set($lockKey, $uid, 'PX', 3000, 'NX'); if (!$lockVal) { throw new \Exception('系统繁忙,请稍后再试'); } try { DB::transaction(function () use ($uid, $auctionId, $price) { // 1. 读拍品并加行锁 $auction = DB::table('auctions') ->where('id', $auctionId) ->lockForUpdate() ->first(); if (!$auction || $auction->stock <= 0) { throw new \Exception('拍品已售罄'); } if (strtotime($auction->end_time) < time()) { throw new \Exception('竞拍已结束'); } // 2. 幂等:每人最多 3 次出价 $bidCount = DB::table('bids') ->where('auction_id', $auctionId) ->where('user_id', $uid) ->count(); if ($bidCount >= 3) { throw new \Exception('您已出价 3 次,不可继续'); } // 3. 价格必须高于当前最高 $topPrice = Redis::zRevRange("auction:$auctionId:bids", 0, 0, 'WITHSCORES'); $maxPrice = $topPrice ? array_values($topPrice)[0] : $auction->init_price; if ($price <= $maxPrice) { throw new \Exception('出价必须高于当前最高价'); } // 4. 写 MySQL & Redis DB::table('bids')->insert([ 'auction_id' => $auctionId, 'user_id' => $uid, 'price' => $price, 'created_at' => now(), ]); Redis::zAdd("auction:$auctionId:bids", $price, json_encode([ 'uid' => $uid, 'price' => $price, ])); // 5. 扣库存 DB::table('auctions') ->where('id', $auctionId) ->update([ 'stock' => $auction->stock - 1, 'version' => $auction->version + 1, ]); }); } finally { Redis::del($lockKey); } // 6. 推送 broadcast(new \App\Events\BidPlaced($auctionId, $price)); } }4. 性能与安全:压测与加固
4.1 压测结果
- 环境:4C8G 云主机,Docker 启动 php-fpm + MySQL 8.0 + Redis 7。
- 工具:wrk 脚本模拟 500 并发持续 30s,QPS≈420,平均延迟 120ms,P99 520ms。
- 瓶颈:MySQL 行锁竞争,CPU 占用 68%;Redis 单核 30%,仍有富余。
4.2 防刷策略
- IP 级漏桶:Laravel
RateLimiter限定单 IP 10 req/s。 - 用户级令牌桶:登录后发放 JWT,每分钟 30 次出价。
- 滑动窗口计数:Redis
ZINCRBY记录行为分,达到阈值弹验证码。
4.3 SQL 注入防护
- 全部走 Eloquent/QueryBuilder,预编译语句。
- 对
search参数使用LIKE时,用whereRaw('title LIKE ?', ["%{$kw}%"])占位。
5. 生产环境避坑指南
- 时间同步误差:
云主机默认 NTP 同步周期 17min,竞拍结束时间若精确到秒,务必安装chrony并开启makestep。 - 事务隔离级别:
MySQL 默认 RR,拍品行锁与插入意向锁冲突频繁,可改为 RC +SELECT ... FOR UPDATE,减少死锁。 - 冷启动延迟:
Laravel 首次加载 450+ 文件,OPcache 必开;Docker 镜像里预生成config:cache、route:cache。 - Redis 持久化:
若主机重启,出价队列丢失,可在 Lua 里双写 AOF 与 MySQL,或把zAdd改为 Stream 持久化。 - 日志与排错:
把DB::listen回调写入 daily 通道,SQL 执行时间 >100ms 自动告警,方便答辩现场演示。
6. 留给你的课后作业
- 延时出价:在 Redis 用
ZADD把「未来时间戳」当 score,写守护进程每秒扫描到期任务,再调用BidService。 - 代理出价:用户预设「最高可接受价」,系统代替他每次比当前最高价多 1 个阶梯,直到预算耗尽——注意也要解决幂等。
把这两个功能跑通,你就能在答辩 PPT 里写上「支持自动代理 & 预约出价」,老师很难不给你加分。
凌晨两点,走廊灯自动熄灭,屏幕的光照着我通红的眼。
当 Grafana 的曲线终于稳成一条直线,我才敢合上电脑。
希望这篇小记,也能让你在面对「高并发」三个字时,心里不那么发怵。
动手吧,把延时出价脚本写完,下一次亮灯,可能就是答辩现场的大屏投影。