尧图精选

高并发票务系统实战:座位锁、库存扣减与超卖防范

🕒 发布时间:2026/9/18 18:50:34 📁 来源:尧图网络
简介这份资源是一篇基于JSP的网上演唱会票务系统毕业设计论文文档面向计算机专业大学生、课程项目开发者以及希望深入研究Java Web技术栈的技术爱好者可用于毕业设计参考、课程作业复现或学习完整的Web项目开发流程。压缩包内共1个doc文件约2.59MB采用毕业设计论文的标准格式撰写包含封面、分类号、中英文摘要与目录等完整结构便于直接对照论文规范进行学习与借鉴。正文围绕绪论、开发工具及技术、可行性分析、需求分析等章节展开涉及JSP动态网页技术、MVC架构与MyBatis框架并介绍了Eclipse、Tomcat与MySQL的搭配使用同时对国内外票务系统现状、系统开发背景与目的进行了梳理兼顾需求分析、系统设计、编码实现、测试部署等项目管理与开发技巧。目前已有50人学习适合想了解Java Web票务系统实现思路、积累真实项目经验的读者参考。1. 从一张座位图说起web 票务系统真正难的不是页面演唱会开票那一秒几千个用户同时点向同一个内场 A 区 12 排 8 号页面要立刻告诉他锁座成功15 分钟内付款而数据库里这张座位只能被卖出去一次。基于 web 的网上演唱会票务管理系统要解决的正是这件事把座位图渲染、选座、锁座、下单、支付、出票、退票串成一条在高并发下不超卖、又不被黄牛刷穿的链路。它既适合做 Java web 课程设计或企业级 web 开发练手的同学也适合刚接手小型票务系统的后端同学。真正决定这套系统能不能上线的从来不是前端座位图画得多漂亮而是库存扣减、锁超时回收、订单状态机这三处有没有写对。2. 演唱会票务系统的数据建模演出、场次、票档、座位怎么落表票务系统的第一道分水岭在表结构。很多人一上手就建一张ticket表字段里塞上演出名、时间、价格、座位号结果开票当天改一个价格要全表更新加一个场次要复制一堆数据。正确的做法是沿着演出—场次—票档—座位这条业务链拆表让每一层只负责一件事。下面这套结构在中小型 web 票务项目里跑了很久改动成本低也方便后续接入座位图前端。2.1 演出、场次、票档、座位与订单的表关系先把关系理清楚一场演出show会在不同日期开多个场次session每个场次下按区域划分票档price_tier每个票档下挂具体的座位seat。用户下单产生订单t_order订单里的每张票对应一条明细t_order_item明细指向具体座位。座位状态是整条链路的核心用status表示0 可售、1 已锁定、2 已售出、3 已退票。表名关键字段说明t_showid, title, artist, venue_id, poster_url演出基本信息不含时间价格t_sessionid, show_id, start_time, sale_start_time, sale_end_time场次决定什么时候能买t_price_tierid, session_id, tier_name, price, total_stock, sold_stock票档与库存计数t_seatid, session_id, tier_id, row_no, col_no, status, lock_order_no, lock_expire_at座位原子状态t_orderid, order_no, user_id, session_id, amount, status, expire_at订单主表t_order_itemid, order_no, seat_id, tier_id, price订单明细一票一行要注意t_price_tier.sold_stock这种冗余计数只用来做展示和限流判断绝不能作为唯一的扣减依据真正的卖没卖出去必须由t_seat.status说话。两者不一致时以座位表为准票档计数通过定时对账任务回补。2.2 库存扣减为什么必须落在座位行上用count(*)实时统计剩余座位是最常见的错误写法。SELECT count(*) FROM t_seat WHERE session_id? AND status0在几万行数据下配合下单事务会迅速把行锁升级成范围锁并发一上来就是死锁和超时。更关键的是即使查出来还有 1 个座位两个事务也可能同时读到这个结果最后双双插入订单。把库存判断下沉到单行更新问题就变成了数据库天然支持的原子操作。下面这段 DDL 是座位表的核心部分status与lock_order_no的组合加上唯一索引能在数据库层面兜住超卖CREATE TABLE t_seat ( id BIGINT NOT NULL AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT 场次ID, tier_id BIGINT NOT NULL COMMENT 票档ID, row_no INT NOT NULL COMMENT 排号, col_no INT NOT NULL COMMENT 座位号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售 3退票, lock_order_no VARCHAR(32) DEFAULT NULL COMMENT 锁座订单号, lock_expire_at DATETIME DEFAULT NULL COMMENT 锁过期时间, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, PRIMARY KEY (id), UNIQUE KEY uk_session_seat (session_id, row_no, col_no), KEY idx_expire (status, lock_expire_at) COMMENT 扫描超时锁用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明唯一索引uk_session_seat保证同一场次不会出现重复排号座位这是数据正确性的最后一道墙idx_expire让回收超时锁的定时任务能走索引不必全表扫描version字段是为乐观锁准备的下面一章会用到。参数说明status用 TINYINT 而不是字符串省空间也避免大小写歧义lock_expire_at必须落库而不是只放缓存因为缓存会丢丢完座位就永远锁死了。常见做法是锁定时长 15 分钟与订单支付超时保持一致。2.3 web 技术栈选型Java web 还是 Python web 框架选型只看两条事务能力和团队熟悉度。票务系统的核心操作是有状态、要回滚的Java web 生态里 Spring Boot MyBatis MySQL 的组合在事务控制、连接池监控、分布式锁接入上资料最全出问题也最好查企业级 web 开发里这套是默认答案。Python 侧的 FastAPI SQLAlchemy 更轻写接口快适合做后台管理端或者票务数据接口但涉及高并发扣减时要注意 SQLAlchemy 会话与事务边界的写法比 MyBatis 更容易踩坑。2.3.1 前后端分离下的接口边界前端web 前端开发常用 Vue 或 React只负责三件事拉座位图数据、提交锁座请求、轮询订单状态。所有与库存相关的判断都必须放在服务端前端传过来的seatId、tierId、price全部不可信价格一律以服务端查询结果为准。2.3.2 项目落地形态课程设计或小团队项目直接用 Spring Boot 打成 jar 包用mvn clean package构建后java -jar ticket.jar --spring.profiles.activeprod启动即可如果是老式教学环境要求 war 包则需要按 tomcat 部署 web 项目的规范把打包方式改成 war 并让启动类继承SpringBootServletInitializer。两种形态对业务代码没有影响只影响部署方式。3. 用 Spring Boot 实现选座下单接口、锁与库存扣减的代码数据模型定下来之后真正要写的只有三个接口查座位、锁座位、下单支付。这一章给出可以直接抄的写法重点在锁座和扣减这两步因为 90% 的超卖事故都发生在这里。以下代码基于 Spring Boot MyBatis接口路径和参数命名可以按自己项目习惯调整。3.1 选座接口的请求体与参数校验锁座接口只接收座位 ID 列表其余信息全部服务端推导。这样可以避免前端篡改场次和价格。public class LockSeatRequest { private Long sessionId; private ListLong seatIds; // 最多一次锁 4 个座位防止黄牛批量占座 } PostMapping(/api/seat/lock) public ResultLockSeatVO lock(RequestBody Valid LockSeatRequest req, RequestHeader(X-User-Id) Long userId) { if (req.getSeatIds() null || req.getSeatIds().isEmpty() || req.getSeatIds().size() 4) { return Result.fail(每次最多选择 4 个座位); } return Result.ok(seatService.lockSeats(req.getSessionId(), req.getSeatIds(), userId)); }逻辑说明X-User-Id由网关或登录拦截器注入不在请求体里传防止越权替别人锁座限 4 张是业务层面的反黄牛手段比限流更直接。参数校验失败直接返回不进入数据库。参数说明sessionId必须校验归属确认这些座位确实属于该场次否则会出现跨场次锁座的数据污染。校验方式就是一次SELECT COUNT(*) FROM t_seat WHERE session_id? AND id IN (...)数量对不上直接拒绝。3.2 基于乐观锁的座位扣减 SQL锁座的核心是一条带条件的 UPDATE用影响行数判断是否抢到UPDATE t_seat SET status 1, lock_order_no #{orderNo}, lock_expire_at DATE_ADD(NOW(), INTERVAL 15 MINUTE), version version 1 WHERE id #{seatId} AND status 0 AND version #{version};逻辑说明AND status 0保证只有可售座位能被锁AND version #{version}是乐观锁防止两个线程读到同一版本后都更新成功。返回值为 1 表示抢到为 0 表示被别人抢先此时立刻回滚整个事务并给前端返回该座位已被选走。多座位场景下不要逐条 UPDATE 后再统一提交正确做法是在同一事务里循环执行任意一条返回 0 就抛异常回滚已经成功的那些更新随事务一起撤销。参数说明#{version}来自锁座前的一次查询也可以在 SQL 里直接省略 version 条件因为status 0已经足够version 主要用在改价、退票等需要精细控制的场景。注意MySQL 默认隔离级别是 RRUPDATE 会自动加行锁不要在事务里先 SELECT 再 UPDATE 到别的行否则容易产生间隙锁互相等待。3.3 Redis 预扣与异步落库的取舍单机 MySQL 在几千 QPS 的热门场次下会顶不住常见做法是在前面加一层 Redis 预扣用SET seat:{sessionId}:{seatId} {orderNo} NX EX 900抢锁抢到之后再走数据库扣减。这样能挡掉绝大部分无效流量。# 一把锁只允许一个订单持有900 秒后自动释放 redis-cli SET seat:1001:88231 ORD20250612001 NX EX 900 # 返回 OK 表示抢到返回 (nil) 表示已被占用逻辑说明NX保证只有第一次设置成功EX 900与数据库的 15 分钟锁一致。Redis 抢到之后仍然要执行 3.2 的 SQLRedis 只是过滤器不是真相来源。如果 Redis 挂了系统降级为直接走数据库宁可慢也不能错。异步落库要谨慎把订单创建丢进消息队列确实能提高吞吐但必须保证扣减库存和发消息在同一个本地事务里消息发送失败时回滚座位。更稳妥的顺序是先落库生成待支付订单再异步发通知而不是反过来。4. web 票务系统上线排错超卖、重复下单、越权与部署坑代码写完只是开始真正折磨人的是上线后的各种诡异现象。这一章按事故类型给排查路径涉及 web 安全的部分尤其要注意票务系统被刷的直接损失是真金白银。4.1 超卖排查从唯一索引到对账 SQL发现超卖时先别改代码先确认数据状态。执行下面的对账查询能一眼看出是座位表出错还是订单表出错-- 找出同一座位对应多条有效订单的情况 SELECT oi.seat_id, COUNT(*) AS cnt FROM t_order_item oi JOIN t_order o ON o.order_no oi.order_no WHERE o.status IN (1, 2) -- 1待支付 2已支付 GROUP BY oi.seat_id HAVING cnt 1; -- 找出座位状态与票档计数不一致的场次 SELECT t.session_id, t.sold_stock, s.real_sold FROM t_price_tier t JOIN (SELECT tier_id, COUNT(*) AS real_sold FROM t_seat WHERE status 2 GROUP BY tier_id) s ON s.tier_id t.id WHERE t.sold_stock s.real_sold;逻辑说明第一条查重复占用如果查得出结果说明t_order_item上缺少UNIQUE KEY uk_seat (seat_id)补上索引并用定时任务清理冲突订单。第二条查计数漂移漂移一般来自异步回滚没执行成功或者人工改库用UPDATE t_price_tier t SET sold_stock (子查询)回补即可。参数说明status IN (1,2)里的 1 是待支付但已锁座这类订单也算占用不能让用户把钱付了才发现座位被回收。4.2 web 安全越权取票、接口重放与限流票务系统的接口只要暴露了orderNo就可能被遍历。防护要点有三条订单查询接口必须带userId条件不能只按orderNo查下单接口需要一次性令牌防止重复提交和脚本重放锁座接口按用户和 IP 双维度限流。// 幂等令牌同一 token 只允许成功一次 String key idem: token; Boolean first redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofMinutes(10)); if (Boolean.FALSE.equals(first)) { throw new BizException(请勿重复提交); } // 用户维度限流1 分钟内最多 20 次锁座 String limitKey limit:lock: userId; Long count redisTemplate.opsForValue().increment(limitKey); if (count ! null count 1) { redisTemplate.expire(limitKey, Duration.ofMinutes(1)); } if (count ! null count 20) { throw new BizException(操作过于频繁); }逻辑说明幂等令牌由前端在进入选座页时向后端申请提交时带回来服务端用setIfAbsent判断是否首次限流用increment计数并只在第一次设置过期时间避免每次刷新都把窗口延长。IP 维度限流可以放在 nginx 层做减少应用压力。web 服务器安全的基础项也别漏数据库账号禁止使用 root、生产环境关闭spring.jpa.show-sql、接口错误信息不要直接把异常堆栈返回给前端。4.3 nginx 部署多个 web 项目时的会话与静态资源坑同一台机器上跑多个 web 项目时最容易出问题的是会话。如果前端静态资源和后端接口分属两个 server 块浏览器的 Cookie 会互相覆盖。解决办法是给每个项目设置不同的 Cookie 名称和路径server { listen 80; server_name ticket.example.com; location / { root /data/www/ticket-web; try_files $uri $uri/ /index.html; # 前端路由刷新不 404 } location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }逻辑说明try_files解决单页应用刷新后 404 的老问题proxy_set_header必须带上真实 IP否则 4.2 里的 IP 限流会把所有请求算在 nginx 头上一个人触发限流全站被限。注意proxy_pass结尾带不带斜杠会改变路径拼接结果http://127.0.0.1:8081/会去掉/api前缀http://127.0.0.1:8081则保留配错就是 404。5. 压测与对账验证确认票务系统真的不超卖功能跑通不代表并发下正确。开票前至少要做一轮压测加一轮对账两件事都能用具体命令跑出来。先看压测。用 JMeter 建一个线程组模拟 500 个并发用户对同一场次的锁座接口发起请求线程数设 500、Ramp-up 设 1 秒让请求尽量集中、循环 1 次请求头带上不同的X-User-Id。座位 ID 从 1 到 100 随机取这样必然产生冲突。压测跑完后不看响应时间先看数据库-- 已售 已锁的座位数不能超过场次总库存 SELECT COUNT(*) FROM t_seat WHERE session_id 1001 AND status IN (1, 2); -- 同一座位不允许出现两条有效明细 SELECT seat_id, COUNT(*) c FROM t_order_item GROUP BY seat_id HAVING c 1; -- 锁已过期但仍未支付的座位应该被回收 SELECT COUNT(*) FROM t_seat WHERE status 1 AND lock_expire_at NOW();第一条结果必须小于等于总库存第二条必须返回空集返回任何一行都说明唯一索引没建或事务没生效第三条如果是大数字说明锁回收定时任务没跑起来。回收任务本身很简单用 Spring 的Scheduled(cron 0 */1 * * * ?)每分钟扫一次UPDATE t_seat SET status 0, lock_order_no NULL, lock_expire_at NULL WHERE status 1 AND lock_expire_at NOW() LIMIT 500;逻辑说明LIMIT 500是防止一次性更新几十万行把数据库卡住分多轮执行更安全。参数说明lock_expire_at NOW()用数据库服务器时间而不是应用服务器时间两者时区不一致会导致锁提前回收或永不回收这是实际项目里非常常见的坑。压测之外还有两件事值得在正式开票前做完。一是准备人工干预入口后台要能按orderNo强制释放座位、能按seatId强制置为可售出事故时这比改代码快得多。二是把订单超时关单和座位回收放进同一条链路用消息队列的延迟消息在 15 分钟后触发关单关单成功后立即回收座位不要依赖用户点取消按钮。最后开票当天的监控指标只需要盯三个锁座接口 P99 耗时、status 1的座位总数、锁回收任务每分钟处理行数前两个异常说明压力到了第三个掉到 0 说明回收链路断了。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →