1. 项目缘起与技术选型:为什么要做这个机票预定系统
今年年初我接手了一个模拟项目X——基于Spring Boot的航空机票预定管理系统。说实话,这类业务系统在Java后端开发里算是个很经典的练手项目,因为它的业务链路足够完整:从用户注册、航班查询、机票预定、订单生成到支付回调,覆盖了Web开发中最常见也最核心的CRUD和事务处理场景。
很多人在学习Spring Boot时只做那种"单表增删改查"的Demo,学完发现自己还是不会做真实项目。而机票预定系统刚好弥补了这个断层。它天然带有库存概念(每个航班的座位数)、时间窗口概念(航班日期和起降时间)、状态机概念(订单从待支付到已出票到已退票),这三样东西恰好是绝大多数业务系统的灵魂。把这个项目的逻辑真正吃透,往后你去做电商秒杀、预约挂号、酒店预订,核心思路都是通的。
技术选型上我最终敲定的是Spring Boot 2.7 + MyBatis Plus + MySQL 8.0 + Redis做缓存 + JWT做无状态登录。为什么这么选,后面我逐一讲。先给一个整体结论:这套组合在目前中小型项目的实践中非常主流,公司能用,新手能学,遇到问题网上资料也好找。
再说说适合谁参考。如果你已经会基础的Java语法和MySQL增删改查,想跳出"跟着视频敲但不知道自己敲的是什么"的困境,这个项目是一个很好的跳板。如果你是个刚入职的初级开发,被分配去做业务系统的功能迭代,这篇博客能帮你理解那些老代码为什么长那样。如果你是在校生要交课程设计或者准备面试项目经验,这套系统的完整开发实录也能直接给你素材。
2. 系统规划与数据库设计:先想清楚再动手
2.1 核心需求拆解:这系统到底要管哪些事
航班机票预定管理系统从角色的角度天然分成三类参与者:普通用户、系统管理员,以及背后支撑的数据库和缓存基础设施。
用户侧最核心的诉求是查票和买票。但"查票"这件事拆开来看至少有三种查法:按出发城市和到达城市查、按航班号和日期精确查、按价格区间查。这三种查询对应到数据库上,就得在航班表和价格字段上做索引设计,否则数据量一上来查询就会很慢。而"买票"这件事听起来简单,实际牵扯到库存扣减和订单状态流转两个关键问题,哪个没处理好都会出大事故。
管理员侧的诉求是基础数据维护和运营管理。航班的基础信息(航班号、起降机场、起降时间、机型、餐食标准等)需要有人定期维护;舱位价格体系(经济舱、商务舱、头等舱动态调价)需要支持修改;对用户订单的查看和退款审核也得有相应入口。这部分功能看起来不起眼,但它是整个系统的数据源头——航班数据如果乱,用户查到的一切都是错的。
除了这两类角色,系统还有一个隐性的需求点:并发控制。机票有库存属性,节假日高峰期同一航班可能有多人同时下单,系统得保证不会出现超卖。为了应对这一点,我引入了Redis缓存库存,并且用分布式锁来保护关键的扣减操作。
2.2 数据表设计:六张核心表如何构成闭环
数据库表设计是这类系统最关键的一环。我最终拆出了6张核心表:用户表(sys_user)、航班表(flight)、舱位价格表(flight_cabin)、订单表(order_info)、订单明细表(order_item)、支付记录表(payment_record)。
用户表不需要多说,重点是注册字段要考虑后续扩展,比如手机号、邮箱、密码哈希值、盐值。密码我用的BCrypt加密,密文长度是60位,字段别抠抠搜搜只给32位,这是很多新手容易踩的坑。
航班表是核心中的核心。它需要存储航空公司、航班号、飞机型号、出发机场三字码、到达机场三字码、计划起飞时间、计划到达时间、飞行时长、总座位数、状态(正常/取消)。这里有个细节:机场信息我没有单独拆表,而是直接用三字码关联到一张系统字典表,避免每次查航班都要JOIN两张表。如果你后续想把系统做成规模化的运营系统,可以考虑单独拆一个机场表和一个航空公司表,但现在这个体量内聚起来更实用。
舱位价格表用来解决"同一个航班不同舱位不同价格"的问题。它通过flight_id关联航班表,存舱位代码(Y经济舱/C商务舱/F头等舱)、舱位名称、座位数、基础价格、当前票价。为什么要单独放一张表而不是直接在航班表里加三个价格字段?因为这样后续如果要支持价格策略调整(比如一周内提前购票折扣、节假日上浮比例),只需要加一个策略字段,不需要动主表结构。
订单表关联用户和航班。这里我刻意做了冗余——把航班号、航线、起降时间、舱位、价格、乘客姓名和证件号直接快照进订单表。原因是订单是交易凭证,如果将来航班信息被调整了,用户查自己历史订单时看到的应该是下单那一刻的信息,而不是当前的最新信息。这就是典型的"业务快照"设计思想,不是数据库冗余的锅,是业务需求的产物。
订单明细表是针对一个订单多个乘客的场景。比如一次下单买了3张票,订单表存一条总记录,明细表存三条分记录,每位乘客对应一条。这种设计为后续部分退票留了余地。
支付记录表存每一笔支付的外部流水号、支付渠道、支付金额、状态以及回调数据。它的重要性在于对接支付回调时,为了保证接口的幂等性,必须检查本地订单状态和支付流水。
2.3 一个容易忽视但极其关键的索引设计细节
表结构设计完后,索引设计我会专门说一节。因为很多人的表建完就开写代码,写到列表查询变慢了才回头加索引,其实一开始就能想清楚。
航班表上的索引主要围绕查询条件来设计。用户最常用的查询条件是"出发城市+到达城市+日期",所以在flight表上建立一个联合索引 (departure_airport_code, arrival_airport_code, departure_date)。这里有个原则:区分度高的字段放在联合索引前面。出发和到达机场三字码的区分度都比日期高,所以日期放在最后。如果你反过来建 (departure_date, departure_airport_code, arrival_airport_code),用户的按日期范围扫描范围仍然很大,索引效果会打折扣。
订单表上的索引跟状态查询强相关。常见查询有"查某用户的全部订单"和"查某时间段内的订单",所以 (user_id, create_time) 联合索引很关键。而 order_status 这种枚举值区分度很低,不适合单独建索引。我看到过很多人踩坑:为状态字段单独建索引,结果MySQL压根不走它,还不如不建。
支付记录表的主查询路径是"根据订单号+渠道查流水",可以建 (order_id, channel) 联合索引。另外外部流水号要加唯一索引,因为渠道回调时要用它来更新记录,有唯一索引兜底,就算回调消息重复送达也不会插入重复数据。
3. 核心业务实现:登录鉴权与航班检索
3.1 JWT登录方案:无状态会话如何落地
登录方案我没有选择传统的Session方案,原因有两点:一是后台管理接口和用户接口后续很可能要拆成独立服务,无状态JWT天然适合这种场景;二是前后端分离下JWT处理跨域携带凭证更优雅。这套系统用JWT做主鉴权手段,Redis缓存做登录态管理配合做强制下线能力。
实现上,用户登录成功后后端生成一个JWT,payload里存放userId、role、tokenId(随机UUID),过期时间设为24小时。JWT的密钥我放在配置文件里,用一个至少256位的随机字符串。注意,实际项目中这个密钥一定要环境隔离,DEV、STAGE、PRD各用一套,绝不能让测试环境的密钥流到生产环境。
代码层面我写了一个拦截器来校验JWT。拦截器从请求头Authorization字段里取出Bearer Token,解析并判定签名是否合法、是否过期。校验通过后把用户信息存入ThreadLocal。这个ThreadLocal在当前请求结束前所有业务代码里都可以直接取到当前用户,避免了在每个Controller的方法参数里反复传用户信息。
线程安全提示:ThreadLocal必须在拦截器的afterCompletion方法里调用remove方法清理,否则在高并发容器里可能会因为线程复用导致上一个请求的用户信息泄漏到下一个请求里。
比较值得说的一个设计是:我还用Redis维护了一个"token黑名单"机制。当用户修改密码或管理员强制某个用户下线时,会把该用户对应的tokenId加入Redis黑名单,TTL设置为剩余过期时间。拦截器校验JWT时,除了验签名,还要查一下这个tokenId是否在黑名单里。这个方案在小系统里既保留了JWT无状态的优势,又补上了"服务器无法主动吊销JWT"的短板。
3.2 航班查询优化:缓存与索引配合的实战经验
航班查询最怕的是页面一打开,所有人都直接打到MySQL上。所以我在查询链条里加了一层Redis缓存。缓存设计并不复杂:查询条件按"出发城市_到达城市_日期"拼成一个key,value存的是航班列表的JSON。查询时先查缓存,缓存未命中再查数据库,回填缓存并设置过期时间为5分钟。
这里有几个细节值得展开:
航班数据属于低频变更数据,5分钟缓存时长完全能满足一致性需求,不会让用户觉得"刚上架就没了"。
缓存击穿问题。如果某个热门航线在缓存过期的瞬间涌入大量请求,全部打到数据库上。我用一个简单的互斥锁解决:缓存miss后先尝试获取Redis分布式锁,拿到锁的请求去查库并回填缓存,没拿到锁的请求短暂等待后重试查缓存。这样同一个key只放行一个数据库请求,其余全部走缓存。
缓存穿透问题。如果有人恶意构造大量不存在的航线组合,缓存和数据库都没有数据,这种请求每次都会穿透到DB。我加了双重防护:一是布隆过滤器预判航线是否存在,二是即便查询结果为null也缓存一个空值并设置短TTL。小系统用空值缓存就够了,布隆过滤器属于锦上添花。
航班查询的数据库SQL也用了MyBatis Plus的LambdaQueryWrapper,查询条件动态拼接。这里要说一个尤其重要的点:查询日期需要做参数绑定,不能直接在字符串里拼接用户输入的日期。SQL注入虽然老生常谈,但这类系统真的会有人拿航班日期字段做攻击尝试。MyBatis的 #{} 预编译机制就是为这个设计的,任何动态查询都别用 ${} 拼接。
4. 订单与交易链路:购票流程的完整实现
4.1 从选定航班到下单:状态机和事务边界
购票流程是系统业务含金量最高的部分。整个流程按顺序是:用户选定航班和舱位 -> 填写乘客信息和联系人信息 -> 校验库存 -> 生成订单 -> 锁定库存 -> 模拟支付 -> 扣减真实座位 -> 出票。
这个流程中订单状态的流转我用状态机来管理。订单一开始是 PENDING_PAYMENT(待支付),支付成功后变成 BOOKED(已出票),用户发起退票后变成 REFUNDING(退票中),管理员审核通过后变成 REFUNDED(已退票),超时未支付则自动变成 CANCELLED(已取消)。
为了保证状态流转安全,我定义了一个状态机映射表,明确每一种操作允许的初始状态和目标状态,任何非法的状态跳转都会被拦截。比如用户不能从一个已出票的订单状态直接发起支付,不能对一个已取消的订单执行出票操作。这种做法在代码实现上引入了一个枚举类,里面存了状态流转的合法性集合,每次状态变更前先校验合法性。
事务边界的设计更为关键。下单时会做三件事:插入订单记录、更新库存、锁定座位。这三件事必须在一个数据库事务里完成。我在服务类方法上加 @Transactional 注解,默认传播行为REQUIRED就能满足。但有个细节:微服务拆分之后,跨服务操作无法本地事务保证,就需要引入分布式事务方案。我这个系统目前还是单体架构,本地事务就够用。单体架构下,@Transactional的默认回滚机制只处理RuntimeException,如果业务代码捕获了异常但没有往外抛,事务是不会回滚的。写代码的时候必须注意,无论哪里捕获了异常,业务不满足时一定要抛出运行时异常触发回滚,而不是吞掉异常后返回一个友好的error字符串了事。
4.2 库存扣减:Redis预扣加上分布式锁的完整细节
库存是机票预定系统里最容易出事故的位置。传统的做法是直接UPDATE行数减一,靠数据库行锁保证原子性。简单场景下够用,但只要并发量上来,连接池会被慢SQL拖垮,而且"先查库存再检查够不够再扣减"这种check-then-act模式在高并发下必然出现超卖。我自己在一开始模拟压测时就用100个并发用户抢同一个航班的10张余票,结果数据库里的最终售出数量变成了13张。
这就是经典的超卖问题。后来我改成了"Redis预扣+DB最终确认"的两段式方案。
第一步抢锁。用户发起下单时,先根据 flightId + cabinCode 拼出一个锁key,用Redis的SETNX(实际上用Redisson的tryLock)加分布式锁。锁的过期时间设置在5秒左右,持有锁的线程必须在过期前完成库存检查和扣减操作。之所以用锁,是因为同一航班同一舱位的并发下单要串行化。
第二步预扣。库存我在Redis里维护一个键,比如 stock:flight:1001:Y,每次下单前先DECR。如果DECR后的值大于等于0,说明有票,可以继续。如果DECR后的值小于0,说明库存不够,要把这个值再INCR回去(补回这一次扣减),然后直接返回"余票不足"。
第三步异步落库。预扣成功后,订单已经在数据库里生成了,这时库存的真实值作为快照记录在订单表里。每隔一段时间或者同一个用户再次请求时,触发一次同步任务,把Redis里的最终库存数定期同步回flight_cabin表。这么做的好处是:高并发下数据库的压力极小,预扣操作走内存,速度极快。
这套方案需要注意几个细节:
分布式锁必须设置合理过期时间,避免锁持有者宕机导致死锁。但过期时间也不能太长,否则一个慢请求会长时间阻塞其他正常请求。比较好的做法是启动一个看门狗线程,在锁快过期时自动续期。
释放锁时必须校验锁的持有者是不是自己。如果锁内跑了很久的线程搞到超时,锁自动释放,另一个线程拿到了锁把新的锁值写进去了,原本那个线程这时候去DEL锁,会把别人的锁删掉。解决方案是锁的value里存一个唯一请求ID,删除前先比较。
Redis库存和数据库最终值的一致性,靠定时任务兜底。每小时做一次全量对账,把Redis库存值和数据库的已售数量加起来跟总座位数量做核对,有偏差就告警。这个对账机制虽然土,但上线后帮我又抓出过两个并发边界Bug,强烈建议你也写一个。
4.3 支付回调处理:如何保证接口的幂等性
支付回调是交易链路中最后一块拼图。我接的是一个模拟的支付渠道,真实接口回调逻辑是一样的。第三方支付平台在用户支付成功后,会向服务器发送一个回调通知。这个回调携带订单号、支付渠道流水号、支付金额、支付状态等参数。服务器收到回调后的核心任务是:校验签名和金额,检查订单状态,然后更新订单状态和支付记录。
最容易出问题的就是重复回调。支付平台为了保证消息送达,会按一定策略重试回调,同一笔订单可能收到多次内容一样的通知。处理这种场景的手段就是幂等。
我的做法是先查订单状态。如果订单已经是"已支付"状态,直接返回成功应答给渠道,不再重复处理。同时对payment_record表的外部流水号加唯一索引,即使回调并发到达导致两条插入同时执行,数据库层也会拦截重复流水号。在单系统里,数据库唯一索引就是幂等最坚实的兜底。相比分布式锁、Redis状态标记这类手段,数据库约束最不容易被绕过。
金额校验也要仔细。回调里带一个支付金额字段,服务器必须拿它和本地订单表中的应付金额做比较,一致才允许更新状态。只凭渠道回调就更新订单却不校验金额,是真实项目中已知的安全隐患。如果金额不一致,这条回调要记录日志并触发人工介入流程。
支付成功后的出票动作,在我的设计里是一个异步任务。订单状态改为已支付后,向消息队列发布一条出票消息,消费者收到消息后执行出票操作:更新订单状态为已出票、给乘客分配座位号、生成票号、触发通知。这个异步化设计把核心交易链路的响应时间控制在100毫秒以内,用户体验好很多。
5. 管理端功能与测试经验总结
5.1 航班管理与订单审核:典型的后端管理系统功能
后台管理的航班录入和修改,功能本身是常规的单表操作,但有几个业务逻辑要处理好。
航班号唯一。航班录入时校验航班号不能重复,重复航班会造成用户查询结果混乱。数据库加了unique索引,业务层也要先查一次给出友好提示。
航班状态流转。修改航班前要检查当天有没有已出票的订单存在。如果有,不能直接改航班状态为取消,而是先给涉及订单的乘客发通知,引导他们走退改签流程。这个规则,很多业务不熟的开发容易漏。
订单审核功能集中在退款审核上。用户申请退票后,订单状态变为REFUNDING,管理员后台看到申请列表,审核通过后触发退款。退款的金额计算规则是可配置的:起飞前24小时以上退票扣5%,起飞前4到24小时扣20%,起飞前4小时内不可退票。这个规则我用一个政策配置表来管理,而不是写死在代码里,这样业务方调整政策时不用重新发版。
后台管理前台还有一个比较常用的功能:统计报表。我统计了每日订单量、销售额、热门航线TOP10、退票率等数据。报表功能不必实时计算,每天凌晨跑一个批处理,把结果写入统计表,页面直接查统计结果。这种方式构建成本低,数据实时性对这种运营看板完全够用。上来就引入ClickHouse或大宽表方案,对小系统是过度的。
5.2 接口测试与并发模拟:压测实录和参数调优
测试方面,常规的功能测试我用了Postman做接口联调,集成测试用Spring Boot Test + 内存数据库H2。但最值得分享的是并发场景的压测经验。
我是用JMeter做了三组场景的压测,每一组都是100个线程并发下单抢同一个航班的10张余票。
第一组是没加任何保护措施的原始版本,测试结果:订单表里最终生成13个成功订单,数据库余票字段变成了负数。这就是超卖问题直白粗暴的证据。
第二组是在扣库存代码里加了synchronized关键字,测试结果:本机上是正常的,但我把应用部署到两台机器上用Nginx做负载均衡后再压测,又出现超卖了。原因很简单,synchronized只能锁住单台JVM内部的线程,跨JVM的竞争它管不了。这就是为什么必须用Redis分布式锁的原因。
第三组是用Redis分布式锁方案,实测最终订单数稳定在10个,数据库余票归零,Redis里库存也归零。整个压测过程服务没有出现任何锁冲突报错,平均响应时间在900多毫秒,锁等待耗时可控。
压测过程中还顺手发现了一个数据库连接池参数问题。默认的HikariCP最大连接数是10,我模拟50个并发查询时,部分接口响应时间突然飙升,看日志发现是连接等待。后来把最大连接池调整到20,并设置了连接最大生命周期为30分钟,情况明显好转。这里想提醒你:连接池并不是越大越好,每个连接背后都有对应的数据库线程和内存开销。CPU核数不算多的机器,连接池设到50之后性能反而下降。
5.3 系统扩展方向与核心收获
项目收尾后我复盘了一下后续值得扩展的方向。一是引入消息队列来削峰填谷,目前出票虽然已经异步化了,但还可以进一步将下单流程中的短信通知、邮件通知、行程单生成服务全部改成监听消息驱动。二是支持航班动态调价策略,可以对不同提前购买天数、不同节假日级别配不同的价格计算规则。三是追加一个定时任务调度框架去实现每天的全量对账和报表生成,目前我是用Spring自带的Scheduled注解做简单调度,后续任务多了就换成分布式任务调度组件。
还有人问过我为什么不用前后端分离的更重型的框架,比如Redis + Spring Cloud Alibaba全家桶。就当前系统的规模,单体架构在代码维护性和部署复杂度上比微服务舒服太多。这两个技术方向我很熟悉,但没有必要在这个体量上硬上。这中间的平衡拿捏,很多时候比堆技术栈更能反映一个开发者的工程判断力。
最后说几句真心话。这个系统从建表到跑通全流程,我踩得最深的一个坑是库存一致性问题,它逼着我理解了数据库事务边界、分布式锁的使用场景和缓存与DB双写的一致性难点,这三件事在国内业务系统开发里出现的频率非常高。如果你自己动手把这个项目的完整代码写一遍,而不是只看别人的Demo,你一定会经历从"能跑"到"抗得住并发"的蜕变。这个过程学到的,是面试和实际工作中都非常值钱的东西。