简介:这份压缩包是一份基于WEB的车票预订信息系统设计与实现的完整项目源码及配套资料,主要面向Java Web方向的高校学生、毕业设计者、课程设计者及有Java基础的自学者。系统采用Java、Servlet、JSP结合MySQL数据库构建,覆盖车次查询、在线预订、订单管理等核心业务,适合在MyEclipse环境下练习Web应用分层开发与JDBC数据操作。压缩包内共1337个文件,整体约55.82MB,除JSP页面、Java源码、SQL脚本和jar依赖库外,还包含大量JS、CSS、图片等前端资源,以及mp4讲解视频与工程配置文档,便于对照学习和快速部署。目前已有1105人学习下载。学习这套源码可完整走通从数据库表设计、Servlet请求处理到JSP页面渲染的开发流程,并借鉴项目中的分层结构、文件组织与接口调用方式,快速迁移到车站票务、活动报名等类似系统开发,整体实践性较强。
1. 从票务后台到线上窗口:车票预订信息系统到底解决什么问题
车票预订信息系统是那种看起来简单、做起来却很容易翻车的 Web 项目。表面上是把售票窗口搬到浏览器里,但一旦往深做,就会撞上余票扣减的并发冲突、订单状态在不同节点间不同步、同一张车票被两个用户同时锁定等一堆问题。标题里的“基于WEB”不是指写几个 HTML 页面,而是整个预订闭环——选线路、查余票、下订单、锁定座位、生成票据——都以浏览器为入口跑通。它解决的是传统手工登记模式下最头疼的两件事:一是余票实时不可见,二是购票凭证难以统一核验。适合正在做课程设计、找就业项目的开发者,也适合需要一套内部票务系统的中小企业。
做这个系统的难点不在功能多,而在事务边界怎么划。车票的余量、订单锁座、支付确认这三个步骤如果拆成三次独立请求,中间任一步掉线,系统就会出现幽灵座位或超卖票。常见做法是后端把整条链路放进一个数据库事务里,同时用状态机控制订单流转。这篇文章会按选型、数据库设计、核心接口实现一路讲下去,并把复现过程中最常踩的几个坑单独拉出来分析。
2. 先定架构再写代码:车票预订信息系统的三层结构
2.1 选型:为什么 Spring Boot + MySQL 够用
车票预订系统的业务量有一个明显特征:读多写少但写操作必须精确。绝大多数用户进来先查票、再看余量,真正点击“预订”并完成支付的占比很低。所以技术选型的关键不是追求超高并发,而是保证每一次写操作都不会出错。常见组合是 Spring Boot 提供接口层和事务管理,MySQL 存数据和做行锁控制,前端用 Vue 或原生 HTML 都行。
选 Spring Boot 而非传统 Servlet 项目的理由有几个。第一,事务注解@Transactional能把查票、扣减、下单封装成一个原子操作,这也是预订系统最核心的可靠性要求。第二,内嵌 Tomcat 之后部署成本很低,课程设计或小企业内部系统都能很快上手。第三,MyBatis 的动态 SQL 对多条件组合查询支持很好,车票查询场景按线路、日期、席别三个条件组合完全够用。
缓存层面不用急着引入 Redis。系统初期流量不大,MySQL 本身的行锁足够承担预订压力。加了缓存反而要处理缓存与数据库一致性,比如余票刚被扣掉但缓存未更新,展示页就会显示错误余量,这是很多学生项目最常见的翻车点。等到真的需要压测出性能瓶颈了,再在热点查询前加一层缓存,收益比从一开始就搭 Redis 划算。
2.2 数据库设计:车票、订单、座位三个核心表
车票预订系统的表结构不能只设计成一张“车票总表+订单表”,那样余票和座位席位之间的状态很容易在业务代码里绕晕。我一般会拆成三张业务表。车票表负责线路基本信息和总余票量,订单表记录每一次预订的明细与状态,座位表记录每一张实体座位的锁定归属。这种拆分在前中期看起来多写几个关联查询,但后期做余票分析、退票回收时,每张表的职责边界会非常清晰。
车票表字段至少要包含车次编号、出发地、到达地、发车日期、发车时刻、总座位数、已售座位数和座位等级。余票量通过“总座位数-已售座位数”实时计算,避免单独维护一个 ticket_left 字段造成双写不一致。订单表要有订单号、关联车票 ID、购票人联系方式、座位数量、订单状态、创建时间和支付时间。座位表用状态字段标记“空闲 / 已占 / 已锁定”,当用户下单时从空闲座位中取一席锁定,支付完成后置为已占。
三张表之间的关系要保证一个约束:座位表中同一个车次同一个座位只能有一条状态为“已占”的记录。这个约束可以在应用层控制,也可以在座位表上建唯一索引。为了防止并发下同一座位被两条订单选中,更可靠的做法是让下单 SQL 直接更新座位状态时带上条件,更新行数为 0 就说明该座位已被占用,立即回滚并重新取座。
2.3 接口设计:从查票到下单的四个关键 API
预订系统的接口不用设计太多,核心围绕四个操作展开。查票接口接收出发地、到达地、日期、席别四个参数,返回符合条件且余票大于 0 的车次列表。下单接口接收车次 ID、乘车人姓名、身份证号、联系电话,后端在同一事务内完成座位锁定和订单创建。支付通知接口接收票务渠道回传的支付结果,更新订单状态为已支付并确认座位占用。查询订单接口用于前端轮询或用户手动刷新,展示订单从待支付到出票成功的实时状态。
这里最容易被忽略的是订单状态机。一张订单至少要有待支付、已支付、已出票、已取消、已退票五个状态,并且状态流转必须单向。比如已出票不可以直接跳回已取消,必须先走退票流程。状态流放在后端做校验,不要依赖前端隐藏按钮来控制,否则 Postman 直接调接口就能把订单状态改乱。接口返回格式统一用 code + message + data 的结构,前端判断起来省心,后端排查问题时也不用来回对字段名。
3. 用代码把预订流程跑通:查票、锁座、下单的关键实现
3.1 建表 SQL:余票与订单的原子更新
先给出建表脚本,这是后续所有查询和下单逻辑的基础。车票表将余票量通过两个字段计算出来,而不是直接存储一个余票数,这么做的好处是并发扣减时不容易产生数据不一致。
CREATE TABLE ticket_route ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL COMMENT '车次编号', departure VARCHAR(50) NOT NULL COMMENT '出发地', arrival VARCHAR(50) NOT NULL COMMENT '到达地', depart_date DATE NOT NULL COMMENT '发车日期', depart_time TIME NOT NULL COMMENT '发车时刻', total_seats INT NOT NULL COMMENT '总座位数', sold_seats INT NOT NULL DEFAULT 0 COMMENT '已售座位数', seat_level VARCHAR(10) DEFAULT '二等座' COMMENT '席位等级', UNIQUE KEY uk_train_date (train_no, depart_date, seat_level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单号', route_id BIGINT NOT NULL COMMENT '车次记录 ID', passenger_name VARCHAR(50) NOT NULL, passenger_id_no VARCHAR(20) NOT NULL, passenger_phone VARCHAR(20) NOT NULL, seat_count INT NOT NULL COMMENT '购票张数', order_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已出票 3已取消 4已退票', create_time DATETIME NOT NULL, pay_time DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_route_status (route_id, order_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE ticket_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_id BIGINT NOT NULL, seat_no VARCHAR(10) NOT NULL, order_id BIGINT DEFAULT NULL COMMENT '占用该座位的订单 ID', seat_status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1锁定 2已占', UNIQUE KEY uk_route_seat (route_id, seat_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 SQL 里做了几处和预订业务强相关的设计。ticket_route表上的联合唯一索引uk_train_date保证了同一个车次在同一天、同一席别不会重复插入两条配置数据;ticket_seat表的uk_route_seat唯一索引防止同座位被重复初始化。两处唯一索引都是为并发环境兜底,而不是靠业务代码判空。
order_status用 TINYINT 而不是字符串,一是节省空间,二是状态比较时避免字符串大小写不一致的问题。状态含义需要写清楚注释,不然过三个月回来看代码,谁都无法凭数字猜出 2 和 4 的区别。建表后建议立即用测试数据初始化一张车次的下一天的所有座位,后续下单逻辑才能基于这些座位记录做行级锁定测试。
3.2 查票接口:按线路、日期、席别筛出可用车次
查票接口的 SQL 不难,但要注意时间和日期的边界处理。用户选择的是“发车日期”,但实际车次可能在不同日期还有库存,所以查询条件里必须同时带车次日期和席别。余票量直接在 SQL 中用total_seats - sold_seats计算,并放在大于 0 的条件里。
@GetMapping("/tickets") public Result queryTickets(@RequestParam String departure, @RequestParam String arrival, @RequestParam String departDate, @RequestParam String seatLevel) { List<TicketRouteVO> routes = ticketMapper.selectAvailable( departure, arrival, departDate, seatLevel); return Result.success(routes); }对应的 MyBatis Mapper XML 写成这样:
<select id="selectAvailable" resultType="TicketRouteVO"> SELECT id, train_no, departure, arrival, depart_date, depart_time, seat_level, (total_seats - sold_seats) AS available_seats FROM ticket_route WHERE departure = #{departure} AND arrival = #{arrival} AND depart_date = #{departDate} AND seat_level = #{seatLevel} AND (total_seats - sold_seats) > 0 ORDER BY depart_time ASC </select>available_seats是查询时实时计算出来的,没有依赖缓存或者冗余字段,所以余票展示结果是当前数据库的最真实状态。ORDER BY depart_time让用户优先看到早班车次,这个排序逻辑可以按产品需求调整,但对车票系统来说先到先发车是符合直觉的默认策略。
这里有一个小坑:用户输入日期常常是2025-06-30格式,而数据库中depart_date是 DATE 类型,JDBC 会自动做字符串与日期类型的转换。但如果前端传的是带时间的完整日期格式,比如2025-06-30 00:00:00,直接用等值匹配会查不到数据,建议在 Service 层先统一格式化再传参。
3.3 下单接口:用事务锁住座位再写订单
下单接口是这个系统的核心,也是最容易出并发问题的位置。整个流程要保证三个动作不可分割:检查余票、锁定座位、创建订单。Spring 的@Transactional默认传播级别下,只要这三个操作抛出运行时异常,数据库事务就会整体回滚,不会出现“座位扣了但订单没建”的中间状态。
@Transactional(rollbackFor = Exception.class) public OrderResult createOrder(OrderCreateRequest req) { TicketRoute route = ticketRouteMapper.selectByIdForUpdate(req.getRouteId()); if (route == null) { throw new BizException("车次不存在"); } int available = route.getTotalSeats() - route.getSoldSeats(); if (available < req.getSeatCount()) { throw new BizException("余票不足"); } List<TicketSeat> seats = ticketSeatMapper.lockFreeSeats( req.getRouteId(), req.getSeatCount()); if (seats.size() < req.getSeatCount()) { throw new BizException("可选座位不足,请重试"); } String orderNo = generateOrderNo(); TicketOrder order = new TicketOrder(); order.setOrderNo(orderNo); order.setRouteId(req.getRouteId()); order.setPassengerName(req.getPassengerName()); order.setPassengerIdNo(req.getPassengerIdNo()); order.setPassengerPhone(req.getPassengerPhone()); order.setSeatCount(req.getSeatCount()); order.setOrderStatus(0); ticketOrderMapper.insert(order); ticketSeatMapper.bindOrder(seats, order.getId()); ticketRouteMapper.increaseSoldSeats(req.getRouteId(), req.getSeatCount()); return OrderResult.of(orderNo, order.getId()); }selectByIdForUpdate是这里的关键操作,它会对ticket_route表中这一行记录加排他锁,后续其他事务想再读取或修改这一行时必须等当前事务结束。这个行锁把并发下单的“检查余票-扣减”过程串行化了,相当于在数据库层面解决了超卖问题。如果不加FOR UPDATE,两个请求同时读到余票为 1,再同时进入扣减逻辑,就会出现卖两张票只扣一张库存的严重事故。
lockFreeSeats的 SQL 需要配合LIMIT和条件更新一起用,保证只在空闲座位里取。
<update id="bindOrder"> UPDATE ticket_seat SET order_id = #{orderId}, seat_status = 1 WHERE route_id = #{routeId} AND seat_status = 0 AND id IN <foreach collection="seatIds" item="seatId" open="(" separator="," close=")"> #{seatId} </foreach> </update>seat_status = 1表示座位被锁定。如果这一行 UPDATE 影响的行数小于传入的seatIds数量,说明有座位在这几毫秒内被人抢走了,此时事务应该整体回滚。这个判断不能省略,因为selectByIdForUpdate锁住的是车次表那一行,而座位表本身的并发更新要靠seat_status = 0这个条件来挡住冲突。
座位锁定后,订单状态先写入“待支付”,这个状态意味着座位已经为这个用户保留。一般业务上可以设置一个保留时长,比如 15 分钟内未支付就自动释放座位,释放操作只需要把该订单关联的座位状态改回 0,同时把车次表的sold_seats减回去。
increaseSoldSeats对应扣减余票的 SQL,也要写在事务里,并且只在更新行数为 0 时抛异常提示重试:
UPDATE ticket_route SET sold_seats = sold_seats + #{count} WHERE id = #{routeId} AND (total_seats - sold_seats) >= #{count}由于这一行已经被前面的FOR UPDATE锁住了,正常情况下走进了这个方法就不会更新失败,但保留这个条件更新是为了防止未来代码重构时丢掉了行锁,多一层保险比少一层更可靠。
4. 车票预订系统的四个高频踩坑点:并发、状态与支付一致性
4.1 并发下单时数据不一致:订单建了但座位没锁上
现象:两个用户同时订最后一张票,两边都收到“预订成功”的响应,但成功后查询订单发现只有一个订单有座位。
原因:下单逻辑没有放在同一个事务里。有的实现会在 Service 层先查余票,查到大于 0 后继续执行座位锁定与订单创建,但这些操作被拆成了两个 Service 方法,各自带@Transactional,第一个事务提交后,第二个事务才开启,中间产生间隙让两个并发请求同时通过余票检查。
解决:整个下单流程必须收敛到一个方法中,@Transactional加在对外暴露的入口方法上。同时用selectByIdForUpdate锁住车次行,让并发请求排队进入余票检查。做完这两步之后,再配合座位更新条件seat_status = 0,相当于有三道防线兜底,检查逻辑才算是真正闭合。
4.2 支付回调丢失导致订单一直停在待支付
现象:用户已经付款成功,但订单状态始终是“待支付”,座位一直处于锁定状态,15 分钟后被自动释放,用户明明付了钱却险些丢票。
原因:支付渠道的回调通知是异步的,如果回调地址不通、服务重启、或者通道方做了重试但重试窗口已过,订单就永远得不到支付确认。很多开发者只处理“支付成功”回包,却忽视了回调可能重复、乱序到达的情况。
解决:订单状态更新必须设计为幂等。收到支付成功通知后,先把该订单的当前状态查出来,只有状态是“待支付”时才更新为“已支付”,同时记录回调流水号。如果回调重复到达,第二次进来发现状态已经是“已支付”,直接返回成功响应,不再做任何修改。这样即使用户回调延迟,只要最终到达,订单都能被正确标记。
4.3 日期边界处理错位:用户买到明天之前的车票
现象:用户选择当天日期查询,接口返回了已过发车时刻的车次,下单也能成功,造成系统允许购买历史车票。
原因:查票接口只过滤了余票数量,没有过滤发车日期与当前日期的关系。如果数据库里有一周前的车次数据,余票没有清零,就会被查出来继续售卖。
解决:查询条件增加一个时间过滤。当天查询时,depart_time必须大于当前时间;非当天查询时,只比较日期即可,不必比较时刻。更稳妥的做法是在 SQL 里直接拼条件:日期等于今天时带上时刻大于当前时间的判断,日期大于今天时只保留日期判断。这个逻辑尽量写进 SQL 而不是 Java 代码中做全量过滤,否则分页查询会出现总页数对不上实际数据的问题。
4.4 事务回滚不彻底:座位释放了但余票没加回来
现象:订单取消后,座位状态已改回空闲,但车次表的sold_seats没有减回去,导致系统显示的余票越来越少。
原因:取消订单的方式五花八门。有的通过接口直接修改订单状态,有的是在定时任务里把超时订单批量置为取消,但两条路径没有共用同一个取消逻辑,减库存的代码只写在其中一处。
解决:把“取消订单并释放座位”提取成一个独立 Service 方法,内部同时完成四件事:更新订单状态、释放座位、扣减已售数量、记录取消日志。无论是人工退票还是超时任务,都调用这一个方法,避免逻辑分支分叉。如果后续加了“部分取消”需求,也就是一张订单多张票只退其中几张,这个方法还要再扩展成精细处理座位子集的版本。
5. 从 Demo 到能上线:用并发压测与状态校验验收车票预订系统
5.1 用 JMeter 并发下单验证超卖是否真的被堵住
写完代码后必须做一次并发验证,这是判断系统是否真正解决超卖问题的唯一方式。用 JMeter 开 200 个线程,同时对一个只有 5 张余票的车次发起下单请求,每个线程独立携带不同手机号。压测结束后执行两个查询:一是查订单总数,二是查车次表的sold_seats字段。
正常情况下订单总数和sold_seats都应该等于 5,任何大于 5 的结果都说明锁或事务设计有漏洞。压测时注意不要用内存数据库,MySQL 的行锁行为在真实环境才会完全生效。如果压测发现超卖,优先查selectByIdForUpdate是否真的加上了,以及seat_status = 0条件更新是否在座位表上生效。
5.2 状态机验收:模拟待支付、超时取消、支付成功三条路径
功能验收不能只在页面点一遍,要直接操作数据库模拟边界条件。先把订单置为待支付,接着手动调用超时任务,确认订单变为已取消、座位释放、余票恢复。再把另一笔订单置为待支付,直接写入支付回调流水,确认订单变为已支付且座位状态是已占。这两条路径走完,系统的核心可靠性才算达标。
顺带要验证重复支付回调。同一笔订单连续回调两次,订单状态不能发生二次流转,调用日志中第二次回调应显示幂等拦截。这类验证最好写成单元测试,放在 CI 里每次修代码后自动跑一遍,防止后续引入退票功能时把支付链路改坏。
5.3 权限与合规:这些隐藏问题别等到答辩才被发现
管理员接口和用户接口要分离,至少不能用一套接口同时承担“查所有订单”和“查自己的订单”两个职责。用户只能通过登录态中的身份信息拉取自己的订单列表,管理员操作必须单独校验角色。如果系统不做账号体系,也至少要加一个请求 token 参数,避免用户在浏览器地址栏直接拼/admin/orders访问内部数据。
支付环节不要自己造加密算法,直接对接现有支付渠道的沙箱环境,按官方文档接入签名和回调验签。车票预订系统涉及用户身份证号,数据库存储时做加密或脱敏,日志不要打印完整证件号。不处理这些细节,功能做得再好都只能停在演示阶段。
我自己做过一次教训很深的交付:当时为了赶答辩日期,跳过了回调幂等校验,线上演示时支付成功回调用 Postman 重复发了几次,订单状态被改得一团乱,当场翻车。后来我把所有状态流转全部收敛到一个 Service 方法中,每个操作前先查状态再执行,这个习惯从此保留了下来。希望这篇笔记能帮你少走这段弯路,做一套能拿去真实运行的车票预订系统。
本文还有配套的精品资源,点击获取