ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

抢单系统架构解析:从Redis锁到PHP异步落库的实战指南

抢单系统架构解析:从Redis锁到PHP异步落库的实战指南 1. 抢单系统为什么难写它和秒杀根本不是一回事先说个结论网上很多教程把抢单和秒杀混为一谈这是个大坑。秒杀的核心是对同一商品库存做原子扣减但抢单系统本质是任务分发——多个人抢同一张单规则可能是先到先得也可能是大客户优先或限时竞价。我拆过几套TK相关的抢单源码发现凡是直接把秒杀代码拿过来套的基本都在上线后栽在同一个地方订单状态不一致。你想想一套完整的TK抢单系统至少涉及两个端用户端是UniApp打包成的App或H5管理端是Web后台后端是PHP接口层加MySQL存储中间还可能夹着一层Redis做热数据和队列削峰。其中真正的难点不在抢这个动作而在抢完之后的一致性。用户点击抢单此时单子被锁定了但如果后续支付超时、用户取消、风控校验失败单子要重新释放回池子里——这个释放过程做不好就会超卖或者漏单。我个人的经验是在动手写任何代码之前先把下面这几个业务规则想清楚一张单同时能被几个人抢通常抢单场景下一张单同一时刻只允许一个用户抢到但抢的过程可能有多个人同时发起。抢单成功后用户是多长时间内必须完成后续动作比如支付或确认接单超时后单子如何释放是立即回到池子里还是进入排队重新分配队列同一个用户能不能重复抢同一张单连抢多张单有没有风控限制这套规则直接决定了你底层用Redis的哪种数据结构、PHP代码要不要引入队列、MySQL的事务隔离级别怎么设置。源码里如果这些点都没有处理那不管页面做得多漂亮上线一周必出乱子。还有一个容易被忽略的点抢单系统的核心指标是有效请求成功率不是QPS。因为用户疯狂点击时接口可能收到几千个并发请求但真正能成交的就那么几十单。系统要保证的是不要把有效请求弄丢、不要把同一单发出去两次、不要让用户在界面上看到已抢到但后端根本没记录。明白这个之后再看高并发优化才有方向。2. 一套真实项目的目录结构UniApp前端与PHP后端的协同方式我拆解源码的习惯是先看目录再读入口文件最后追关键的几条链路。这里我基于一般TK抢单系统的常见结构给出一份比较规范的参考你看完就明白前端和后端是怎么配合的。2.1 UniApp前端部分应该怎么组织UniApp最大的优势是一套代码编译到App、H5、微信小程序等多个端。抢单这种强交互场景我建议目录这样分├── pages/ │ ├── index/ # 首页任务大厅列表 │ ├── grab/ # 抢单页核心页面 │ ├── order/ # 我的订单包括抢单记录 │ └── user/ # 个人中心 ├── components/ │ ├── order-card.vue # 单子卡片组件 │ └── countdown.vue # 倒计时组件抢单必备 ├── api/ │ ├── request.js # uni.request二次封装 │ └── order.js # 抢单相关接口 ├── store/ │ └── index.js # 用户状态管理 └── pages.json # 页面路由与导航配置这里要重点说api/request.js。很多初学者每个页面都直接写uni.request结果要改域名、加token校验、做错误拦截时就得满项目翻代码。我见过一份源码把请求封装写得极其松散导致后来加一个请求签名功能时改了几十个文件还没改全。规范的做法是统一封装请求层把token注入、错误码处理、超时重试都放在一个文件里。拆解中发现一个细节值得学习请求拦截器里不只是带token还会带一个device_id。这个字段是用户在抢单时生成并存在本地缓存里的用于风控标识设备。虽然它很容易被伪造但至少能挡住一大批脚本刷单。2.2 PHP后端的目录与分层思想PHP端我见过两种风格一种是把所有逻辑直接写在order.php一个大文件里另一种是引入简单的MVC。抢单这种对并发和扩展性要求高的项目我强烈建议至少做轻量分层├── application/ │ ├── api/ # 对外接口层只做参数接收和响应 │ ├── service/ # 业务逻辑层抢单核心逻辑 │ ├── model/ # 数据模型层操作数据库 │ └── common/ # 公共函数、Redis封装、工具类 ├── config/ # 数据库、Redis、队列配置 ├── route/ # 路由规则 └── public/ # 入口文件 index.php为什么要分层直接原因是抢单逻辑需要复用。用户端抢单会调用一个方法后台管理系统强制释放订单也会调用类似的方法如果逻辑都堆在控制器里两边就维护了两份代码很容易出现改了这边漏了那边的情况。我拆的那套源码OrderService::grab()函数被用户端和后台同时调用核心逻辑只有一份这就是好的设计。另外PHP是请求结束后自动释放所有资源的模型这一点和常驻内存的服务不同。在高并发抢单场景下数据库连接池/复用就非常关键。很多源码用ThinkPHP或Laravel框架自带的connection()-table()每次请求都会新建连接并发一起来MySQL直接报Too many connections。后面优化时要么上连接池方案如Swoole后端的连接池要么至少把数据库连接配置改成符合实际压力的复用方式。2.3 前后端联调时的接口约定拆解源码时我注意到接口设计直接决定了并发控制的难易程度。一套成熟的抢单接口一般包含以下几个关键端点接口方法作用核心参数/api/order/listGET获取可抢单列表page, limit/api/order/detailGET单子详情含倒计时order_id/api/order/grabPOST抢单动作核心接口order_id, device_id/api/order/payPOST抢单后支付或确认order_id/api/order/cancelPOST取消订单释放单子order_id/api/order/timeoutPOST超时处理服务端定时触发—这里最有讲究的是/api/order/grab的返回值设计。初始版本源码返回的是布尔值true/false但这样客户端根本无法区分抢成功了和抢失败了。后来改成返回状态码200抢单成功返回锁定订单编号410单子已被别人抢走请刷新列表429抢得太快被限流了500系统内部错误401登录失效客户端拿到不同的状态码展示不同的提示文案和交互逻辑。比如收到410页面就直接把这张单子从列表中剔除收到429前端可以做3秒的本地冷却。这个细节看似简单但能极大减少无效请求打到后端。3. 抢单核心链路源码解析从用户点击到订单落库这一部分是整篇博文最核心的内容。抢单链路我按请求依次经过哪些层来拆每一步都对应源码里的关键函数和关键数据结构。3.1 用户点击之后UniApp端做了什么前端代码的handleGrab()函数大致是这个流程// pages/grab/index.vue 中简化后的抢单逻辑 handleGrab(orderId) { // 检查是否已经提交过防止连点 if (this.submitting) return; this.submitting true; // 简单的本地倒计时校验 const order this.orderList.find(item item.id orderId); if (!order || order.countdown 0) { uni.showToast({ title: 该单已过期, icon: none }); this.submitting false; return; } // 调用统一封装的请求方法 request({ url: /api/order/grab, method: POST, data: { order_id: orderId, device_id: uni.getStorageSync(device_id) } }).then(res { if (res.code 200) { // 抢单成功跳转支付页 uni.navigateTo({ url: /pages/order/pay?id${res.data.order_no} }); } else if (res.code 410) { // 单子已被抢走从列表移除 this.orderList this.orderList.filter(item item.id ! orderId); uni.showToast({ title: 手慢了单子被抢走了, icon: none }); } // 其他状态码统一提示 }).finally(() { // 无论成功失败1秒内不允许再次点击 setTimeout(() { this.submitting false; }, 1000); }); }注意这里有个关键变量submitting它在网络请求还没返回时禁止再次提交。为什么要加这个因为很多真实用户会疯狂点屏幕如果每次点击都发一次请求前端就在同一张单上发出了多个并发请求。后端即使事务做得再好这些重复请求也白白消耗了系统资源。其实更好的做法是不仅前端限制后端也需要对同一用户同一订单做去重限制后面讲Redis时我会说。3.2 PHP端抢单接口三段式加锁逻辑这是抢单系统精髓中的精髓。拆解源码后我把后端grab()函数的核心逻辑梳理成三段式加锁第一段Lua脚本原子性校验加锁定PHP代码虽然在这里但真正抢单瞬间的热点操作是在Redis里用Lua脚本完成的。我见过最稳的写法是// OrderService.php 中抢单核心方法 public function grab($userId, $orderId) { // 1. 参数校验 $orderInfo $this-getOrderById($orderId); if (!$orderInfo || $orderInfo[status] ! 1) { return [code 410, msg 订单不存在或已被抢]; } // 2. 用户去重校验防止同一用户重复抢单 $userKey grab:user:{$userId}; if (Redis::sismember($userKey, $orderId)) { return [code 429, msg 您已抢过此单]; } // 3. 核心Lua脚本原子操作 // 这一步同时完成三个动作 // 检查订单是否可抢 - 标记为已锁定 - 记录用户 $lua LUA local orderKey KEYS[1] local userKey KEYS[2] local orderId ARGV[1] local userId ARGV[2] -- 检查订单状态是否仍可抢 if redis.call(hget, orderKey, status) ~ 1 then return 0 end -- 原子性更新状态为2已锁定 redis.call(hset, orderKey, status, 2) redis.call(hset, orderKey, lock_uid, userId) redis.call(hset, orderKey, lock_time, ARGV[3]) -- 记录用户抢过该单 redis.call(sadd, userKey, orderId) return 1 LUA; $res Redis::eval($lua, 2, $orderKey, $userKey, $orderId, $userId, time()); if (!$res) { return [code 410, msg 手慢了单子已被抢走]; } // 4. 锁定成功后投递到队列异步落库 Queue::push(orderLocked, [order_id $orderId, user_id $userId]); return [code 200, msg 抢单成功, data [order_no $this-generateOrderNo()]]; }这段代码的思路是用Redis集群完成抢单瞬间的高并发操作用MySQL落库成最终数据。为什么不用MySQL的UPDATE ... WHERE status1来完成抢单因为在高并发下这个操作会频繁触发MySQL的行锁等待和死锁检测数据库连接数迅速耗尽。而Redis单线程模型配合Lua脚本天然就是原子的比数据库行锁快几个数量级。这里面的一个小细节是锁定后并不直接写MySQL而是投递一个orderLocked的异步任务进队列。为什么这样做因为队列消费者可以批量把Redis中的锁定状态同步到MySQL避免每个请求都触发一次数据库写入。当然如果单量没有那么大直接写MySQL问题也不大但为了高并发场景留出弹性用队列是更稳的架构选择。第二段队列消费者异步落库队列我用的是Redis自身的list结构实现的简易队列配合一个常驻的PHP消费者进程。核心代码// Consumer.php 常驻进程 while (true) { $orderData Redis::brpop(grab:queue, 5); if (!$orderData) continue; list(, $data) $orderData; $data json_decode($data, true); // 开启事务写入MySQL $pdo-beginTransaction(); try { $pdo-prepare(INSERT INTO orders (order_no, user_id, order_id, status, lock_time) VALUES (?,?,?,2,?)) -execute([$data[order_no], $data[user_id], $data[order_id], $data[lock_time]]); $pdo-prepare(UPDATE orders SET status2, lock_uid? WHERE id? AND status1) -execute([$data[user_id], $data[order_id]]); $pdo-commit(); } catch (Exception $e) { $pdo-rollBack(); // 记录日志待人工处理 } }这里有一个执行顺序的设计先插入用户订单记录再更新订单状态。为什么因为你可能同时有多个人抢到了不同的单如果先更新订单状态再插记录一旦插入失败就会出现订单状态显示已锁定但找不到任何人的记录的脏数据。先记录后更新即使更新失败也保留了完整的审计信息。第三段超时释放与状态流转用户锁定一张单后如果迟迟不支付或取消系统必须自动释放。源码里有一个定时任务// TimeoutTask.php 每分钟执行 public function run() { // 找出所有锁定超过5分钟的订单 $timeoutOrders OrderModel::where(status, 2) -where(lock_time, , time() - 300) -get(); foreach ($timeoutOrders as $order) { // 判断订单是否已支付 if (!$order-paid_at) { // 更新订单状态回可抢 OrderModel::where(id, $order-id) -where(status, 2) -update([status 1, lock_uid null, lock_time null]); // 同步更新Redis中的状态 Redis::hmset(order:{$order-id}, status, 1, lock_uid, ); Redis::srem(grab:user:{$order-user_id}, $order-id); } } }这里的关键是释放操作在线程里执行同样使用了条件更新WHERE status2。这是为了保证只有当前还处于锁定状态的订单才能被释放防止用户刚好付款时被定时任务释放了。支付回调里也加了同样的判断支付时如果状态不是2说明单子已被系统释放这时应该走退款流程。这种状态机设计需要全局一致否则就会出现支付成功了但系统说单子不存在的恶性Bug。3.3 数据库表设计三个关键表不能少读源码时我还专门看了数据表。抢单系统的核心表一般至少有三张它们的字段设计很大程度上决定了业务扩展的灵活性订单池表orders管理可抢的单子id BIGINT 主键 order_no VARCHAR 业务订单号 title VARCHAR 任务标题 amount DECIMAL(10,2) 任务金额 status TINYINT 1可抢 2已锁定 3已完成 4已取消 lock_uid BIGINT 锁定用户ID lock_time INT 锁定时间戳 created_at INT 创建时间用户抢单记录表user_grabs记录谁抢过什么id BIGINT 主键 user_id BIGINT 用户ID order_id BIGINT 订单ID grab_time INT 抢单时间 source VARCHAR 渠道来源app/h5/小程序 status TINYINT 1已锁定 2已完成 3已取消任务表tasks业务扩展信息id BIGINT 主键 order_id BIGINT 关联订单 task_no VARCHAR 任务编号 detail TEXT 任务详情 deadline INT 截止时间拆解源码时我看到一个比较惊艳的设计订单池表和用户抢单记录表之间不直接建立外键而是通过业务代码保证一致性。这在互联网高并发项目里是常见做法因为外键在插入和更新时会带来额外的检查开销而且分库分表后物理外键会成为障碍。但代价是代码里必须处处把关少更新一个表就会产生脏数据。我个人的建议是项目早期团队对业务还不熟悉时可以用物理外键约束防呆等架构真正需要拆分时再改为代码维护。4. 高并发场景下的真实瓶颈压测发现的三个坑这一节讲的不是理论而是我自己在测试环境压测和上线初期真实遇到的问题。如果你能把这三个坑提前避开能省掉大把的排查时间。4.1 第一个坑PHP-FPM连接池被瞬间打满项目刚上线那会儿推广活动一开用户大量涌入MySQL连接数瞬间飙到几百数据库直接拒绝连接。过了一段时间代码没变但系统自己恢复了——其实是用户刷了一会儿发现打不开就流失了连接数才降下来。这种自动恢复是错觉不是你系统处理得了并发而是用户被你自己赶走了。排查过程先看SHOW PROCESSLIST发现大量Sleep状态的连接占着连接池。原因是PHP-FPM默认每请求结束才释放MySQL连接而抢单瞬间并发量高二、三百个PHP进程同时建立MySQL连接又因为事务中有锁等待连接释放变慢。解决思路分两步。第一步是调整数据库连接配置限制单个FPM进程的连接生命周期; php-fpm.conf pm.max_children 100 pm.start_servers 30 pm.min_spare_servers 30 pm.max_spare_servers 60第二步才是在代码层面解决把抢单的热点校验全部放到Redis让MySQL不再承担抢单瞬间的并发压力。压测数据对比优化前300并发时系统可用性不足50%优化后300并发时成功率稳定在99.9%以上MySQL连接数始终控制在100以内。我的体会是线上压测动不动就得出方案不行的结论之前先看看是不是底层资源的连接池配得太小。4.2 第二个坑Redis超时导致雪崩效应解决MySQL连接问题后新的问题来了并发一高Redis偶尔报超时然后系统秩序完全乱了——明明单子被人抢了过几秒又显示可抢因为超时后代码走了回退分支释放了锁。这个问题要分两层看。一层是Redis本身超时。PHP默认Redis扩展的超时时间设置为0永不超时但在高并发下TCP连接能建起来不代表能及时读到数据。所以我把所有Redis操作的超时时间显式设置了连接超时0.5秒读超时1秒写超时1秒。看起来是限制其实是保护不让单个慢请求挂死整个FPM进程。另一层是业务超时后怎么办。如果Redis因为网络问题误超时绝不能直接执行后续操作否则会把一个状态未知的判断当成抢单失败处理。正确做法是快速失败返回系统繁忙让用户重试同时记日志排查。宁可让用户多点击一次也不能让用户看到误操作的结果。// 设置Redis超时 $redis new Redis(); $redis-connect(127.0.0.1, 6379, 0.5); $redis-setOption(Redis::OPT_READ_TIMEOUT, 1); $redis-setOption(Redis::OPT_TCP_KEEPALIVE, 1);4.3 第三个坑事务锁等待导致MySQL死锁异步落库的消费者进程在处理大量订单写入时我看到MySQL日志里出现死锁报错。原因是我在上面Consumer.php里的更新操作同时持有了多个订单的行锁不同消费者进程以不同顺序锁定同一个订单组就产生了死锁。解决办法有两种源码里我挑了更简单的一种按订单ID排序后再更新所有消费者进程都以相同的顺序加锁。// 更新前先排序避免死锁 $orderIds $data[order_ids]; sort($orderIds); // 这是重点 foreach ($orderIds as $orderId) { $pdo-prepare(UPDATE orders SET status? WHERE id?)-execute([$status, $orderId]); }另一种是MySQL的innodb_lock_wait_timeout调小一点让死锁快速报错而不是干等50秒。但根治还是要靠加锁顺序一致。5. UniApp端抢单体验优化的若干细节很多人以为抢单系统只要后端能扛住并发就万事大吉了实际上一线反馈中用户抱怨最多的往往是前端体验问题点半天没反应、倒计时飘忽不定、页面经常白屏。我挑三个最典型的细节展开说。5.1 倒计时的准确性问题抢单任务的倒计时是刚需功能但很多开发的实现方式是在订单列表返回时取一个expire_time然后前端写一个定时器每秒减1。这样有两个问题用户锁屏一段时间后JS定时器会被系统挂起回来时倒计时还是离开时的值导致用户以为还有时间结果一点抢单就提示已过期。前端时间戳容易被本地设备时间误差干扰用户手机时间快了1分钟就会出现列表显示还有时间抢的时候却已经截止的情况。改进方案是返回订单时间信息时同时返回服务器当前时间戳和截止时间戳。前端用Date.now()差值先算出本地与服务器的偏差然后倒计时统一用截止时间戳 - 服务器当前时间戳来计算并且定时器每次回调都重新校正一次。// countdown.vue 核心逻辑 startCountdown(expireTime, serverNow) { const offset Date.now() - serverNow * 1000; // 本地与服务器偏差 this.timer setInterval(() { const remain expireTime * 1000 - (Date.now() - offset); if (remain 0) { this.$emit(timeout); clearInterval(this.timer); } else { this.remainText this.formatTime(remain); } }, 250); }选250毫秒的更新间隔是有原因的每秒更新一次的话偶尔一次定时器卡顿会让用户看到连续两秒显示同一个数字。250毫秒刷新能保证视觉上足够顺滑同时CPU开销也不大。5.2 快速点击与防重复提交前端的submitting变量能挡住同一用户连点但这只是最低级的防护。实际场景中用户可能在一个页面上反复抢不同单子这种高频操作也需要节流。我在request.js的封装里加了一个全局的接口节流队列let lockMap {}; function request(options) { const key options.url JSON.stringify(options.data || {}); if (lockMap[key]) { return Promise.reject({ code: 429, msg: 操作太频繁 }); } lockMap[key] true; return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, success: (res) { resolve(res.data); }, fail: (err) { reject(err); }, complete: () { setTimeout(() { delete lockMap[key]; }, 500); } }); }); }这个方案的意义在于即使某个页面开发时忘了写submitting保护请求层也能兜底同一参数的请求500毫秒内不重复发出。5.3 App端与H5端的差异化处理UniApp打出来的App和H5在抢单体验上有几个天然差异源码里也做了分支处理App端可以使用5原生能力做本地消息推送抢单成功后的通知直接本地生成用户不会错过。H5端没有这个能力只能用页面内弹窗。App端的uni.request在iOS上如果用户开启了低电量模式长连接可能会被系统断开导致请求超时时间被拉长。我在App端的请求超时时间设置为10秒H5端设置为15秒因为H5可能存在网络层差异。H5端在部分安卓WebView里localStorage不可靠存device_id时用了uni.setStorage的自动降级方案。这些细节表面上看不直接影响高并发但它们决定了一个系统在真实用户手里是不是能用。抢单抢不到用户还能忍体验卡顿或者显示错误是绝对不能忍的。6. 再聊一点架构层面的选择与权衡作为收尾我想聊几个很多人会纠结的架构决策。这些没有绝对的对错但根据项目阶段不同选择会不一样。6.1 到底要不要用Swoole或者Workerman很多文章说PHP高并发必须用Swoole常驻内存。这话只对了一半。如果项目是从零开始并且你有自信后续并发级别会持续走高那么Swoole确实是值得的投资。它的常驻内存特性避免了PHP-FPM每次请求重新加载所有类的开销理论上性能提升非常明显。但如果你是在现有ThinkPHP或Laravel项目上改造我建议先不要动运行模式这层。因为Swoole模式下很多框架的中间件、Session、文件上传等机制都会踩到坑调试成本非常高。我曾经试过把一套基于ThinkPHP的抢单系统直接迁移到Swoole下结果框架自带的文件锁、模板引擎、Session机制全部要改改动量几乎等于重写。比较务实的路线是业务代码保持PHP-FPM架构不变抢单链路的关键动作用Redis配合Lua脚本扛住高并发把耗时操作异步化。这套方案足以支撑几十万用户量级的抢单场景。等真有更高的并发需求了再逐步把核心接口迁移到Go或Java服务上原来的PHP层退化为管理后台和普通业务接口。慢但稳。6.2 Redis数据怎么保证不丢抢单系统里Redis里存的是实时状态MySQL存的是最终数据。Redis如果宕机就会造成Redis里显示某单已锁定但MySQL里还是可抢状态的不一致。我用的方案是给Redis开启AOF持久化并且设置appendfsync everysec。这样如果Redis进程崩溃最多丢失不到1秒的数据。而系统本身的兜底逻辑是启动时从MySQL读取所有订单状态重建Redis缓存。流程是MySQL中所有status1的订单写入Redis并设置状态为1MySQL中所有status2且未过期的订单写入Redis并设置状态为2和lock_uid超过锁定时间的订单统一执行一次释放任务这样即使Redis完全清空也能在几十秒内自动重建。这个方案实践下来最稳比单纯依赖Redis本身的持久化要可靠得多。6.3 压测工具怎么选最后说一个很实际的点不要用浏览器F5去压测接口那是压不出真实数据的。我用的是阿里云PTS和开源的wrk这两种组合。简单说一下怎么用# wrk 压测抢单接口 wrk -t8 -c200 -d30s -s post.lua http://your-api.com/api/order/grabpost.lua里要模拟真实用户的请求体wrk.method POST wrk.headers[Content-Type] application/json wrk.body {order_id:1,device_id:test-device}压测前一定要先在数据库把订单池准备充分不然一边压一边还得造数。另外压测结果不能只看平均响应时间要看p99和成功率。wrk默认不输出p99你可以用wrk --latency参数。在同样的并发量下p99如果超过500毫秒就说明系统有性能瓶颈需要优化了平均时间在这个场景下参考价值不大。这几轮优化做下来我的整体体会是抢单系统优化的核心不是快而是稳。快速响应用户点击可以通过缓存解决但状态一致性、超时释放、重复提交这些细节才是上线后真正决定口碑和收益的关键。你把这些底层逻辑吃透了再去套任何一门具体技术都会顺手得多。
返回列表