ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot航班订票管理系统实战:从订单设计到并发控制

Spring Boot航班订票管理系统实战:从订单设计到并发控制

三个标题放在一起看,其实说的是同一个系统:基于Spring Boot的航班订票管理系统、航空票务预订与行程管理平台、智慧民航出行服务与机票订购系统。核心都在做一件事——把用户从搜索航班、选择座位、填写乘机人信息、提交订单,到支付出票、管理行程的整条链路串起来。这个题目是这几年计算机毕业设计里出现频率非常高的一个,因为它刚好落在“纯增删改查”和“真正的企业级业务”之间:表结构不复杂,但订单状态流转、余票并发控制、退改签事务这些问题都有得挖,既能保证做完,又能做出亮点。

这篇文章我按实际开发顺序来讲,从需求拆解、数据库设计、核心业务实现,到并发处理、常见的坑、最后怎么演示和答辩,每一段都是能直接落地的东西。适合正在选这个题目的学生、想快速改造成其他预订类系统的开发者,以及刚入行需要理解民航票务系统核心逻辑的程序员。

1. 先别急着写代码:业务到底怎么拆,模块边界在哪里

1.1 从用户行为路径推导模块划分

很多同学拿到这个题目,第一反应是打开设计工具画用例图。我不是说用例图没用,而是在画图之前,先把“用户打开系统以后会做什么”从头到尾走一遍,这条路径比任何文档都更接近真实需求:

  1. 用户注册并登录系统
  2. 输入出发城市、到达城市、出行日期,点击搜索
  3. 系统展示符合条件的航班列表,包含航班号、起降时间、价格、余票
  4. 用户选择一个航班,填写乘机人姓名、身份证号、手机号
  5. 提交订单,进入支付(毕设阶段一般是模拟支付)
  6. 支付成功后,系统出票,用户可以在“我的行程”中看到订单状态
  7. 行程变更时,用户可以对已出票的订单发起退票或改签

这条路径就是整个系统的核心闭环。顺着它把功能模块拆出来,编码时才不会东一榔头西一棒子。

拆完之后的模块大致是:用户模块、航班模块、订单模块、后台管理模块。用户模块负责注册、登录、个人信息维护;航班模块面向管理员做增删改查,面向用户做条件搜索;订单模块是整个系统最核心的部分,包含下单、支付、出票、退票、改签,每一笔操作都涉及状态变更;后台管理模块则是管理员视角的航班管理和订单处理界面。

模块边界清晰之后,表结构、接口设计、前端页面都会跟着清晰,最怕的是想到哪写到哪,做到一半发现订单和航班的关系没理清楚,回头改表。

1.2 技术选型:为什么是Spring Boot 2.7,而不是3.x

技术栈方面,Spring Boot + MyBatis-Plus + MySQL 是这类系统最常见的组合,Redis可以加也可以不加,后面我会单独说什么时候加。前端可以用Thymeleaf做服务端页面,也可以用Vue做前后端分离。我个人的建议是,除非你对Thymeleaf特别熟,否则直接用Vue这类前端框架加接口对接,理由有两个:

一是前后端分离之后,后端只需要负责返回JSON,接口职责更清晰,答辩的时候也好讲清楚“我设计了哪些接口”;二是Thymeleaf把页面逻辑和服务端代码混在一起,出问题以后排查链路过长,对新手不太友好。

版本上,强烈建议Spring Boot选2.7.x,不要选3.x。3.x起最低要求Java 17,很多人的开发机还是Java 8,选2.7.x可以省掉大量环境适配的时间。Java语言的版本用8或11都行,这个项目用不到17的新特性,没必要为了“新”给自己找麻烦。

持久层框架为什么不用JPA而选MyBatis-Plus:因为这个系统的核心查询是航班搜索,条件是出发城市、到达城市、日期三个参数都可能为空,属于典型的动态SQL场景。MyBatis-Plus的条件构造器写起来干净,多表查询时SQL也能完全掌控。JPA当然也能做,但动态查询的写法相对绕一些,对新手不友好。

2. 数据库设计:三张核心表怎么建,才能不返工

2.1 用户表、航班表、订单表的核心字段设计

很多做毕设的同学直接照搬课程设计的三张表:用户表、航班表、订单表,这方向没错,但细节上一旦设计错了,后面改起来相当痛苦。先说最终可用的表结构设计方案,再解释每个字段为什么这么设计。

表名主要字段说明
t_userid, username, password, real_name, phone, id_card, create_time用户账号信息与乘机人身份信息
t_flightid, flight_no, airline, depart_city, arrive_city, depart_airport, arrive_airport, depart_time, arrive_time, price, total_seats, sold_seats, status航班基础信息与余票字段
t_orderid, order_no, user_id, flight_id, passenger_name, passenger_id_card, passenger_phone, price, status, create_time, update_time订单信息,冗余乘客信息

有两个设计细节要特别注意。第一个是订单表里直接冗余了乘客姓名、身份证号、手机号,而不是单独建一张乘客表。原因很简单:订票时允许为他人代订,比如帮父母订票,此时乘客和登录用户不是同一个人。如果建独立的常用乘机人表,需要额外做CRUD;如果只允许订自己的票,业务上又说不通。把乘客信息冗余到订单表里,查询订单时一条SQL就能带出所有需要展示的内容,也方便退改签时做信息比对,这是最省事也最稳定的方案。

第二个细节是价格字段必须用DECIMAL(10,2),不能用float或double。浮点数在计算时会有精度损失,订票系统涉及金额,哪怕差一分钱,在答辩演示时被问到也会很尴尬。时间字段用DATETIME,不要用字符串存,字符串无法在SQL里直接做时间范围比较和排序。

2.2 订单状态字段:用数字字典,别用中文描述

订单表里的status字段是整个系统中最核心的字段,我用数字字典来维护状态,而不是直接存“待支付”“已出票”这类中文。原因有两方面:一是数字比较效率更高,查询也更稳定,中文描述可能存在前后端编码不一致的问题;二是状态是有限的枚举集合,用数字扩展起来不伤表结构。

状态定义如下:0表示待支付,1表示已支付待出票,2表示已出票,3表示已改签,4表示已退票,5表示已取消。

public class OrderStatus { public static final int PENDING_PAYMENT = 0; public static final int PAID = 1; public static final int TICKETED = 2; public static final int CHANGED = 3; public static final int REFUNDED = 4; public static final int CANCELLED = 5; }

在代码里维护好常量类,业务判断时用常量比较,不要出现魔法数字。比如查询“用户这笔订单能不能退票”,就判断order.getStatus() == OrderStatus.TICKETED。

航班表里的sold_seats和total_seats是余票计算的关键。余票不是单独存一个字段,而是通过total_seats - sold_seats算出来,这样并发扣减时可以直接用SQL做原子操作。后面第4章我会专门讲这个字段在并发场景下怎么处理,这里先记住:销售座位数需要存起来,不要每次订单都去count数据库,否则并发高一点就会被拖垮。

2.3 索引、唯一约束和建表SQL示例

索引设计不复杂,但要做对。t_flight表上给(depart_city, arrive_city, depart_time)建联合索引,因为用户搜索航班时三个条件总是同时出现;t_order表的order_no建唯一索引,防止重复下单;再给(user_id, status)建组合索引,支撑“我的行程”列表查询。

CREATE TABLE t_flight ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', flight_no VARCHAR(20) NOT NULL COMMENT '航班号', airline VARCHAR(30) NOT NULL COMMENT '航空公司', depart_city VARCHAR(30) NOT NULL COMMENT '出发城市', arrive_city VARCHAR(30) NOT NULL COMMENT '到达城市', depart_airport VARCHAR(50) NOT NULL COMMENT '出发机场', arrive_airport VARCHAR(50) NOT NULL COMMENT '到达机场', depart_time DATETIME NOT NULL COMMENT '起飞时间', arrive_time DATETIME NOT NULL COMMENT '到达时间', price DECIMAL(10,2) NOT NULL COMMENT '票价', total_seats INT NOT NULL COMMENT '总座位数', sold_seats INT NOT NULL DEFAULT 0 COMMENT '已售座位数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停飞', KEY idx_city_time (depart_city, arrive_city, depart_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='航班表';

这里提醒一下,数据库引擎必须用InnoDB,不要用MyISAM。InnoDB支持事务和行级锁,订票系统里扣减余票、状态流转都需要事务保证,MyISAM在这两个能力上等于没有。

3. 核心业务链路:从搜索航班到出票,状态是怎么流转的

3.1 航班搜索接口:动态条件与日期边界处理

航班搜索是用户进入系统后的第一个核心操作。前端传三个参数:出发城市、到达城市、出行日期。注意出行日期是一个LocalDate,而数据库里的depart_time是DATETIME。如果直接比较日期,会把出行当天从零点到次日零点的时间段都查出来,所以要把LocalDate转成当天的起始时间和结束时间,再进行范围查询。

@Override public List<Flight> searchFlights(String departCity, String arriveCity, LocalDate departDate) { LocalDateTime startTime = departDate.atStartOfDay(); LocalDateTime endTime = departDate.atTime(LocalTime.MAX); LambdaQueryWrapper<Flight> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(departCity), Flight::getDepartCity, departCity) .eq(StringUtils.hasText(arriveCity), Flight::getArriveCity, arriveCity) .between(departDate != null, Flight::getDepartTime, startTime, endTime) .orderByAsc(Flight::getDepartTime); List<Flight> flights = flightMapper.selectList(wrapper); // 查询余票情况:totalSeats - soldSeats flights.forEach(f -> f.setRemainSeats(f.getTotalSeats() - f.getSoldSeats())); return flights; }

API接口用GET方式,形如/api/flight/search?departCity=甲城&arriveCity=乙城&departDate=2025-05-20,返回JSON数组,前端渲染成航班列表。这里有个小经验:余票数可以在查完航班后通过total_seats - sold_seats算出并存到返回对象中,不要在SQL里每个航班都去做子查询,数据量不大时没必要。

搜索结果列表中,用户最关心的是价格排序和起飞时间排序。排序规则写在SQL里就行,前端点击排序列头时,换个排序字段重新请求一次接口,逻辑简单,也稳定。

3.2 下单流程:订单状态机是怎么跑起来的

下单流程是整个系统中最复杂的部分,因为它不是一步操作,而是多个步骤串起来的。正确顺序是:

  1. 判断用户是否登录,未登录则提示去登录
  2. 校验航班状态和余票
  3. 创建订单,初始状态为“待支付”,不扣余票
  4. 用户进入模拟支付,支付成功后在一个事务里同时更新订单状态和航班余票
  5. 订单状态变成“已支付待出票”,系统模拟出票后变成“已出票”

注意第3步的逻辑:下单时不扣减余票,只有支付成功后才扣。这和普通商品秒杀系统不一样。原因是机票存在“占座”概念,但真正占用座位是在确定要出行之后。如果下单就占座,会出现大量用户生成订单却不支付,把航班余票“占光”的情况;如果等到出票再扣余票,则可能出现支付成功但航班已满的尴尬。折中方案是:支付成功时判断余票并原子扣减,扣减失败则说明航班刚满,订单自动失败并退款。

状态流转可以用一张表表达清楚:

当前状态触发操作目标状态
待支付用户取消已取消
待支付模拟支付成功已支付待出票
已支付待出票系统出票已出票
已出票用户发起退票已退票
已出票用户发起改签已改签
已改签生成新订单新订单待支付

用状态机表格把流转画出来以后,代码里的switch-case或者状态更新条件就非常明确,不会出现“把已退票的订单又改成已出票”这种逻辑漏洞。

3.3 退票与改签:数据处理比想象中的多

退票相对简单:判断订单处于已出票状态,把订单状态改为已退票,同时把航班表的sold_seats减1,最后记录一条退票记录(一张独立的退票日志表可以承担这个职责)。

改签稍微复杂一点:原订单状态改为已改签,释放原航班座位,再为新航班创建一个待支付订单,用户支付差价后走正常的支付成功流程。这两步必须在一个事务里完成,否则可能出现原座位释放了、新座位还没占上的中间状态。

@Transactional(rollbackFor = Exception.class) public void changeFlight(Long oldOrderId, Long newFlightId) { Order oldOrder = orderMapper.selectById(oldOrderId); if (oldOrder.getStatus() != OrderStatus.TICKETED) { throw new BusinessException("只有已出票订单才能改签"); } // 释放原航班座位 flightMapper.deductSeat(oldOrder.getFlightId(), -1); // 占用新航班座位 int affect = flightMapper.deductSeat(newFlightId, 1); if (affect == 0) { throw new BusinessException("新航班已满员,改签失败"); } oldOrder.setStatus(OrderStatus.CHANGED); orderMapper.updateById(oldOrder); // 创建新订单,状态为待支付,支付时再确认余票 Order newOrder = createOrder(newFlightId, oldOrder.getPassengerName()); orderMapper.insert(newOrder); }

这段代码里最值得注意的点是:释放原座位和占用新座位之间如果有一个失败,整个事务会回滚,不会留下脏数据,这归功于@Transactional。deductSeat这个Mapper方法的具体SQL就是第4章要讲的并发控制核心。

4. 并发与数据一致性:余票怎么卖才不超卖

4.1 超卖问题的本质:为什么查了余票还会超卖

订票系统在高并发下的那个经典问题:两个人同时查询同一航班的余票,发现都还剩2张,同时支付,结果系统卖出了3张票。这就是超卖。

原因在于“查询余票”和“扣减余票”是两个独立操作,中间隔了几毫秒。在这几毫秒里,另一个请求可能已经完成了扣减。如果两笔业务都基于同一个旧值判断“有票”再执行扣减,数据库最终只会把sold_seats加到某一个值上,但实际上两张订单都支付成功了。

要解决这个问题,必须把“判断余票是否充足”和“扣减余票”合并成一个原子操作,让数据库在物理层面保证这两个步骤之间不会有并发操作插入。这也是这一章真正有价值的核心点,答辩时如果能把这个问题讲透,会明显加分。

4.2 核心方案:一条UPDATE命令同时完成判断和扣减

我是这样做的:自定义一个SQL更新方法,把判断和扣减放在同一个UPDATE语句里。

@Mapper public interface FlightMapper extends BaseMapper<Flight> { @Update("UPDATE t_flight SET sold_seats = sold_seats + #{step} " + "WHERE id = #{flightId} AND sold_seats + #{step} <= total_seats") int deductSeat(@Param("flightId") Long flightId, @Param("step") int step); }

调用时,step传1表示卖出一张票,传-1表示释放一张票。这条SQL的逻辑是:只有满足sold_seats + 1 <= total_seats条件时才执行更新,并且把sold_seats加1。MySQL的UPDATE自带行锁,两条并发请求都执行时,后执行的那条会因为条件不满足而更新0行。

在Service层里判断影响行数:

int affected = flightMapper.deductSeat(flightId, 1); if (affected == 0) { throw new BusinessException("航班余票不足,购票失败"); }

这样做的好处是,代码里不需要先查再判断,也不用手动加锁,数据库的原子更新帮我们把并发问题解决了。整个支付成功流程如下:先调用deductSeat扣减余票,如果影响行数为0,说明余票不足,事务直接回滚,提示用户;如果影响行数为1,继续更新订单状态为已支付待出票,提交事务。

这里有个测试细节要记住:要验证不超卖,光靠Postman手工点是不够的,可以写一个压测小脚本,或者用一个并发请求工具模拟同一航班10个用户同时下单,观察最终sold_seats有没有超过total_seats。

4.3 进阶扩展:Redis什么时候值得加

如果想让系统看起来更有层次,可以在两个地方用Redis:一是航班查询结果缓存,二是支付时预扣库存。但我要提醒一点:加Redis是为了解决数据库压力问题,不是为了“显得高级”。

简单场景下,上面那条原子UPDATE已经解决了超卖问题,数据库不会出错,只是并发量很高时数据库压力大。比如航旅类产品在做促销时,同一航班瞬间有上千次查询,把热门查询结果缓存到Redis里能明显降低数据库负载。支付时的预扣库存方案是:用户下单时先在Redis里DECR一个库存键,支付成功后再同步更新数据库;支付失败或超时时INCR把它还回去。

Redis方案的优点是抗并发能力强,缺点是逻辑复杂,需要处理Redis和MySQL的双写一致性。我的建议是,毕设阶段把原子UPDATE方案做到位,Redis缓存可以做,但不要为了加缓存把系统的数据一致性搞复杂,否则演示时一旦前后端数据对不上,反而暴露问题。

4.4 事务边界与事务失效的几个情况

扣余票和更新订单必须包在同一个事务里。我用@Transactional注解实现,但这里有几个新手极容易踩的坑:

第一,只有通过Spring容器调用外部代理对象时,事务注解才会生效。同一个类里A方法调B方法,B方法上写了@Transactional是无效的,因为调用发生在类内部,没有经过代理。解决办法是把事务方法拆到另一个Service类里,或者用自注入的方式调自己,更推荐直接拆类。

第二,事务只对RuntimeException和Error回滚,对受检异常默认回滚策略不同。如果业务中抛的是自定义检查异常,记得在注解里指定rollbackFor = Exception.class,否则可能出现订单状态更新到一半但事务没回滚的诡异现象。

第三,InnoDB的SELECT ... FOR UPDATE确实可以用行锁解决竞态,但使用时要特别注意锁范围。如果查询条件没有命中索引,行锁可能升级为表锁,反而拖垮整个系统。这也是我更推荐原子UPDATE方案而不是手动加锁的原因——代码更少,出错概率更低。

5. 开发过程中反复折磨我的几个坑

5.1 时间日期格式:前后端对不上,查询和显示全是问题

航班系统里全是时间字段,起飞时间、到达时间、创建时间,时间处理是第一大坑。我遇到过的典型问题是:后端返回的LocalDateTime被序列化成2025-05-20T08:30:00这种带T字样的格式,前端直接显示出来很难看,需要额外处理。

我最终采用的统一方案是:后端实体用LocalDateTime,在配置文件里全局指定Jackson序列化格式:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

前端拿到的时间字符串就是2025-05-20 08:30:00,直接展示或者用dayjs格式化都方便。另外,搜索接口传入的日期不要用字符串,用@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate来接参数,避免时区偏移问题。

5.2 MyBatis-Plus分页查询不生效

分页查询是后台管理列表页面的必备功能。MyBatis-Plus如果不做任何配置,直接调selectPage,你会发现返回的records里是全量数据,翻页完全无效。原因很简单:分页插件没有注册到MyBatis拦截器链里。

解决办法是配置一个分页拦截器:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

这个配置不复杂,但几乎每个用MyBatis-Plus的人都会踩一次。如果每次查询都返回全量数据,后台航班管理页面数据一多就会非常卡,被老师演示时看到会很减分。

5.3 登录拦截器拦截了预检请求

我做前后端分离时,后端写了一个简单的登录拦截器,拦截所有未登录访问。排查的时候发现一个很隐蔽的问题:前端每次跨域请求之前,浏览器会先发一个OPTIONS预检请求,这个请求里不会带着业务参数,如果拦截器把它也拦了,前端就会看到“请求失败”,但实际上后端接口本身是通的。

我的处理方式是,在所有请求进入拦截器之前,先放行OPTIONS方法,或者在拦截器里判断若方法是OPTIONS就直接返回true:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 登录校验逻辑 }

同时,在WebMvcConfigurer中配置跨域映射,允许指定的路径和请求头访问。很多项目连不上接口的“玄学问题”,最后查下来都是这个。

5.4 订单删除:要用逻辑删除,不要物理删除

订单列表页都会提供“删除订单”功能,但这里的删除千万不能是DELETE FROM t_order。原因有两点:一是订单数据有统计价值,物理删除会导致“总订单数”“营收金额”等统计维度失真;二是退改签的日志记录需要依赖原订单信息。我用的方案是加一个逻辑删除字段,或者直接把订单状态置为“已取消”,查询列表时过滤掉取消状态的记录。

逻辑删除不仅适合订单,航班表里如果以后要软下架再上架,也同样适用。答辩时很容易被问“为什么不用物理删除”,回答逻辑删除保数据、可追溯,是一个很好的加分点。

6. 从开发到演示:数据准备、演示顺序和答辩亮点

6.1 演示数据:10条以上航班数据是基本盘

很多系统跑起来之后,演示效果差,问题往往不是功能有bug,而是数据太单薄。航班表里只有两三条数据,输入“甲城到乙城”时结果不够丰富,整个界面显得空荡荡。

我建议在项目启动时用CommandLineRunner自动初始化演示数据。准备至少10条航班,覆盖不同时间段:早上、中午、晚上,不同价格档位:经济舱、公务舱稍作区分,不同出发到达城市组合。这样演示过程中能充分展示搜索、排序、筛选的能力,还能在切换日期时让用户看到不同日期的不同航班列表。

航班编号要做得像真实的航班号,比如“WJ601”这种格式,起降时间要有逻辑,到达时间必须晚于起飞时间,不能出现穿越时间的航班,这种细节虽然小,但被老师指出“时间逻辑不对”就很扣分。

6.2 演示顺序:先讲用户路径,再讲后台管理

现场演示最忌讳的是一上来登录管理员账号去增删改查,因为评委没法快速理解系统是给谁用的。我惯用的演示顺序是:

先以普通用户身份演示完整订票流程:注册账号、登录、搜索航班、选择航班填写乘机人、提交订单、模拟支付、查看“我的行程”中订单状态变为已出票。此时再从用户视角演示退票或改签,让评委看到状态的变化。

然后再登录管理员账号,演示航班的新增和停飞、订单查询和处理。这个顺序实际上就是用户体验的完整闭环,中间穿插说明对应模块的技术实现,比如“支付成功后,这里用了一条原子UPDATE命令来扣减余票,防止超卖”,比纯粹演示页面更有说服力。

6.3 容易被打动的亮点和技术扩展方向

答辩的时候,把“超卖问题怎么解决”放在最核心的位置,讲清楚以下链路:分布式场景下的竞态条件、用一条原子UPDATE把判断和扣减合并、影响行数为0时说明余票不足、结合事务保证订单数据不出现中间态。这一套讲下来,已经能证明你不只是会抄代码,而是真的理解并发和数据一致性。

如果还有余力,可以提一个实际能落地的扩展方向:出票模块对接消息队列异步处理,支付成功后发出一条出票消息,系统处理完成后更新订单状态。这个方案的好处是不让用户请求在支付环节卡太久,也符合真实航司系统的异步出票思路,是一条非常容易在答辩时引发讨论的点。

把这篇内容从头看下来,三个系统的名称变了,核心其实没变:一个会搜航班、能下单、支持退改签、能管理行程的Java Web系统。做这个题目的过程中,你会把数据库设计、事务边界、并发控制、前后端联调这些课本上零散的点串成一条完整的链路,亲手踩一遍坑获得的经验,比背十篇面试题都扎实。希望这些实战细节能帮正在做类似题目的人少走几步弯路,代码可以不用照抄,坑尽量别重踩。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进