还真有人问过我这个问题:用Vue做前端、PHP做后端,能不能撑起一套篮球、足球联赛的购票系统?我的回答是能,而且对于业余联赛、校园联赛、企业邀请赛这个量级,这套组合的性价比远比我一开始想象的更高。我帮某个赛事组委会搭过类似系统,从数据库设计、下单接口到选座交互都踩过不少坑,这篇就把整套设计思路和关键代码拆开写清楚,给准备动手的人一份能直接参考的“作业”。
很多人以为购票系统就是“查比赛、选票、付款”三个步骤,实际上它背后涉及库存扣减、订单状态机、支付回调、核销验票、防超卖防刷票等一系列工程问题。这篇文章不会只讲Vue和PHP的语法,而是从真实业务出发,把一套购票系统从0到1的完整设计讲透。适合三类人看:一是想用PHP做中型Web系统的开发者,二是要给学生项目或外包项目做技术方案的人,三是赛事主办方里的技术对接人,看完至少能判断出一个方案靠不靠谱。
1. 看球买票背后的真实需求,比你想的要多
1.1 一场比赛从开票到入场,到底经历了什么
很多人第一次接触票务系统,会把注意力全放在“下单支付”上,但真正的业务链路远比这长。我接到需求时,主办方给我列了一个很朴素的口头描述:“能卖票、能验票就行”,结果我拉完流程后发现,至少需要覆盖下面这些环节:
- 主办方创建联赛,配置赛季,录入参赛球队。
- 为每场比赛设置场馆、开赛时间、开票时间、停票时间。
- 配置票档,比如早鸟票、普通票、VIP票,每个票档有独立价格、库存和限购规则。
- 观众浏览比赛列表,进入详情查看座位图,选座并下单。
- 订单进入待支付状态,超过时间未支付要自动释放座位。
- 第三方支付平台回调后,订单变为已支付,系统生成电子票和核销码。
- 比赛当天,检票员扫描核销码验票入场。
- 赛后主办方要看售票数据,比如各票档卖出多少、退款多少、上座率多少。
就算不做退票、改签、转赠这些高级功能,上面的链路也已经涉及到了库存、并发、支付、定时任务、二维码生成、数据统计六个独立的模块。所以“购票系统”本质上不是一个页面项目,而是一个带状态流转的Web系统。
1.2 三种角色,操作习惯完全不同
设计系统时,我习惯先列角色,再列每个角色的关键路径,因为不同角色对系统的要求甚至是对设备的要求都不一样。
| 角色 | 典型操作 | 核心诉求 |
|---|---|---|
| 主办方/管理员 | 创建比赛、配置票档、开票关票、查看报表 | 批量操作方便,实时掌握库存和收款 |
| 普通观众 | 查比赛、选座、支付、查看电子票 | 流程顺畅,选座不卡,支付不重复扣款 |
| 检票员 | 扫码核销、处理异常票 | 弱网环境能用,识别速度快,防止重复核销 |
这里特别容易忽略的是检票员这个角色。球场现场的网络状况大家应该有体会,几千人同时在场馆里,基站拥堵是常事。如果核销页面强依赖实时请求,基本上一到入场高峰就卡死。所以我在设计前端时,单独给核销页做了一个离线缓存方案:核销码生成后先存本地,验证请求失败时提示检票员保持当前页面,等到网络恢复再统一提交。这些需求如果不是提前跟检票员聊过,光坐在电脑前面是根本想不到的。
2. 技术选型:Vue + PHP 不是将就,是匹配
2.1 为什么用 PHP,而不是 Java 或 Go
先说结论:对于一个单场上千人、一个赛季几千到几万订单的联赛购票系统,PHP-FPM 完全够用,而且开发效率比 Java 高不少,部署成本也低很多。我不否认 Java 和 Go 在高并发、长连接场景下更稳健,但这个系统的特点是“卖票瞬间并发高、平时流量低”。比如一场热门比赛开票,前几秒可能有几百上千人同时请求,但也就持续那么几分钟;等票卖完,系统一天可能都没多少访问量。
这种“短时脉冲型”流量,用 PHP 处理时只要把下单接口写好、数据库锁用对,完全能扛住。再说,主办方通常不是互联网公司,服务器预算有限,一台2核4G的云主机跑 PHP + MySQL 就能支撑整个赛季,连 Redis 都可以暂时不引入,用文件锁或数据库乐观锁先顶着。如果用 Java 那套,光一个应用服务器加注册中心就能劝退一半主办方。
当然,PHP 也有不适合的场景,比如需要大量长连接实时推送、需要流式计算,那确实不适合。但购票系统里最核心的“库存扣减”是短事务,PHP 处理短事务的能力不差。
2.2 前端为什么非 Vue 不可
选 Vue 而不是 React,原因也很实际。第一,团队里如果要临时找会写 Vue 的人,比找会写 React 的人容易,国内这个差距尤其明显。第二,Vue 的响应式系统和模板语法,在实现“选座联动”“票档切换”这类交互时非常顺手。第三,Vue 3 + Vite 的开发体验很好,项目启动快,构建配置简单,对小型团队非常友好。
另外要提一下为什么不做成纯服务端渲染。有些开发者习惯用 PHP 模板直接渲染页面,但票务系统的选座界面需要大量前端状态管理,服务端模板写起来会很痛苦。而用户端本质上是一个登录后才能购票的应用,不依赖搜索引擎收录,所以不需要什么SSR和SEO优化,直接用 Vue SPA 就够了。
2.3 一套不豪华但很实用的工程结构
我最终采用的目录结构是这样的:
project/ ├─ admin/ # 管理后台前端(Vue 3) ├─ web/ # 用户端前端(Vue 3) ├─ api/ # PHP 后端 │ ├─ public/ │ │ └─ index.php # 前端控制器,统一入口 │ ├─ app/ │ │ ├─ Controllers/ # 控制器层,只做参数接收 │ │ ├─ Services/ # 服务层,业务逻辑集中在这 │ │ ├─ Models/ # 数据模型,数据库操作 │ │ └─ Helpers/ # 通用函数,比如响应封装 │ ├─ routes/ │ │ └─ api.php # 路由文件 │ └─ storage/ │ └─ logs/ └─ database/ └─ migrations/ # 建表SQL脚本把管理后台和用户端拆成两个独立前端工程,是我实践中验证过比较舒服的做法。原因是这两边的页面差异太大:用户端需要精简、主要是比赛浏览、选座、支付、电子票几个页面;管理后台则是典型的CRUD密集场景,表格、表单、弹窗占了一大半,如果把两边塞进同一个前端工程,路由和权限管理会越来越乱。拆开后包体积也干净很多。
3. 数据库设计:票务系统的地基全在这几张表里
3.1 核心表结构:从联赛到订单
票务系统的核心数据模型,我总结就是七张表:联赛表、球队表、比赛表、票档表、订单表、订单明细表、核销码表。下面是每张表的关键字段设计:
比赛表(matches)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| league_id | int | 所属联赛 |
| home_team_id | int | 主队 |
| away_team_id | int | 客队 |
| venue | varchar | 场馆名称 |
| start_time | datetime | 开赛时间 |
| ticket_open_time | datetime | 开票时间 |
| ticket_close_time | datetime | 停票时间 |
| status | tinyint | 0草稿 1售票中 2已停售 3已结束 |
票档表(ticket_levels)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| match_id | int | 所属比赛 |
| name | varchar | 票档名称 |
| price | decimal | 售价 |
| stock_total | int | 总库存 |
| stock_left | int | 剩余库存 |
| max_per_user | int | 每人限购张数 |
| sale_start | datetime | 票档开售时间 |
| sale_end | datetime | 票档停售时间 |
订单表和订单明细表比较简单:订单表存订单号、用户ID、比赛ID、总金额、状态、支付时间、过期时间;订单明细表存每一张票对应的票档、座位号、单价。这里我特别提一下,为什么订单状态字段要用数字而不是直接存字符串。字符串虽然可读性好,但写业务判断时容易乱,而且索引性能不如数字。我习惯用状态码映射,比如0待支付、10已支付、20已使用、30已退款、40已关闭,然后在服务层写一个状态常量的映射类,代码里用常量名,数据库里存数字。
3.2 库存模型:计数库存和座位库存怎么选
这是整个设计里最容易搞错的地方。篮球和足球联赛的场馆有两种情况:一种是固定座位场馆,观众必须对号入座;一种是混合场馆,部分区域是站票。这两种场景的库存模型完全不一样。
- 计数库存:只记录某个票档还剩多少张,不关心具体座位。适合站票区、通票、不指定座位的场地。
- 座位库存:一张票对应一个确切的座位,需要维护一张座位表,每个座位有独立状态。
我建议的做法是都支持,但不要把两种模型混在一张表里。座位库存单独建一张seats表,字段大致是:id、match_id、zone(区域)、row(排)、col(列)、status(0可售 1已售 2已锁定 3预留),再加上一个order_item_id,用来关联锁定的订单。一个座位上如果已经有未支付订单占着,其他人就看不到或点不了。
这样设计的好处是,普通票和VIP票可以走座位库存,站票走计数库存。下单时,代码按票档类型分别调用不同的扣减逻辑,互不干扰。
3.3 订单状态机与超时释放
订单状态机我见过很多项目都写得很随意,动不动就状态满天飞。票务系统的订单状态必须严格并且收敛。我设计的流转很简单:
- 用户下订单后进入“待支付”。
- 5分钟内支付成功,进入“已支付”。
- 5分钟未支付,定时任务关闭订单,释放库存。
- 检票核销后,进入“已使用”。
- 主办方同意退款,进入“已退款”。
这里最大的坑在于超时释放。为什么不能用户下单时扣掉库存、支付失败也不管?因为会有大量“占着茅坑不拉屎”的订单,把票锁死,真正想买的人买不到。所以下单成功后,我不仅会在订单表记一个expire_at字段,还会在票档表里增加一个locked_count字段,下单时stock_left减一、locked_count加一。定时任务扫描超时订单时,执行的是反向操作:stock_left加一、locked_count减一,并把订单状态改成已关闭。
3.4 一张容易被忽略的核销码表
核销码不能直接放在订单明细表里,更不能明文存储。我单独建了一张verification_codes表,字段是id、order_item_id、code_hash、status、used_at。核销码本身是随机字符串,数据库中只存哈希值,检票端扫码后把码传回后端,后端用同样的哈希算法比对是否一致。这样即使数据库泄露,也不会出现一批能直接入场的二维码。
4. 后端接口设计:从查场次到支付回调的完整链路
4.1 接口清单:不多一个,也不少一个
后端接口我按业务模块拆,设计时的原则是“一个页面一个主接口,一个操作一个不可拆分的接口”。核心接口如下:
| 接口 | 方法 | 说明 |
|---|---|---|
| /api/matches?status=on_sale | GET | 比赛列表 |
| /api/matches/{id} | GET | 比赛详情 |
| /api/matches/{id}/seat-map | GET | 获取座位图 |
| /api/orders | POST | 创建订单 |
| /api/orders/{order_no}/pay-params | POST | 获取支付参数 |
| /api/payment/callback | POST | 支付回调 |
| /api/orders/{order_no} | GET | 查询订单状态 |
| /api/orders/{order_no}/refund | POST | 发起退款 |
| /api/verify | POST | 核销验票 |
所有接口统一返回JSON,数据格式是{ code: 0, msg: "success", data: {} }。前端的请求封装里只需要判断code是否等于0,其他情况统一弹错误信息。
4.2 下单接口:扣库存和创建订单必须在一个事务里
下单接口是整个系统的命门。我见过不少新手写代码,先查库存,再insert订单,最后update库存,这个顺序在并发下必出超卖。正确的做法是:
public function createOrder($userId, $matchId, $levelId, $items) { $pdo->beginTransaction(); try { // 1. 直接更新库存,用 stock_left > 0 作为条件实现乐观锁 $sql = "UPDATE ticket_levels SET stock_left = stock_left - 1, locked_count = locked_count + 1 WHERE id = :id AND stock_left > 0 LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->execute([':id' => $levelId]); if ($stmt->rowCount() === 0) { throw new BusinessException('库存不足'); } // 2. 插入订单主表 $orderNo = generateOrderNo(); $sql = "INSERT INTO orders (order_no, user_id, match_id, total_amount, status, expire_at, created_at) VALUES (?, ?, ?, ?, 0, DATE_ADD(NOW(), INTERVAL 5 MINUTE), NOW())"; $pdo->prepare($sql)->execute([$orderNo, $userId, $matchId, $totalAmount]); // 3. 插入订单明细 $orderId = $pdo->lastInsertId(); insertOrderItems($orderId, $items); // 4. 如果启用座位模型,同步锁定座位 lockSeats($pdo, $orderId, $items); $pdo->commit(); return $orderNo; } catch (Throwable $e) { $pdo->rollBack(); throw $e; } }这段代码的核心是第一步的UPDATE语句,它把“判断库存是否充足”和“扣减库存”合并成一个原子操作。多个请求同时执行这条SQL时,数据库行锁会让它们排队执行,第二个请求会发现stock_left已经变成0,影响行数为0,从而判断为库存不足。
这里多说一句:事务里的操作顺序有讲究。先把库存UPDATE放在最前面,再插入订单表,目的是让行锁持有时间尽可能短。如果把查询、计算、插入都放在更新库存之前,事务时间拉长,开票瞬间会出现大量锁等待,数据库连接很快被打满。
4.3 支付回调:怎么处理重复通知和超时释放
支付回调是最容易出bug的地方。第三方支付平台为了保证通知送达,往往会重试多次,同一个支付成功事件可能传进来两三遍。如果代码没有做幂等,就会出现同一个订单被更新两次状态、甚至重复发货的问题。
我的处理逻辑是三步。第一步验签,第二步查订单,第三步幂等处理。具体到代码上,我会加一张payment_transactions表,字段包括transaction_id、order_no、amount、status、raw_data。这张表有一个唯一索引,加在order_no和transaction_id上。回调进来时先尝试插入记录,如果插入失败说明处理过了,直接返回成功;插入成功才继续更新订单状态。
还有一个特别容易踩的坑:支付回调延迟,导致订单在超时任务里被关闭,但用户的钱其实已经扣了。这种情况如果处理不好,用户会投诉“扣了钱不给我票”。我的办法是,超时任务在关闭订单前,先检查这个订单是否收到了支付回调。如果订单已经关闭但支付回调后来才到,就不能简单地把订单直接改回已支付,而是要进入补单流程:恢复库存、重新生成电子票,并发送通知告诉用户票已补上。
5. 前端Vue实现:选座组件、倒计时和弱网核销
5.1 路由和页面拆分
用户端我建议拆成这么几个路由:/是首页,展示热门比赛;/matches是全部比赛列表;/matches/:id是比赛详情,包含票档选择和座位图;/checkout是下单确认页;/pay-result是支付结果页;/orders是订单列表;/ticket/:ticketId是电子票页面。
有人问为什么不选个UI框架,我建议如果项目时间紧,直接用现成的组件库没问题;但座位图、核销这两个核心组件必须自己写,因为没有任何组件库能覆盖体育场座位图的交互。项目结构上,我会把components文件夹里和业务强相关的组件单独分一个目录,比如components/seat-map和components/ticket-card,便于以后复用。
5.2 选座组件:座位状态与当前选择联动
选座组件最核心的逻辑就是:用一个类型记录每个座位的状态,另一个Set记录当前用户已选中的座位。Vue的响应式在这里非常适合,但要注意不要用reactive包一个庞大的二维数组,因为座位数量可能非常大,数据一变会触发大量DOM更新。
我的做法是用一个普通数组存座位数据,用v-for渲染,选中的座位单独维护一个Set。每次点击座位时,先判断状态,再更新Set。核心逻辑大概是:
const selectedSeats = ref(new Set()) function toggleSeat(seat) { if (seat.status === 1 || seat.status === 2) return if (selectedSeats.value.has(seat.id)) { selectedSeats.value.delete(seat.id) } else { if (selectedSeats.value.size >= maxPerUser.value) { ElMessage.warning(`本场限购${maxPerUser.value}张`) return } selectedSeats.value.add(seat.id) } // 把选中态同步给座位库,触发对应座位样式变化 seat.status = seat.status === 0 ? 3 : 0 }在模板里,每个座位我渲染成一个按钮或者div,样式通过status绑定。关键的点是:不要在点击事件里发请求去检查座位是否可售,开票瞬间这种做法会把后端打爆。座位可售状态应该在进入页面时一次性拉取,然后前端只管本地交互,真正锁定在后端下单时执行。
如果场馆座位数过万,比如一个大型体育场,直接渲染一万个DOM节点在移动端会很卡,卡到用户根本没法选座。我最后使用的方案是按区域分块渲染,用户先选区域,再加载该区域的座位,而不是一次加载全场座位。如果以后要支持全图预览,再考虑上Canvas绘制。
5.3 下单倒计时:本地展示和后端释放的配合
下单后的倒计时不能只靠前端搞。很多初级开发会在前端setTimeout,时间一到就提示订单关闭,却不知道后端库存可能还锁着。倒计时的正确理解是:后端订单在5分钟后过期,前端只是把这个过期时间同步展示给用户。
我的实现是下单成功后,接口返回expire_at时间戳,前端用这个时间戳做倒计时,而不是本地开一个5分钟的计时器。这样即使页面刷新,倒计时也能从后端真实过期时间继续算。到了0秒后,前端跳回比赛页并提示“订单已超时”,同时主动调一次关闭订单接口,让后端立刻释放库存,不用等定时任务扫描。
5.4 核销页:弱网环境不能掉链子
核销页我用Vue单独做了一个轻量页面,不打包任何无关组件。页面上的核心是二维码显示区域和一个当前网络状态提示。每次核销成功,二维码变灰并标记为已使用,同时把核销记录写到localStorage。检测到网络断开时,页面不报错,而是显示“网络异常,请检查连接”,但保留最近几条核销记录,这样检票员可以确认自己刚才的操作没有丢。
这个需求很不起眼,但比赛日现场的实际体验全靠它。我第一次上线时没做离线处理,结果入场高峰网络卡顿,检票员拼命刷新页面,反而把服务刷得更慢。后来改成离线优先的思路,核销请求只在网络可用时自动补发,问题才算真正解决。
6. 防超卖、防刷票、防异常订单的工程化细节
6.1 超卖是怎么发生的:并发先查后改是万恶之源
假设票档剩余库存只有1张,A用户和B用户同时下单。如果在代码里先执行SELECT查到库存为1,两个请求都看到有票,然后都执行INSERT订单、都执行UPDATE扣库存,最后就会出现一张票卖给了两个人。这就是典型的并发超卖。解决思路只有一个:让“判断库存”和“扣减库存”成为原子操作,数据库行锁或CAS都能做到,但绝不能在应用层先查后改。
6.2 三种扣减方案的对比和选型
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 乐观锁 | UPDATE ... SET stock_left=stock_left-1 WHERE stock_left>0 | 实现简单,无额外依赖 | 高并发下大量请求失败,用户体验差 |
| 悲观锁 | SELECT ... FOR UPDATE 后更新 | 几乎不会错 | 事务时间拉长,锁等待容易拖垮连接池 |
| Redis原子扣减 | DECR/INCR 预扣库存,异步同步到DB | 抗高并发能力强 | 增加Redis运维成本,需处理DB与缓存一致性问题 |
小项目、一场比赛几百张票,直接用乐观锁即可,不用上Redis。如果某个热门场次有5000人抢2000张票,乐观锁会导致大约3000个请求在第一步就失败,但这是正常的理性行为——用户会看到“已售罄”而不是“系统错误”。等真正要做多场次全国性销售,再引入Redis预扣不迟。
6.3 限购、实名与接口防刷
票务系统的防刷不能只靠一个验证码。我的策略是分三层:
第一层,账号维度限购。每个账号在同一个票档下只能买max_per_user张,数据库层面对这个规则设计必须支持。第二层,实名维度限购。热门比赛一张身份证同一个场次只能买一张,防止同一个人注册多个账号刷票。这是我后来被主办方要求加的功能,当时他们反馈有人用家人手机号注册了好几个账号抢VIP票,然后加价转卖。第三层,接口频控。下单接口对同一用户每分钟限制3次,对同一IP每分钟限制30次,再配合滑块验证码在开票瞬间开启。
这里要特别提醒:所有限制条件必须在后端校验,前端传过来的参数一律不可信。我在第一次实现时偷懒,把限购数直接放在前端校验,后端只做扣库存,结果被人绕过前端直接调接口,一晚上刷走几十张票。
6.4 异常订单:支付成功但库存已被释放怎么办
这种场景我在4.3里提过,这里再补充一下处理方案。当定时任务把超时订单关闭后,如果支付回调姗姗来迟,订单状态就变成“已关闭但已付款”的矛盾状态。我的做法是专门建一张abnormal_orders表,记录这类异常,然后通过补单流程处理:重新锁定库存、生成电子票、通知用户。如果库存已经被其他人买走,则自动进入退款流程,并给用户发一张下次购票可用的优惠券作为补偿。这个异常链路必须在上线前就做好,因为它是支付系统和订单系统之间异步通信的必然结果,不是你写代码小心一点就能避免的。
7. 上线前的压测与一次真实开票复盘
7.1 压测前先要想清楚:你模拟的是谁
压测不是随便发几个请求就完事。我当时的做法是先造数据:创建10场比赛,每场比赛2个票档,普通票2000张、VIP票500张,再模拟1000个用户账号。然后用WRK脚本模拟这1000个用户同时下单,重点观察两个数据:下单接口的错误率和5分钟内关闭的超时订单数。
开票瞬间的流量特征和普通压测不一样,它是极短时间内的爆发,持续几秒后迅速退潮。所以我压测时用的是一个动态脚本,前10秒每秒并发500个下单请求,后面逐步降到并发50个,这样才能模拟出真实开票的“尖峰”形状。
7.2 实战中踩到的三个瓶颈
第一次压测结果很打脸,下单接口的错误率到了15%,当时还没扛住真正的观众。排查下来有三个瓶颈,都很典型:
第一个是MySQL连接数打满。PHP-FPM每一个进程在处理请求时会占用一个数据库连接,默认max_connections只有150,开票时并发一上来,连接直接被耗尽。解决办法是把max_connections调到300,同时给PHP-FPM配置pm.max_children控制在合理范围,别让进程数量无限增长。
第二个是事务内的死锁。座位库存模式下,两个订单如果刚好要锁定相邻的两个座位,事务按座位ID更新的顺序不一致,就会产生死锁。解决办法是应用层强制按座位ID升序更新,让所有事务都按同一个顺序拿锁。
第三个是接口响应体太大。比赛详情接口一次性返回了全部座位图数据加比赛信息,一个响应体接近2MB,用户手机根本加载不动。后来拆成了基础信息和座位图两个接口,座位图改为按区域加载,响应体积下降了90%以上。
7.3 上线后的一个真实教训:定时任务频次不是越高越好
超时释放订单的定时任务,我一开始设置为每30秒跑一次,结果发现数据库压力很大,因为每次要扫描orders表里所有待支付订单,还要逐单恢复库存。后来改成每分钟跑一次,并且在status字段和expire_at字段上建了联合索引,数据库压力才降下来。这个逻辑很简单:不是扫描得越频繁就越好,关键是让扫描的数据量尽可能小。
还有一个坑是定时任务和用户主动取消的并发。用户可能在倒计时还剩10秒时主动关掉了订单,同时定时任务也在处理这个订单,两边都执行恢复库存就会出现库存多加一次。解决办法是执行恢复库存前先对订单状态做一次CAS更新:只有当状态是待支付时才允许改成已关闭,更新影响行数为0说明已经被处理过。
8. 最后说点掏心窝的
这套系统做完给我最大的感受是:Vue和PHP从来都不是决定项目成败的因素,真正决定成败的是你对业务状态的理解和对并发边界的敬畏。票务系统的表象是页面和接口,里子是库存模型和订单状态机,只要这两个骨架搭正了,后面填什么功能都不慌。
如果现在有人让我用一个词总结这套系统,我会说是“克制”——限购规则要克制,接口返回要克制,定时任务频次要克制,连前端请求的频率也要克制。做票务系统不是功能越多越好,而是把每一次扣库存、每一次状态流转都做得无懈可击。这套设计我用在一个赛程超过三个月的联赛项目上,整个赛季跑了十几场比赛,没有出过一次超卖事故,希望这篇拆解也能让你的项目少踩几个坑。