news 2026/10/30 9:22:01

Java航空订票系统源码解析:并发库存扣减与订单状态机设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java航空订票系统源码解析:并发库存扣减与订单状态机设计

简介:这份资源是基于Java的航空订票管理系统设计源码,面向学习Java桌面开发、需要课程设计或毕业设计参考的学生与开发者,帮助解决票务预订、退票、账户管理等业务场景的实现问题。压缩包共33个文件,约2.87MB,以17个Java源文件为核心,配合10张png界面截图、1个sql建库脚本、1个MySQL连接jar包及readme说明,覆盖登录注册、主界面、退票等模块,源码按view、control、database等目录分层组织,结构清晰便于二次开发。目前已有357人学习下载。读者可从中获得一套可直接运行的Swing+JDBC+MySQL 8.0完整项目,理解事件处理、数据库连接封装与界面布局思路,并借助截图与SQL脚本快速还原运行环境,适合作为Java桌面应用入门与实战练习的参考范例。

1. 从一份 Java 航空订票管理系统源码,看清它到底解决什么问题

航班座位是典型的稀缺资源,同一时刻可能有几百个请求抢同一批座位,超卖一张票的代价远高于普通电商订单。基于 Java 的航空订票管理系统设计源码,核心要解决的就是「航班、座位、订单、支付」四件事在并发下的一致性,以及后台运营人员能不能高效地排班、调价、退改签。它适合三类人:正在做 Java 课程设计或毕业设计的学生,需要一套能跑通、能讲清业务闭环的参考实现;想练手 Spring Boot 全栈的初中级开发,拿它当真实业务场景的练手项目;以及需要快速搭一套内部票务原型的工程师,直接复用领域模型和并发控制思路。源码本身不是终点,能不能读懂座位锁定、订单状态机、库存扣减这几处关键设计,才是这份源码值不值得投入的判断标准。

2. 航空订票系统的领域模型怎么拆:从航班到订单的四张核心表

2.1 为什么先定实体边界,再写代码

拿到一份订票系统源码,很多人第一反应是打开 Controller 看接口,结果被一堆 DTO 绕晕。我的习惯是先看数据库表结构和实体类,因为航空订票的业务复杂度几乎全在实体关系上。航班(Flight)是计划层,描述某条航线在某个日期几点起飞、用什么机型;舱位(Cabin)是销售层,同一个航班分头等、经济,各自有独立库存和价格;座位(Seat)是物理层,具体到 12A 这种编号;订单(Order)是交易层,关联乘客、舱位、支付状态。这四层如果混在一起,后面退改签和超卖控制会非常痛苦。

常见做法是把 Flight 和 Cabin 设计成一对多,Cabin 持有totalSeats和availableSeats两个字段,库存扣减只动 Cabin,不直接动 Seat。Seat 表只在需要选座时使用,和订单通过order_id关联。这样设计的好处是:不选座的快速出票流程完全不碰 Seat 表,减少锁竞争;需要选座时再单独处理座位占用。下面是一个精简的实体关系定义,用 JPA 注解表达。

@Entity @Table(name = "flight") public class Flight { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String flightNo; // 航班号,如 CA1234 private String depAirport; // 出发机场三字码 private String arrAirport; // 到达机场三字码 private LocalDateTime depTime; // 计划起飞时间 private String aircraftType; // 机型,影响座位布局 @OneToMany(mappedBy = "flight", cascade = CascadeType.ALL) private List<Cabin> cabins; } @Entity @Table(name = "cabin") public class Cabin { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne @JoinColumn(name = "flight_id") private Flight flight; private String cabinCode; // F/C/Y,头等/公务/经济 private Integer totalSeats; private Integer availableSeats; // 扣减库存的唯一入口 private BigDecimal price; @Version private Integer version; // 乐观锁版本号 }

这段代码的关键在Cabin上的@Version。航空订票的库存扣减如果用悲观锁SELECT ... FOR UPDATE,在高峰期会把数据库连接池打满;用乐观锁配合重试,吞吐量更高,代价是冲突时需要业务层重试。availableSeats是唯一允许扣减的字段,任何绕过它直接改 Seat 表的写法都会导致库存对不上。参数上,cabinCode建议用单字符编码而不是中文,方便索引和前端映射;price用BigDecimal而不是double,金额计算不能有浮点误差。

2.2 订单状态机:别让状态字段变成玄学

订单表最容易翻车的地方是状态字段。我见过不少源码用status一个 int 表示所有情况,结果退票、改签、支付超时全挤在一起,查问题时只能靠猜。可靠的做法是定义明确的状态枚举和允许的迁移路径。典型状态包括:PENDING_PAYMENT(待支付)、PAID(已支付)、TICKETED(已出票)、CANCELLED(已取消)、REFUNDED(已退款)。迁移规则是:待支付只能到已支付或已取消;已支付只能到已出票或已退款;已出票只能到已退款。任何跨状态跳转都应在 Service 层拦截。

public enum OrderStatus { PENDING_PAYMENT, PAID, TICKETED, CANCELLED, REFUNDED; // 定义合法迁移,避免状态乱跳 public boolean canTransferTo(OrderStatus target) { switch (this) { case PENDING_PAYMENT: return target == PAID || target == CANCELLED; case PAID: return target == TICKETED || target == REFUNDED; case TICKETED: return target == REFUNDED; default: return false; } } }

在更新订单状态时,先查当前状态,调用canTransferTo校验,再执行更新。这一步能挡掉大部分因为并发或前端重复提交导致的状态错乱。参数说明:状态枚举建议存字符串而不是序号,数据库里可读性更好,排查问题时不用对照数字;如果担心存储空间,用VARCHAR(20)完全够用。订单表还应该有一个idempotent_key字段,由前端生成唯一值,防止用户连点两次提交产生两笔订单。

2.3 航班查询的索引与分页

航班查询是读多写少的场景,但条件组合多:出发地、目的地、日期、舱位。如果每个条件都建单列索引,MySQL 可能选错索引。我的经验是建一个联合索引(dep_airport, arr_airport, dep_time),覆盖最常用的查询路径。日期范围查询放在最后,因为范围条件后面的列用不上索引。分页用LIMIT offset, size在深分页时性能会下降,航班查询通常只查某一天,数据量可控,但如果要做「未来七天最低价」这类功能,建议用覆盖索引先查主键再回表。

-- 联合索引,顺序不能反 ALTER TABLE flight ADD INDEX idx_route_time (dep_airport, arr_airport, dep_time); -- 查询某天某航线,走索引 SELECT f.flight_no, f.dep_time, c.cabin_code, c.price, c.available_seats FROM flight f JOIN cabin c ON c.flight_id = f.id WHERE f.dep_airport = 'PEK' AND f.arr_airport = 'SHA' AND f.dep_time BETWEEN '2025-06-01 00:00:00' AND '2025-06-01 23:59:59' AND c.available_seats > 0;

注意available_seats > 0这个条件放在 JOIN 之后,它不参与索引过滤,但能挡掉已售罄的舱位。如果查询频率很高,可以在 Redis 里缓存航班基础信息和库存,但库存扣减仍以数据库为准,缓存只做展示。这一步的边界是:缓存和数据库之间允许短暂不一致,但扣减必须走数据库乐观锁。

3. 用 Spring Boot 跑通订票主流程:从选航班到出票的最小闭环

3.1 项目分层与依赖选型

一份能跑的 Java 航空订票管理系统源码,通常用 Spring Boot + Spring Data JPA + MySQL + Redis 这套组合。选 JPA 而不是 MyBatis 的原因是实体关系复杂,JPA 的关联映射能省不少手写 SQL;但如果团队更熟悉 MyBatis,换成 MyBatis-Plus 也完全可行,核心的库存扣减逻辑不变。Redis 用来做分布式锁和缓存,不是必须,单机部署可以用本地锁替代。下面是最小依赖配置。

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>

参数说明:Spring Boot 版本建议 2.7.x 或 3.x,3.x 需要 Java 17,如果课程设计环境是 Java 8,选 2.7.x。MySQL 驱动在 8.x 后包名变成com.mysql.cj.jdbc.Driver,连接串要加serverTimezone=Asia/Shanghai,否则时间字段会差 8 小时,这是血泪经验。Redis 如果只是本地跑,用默认配置即可,生产环境要设密码和最大连接数。

3.2 库存扣减的乐观锁实现与重试

订票最核心的一步是扣库存。前面在 Cabin 上加了@Version,扣减时 JPA 会自动带上版本条件。如果两个线程同时读到 version=1,一个先更新成功 version 变成 2,另一个更新时WHERE version=1匹配不到,抛OptimisticLockException。业务层需要捕获这个异常并重试,重试次数一般设 3 次,超过就返回「当前预订人数过多,请稍后重试」。

@Service public class BookingService { @Autowired private CabinRepository cabinRepository; // 扣减库存,带重试 public boolean deductStock(Long cabinId, int count) { int retry = 3; while (retry-- > 0) { try { Cabin cabin = cabinRepository.findById(cabinId) .orElseThrow(() -> new RuntimeException("舱位不存在")); if (cabin.getAvailableSeats() < count) { return false; // 库存不足,不重试 } cabin.setAvailableSeats(cabin.getAvailableSeats() - count); cabinRepository.save(cabin); // 提交时校验 version return true; } catch (OptimisticLockingFailureException e) { // 版本冲突,短暂等待后重试 try { Thread.sleep(50); } catch (InterruptedException ignored) {} } } return false; } }

这段代码的逻辑说明:先查舱位,判断库存是否足够,足够则扣减并保存。save触发 flush 时如果版本冲突会抛异常,捕获后 sleep 50ms 再重试。参数上,重试次数 3 次是经验值,太多会拖长响应时间,太少在高峰期容易失败。Thread.sleep在生产环境建议换成带随机抖动的退避,避免多个线程同时重试再次冲突。注意:这段代码必须在事务内执行,否则save不会立即 flush,版本冲突检测会延迟到事务提交,重试逻辑就失效了。

3.3 下单接口的幂等与超时取消

用户点击「提交订单」时,网络抖动可能导致重复请求。幂等键由前端生成,放在请求头或参数里,后端用 Redis 的SETNX做去重,key 是幂等键,过期时间设 10 分钟。如果SETNX返回 false,说明已经处理过,直接返回上次的结果。订单创建后进入待支付状态,同时投递一个延迟消息(可以用 Redis 的 zset 或 RabbitMQ 延迟队列),30 分钟未支付则自动取消并回滚库存。

public Order createOrder(Long cabinId, String idempotentKey) { // 幂等校验 Boolean success = redisTemplate.opsForValue() .setIfAbsent("order:idem:" + idempotentKey, "1", 10, TimeUnit.MINUTES); if (Boolean.FALSE.equals(success)) { throw new RuntimeException("请勿重复提交"); } // 扣库存 boolean deducted = bookingService.deductStock(cabinId, 1); if (!deducted) { throw new RuntimeException("库存不足"); } // 创建订单,状态 PENDING_PAYMENT Order order = new Order(); order.setStatus(OrderStatus.PENDING_PAYMENT); order.setIdempotentKey(idempotentKey); orderRepository.save(order); // 投递延迟消息,30 分钟后检查支付状态 delayQueue.offer(order.getId(), 30 * 60 * 1000); return order; }

参数说明:幂等键过期时间 10 分钟要大于订单创建的最长耗时,否则同一请求重试时 key 已过期,去重失效。延迟消息的 30 分钟是行业常见值,可根据航司规则调整。回滚库存时同样要走乐观锁,不能直接UPDATE cabin SET available_seats = available_seats + 1,否则和扣减逻辑不对称,容易在并发下出错。

4. 并发抢票下的坑:超卖、重复订单、状态错乱怎么排查

4.1 超卖:库存扣成负数

现象:available_seats出现负数,或者两个订单占用了同一个座位。原因通常有两种:一是扣减时没用乐观锁或悲观锁,两个线程同时读到 1 都减成 0;二是扣减和订单创建不在同一事务,扣减成功但订单创建失败,库存没回滚。解决:扣减必须走带版本号的更新,且扣减和订单创建放在同一个@Transactional方法里。如果用了 Redis 缓存库存,缓存扣减和数据库扣减要保证最终一致,建议以数据库为准,缓存只做展示。

4.2 重复订单:用户连点两次

现象:同一个用户同一航班出现两笔待支付订单。原因:前端没做防抖,后端没做幂等。解决:前端按钮点击后置灰,后端用幂等键 + RedisSETNX拦截。注意幂等键要由前端生成 UUID,不能用用户 ID + 航班 ID 这种可预测的值,否则同一用户想买两张票会被误拦。如果业务允许同一用户买多张,幂等键应该每次下单都不同,只防重复提交,不防正常多买。

4.3 状态错乱:已退款订单又变成已支付

现象:订单状态从 REFUNDED 跳回 PAID。原因:支付回调接口没有校验当前状态,直接覆盖。解决:所有状态变更走canTransferTo校验,支付回调里先查订单当前状态,只有PENDING_PAYMENT才允许改成PAID。另外支付回调可能重复到达,回调接口也要做幂等,用支付流水号做唯一键。

4.4 航班查询慢:索引没走对

现象:按出发地、目的地、日期查询时响应超过 2 秒。原因:联合索引顺序不对,或者日期用了函数导致索引失效。解决:用EXPLAIN看执行计划,确保type不是ALL。联合索引把等值条件放前面,范围条件放最后。如果查询条件里对dep_time用了DATE(dep_time) = '2025-06-01',索引会失效,改成dep_time BETWEEN ... AND ...。

4.5 定时任务重复执行:库存被回滚两次

现象:超时未支付订单被取消后,库存加了一次,过一会儿又加了一次。原因:定时任务在多实例部署时同时执行,或者任务没有幂等。解决:定时任务加分布式锁,或者用数据库的UPDATE ... WHERE status = 'PENDING_PAYMENT'保证只有一个实例能改成功。回滚库存前先检查订单状态,只有从PENDING_PAYMENT改成CANCELLED的那一次才回滚。

5. 让这份源码真正可用的三个进阶技巧

5.1 用状态机引擎替代手写 if-else

订单状态少的时候手写canTransferTo没问题,但如果后面要加改签、升舱、部分退票,状态迁移会变成一张网。这时候可以引入 Spring StateMachine 或轻量的状态机库,把状态和事件配置化。好处是新增状态时不用改 Service 代码,只加配置。代价是学习成本,课程设计阶段手写足够,生产项目建议上状态机。

5.2 库存扣减的压测验证方法

写完扣减逻辑,怎么确认没有超卖?我的做法是写一个 JMeter 或 Gatling 脚本,模拟 500 个并发请求抢 100 张票,跑完后检查available_seats是否等于 0,订单数是否等于 100。如果库存是负数或订单超过 100,说明锁没生效。压测时注意数据库连接池要调大,否则请求会堵在连接获取上,测不出真正的并发问题。下面是一个简单的验证 SQL。

-- 压测后检查:库存应为 0,订单数应为 100 SELECT available_seats FROM cabin WHERE id = 1; SELECT COUNT(*) FROM `order` WHERE cabin_id = 1 AND status != 'CANCELLED';

如果结果不对,先看日志里有没有OptimisticLockingFailureException,有的话说明重试次数不够或冲突太激烈;没有的话检查事务边界,可能是扣减和订单创建不在同一事务。

5.3 从源码到可演示项目的收尾习惯

一份源码能不能拿得出手,差别往往在收尾。我一般会做三件事:第一,写一个data.sql初始化几条航班和舱位数据,别人 clone 下来直接能跑,不用手动造数据;第二,把application.yml里的数据库密码、Redis 地址抽成环境变量,避免提交敏感信息;第三,在 README 里写清楚启动步骤和压测命令,而不是只贴一张架构图。这三件事花不了半小时,但能让源码从「能看」变成「能用」。我自己踩过的坑是早期项目直接提交了本地数据库配置,换台机器就跑不起来,后来养成用application-dev.yml和application-prod.yml分离的习惯,省了很多后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 9:20:59

对于stm32的GPIO的理解

GPIO的用法一般有&#xff1a; 1.控制引脚输出高、低电平&#xff0c;或者直接驱动LED、继电器等期间实现开关功能&#xff1b; 2.芯片接收外部输出高、低电平&#xff0c;一次确定某些外部设备&#xff08;例如开关、按钮等&#xff09;状态&#xff0c;做出相应的操作&…

作者头像 李华
网站建设 2026/10/30 9:20:17

Gin + Vue3 + ElementPlus 后台选型:支持哪几种数据库

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/30 9:18:09

ESP32-P4 实战部署 Mistral-1B:嵌入式端 4.31 tok/s LLM 推理全链路优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/30 9:16:38

微信小程序+SSM毕业设计:心理咨询聊天室完整实现

简介&#xff1a;这是一套面向计算机专业本科生的微信小程序毕业设计实战项目&#xff0c;聚焦校园心理健康服务场景&#xff0c;提供从用户注册登录、在线心理咨询到实时聊天互动的完整闭环解决方案。资源包含前后端源码、详细说明文档与操作演示录像&#xff0c;技术栈涵盖微…

作者头像 李华