OpenRig:轻量级本地大模型终端编排系统
2026/11/2 14:14:57
毕业答辩前夜,我把电脑搬到宿舍走廊,一边盯着 Grafana 上跳动的 QPS 曲线,一边默念:「库存千万别变负数,千万别。」
如果你也在用 PHP 做拍卖系统,这篇熬夜总结或许能让你少走几个弯路。
校园网里做毕设,最容易「看起来能跑」——直到你把项目搬到公网,让 30 个同学同时点「出价」:
stock>0,都进入减库存逻辑,结果成交 2 笔。这些问题在本地 XAMPP 里很难复现,可一旦放到云主机,Nginx 日志里 502 与 500 交替出现,答辩老师一句「并发下数据一致性如何保证?」就能把人问住。
我把备选方案列成一张表,在宿舍白板前纠结了一晚上:
| 方案 | 并发能力 | 事务/锁 | 生态 | 学习成本 | 结论 |
|---|---|---|---|---|---|
| 原生 PHP + MySQL | 无锁,靠手工SELECT ... FOR UPDATE | 手动,易遗漏 | 少 | 低 | 放弃 |
| ThinkPHP6 | 模型事件丰富,但锁机制仍靠 MySQL | 依赖 DB,性能瓶颈 | 国内文档多 | 中 | 可,但高并发下锁竞争激烈 |
| Laravel + Redis | 原子 Lua、事务、队列一把梭 | 支持分布式锁 | Composer 包多 | 中 | 选它 |
核心原因只有两句话:
DB::transaction()+Redis::funnel()让我能把「数据库事务」与「分布式锁」写在同一段代码里,逻辑清晰,老师一眼能看懂。auctions(id, title, end_time, init_price, stock, version)bids(id, auction_id, user_id, price, created_at)ThrottleRequests针对route('bid')做 IP 级 10 req/s。Rule::exists('auctions','id')并校验price>0。Redis::set("lock:auction:$auctionId", $uid, 'PX', 3000, 'NX')保证同一拍品 3 秒内只有一个请求进入核心逻辑。Redis::zAdd("auction:$auctionId:bids", $price, json_encode(['uid'=>$uid,'price'=>$price]))把价格当 score,天然升序。SELECT ... FOR UPDATE拿到version字段,再比较stock>0。INSERT IGNORE到bids表,再用affected_rows==1判断是否为重复提交。stock并version+1;任一失败抛异常,Laravel 自动回滚。BidPlaced事件,前端实时刷新最高出价。// 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)); } }RateLimiter限定单 IP 10 req/s。ZINCRBY记录行为分,达到阈值弹验证码。search参数使用LIKE时,用whereRaw('title LIKE ?', ["%{$kw}%"])占位。chrony并开启makestep。SELECT ... FOR UPDATE,减少死锁。config:cache、route:cache。zAdd改为 Stream 持久化。DB::listen回调写入 daily 通道,SQL 执行时间 >100ms 自动告警,方便答辩现场演示。ZADD把「未来时间戳」当 score,写守护进程每秒扫描到期任务,再调用BidService。把这两个功能跑通,你就能在答辩 PPT 里写上「支持自动代理 & 预约出价」,老师很难不给你加分。
凌晨两点,走廊灯自动熄灭,屏幕的光照着我通红的眼。
当 Grafana 的曲线终于稳成一条直线,我才敢合上电脑。
希望这篇小记,也能让你在面对「高并发」三个字时,心里不那么发怵。
动手吧,把延时出价脚本写完,下一次亮灯,可能就是答辩现场的大屏投影。