三个标题放在一起看其实说的是同一个系统基于Spring Boot的航班订票管理系统、航空票务预订与行程管理平台、智慧民航出行服务与机票订购系统。核心都在做一件事——把用户从搜索航班、选择座位、填写乘机人信息、提交订单到支付出票、管理行程的整条链路串起来。这个题目是这几年计算机毕业设计里出现频率非常高的一个因为它刚好落在“纯增删改查”和“真正的企业级业务”之间表结构不复杂但订单状态流转、余票并发控制、退改签事务这些问题都有得挖既能保证做完又能做出亮点。这篇文章我按实际开发顺序来讲从需求拆解、数据库设计、核心业务实现到并发处理、常见的坑、最后怎么演示和答辩每一段都是能直接落地的东西。适合正在选这个题目的学生、想快速改造成其他预订类系统的开发者以及刚入行需要理解民航票务系统核心逻辑的程序员。1. 先别急着写代码业务到底怎么拆模块边界在哪里1.1 从用户行为路径推导模块划分很多同学拿到这个题目第一反应是打开设计工具画用例图。我不是说用例图没用而是在画图之前先把“用户打开系统以后会做什么”从头到尾走一遍这条路径比任何文档都更接近真实需求用户注册并登录系统输入出发城市、到达城市、出行日期点击搜索系统展示符合条件的航班列表包含航班号、起降时间、价格、余票用户选择一个航班填写乘机人姓名、身份证号、手机号提交订单进入支付毕设阶段一般是模拟支付支付成功后系统出票用户可以在“我的行程”中看到订单状态行程变更时用户可以对已出票的订单发起退票或改签这条路径就是整个系统的核心闭环。顺着它把功能模块拆出来编码时才不会东一榔头西一棒子。拆完之后的模块大致是用户模块、航班模块、订单模块、后台管理模块。用户模块负责注册、登录、个人信息维护航班模块面向管理员做增删改查面向用户做条件搜索订单模块是整个系统最核心的部分包含下单、支付、出票、退票、改签每一笔操作都涉及状态变更后台管理模块则是管理员视角的航班管理和订单处理界面。模块边界清晰之后表结构、接口设计、前端页面都会跟着清晰最怕的是想到哪写到哪做到一半发现订单和航班的关系没理清楚回头改表。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT航班表;这里提醒一下数据库引擎必须用InnoDB不要用MyISAM。InnoDB支持事务和行级锁订票系统里扣减余票、状态流转都需要事务保证MyISAM在这两个能力上等于没有。3. 核心业务链路从搜索航班到出票状态是怎么流转的3.1 航班搜索接口动态条件与日期边界处理航班搜索是用户进入系统后的第一个核心操作。前端传三个参数出发城市、到达城市、出行日期。注意出行日期是一个LocalDate而数据库里的depart_time是DATETIME。如果直接比较日期会把出行当天从零点到次日零点的时间段都查出来所以要把LocalDate转成当天的起始时间和结束时间再进行范围查询。Override public ListFlight searchFlights(String departCity, String arriveCity, LocalDate departDate) { LocalDateTime startTime departDate.atStartOfDay(); LocalDateTime endTime departDate.atTime(LocalTime.MAX); LambdaQueryWrapperFlight 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); ListFlight flights flightMapper.selectList(wrapper); // 查询余票情况totalSeats - soldSeats flights.forEach(f - f.setRemainSeats(f.getTotalSeats() - f.getSoldSeats())); return flights; }API接口用GET方式形如/api/flight/search?departCity甲城arriveCity乙城departDate2025-05-20返回JSON数组前端渲染成航班列表。这里有个小经验余票数可以在查完航班后通过total_seats - sold_seats算出并存到返回对象中不要在SQL里每个航班都去做子查询数据量不大时没必要。搜索结果列表中用户最关心的是价格排序和起飞时间排序。排序规则写在SQL里就行前端点击排序列头时换个排序字段重新请求一次接口逻辑简单也稳定。3.2 下单流程订单状态机是怎么跑起来的下单流程是整个系统中最复杂的部分因为它不是一步操作而是多个步骤串起来的。正确顺序是判断用户是否登录未登录则提示去登录校验航班状态和余票创建订单初始状态为“待支付”不扣余票用户进入模拟支付支付成功后在一个事务里同时更新订单状态和航班余票订单状态变成“已支付待出票”系统模拟出票后变成“已出票”注意第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 BaseMapperFlight { 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: GMT8前端拿到的时间字符串就是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就直接返回trueOverride 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系统。做这个题目的过程中你会把数据库设计、事务边界、并发控制、前后端联调这些课本上零散的点串成一条完整的链路亲手踩一遍坑获得的经验比背十篇面试题都扎实。希望这些实战细节能帮正在做类似题目的人少走几步弯路代码可以不用照抄坑尽量别重踩。