做TK任务类业务的朋友十有八九都被抢单系统的高并发问题困扰过。早期我们直接用PHP查数据库加行锁来做库存扣减用户量一上来数据库直接被打爆订单超卖、重复抢单、余额错乱各种问题接踵而至。后来我重新做了一版前端用UniApp后端保持PHP但把抢单链路彻底重构用Redis原子操作替代数据库扣库存再配合队列异步落库和一系列限流措施才算真正稳住了。这篇文章就把这套TK抢单系统的完整实现思路和源码关键逻辑拆开讲清楚包括业务设计、数据库结构、高并发抢单的核心代码、UniApp多端适配的坑以及PHP后端从FPM到Swoole的优化路径。适合已经在做或准备做同类任务平台、众包抢单业务的开发者也适合想学高并发落地写法的朋友参考文章里所有方案都是我在真实业务中验证过的照着改就能用。1. 项目整体架构与业务逻辑拆解1.1 TK抢单系统到底在解决什么问题很多刚接触这个业务的人会以为抢单系统就是简单做一个任务列表加一个“立即抢单”按钮。真做起来才知道这套业务的本质是“资金流任务流用户行为”三者的强一致性问题。TK抢单系统的核心业务链路是这样的平台方发布任务比如给指定视频点赞、关注、评论、浏览指定时长等每个任务有数量限制和佣金金额。用户登录后在任务大厅看到可抢任务点击抢单抢到之后在规定时间内去TK完成操作再回传截图或口令作为凭证。平台审核通过后佣金进入用户余额用户后续可以提现或用余额加价抢更高佣金的任务。这里面最要命的就是“抢”这个动作。一个热门任务可能只放出50个名额结果30秒内有几千人同时点击如果抢单逻辑写得糙就会出现两个问题一是库存超卖50个名额卖出去了80单二是重复抢单同一个人用多台设备或者多个账号反复抢。这两个问题处理不好平台赔钱是小事用户信任崩塌才是致命的。1.2 为什么选了UniAppPHP这套组合技术选型这事很多人喜欢追新但做业务系统最重要的是贴合团队实际情况。前端用UniApp核心原因只有一个一套代码覆盖微信小程序、抖音小程序、H5和App。做TK任务业务的用户大部分在微信和抖音这两个生态里如果两端各写一套原生代码开发和维护成本直接翻倍。UniApp基于Vue语法熟悉前端的人上手很快而且它的条件编译机制可以针对不同端写差异化代码比如微信小程序里要用wx.login抖音小程序里要用tt.login这些都能在同一个项目里优雅处理。后端用PHP这个决定我估计会有人质疑——PHP不是号称“性能差”吗高并发场景为什么不用Go或者Java我的回答是对于TK抢单这种业务真正决定系统上限的不是语言本身的性能而是架构设计。PHP有它独有的优势生态极其成熟开发效率高招人容易大部分接这类外包或自研团队本来就是PHP出身。高并发部分完全可以靠Redis、消息队列、Swoole这些中间件来补足没必要为了“高性能”把一个本来就能跑的业务推倒重来。当然如果你需要支撑百万级同时在线那确实得上Go或者Java那套微服务架构。但就TK抢单这种量级的中小平台而言PHPRedis做好的架构单机撑住几千QPS是没有问题的。1.3 高并发场景的四个核心拆解把整个系统拆开看真正会产生高并发压力的只有四个场景其余的CRUD接口根本不用优化加个缓存就够。第一个是抢单接口这是压力最大的点几十上百个用户同时抢同一批任务属于典型的秒杀场景必须用Redis原子操作来保证不超卖。第二个是任务列表接口读多写少用户会频繁刷新任务大厅看有没有新任务这个用缓存就能解决热点数据放到Redis里过期时间控制在30秒左右就行。第三个是任务执行后的凭证上传与审核回调这个不能同步处理否则用户上传完凭证要等几秒才能拿到结果体验很差异步队列才是正解。第四个是佣金结算和提现涉及资金操作一致性要求极高必须保证幂等不能用户提现一次系统扣两次钱。这四块处理好了整个系统的并发骨架就立住了。2. 数据库设计与任务状态流转2.1 核心表结构与字段设计数据库设计这方面我在第一版吃过亏表结构设计得太简单任务表里直接存剩余数量抢单记录和订单表混在一起结果后续加功能时各种别扭。后来重构时做了这几张核心表表名核心字段作用说明usersid, openid, phone, balance, status用户基础信息与余额tasksid, title, type, total_num, remain_num, commission, status, start_time, end_time任务发布与库存控制task_ordersid, user_id, task_id, order_no, status, proof_url, created_at, expired_at用户抢单后的执行记录balance_logsid, user_id, change_amount, before_balance, after_balance, type, order_no余额变动流水资金对账全靠它cash_outid, user_id, amount, status, audit_time提现申请记录card_codesid, code, batch_no, amount, status, used_by, used_time充值卡密表批量生成与兑换任务表里的remain_num是冗余字段真正扣减库存的操作不会直接写这张表而是操作Redis里的同名key后面章节详细讲。task_orders表里有一个order_no这是幂等关键用户抢单时生成唯一订单号数据库里加了唯一索引重复提交直接报错。2.2 任务状态机与超时回收机制任务表的status字段我设计了五个状态0待发布、1抢单中、2已抢完、3进行中、4已结束。发布任务后脚本按开始时间把状态从0切到1任务在Redis里的库存扣完状态自动变为2任务结束时间到了状态置为4。这里有个容易被忽视的细节用户抢到单之后并不代表任务就“完成”了。用户需要在规定时间内比如15分钟去TK执行任务并上传凭证如果超时未操作这条订单必须释放库存加回去让其他用户继续抢。这就是task_orders表里expired_at字段的作用。超时释放不能只靠数据库定时任务扫那样延迟太高。我用的方案是抢单成功后把订单号写入Redis的延迟队列key为“task_order_expire_”加订单号值为过期时间戳另外再用一个每分钟执行一次的定时脚本扫描task_orders表把expired_at小于当前时间且状态还是“待执行”的订单批量标记为“已取消”同时把Redis库存加回去。实测下来两种方式结合订单回流延迟不会超过1分钟。2.3 为什么不能只用数据库扣库存在最早的版本里扣减库存用的是最简单的SQLUPDATE tasks SET remain_num remain_num - 1 WHERE id ? AND remain_num 0;这条语句在低并发下没问题但并发一高就有两个隐患。第一MySQL的UPDATE行锁是串行的当几千个请求同时更新同一行任务记录时后面的请求全部排队等锁数据库连接很快被占满接口响应直接从50ms飙到3秒以上。第二即使加了remain_num 0条件在高并发下依然可能出现“逻辑超卖”——两个请求同时读到剩余数量为1都执行了扣减但两条UPDATE在InnoDB的事务隔离级别下都判断通过。所以在重构时我的原则是所有库存类操作一律不走数据库只走Redis。整个抢单接口里最重要的就是这一步具体代码在下一节讲。3. 高并发抢单核心实现从原子扣减到限流防刷3.1 抢单接口的整体流程设计抢单接口是这套系统的命门我把它的处理流程拆成了六步每一步都有明确的目的参数校验与用户登录态校验顺便做用户维度限流检查校验任务状态只有Redis里的任务状态key为“抢单中”才继续Redis原子扣减库存也就是那个关键的Lua脚本扣减成功后向消息队列写入一条“抢单成功”消息由消费者异步落库生成task_orders订单立即向用户返回“抢单成功”结果在用户端启动倒计时同时前端跳转到“待执行任务”页面。注意第3步和第4步是解耦的扣减库存是同步的因为必须立刻知道有没有抢到订单落库是异步的因为用户不需要等数据库写入完成才看到结果只要Redis扣减成功这个任务名额就已经锁住了。3.2 Redis原子扣减用Lua脚本杜绝超卖Redis保证原子性的方案有很多DECR单独用并不安全因为我们需要“先判断库存足够再扣减”而DECR支持负数的特性会让库存变成负值。正确做法是使用Lua脚本让判断和扣减在一个原子操作里完成。核心Lua脚本如下-- KEYS[1] 任务库存key KEYS[2] 任务状态key -- ARGV[1] 当前时间戳 local remain redis.call(GET, KEYS[1]) if not remain then return -1 -- 库存不存在 end remain tonumber(remain) if remain 0 then return 0 -- 已抢完 end local status redis.call(GET, KEYS[2]) if status ~ 1 then return -2 -- 任务不在抢单中 end redis.call(DECR, KEYS[1]) return 1 -- 抢单成功这个脚本通过redis.call(DECR, KEYS[1])完成扣减因为整个脚本在Redis中是串行执行的所以同一时刻只有一个请求能真正扣到库存从根本上杜绝了超卖。我在这里踩过一个坑脚本写好后没有做库存预热。任务发布时只在数据库里写了一条记录Redis里根本没有库存key结果用户抢单时脚本直接返回-1所有任务都是“不可抢”状态。后来我写了一个发布任务的脚本任务状态变为“抢单中”时顺带执行SET task_stock_任务ID 总数量把库存初始化到Redis这才正常。3.3 抢单结果异步落库与幂等处理Redis扣减库存成功之后消息队列消费者会执行订单落库操作。这里我用的是Redis的List结构做简单队列生产者LPUSH消费者BRPOP不需要引入额外的MQ组件简单可靠。订单落库最怕的是重复插入原因在于同一用户手速极快连点了两次抢单或者网络超时后前端自动重试两条消息都进了队列。解决办法是给task_orders表的(user_id, task_id)加上唯一索引消费者在插入时用INSERT IGNORE重复的订单直接忽略不影响库存。另外我还会生成一个order_no规则是日期 随机串作为业务层的唯一标识方便后续对账。3.4 限流与防刷抢单接口的第一道防线抢单系统最大的风险不在于正常用户的并发而在于恶意脚本的刷单。我见过有人用脚本控制几百个账号在同一秒内抢同一个任务如果没有任何限制再多库存都会被刷干净。防刷策略我做了三层第一层用户维度限流。以user_id 日期为key限流次数按业务规则来比如每个账号每天最多抢50单用INCR加EXPIRE实现一个简单的计数器超过阈值直接拒绝。第二层频率限制。同一个用户两次抢单请求之间至少间隔1秒Redis里存上次抢单时间小于1秒的请求直接返回“操作太频繁”。这一层主要拦截手速异常的用户。第三层IP维度限流。同一个IP每天最多创建5个账号这个放在注册接口做抢单接口则限制同一IP每分钟最多100次请求。// 用户维度限流示例 $key user_daily_orders_ . $userId . _ . date(Ymd); $count $redis-incr($key); if ($count 1) { $redis-expire($key, 86400); } if ($count 50) { return json([code 400, msg 今日抢单次数已达上限]); }防刷这块要特别提醒不要只做用户维度限流盗号刷单、脚本批量注册的账号很可能绕开用户限制IP限流是必须的补充。3.5 抢单结果如何实时通知前端用户抢单成功后前端页面需要立刻反馈结果。如果用户抢到了前端要进入任务执行页面如果没抢到要提示“手慢了下次加油”。这里我用的是WebSocket方案具体是在抢单接口返回结果的同时后端往一个专门的通知通道推送一条消息前端收到后自动跳转。不过WebSocket在PHP-FPM环境下维护长连接比较重如果你的系统支撑不了就退而求其次用前端轮询抢单请求发出后前端每2秒调用一次“查询抢单结果”接口最多轮询5次。虽然不如WebSocket实时但胜在实现简单对中小平台完全够用。4. UniApp前端实战从页面设计到多端适配4.1 抢单页面与按钮状态控制UniApp端抢单页面是整个用户体感的关键。任务卡片展示任务类型、佣金金额、剩余数量和倒计时用户最关心的就是“还有多少可以抢”和“我能不能抢到”。这里有个交互细节抢单按钮不能一直是可点击状态。如果任务已抢完按钮要置灰显示“已抢完”如果任务还没开始显示“即将开始”如果用户今天已抢过该任务显示“已抢过”只有状态为“抢单中”且用户未抢过的情况下按钮才显示“立即抢单”并保持可点。这个状态判断不能只依赖前端本地数据必须每次进入页面都从后端获取任务详情否则会出现用户看到“还有3个名额”但点击后提示“已抢完”的尴尬情况。4.2 登录与会话保持的多端兼容UniApp的登录流程在不同端的实现差异很大。微信小程序要调用uni.login获取code后端再用code换openid抖音小程序走的是tt.loginH5端则用账号密码或手机号验证码登录。我给客户做过的最稳的方案是后端提供一个/api/auth/login接口接收平台类型、code、用户信息后端根据平台类型调用不同的验证逻辑统一返回token。前端把token存到uni.getStorageSync每次请求在header里带上Authorization: Bearer token。Token过期问题在H5端尤其要注意因为H5页面可以被用户停留很久token过期后用户再操作会突然弹“登录过期”。我的处理方式是请求拦截器里判断返回码如果是401就静默调用/api/auth/refresh刷新token刷新失败才跳转登录页这样用户在无感知的情况下续期。4.3 任务列表长列表分页与下拉刷新任务大厅是一个长列表用户会频繁下拉刷新看有没有新任务。UniApp里用onReachBottom触发加载下一页onPullDownRefresh触发下拉刷新。分页加载这里有个常见的坑用户下拉刷新和滚动加载是异步的如果两个请求同时发出会出现数据错位。我的做法是加一个loading锁请求发出时将loading置为true返回后将loading置为false在回调里判断如果loading为true就直接return避免并发请求。另外任务列表必须做缓存。用户每次下拉刷新都会请求接口如果接口没有缓存数据库压力会很大。我在后端做了Redis缓存任务列表接口优先读Redis缓存没有命中才查数据库然后再把数据写回Redis。前端同时也用uni.setStorageSync缓存上一次数据用户重新打开页面时先显示缓存再静默刷新体感会好很多。4.4 H5多域名配置一个容易被忽略的大坑热搜词里有人问“uniapp封装h5如何指向2个域名”这个问题我确实遇到过而且特别典型。UniApp打包H5时接口地址是写死在代码里的但真实部署场景中H5页面可能放在CDN上接口需要指向后端API域名开发环境又要指向本地或测试服务器。如果只有一个固定的baseURL打包后就无法改了。我的解决方案是封装一个request.js不从代码里读死baseURL而是从uni.getStorageSync(apiBaseUrl)获取如果本地没有这个值再用默认值兜底。部署到生产环境后运维在H5页面里执行一段初始化脚本把API域名写入storage这样一套H5代码可以指向任意域名不需要改代码重新打包。// request.js 核心逻辑 const getBaseUrl () { let baseUrl uni.getStorageSync(apiBaseUrl); if (!baseUrl) { // 根据当前环境取不同默认值 baseUrl process.env.NODE_ENV development ? https://dev-api.example.com : https://api.example.com; } return baseUrl; };这个方法在微信小程序里不适用因为小程序的request域名必须在开发者后台配置白名单且只能写死但H5部署灵活度很高这个方案实测下来很稳。4.5 小程序扫码与分享功能实现TK任务业务里小程序扫码和分享是获客的重要入口。用户在一个群聊里看到分享卡片点进来或者扫一个线下二维码进入小程序这样的场景非常常见。微信小程序扫码进入用的是uni.scanCode拿到结果后解析出参数再通过uni.navigateTo跳转到对应页面。分享功能用onShareAppMessage在分享时把任务ID作为参数拼到path里用户点开分享卡片进入小程序时在onLoad里获取参数跳转对应任务详情页。这里有个经验如果用户是从分享卡片进入小程序的首次会话这时候用户还没有登录。你要在获取到分享参数后先跳登录流程登录成功后再根据参数跳转任务页否则会出现“登录后分享参数丢失”的情况。我的方案是把分享参数存到全局变量或本地缓存登录回调里再取出来处理。4.6 防录屏与敏感操作保护热搜词里也提到了“uniapp防止录屏”。说实话纯前端的防录屏方案只能做到“增加录屏成本”不能完全杜绝。微信小程序提供了wx.getPrivacySetting和wx.onUserCaptureScreen两个API前者是隐私协议后者可以监听用户截屏事件但这两个都不能阻止录屏。真正可落地的方案是在涉及敏感信息的页面比如用户上传口令、输入卡密前端做“信息隐藏”比如口令默认打码显示点击“查看”才明文展示然后配合平台的政策限制。另外在小程序管理后台可以开启“禁止截屏/录屏”的权限但这个权限仅对部分iOS版本生效Android端还是需要用其他手段规避。5. PHP后端高并发优化实战5.1 接口性能优化清单PHP后端接口的性能优化不是单一环节的事而是一个从Nginx到PHP再到Redis、MySQL的链路优化。我整理了一份自己的检查清单按优先级排序Nginx层面开启Gzip压缩设置好worker_processes为CPU核数keepalive_timeout保持默认的65秒即可关键是fastcgi_read_timeout要设置得当否则长任务会断连。PHP-FPM层面调整pm.max_children和pm.start_servers可以按服务器的内存大小计算。比如2核4G的服务器每个PHP-FPM进程大约占30MB内存max_children设为80左右比较合适。关键是pm.max_requests要设成500防止内存慢慢泄漏导致进程被拖垮。代码层面所有热数据接口禁用SELECT *只查需要的字段所有列表查询必须分页所有涉及任务详情的接口必须走Redis缓存所有写操作必须使用PDO预处理语句防注入。OPcache一定要开启这个很多人忽略。PHP是解释型语言每次请求都要重新解析编译PHP文件开启OPcache后编译结果会被缓存接口性能提升非常明显尤其在代码量大的项目里QPS能直接翻倍。5.2 队列异步化把同步阻塞变成后台任务抢单系统里有三块业务必须做异步化订单落库、凭证审核、佣金结算。订单落库前面已经讲了用Redis List做队列。凭证审核更典型用户上传截图后如果采用同步审核用户提交后要等系统去调第三方识别接口返回结果可能耗时3到5秒这个等待太长了。异步化之后用户提交凭证接口立刻返回“审核中”后台消费者去调识别接口识别完成再更新订单状态和佣金用户下次刷新页面时就能看到结果。佣金结算的异步化要小心不能简单地把结算操作丢进队列就不管了。结算涉及资金必须保证原子性和幂等性。我的做法是消费者从队列里取到结算消息后先查balance_logs表里有没有相同order_no的流水有就直接跳过没有才执行“查余额→计算新余额→更新用户余额→插入流水”这一系列操作并且整个过程用数据库事务包裹。// 佣金结算消费者核心逻辑 $redis new Redis(); while ($msg $redis-brpop(commission_queue, 5)) { $orderId json_decode($msg[1], true)[order_id]; $order TaskOrder::find($orderId); if (!$order || $order-status ! 2) continue; // 状态校验 $exists BalanceLog::where(order_no, $order-order_no)-exists(); if ($exists) continue; // 幂等校验 DB::transaction(function () use ($order) { $user User::lockForUpdate()-find($order-user_id); // 行锁 $user-balance $order-commission; $user-save(); BalanceLog::create([ user_id $order-user_id, order_no $order-order_no, change_amount $order-commission, type task_commission, ]); }); }5.3 缓存策略不是所有数据都适合缓存我在项目里吃过缓存设计过度的亏把任务详情、用户信息、余额全部塞进Redis结果是数据更新了但缓存没同步用户看到的是脏数据。后来总结出缓存的三条原则第一读多写少的数据才能缓存。任务列表、公告、轮播图适合缓存用户余额、订单状态这类频繁变动的数据不要缓存或者缓存了也要能做到实时失效。第二所有缓存必须设置过期时间。不用纠结过期时间的精确性任务列表缓存30秒、用户会话缓存7天、任务详情缓存5分钟按业务的容忍度来定。第三写操作必须同步处理缓存。更新任务状态后要主动删除对应的缓存key让下一次请求重新从数据库加载而不是更新缓存。简单粗暴的DEL比复杂的缓存更新逻辑更可靠不至于出现缓存与数据库不一致。5.4 压力测试与性能数据对比优化做完了得用数据说话。我用JMeter对抢单接口做了压测模拟200个并发用户同时抢一个只有100个名额的任务测试环境是2核4G云服务器PHP版本8.2Redis 6.x。优化前的表现惨不忍睹平均响应时间2100ms错误率23%数据库连接耗尽最终只有74个订单入库超额严重。优化后的表现平均响应时间180ms错误率0.2%订单数精确等于100个数据库连接池使用率不到30%。这个对比就是高并发架构的价值——同样的机器配置只是改了库存扣减方式和异步落库就能支撑完全不同的业务量级。5.5 PHP-FPM到Swoole的升级路径如果你的平台继续增长PHP-FPM的进程模型会成为瓶颈因为每个请求都要重新加载PHP框架和配置。这时候可以考虑引入Swoole让PHP代码驻留内存接口性能会有质的飞跃。Swoole的改造思路是把PHP-FPM下的HTTP请求处理改为Swoole的HTTP服务器路由、控制器、服务层代码可以复用只需要在入口处改一下。我建议初次改造时别一次性全量迁移先挑抢单列表这种高频接口跑Swoole稳定之后再逐步扩大范围。不过说实话Swoole对开发者的要求比FPM高很多内存泄漏处理不好反而容易导致进程崩溃。中小平台如果FPM配合Redis优化已经能跑不必急着上Swoole架构不是越复杂越好。6. 常见问题排查与避坑指南6.1 订单超卖排查实录有一次上线新活动我收到运营反馈说某个任务显示卖出了120单但实际库存只有100。我第一时间查了task_orders表发现确实有20条订单的task_id对应同一个任务。经过排查问题出在库存预热脚本上新任务发布时我用了SETNX做库存初始化但如果任务是从数据库旧数据同步过来的初始化时Redis里已经有残留keySETNX不会覆盖旧值导致实际库存比预期多。这个问题的教训是任务发布必须走统一接口任何入口都不能绕过Redis初始化逻辑。我在任务发布接口里加了一道校验先删除旧库存key再写入新库存保证每次发布都是全新状态。6.2 任务状态变更后缓存不一致用户抢单时提示“已抢完”但前端任务卡片还显示“有剩余名额”这就是典型的缓存不一致问题。原因是任务列表缓存的过期时间还没到但Redis库存已经扣完了。我的解决办法是缩短任务列表缓存时间从5分钟改成30秒同时在任务状态变化时主动删除任务列表缓存。另外前端的任务卡片状态不要完全依赖列表接口的数据点击“立即抢单”的时候一定要实时请求任务详情接口以详情接口返回的状态为准。6.3 UniApp小程序审核与权限坑UniApp打包微信小程序上线时有几个审核坑必须提前规避。第一小程序后台的request合法域名必须配好否则真机预览和审核版本都请求不了接口第二涉及用户隐私的功能比如获取手机号、上传图片要在小程序后台的隐私协议里写明用途否则审核会被驳回第三分享卡片拉不起来时要先检查onShareAppMessage的path格式以及页面是否存在onLoad接收参数。抖音小程序和微信小程序虽然写法相似但权限申请方式不同抖音要走tt.authorize流程。建议做跨端时把这些权限逻辑都写在条件编译分支里宁可多写几段代码也要保证不同平台的体验正常。6.4 卡密充值功能的实现思路业务运营时经常会用到卡密充值平台批量生成卡密用户输入兑换卡密对应一定金额进入余额。这个功能的核心在于生成和兑换两个环节。生成卡密时我用的是“前缀 日期 随机串”的格式保证唯一性。批量生成时一次生成1000张写入card_codes表状态默认为0未使用。兑换时先查卡密是否存在且状态为0然后在一个事务里更新卡密状态和用户余额、插入余额流水。注意并发下要防止同一张卡密被两个人同时兑换需要在事务里先执行SELECT ... FOR UPDATE锁定该卡密记录。6.5 定时任务重复执行的坑超时释放订单、自动结束任务这类功能依赖定时任务但如果部署了多台服务器或者任务执行耗时较长会出现两个定时任务进程同时执行导致订单被重复释放、余额被重复加。我的方案有两个一是给关键任务加Redis锁执行前先SETNX一个锁key设置过期时间谁抢到锁谁执行二是在数据库中记录任务执行状态执行开始更新状态执行结束再更新回来。实测下来Redis锁更简单可靠推荐优先用。最后分享一个真实心得做这套TK抢单系统折腾了一个多月最大的体会是高并发不是靠某一种技术堆出来的而是靠全链路的协作——前端控制请求频率后端做限流Redis扛住瞬时流量MySQL只做最终落账队列消化掉所有不要求实时返回的业务。任何一个环节松了系统都会出问题。一个小技巧送给正在做同类系统的朋友抢单接口返回结果之后不要立刻向用户展示“抢单成功”的绿色按钮要先从后端拉一次订单详情确认订单确实落库成功再展示。否则用户以为自己抢到了结果后台没单子白高兴一场这种体验比抢不到还让人难受。这套方案算不上什么黑科技但每一个坑都是我用真金白银的服务器费用和用户投诉换来的。如果你也在做类似的系统希望这篇文章能帮你少走一段弯路。
