景区购票系统实战:从PPT到高并发票务系统落地
简介这份PPT资源面向计算机专业学生与Java Web开发者围绕五台山景点购票系统的设计与实现展开可作为课程设计、毕业设计或SSM框架入门练手项目的参考方案。内容涵盖管理员与用户两大角色的功能划分包括景点信息、购票信息、酒店客房、客房预订、交流论坛及系统管理等模块并完整呈现了需求分析、系统结构图、管理员功能图解与系统测试等开发环节。资源包共1个pptx文件约3.17MB以幻灯片形式组织便于课堂汇报、答辩演示或自学梳理项目脉络。其中对SSM组合框架Spring、SpringMVC、MyBatis的职责分工、B/S架构与面向对象开发思路均有说明还记录了页面显示不规范、数据库连接异常等常见问题的解决过程能帮助读者理解项目从设计到落地的完整流程。目前已有71人学习适合需要快速搭建同类旅游购票系统或撰写相关论文的读者借鉴。1. 从一份 PPT 到一个能跑的景区购票系统先想清楚它到底解决什么问题很多人第一次看到“五台山景点购票系统”这个标题第一反应是不就是个卖票的网页吗但真做过景区票务的人都知道它跟普通电商下单完全不是一回事。景区票务的核心矛盾在于“库存有限、时段集中、身份核验严、退改规则复杂”而五台山这类山岳型景区还叠加了“分时段预约、多票种、多入口、淡旺季价差”这些变量。一份 PPT 能讲清楚业务但讲不清楚并发扣减、订单状态机和核销对账这三件事而这三件事恰恰是系统能不能上线的分水岭。这篇文章面向的是拿到类似需求、需要把方案落成可运行系统的开发者尤其是做过后台但没碰过票务的同学。我会按“业务建模 → 库存与订单 → 分时段与核销 → 避坑 → 压测与对账”的顺序把一份 PPT 里的框图翻译成能跑起来的代码和参数。读完之后你应该能独立搭出一套支持日限额、分时段、实名预约、扫码核销的最小可用票务系统并且知道哪些地方一旦写错上线当天就会翻车。2. 业务建模票种、时段、库存三张表怎么设计才不返工票务系统的地基是数据模型。模型错了后面所有代码都是补丁。我见过太多项目一开始把“票种”和“时段”揉在一张表里结果旺季加一个“夜场票”就要改十几处逻辑。正确的做法是把票种、时段、库存拆成三个独立维度再用一张关联表把它们绑起来。2.1 票种表与时段表的字段取舍票种表描述“卖什么”核心字段是票种 ID、名称、基础价、适用人群、是否需要实名、退改规则 ID。时段表描述“什么时候能进”核心字段是时段 ID、入园日期、开始时间、结束时间、最大承载量。注意时段表不要存“已售数量”那是库存表的事存进来就会有时序不一致的问题。-- 票种表定义卖什么 CREATE TABLE ticket_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 如成人票、学生票、老人票, base_price DECIMAL(10,2) NOT NULL COMMENT 基础价分或元按项目统一, need_realname TINYINT(1) NOT NULL DEFAULT 1 COMMENT 是否实名, refund_rule VARCHAR(32) NOT NULL DEFAULT none COMMENT none/free/partial, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架 ); -- 时段表定义什么时候能进 CREATE TABLE time_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, visit_date DATE NOT NULL COMMENT 入园日期, start_time TIME NOT NULL, end_time TIME NOT NULL, capacity INT NOT NULL COMMENT 该时段最大承载量, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_date_slot (visit_date, start_time, end_time) );这里有个容易忽略的点capacity是“时段承载量”不是“票种库存”。一个时段里可以卖成人票、学生票它们共享这个时段的入园名额。所以库存必须按“时段 票种”组合来扣而不是只扣票种。2.2 库存表为什么必须带版本号库存表是并发冲突最集中的地方。最简单的写法是stock字段直接UPDATE ... SET stock stock - 1 WHERE stock 0这在低并发下没问题但一旦要做“先锁库存再支付”就必须引入版本号或状态位否则超卖和少卖都会出现。-- 库存表时段 票种的组合库存 CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, slot_id BIGINT NOT NULL, ticket_type_id BIGINT NOT NULL, total INT NOT NULL COMMENT 总库存, sold INT NOT NULL DEFAULT 0 COMMENT 已售, locked INT NOT NULL DEFAULT 0 COMMENT 已锁定未支付, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_slot_type (slot_id, ticket_type_id) );可售数量 total - sold - locked。下单时先locked 1支付成功后locked - 1, sold 1超时未支付则locked - 1。这个状态流转必须用事务包住并且每次更新都带version条件失败就重试。2.3 用一条 SQL 完成“锁定库存”的原子操作下面这条 SQL 是整套系统的核心它把“判断可售”和“锁定”合并成一次原子更新避免先查后改的竞态。-- 锁定一张票只有可售数量大于 0 时才成功 UPDATE stock SET locked locked 1, version version 1 WHERE slot_id ? AND ticket_type_id ? AND total - sold - locked 1 AND version ?; -- 受影响行数为 1 表示锁定成功为 0 表示库存不足或版本冲突参数说明slot_id和ticket_type_id来自用户选择的时段和票种version是查询时读到的版本号。如果返回 0 行不要立刻报“售罄”先重查一次最新 version 再重试通常重试 2 到 3 次就能区分是真没票还是并发冲突。这个细节在旺季高峰期能明显降低误报售罄的概率。3. 下单与支付订单状态机怎么写才不会出现“已支付但没票”订单模块的难点不在创建订单而在状态流转。票务订单至少要有“待支付、已支付、已核销、已取消、已退款”五个状态而且每个状态之间的迁移必须由明确的事件触发不能靠定时任务随便改。3.1 订单表与状态迁移表CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, ticket_type_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, amount DECIMAL(10,2) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT PENDING, expire_at DATETIME NOT NULL COMMENT 支付截止时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_expire (status, expire_at) );状态迁移规则建议写在一张配置表或代码常量里而不是散落在各个 service 方法中。常见迁移是PENDING - PAID支付回调、PENDING - CANCELED超时或用户取消、PAID - USED核销、PAID - REFUNDED退款。任何其他迁移一律拒绝。3.2 支付回调的幂等处理支付回调可能重复推送这是血泪经验。没有幂等用户付一次钱会生成两张已支付订单或者库存被重复扣减。做法是在订单表上加一个“支付流水号”唯一索引回调进来先尝试更新更新影响行数为 0 就直接返回成功。def handle_payment_callback(order_no, trade_no): # 幂等同一 trade_no 只能成功一次 affected db.execute( UPDATE ticket_order SET status PAID, trade_no %s WHERE order_no %s AND status PENDING , (trade_no, order_no)) if affected 0: # 已经处理过直接返回成功避免重复扣库存 return {code: 0, msg: already handled} # 支付成功锁定转已售 db.execute( UPDATE stock SET locked locked - 1, sold sold 1, version version 1 WHERE slot_id (SELECT slot_id FROM ticket_order WHERE order_no %s) AND ticket_type_id (SELECT ticket_type_id FROM ticket_order WHERE order_no %s) , (order_no, order_no)) return {code: 0, msg: ok}逻辑说明先做订单状态的条件更新只有从PENDING改成PAID才算首次处理随后把库存从“锁定”转为“已售”。参数上trade_no是支付渠道流水号必须唯一order_no是本地订单号。如果项目用的是消息队列这段逻辑要放在消费者里并保证消费幂等。3.3 超时未支付订单的释放策略超时释放有两种做法定时扫表 and 延迟消息。定时扫表简单但有时延延迟消息精确但依赖中间件。我一般会两者结合延迟消息做精确释放定时扫表做兜底补偿扫描条件就是status PENDING AND expire_at NOW()。释放时同样要带状态条件避免把已经支付的订单误取消。4. 分时段预约与核销从“买了票”到“进了门”的最后一公里景区票务和电影票最大的区别是核销环节。电影票核销错了顶多进场纠纷景区票核销错了会直接影响入园秩序和承载量统计。所以核销必须做到“一票一码、核销即失效、可追溯”。4.1 分时段预约的库存分配分时段的核心是每个时段独立库存。用户下单时必须选定slot_id不能只选日期。如果业务允许“当日有效”那也要在库存表里按日期汇总一个虚拟时段否则承载量统计会失真。常见做法是上午场、下午场各一条时段记录夜场单独一条每条都有独立capacity。4.2 核销码的生成与校验核销码不要用自增 ID容易被猜。推荐用“订单号 随机盐”做 HMAC取前 16 位作为核销码同时把核销码存到订单表并加唯一索引。import hmac, hashlib, base64 def gen_check_code(order_no, secret): # 用订单号和密钥生成不可猜测的核销码 raw hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).digest() return base64.urlsafe_b64encode(raw).decode()[:16]核销时闸机或工作人员扫码后调用核销接口接口用UPDATE ... SET status USED WHERE check_code ? AND status PAID做原子更新返回 1 表示核销成功返回 0 表示已核销或无效。这样即使同一张码被扫两次也只有第一次会成功。4.3 核销接口的并发与离线兜底旺季闸机网络可能抖动核销接口要能扛住重试。除了上面的原子更新还建议在核销记录表里写一条流水记录核销时间、设备号、操作人。如果网络断了闸机本地先缓存核销请求恢复后补传服务端靠check_code唯一性做去重。这个兜底方案在山区景区特别实用因为信号盲区是常态。5. 避坑与排查票务系统上线前必须过的五道坎这一章是我自己踩过和见别人踩过的坑按“现象 → 原因 → 解决”写。每一条都对应一个具体的线上故障场景不是理论风险。坑一库存显示有票下单却提示售罄。现象是页面可售数量大于 0但提交订单失败。原因通常是可售数量计算用了缓存而缓存没跟上locked的变化。解决方法是可售数量统一走数据库实时计算或者缓存只做展示、下单时以数据库为准并在缓存里加短过期时间。坑二支付成功但订单还是待支付。现象是用户已扣款订单状态没变。原因多是支付回调地址配错、回调被防火墙拦截或者回调处理抛异常没返回成功。解决方法是回调接口先记原始报文再处理处理失败要返回非成功码让渠道重试同时加对账任务每天核对渠道流水和本地订单。坑三同一张票被核销两次。现象是闸机记录显示同一核销码两次入园。原因是核销接口先查后改中间有并发窗口。解决方法是改成条件更新WHERE status PAID用受影响行数判断不要先SELECT再UPDATE。坑四超时释放把已支付订单取消了。现象是用户支付成功但订单被定时任务改成取消。原因是释放逻辑只判断了expire_at没判断status。解决方法是释放 SQL 必须带AND status PENDING并且支付回调里也要带状态条件两边互斥。坑五分时段库存和总库存对不上。现象是各时段可售之和大于景区日承载量。原因是只做了时段库存没做日级汇总校验。解决方法是在库存变更时同步校验日级上限或者用一张日级库存表做二次约束任何时段扣减都要先过日级检查。6. 压测与对账怎么验证这套系统真的能扛住旺季系统写完不等于能用票务系统的验收标准是“旺季峰值不超卖、对账不差钱”。这一章讲两个具体手段压测脚本和对账任务。6.1 用并发脚本验证不超卖压测的重点不是 QPS而是“并发扣减同一库存”时会不会超卖。下面这个脚本用多线程模拟 200 人抢 100 张票跑完检查sold locked是否等于 100。import threading, requests BASE http://localhost:8000 results [] def buy(): r requests.post(f{BASE}/order, json{ slot_id: 1, ticket_type_id: 1, quantity: 1, user_id: 1 }) results.append(r.json().get(code)) threads [threading.Thread(targetbuy) for _ in range(200)] for t in threads: t.start() for t in threads: t.join() success results.count(0) print(成功下单数:, success) # 期望等于库存数不能超过参数说明slot_id和ticket_type_id要指向同一条库存记录线程数要大于库存数才能暴露超卖。跑完后去数据库查sold locked如果大于total说明锁库存逻辑有漏洞重点检查WHERE条件里有没有漏掉可售判断。6.2 每日对账任务的最小实现对账是票务系统的后悔药。每天凌晨跑一次比对三样东西渠道流水总额、本地已支付订单总额、库存售出数量。任何一项对不上就告警。-- 对账核心查询按日期汇总已支付订单 SELECT DATE(created_at) AS d, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM ticket_order WHERE status IN (PAID, USED) GROUP BY DATE(created_at); -- 库存侧汇总 SELECT visit_date, SUM(sold) AS sold_cnt FROM stock s JOIN time_slot t ON s.slot_id t.id GROUP BY visit_date;两张结果按日期对齐金额和数量都要能解释差异。常见差异来源是退款未同步、跨天支付、测试订单混入。对账任务不要自动修数据先告警再人工确认避免把正确数据改错。6.3 一个我常留的“后悔药”字段最后分享一个习惯订单表里永远留一个remark或ext字段存原始请求报文和渠道返回报文。平时用不上一旦出现“用户说付了、系统说没付”的纠纷这个字段就是唯一能还原现场的黑匣子。字段类型用TEXT写入时截断到合理长度避免大报文拖慢查询。这个习惯帮我省过很多次排查时间希望帮到你。本文还有配套的精品资源点击获取