ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot航空机票预定系统:从数据库设计到并发控制实战

SpringBoot航空机票预定系统:从数据库设计到并发控制实战 每年到了毕业季总有一批计算机专业的同学被毕设选题折磨到头秃。航空机票预定管理系统这个题目几乎可以算是Java Web方向的老牌经典——它不像电商系统那么俗套又比图书管理、学生管理这类“大路货”有辨识度业务逻辑里藏着航班查询、余票控制、订单状态流转、价格计算这些真实系统才会遇到的问题。我当年带过的学生里选这个题的人不少但真正能把它做得漂亮、答辩时不心虚的并不多。原因很简单这个系统看起来就是“增删改查”但真要落地涉及的技术点其实相当密集。SpringBoot作为当前Java后端的主流框架配上Maven、MyBatis、MySQL再加一个Vue或Thymeleaf前端几乎就是目前就业市场上最通用的组合。这篇博文我不想给你堆一堆官网文档式的废话而是站在一个真正做过项目、也帮人改过答辩项目的人的角度把这个系统从选型、设计到编码、部署每一步背后的“为什么”和“坑”都讲清楚。无论你是准备用它当毕业设计的在校生还是想拿一个完整项目练手、准备Java面试的初学者这篇文章都值得你从头到尾看一遍。我会把模块设计、数据库表结构、核心接口实现、事务和并发处理、以及我在实际中踩过的那些典型的雷都一并拆开揉碎讲给你。1. 项目整体设计与技术选型解析1.1 为什么是SpringBoot而不是SSH或SSM很多学校教材还在教SSH或者SSM但现实中你去求职网站上翻一翻要求SpringBoot的岗位占绝对多数。原因不复杂SpringBoot本质上是Spring的“自动配置”封装它把以前SpringMVCMyBatis整合时要写的XML配置、web.xml、DispatcherServlet注册、数据源配置全部做成了约定优于配置的启动器。比如你要连MySQL以前要写jdbc.properties、要配SqlSessionFactoryBean、要配MapperScannerConfigurer而SpringBoot里只要引入spring-boot-starter-data-jpa或者mybatis-spring-boot-starter在application.yml里写上数据源地址、用户名、密码剩下的交给自动配置完成。对于毕业设计来说这意味着你能把时间花在业务逻辑上而不是花在“配置文件里忘了一个bean导致Tomcat启动报错”这种毫无意义的事情上。更重要的是毕业设计答辩时老师几乎必然会问“为什么选SpringBoot”。你如果能讲出“它简化了传统Spring的XML配置利用自动配置和starter机制提升了开发效率同时内嵌Tomcat可以直接以jar包方式部署”这几点比背概念强太多。1.2 系统核心模块拆解与业务流梳理航空机票预定管理系统核心参与角色其实就三类乘客用户、航空公司后台管理员、系统管理员。实际开发中我们一般把系统拆成两个端用户端注册登录、航班查询、机票预订、订单管理、在线支付通常用模拟支付、退票改签。管理端航班信息管理航班号、航线、起降时间、机型、舱位、航线管理出发城市、到达城市、订单管理、用户管理、公告管理。业务流程主线非常清晰用户登录 - 搜索航班 - 选择航班和舱位 - 填写乘客信息 - 生成订单 - 模拟支付 - 订单状态变为已支付 - 出票。退票流程则是用户发起退票 - 管理员审核 - 退款 - 订单关闭。这个流程里最值得深挖的是订单状态机。我见过很多学生把订单表里放一个字符串字段随便存“未支付”、“已支付”完全没有状态流转的概念。一旦被追问“怎么保证一个订单只能从已支付变更为已出票而不能直接从未支付变成已出票”就答不上来。所以在设计阶段我建议把订单状态定义为一组有序常量CREATED已创建、PAID已支付、TICKETED已出票、CANCELLED已取消、REFUNDED已退款。每一个状态变化都对应一个操作并且这些操作都在Service层统一封装不允许在Controller里直接改状态字段。2. 数据库设计与核心数据表实战2.1 数据表结构与字段说明航空系统的数据库是整个项目的灵魂。表设计得好后面的代码就像在搭积木设计得烂就等着后期疯狂改SQL吧。我一般会设计这几张核心表user用户表。字段包括id、username、password记得要加密存储推荐BCrypt、real_name、id_card身份证号、phone、email、create_time。airline航空公司表。包括airline_code如CA、MU、airline_name、logo等。airport机场表。包括airport_codePEK、PVG、airport_name、city。flight航班表。这是核心中的核心。字段包括flight_no、airline_id、departure_airport_id、arrival_airport_id、departure_time、arrival_time、aircraft_type、economy_price、business_price、economy_stock、business_stock、status正常/取消。orders订单表。字段包括order_no、user_id、flight_id、cabin_type经济舱/商务舱、passenger_name、passenger_id_card、ticket_price、status、create_time、pay_time、cancel_time。announcement公告表。用于管理端发布航班动态或系统公告。这里有两个容易忽略的细节。第一个是金额字段要用decimal而不是float。很多新手用double存价格结果支付显示99.999999被老师当场指出来非常尴尬。第二个是订单号不要用自增id。因为自增id容易暴露系统数据量而且不够随机。我在项目里习惯用时间戳随机数拼一个20位左右的唯一字符串yyyyMMddHHmmss 6位随机数虽然简单但足够应付毕业设计场景。2.2 多表关联查询的SQL设计技巧航班列表页的数据其实来自多张表。前端显示“CA1837 北京首都 - 上海虹桥 07:30 - 09:45 经济舱¥1280”需要关联flight、airline、airport三张表。为了不每查一次都写一堆join我建议在FlightMapper.xml里预置一个统一的selectFlightDetail把需要用到的字段一次性查出来并且对应一个DTOFlightDetailDTO避免把实体类直接丢给前端。这里我想特别说一个点DTO的重要性。有些同学图省事直接返回Entity对象结果数据库表字段变更时前端就跟着崩。毕业设计项目虽然不大但如果你答辩时说得出“我采用了DTO隔离持久层和表现层避免敏感字段直接暴露”老师对你的印象分会直线上升。再就是模糊查询与多条件组合查询。航班搜索不是单纯的一个where flight_no ?而是常常需要出发城市、到达城市、出发日期、舱位类型、最低价最高价。我建议用MyBatis的where标签动态拼SQL不要用字符串拼接SQL否则会有SQL注入风险。比如查询航班时select idsearchFlights resultTypecom.example.dto.FlightDetailDTO SELECT f.id, f.flight_no, a1.airport_name AS dep_airport, a2.airport_name AS arr_airport, f.departure_time, f.arrival_time, f.economy_price, f.business_price, f.economy_stock, f.business_stock FROM flight f LEFT JOIN airport a1 ON f.departure_airport_id a1.id LEFT JOIN airport a2 ON f.arrival_airport_id a2.id where if testdepartureCity ! null and departureCity ! AND a1.city #{departureCity} /if if testarrivalCity ! null and arrivalCity ! AND a2.city #{arrivalCity} /if if testdepartureDate ! null AND DATE(f.departure_time) #{departureDate} /if if testmaxPrice ! null AND f.economy_price lt; #{maxPrice} /if /where /select这种动态SQL既安全又灵活而且展示给老师看老师一看就知道你理解MyBatis的核心能力。3. 核心功能实现与业务逻辑细节3.1 航班余票控制与并发超卖问题航空订票系统最经典的问题就是超卖。想象一下同一时刻有一百个用户抢同一航班的经济舱系统只放了10张余票如果你写的是Flight flight flightMapper.selectById(flightId); if (flight.getEconomyStock() 0) { flight.setEconomyStock(flight.getEconomyStock() - 1); flightMapper.updateById(flight); // 创建订单 }这在单线程测试下没问题但一旦并发请求打进来两个线程同时读到stock1同时判断0成立都去减库存最后就会卖出11张票。这就是典型的并发超卖。毕业设计虽然不会真的承受高并发但老师肯定会问“你怎么处理并发下的库存问题”。这里我推荐一个恰到好处的方案使用数据库的行锁来保证原子性。在MySQL中可以这么写UPDATE flight SET economy_stock economy_stock - 1 WHERE id #{flightId} AND economy_stock 0;然后把UPDATE的影响行数作为判断依据如果返回1说明扣减成功可以继续创建订单如果返回0说明余票不足就抛异常回滚事务。这种做法叫“乐观锁写得少悲观锁用UPDATE加锁”比在Java层加synchronized要靠谱得多。实际实现时你的Service方法上要加Transactional让库存扣减和订单插入在同一事务里谁先谁后无所谓关键是不能只扣库存没订单或者只建订单没扣库存。当然如果是在真实大型系统中还会使用消息队列削峰或者Redis预扣库存但毕设答辩时你能讲清楚“数据库行锁事务”这个方案已经绰绰有余。如果要扩充亮点你可以补充一句在实际高并发场景还可以引入分布式锁如Redis的SETNX但考虑到毕设系统规模和部署环境数据库悲观锁已经能保证数据一致性。3.2 订单状态流转与表单校验订单模块我强烈建议你用一个独立的OrderService并且把状态变更操作全部收口在这个Service里而不是让Controller直接操作Dao。比如createOrder、payOrder、cancelOrder、refundOrder每个方法都有自己的一套前置校验逻辑。payOrder的核心逻辑是查询订单判断订单是否存在校验订单状态是否为CREATED不是则抛异常“订单状态异常”模拟支付这里我一般让学生做一个假的支付接口直接让前端调用/order/pay后端把状态置为PAID同时设置pay_time now()支付成功后可以顺便把订单关联航班的余票再次确认一下其实在创建订单时已经扣过了这里不用重复扣否则会多扣一次。这里有个细节创建订单时扣库存支付时不需要再扣但如果用户一直不付款订单会占住余票。真实系统里会有“订单超时未支付自动释放库存”的定时任务毕设里可以用Quartz写一个定时任务每30分钟扫一次超时订单把状态从未支付改为已取消同时把库存加回来。这个功能如果写出来直接就是答辩亮点。表单校验也是很容易被忽视的一块。乘客姓名、身份证号必须做后端校验不能只靠前端。身份证号码是一个18位字符串你可以简单校验一下长度和最后一位校验码规则不需要实现完整算法但至少要判断非空和格式。用SpringBoot的Validated配合NotNull、Pattern注解既简洁又专业。3.3 价格计算与舱位类型设计价格这块不同航班的机票价格可以用一张价格表存储也可以直接存在flight表里。毕设场景直接用两个字段economy_price和business_price。但真正有意思的是“价格如何计算折扣”这个点很多同学不知道怎么扩展。你可以设计一个PricingRule表按“提前购票天数”、“乘客年龄”等条件设置折扣率也可以不做得那么复杂直接在前端传一个票价过来后端再跟数据库里的标准价做比对校验防止用户篡改价格。防止篡改价格这件事值得多说一句。有学生会把票价直接放在前端表单里提交order.setPrice(request.price)那真是太天真了。正确的做法是后端根据航班id和舱位类型从数据库里查出标准价格然后计算比如儿童票五折学生票八五折任何前端传来的价格都不应该直接信任。这个思路恰恰是面试官喜欢听到的“服务端校验一切输入”。舱位类型我建议用枚举或者一个String常量化不要写裸字符串“经济舱”到处比不然改个名字你会非常痛苦。Java枚举是不错的选择public enum CabinType { ECONOMY(经济舱), BUSINESS(商务舱); private final String desc; // getter... }这样你在代码里写CabinType.ECONOMY编译器能帮你检查拼写还能防手滑。4. 实操过程从零搭建一个可运行的SpringBoot机票系统4.1 环境准备与项目初始化步骤先交代一下我个人推荐的开发环境JDK 1.8如果你不想折腾1.8最稳定SpringBoot 2.7.X完美适配、Maven 3.6、IntelliJ IDEA社区版足够、MySQL 5.7、Navicat或DataGrip。前端部分你可以选Thymeleaf服务端渲染一个依赖搞定也可以选Vue前后端分离。如果你是急着做毕设时间有限选Thymeleaf会节省很多跨域问题的麻烦。初始化一个SpringBoot项目最标准的方式是去Spring Initializrhttps://start.spring.io生成基础骨架。除了最核心的spring-boot-starter-web我还要特别提醒你加上这几个依赖mybatis-spring-boot-starterMyBatis官方提供的自动配置starter版本要与SpringBoot兼容mysql-connector-java注意新版驱动是com.mysql.cj.jdbc.Driverlombok能省掉一堆getter/setter会让你的实体类干净得让答辩老师眼前一亮spring-boot-starter-validation做表单校验spring-boot-starter-test写几个基础单元测试这又是加分项然后配置application.yml我习惯把数据源配置写在单独的application-dev.yml留出一个生产环境的切换意识。这里要特别留意时区问题serverTimezoneAsia/Shanghai否则日期字段有时差8个小时调试时非常诡异。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/air_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case也是容易被忽略的配置。数据库字段departure_time默认会映射到实体属性departureTime如果你不打开这个开关你只能自己写resultMap或者把属性名改得跟字段一样都不优雅。4.2 核心代码实现登录鉴权与航班预订接口登录鉴权是每个系统必有的模块。毕设里最常用的是JWTJSON Web Token或Session。如果做前后端分离我推荐JWT如果使用ThymeleafSession足够不一定要上JWT但很多学生为了展示会顺手集成JWT这里我讲一讲靠谱的做法。JWT的核心是用户登录成功后服务端生成一个包含用户信息、过期时间的token返回给前端。前端在请求头里带Authorization: Bearer token后端通过拦截器校验token有效性。SpringBoot中一般会写一个JwtInterceptor实现HandlerInterceptor再注册到WebMvcConfigurer里。注意要把登录接口、注册接口排除在拦截器之外不然你还没登录就被拦截了。航班预订接口是核心业务接口我给大家理顺一个完整的代码骨架照着这个思路去写基本不会乱PostMapping(/order/create) Transactional(rollbackFor Exception.class) public Result createOrder(RequestBody Validated OrderCreateDTO dto, RequestAttribute(userId) Long userId) { // 1. 查询航班校验是否存在 FlightDetailDTO flight flightMapper.selectForUpdate(dto.getFlightId()); if (flight null) { return Result.fail(航班不存在); } // 2. 判断舱位类型并尝试扣减库存 int affected 0; if (CabinType.ECONOMY.name().equals(dto.getCabinType())) { affected flightMapper.deductEconomyStock(dto.getFlightId()); } else if (CabinType.BUSINESS.name().equals(dto.getCabinType())) { affected flightMapper.deductBusinessStock(dto.getFlightId()); } if (affected 0) { throw new BizException(该航班余票不足); } // 3. 从数据库重新读取价格防止前端篡改 BigDecimal price CabinType.ECONOMY.name().equals(dto.getCabinType()) ? flight.getEconomyPrice() : flight.getBusinessPrice(); // 4. 创建订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setFlightId(dto.getFlightId()); order.setCabinType(dto.getCabinType()); // 设置乘客信息、价格、状态为未支付 ... orderMapper.insert(order); // 5. 返回订单号 return Result.success(order); }这段代码里flightMapper.selectForUpdate就是SELECT ... FOR UPDATE它会锁定这行航班的记录直到事务结束。这是防止超卖的关键。很多同学会忘记在方法上写Transactional虽然SELECT FOR UPDATE依然有效但锁在事务提交后才会释放如果你没开事务那锁会在SQL执行后立即释放就会失去防止并发的作用。所以这两个点必须联用。补充一点响应用统一Result包装code, message, data而不是把Map直接返回。这类细节别觉得“无所谓”答辩的时候一段规范的前后端交互协议胜过你在那空口讲“我的系统很规范”。4.3 前端页面与后端接口联调要点如果选Thymeleaf页面直接丢在src/main/resources/templates里静态资源放static并使用Thymeleaf的th:each去遍历航班列表。这里我提醒一个常见的坑Thymeleaf字符串拼接要用|竖线不要用。比如URLth:href{/flight/detail/{id}(id${flight.id})}如果你写成{/flight/detail/} ${flight.id}渲染会报错。如果选VueSpringBoot前后端分离那要处理跨域。开发环境最省心的是在SpringBoot里配置CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns(*)比allowedOrigins(*)更宽松因为后者和allowCredentials(true)在较新的Spring版本里冲突。前端Vue里统一用axios加上请求拦截器从localStorage取出token放进header。后端拦截器放行OPTIONS请求不然预检请求会卡住。5. 部署上线与常见问题排查实录5.1 本机打包与云服务器部署毕设项目最后都要能跑起来给老师演示。SpringBoot的打包很简单我一般用Maven的package命令在pom.xml里配好maven-shaade其实不需要直接用spring-boot-maven-plugin即可。打包完成后会在target目录生成一个可执行jar包。服务器上跑的命令非常简单java -jar air-ticket-system.jar --spring.profiles.activeprod建议你用nohup后台运行nohup java -jar air-ticket-system.jar app.log 21 这里有一个经常让新手崩溃的问题端口被占用。如果你在服务器上已经跑了一个Tomcat或者其他服务8080被占了,启动会报Port already in use。这时候改一下配置端口比如8081或者用lsof -i:8080查一下进程再kill。但毕设演示我一般建议直接用8080因为演示环境越简单越好。部署前数据库记得在服务器上建好并且执行init.sql。我习惯把表结构和初始数据写成一个db/init.sql文件一方面方便自己在不同环境重建另一方面也是交付物的一环老师要源码和文档的时候你交一个初始化脚本会显得专业很多。5.2 高频BugMyBatis映射、日期时间、事务失效这里整理几个我在学生项目里反复看到的Bug大部分都是细节问题但排查起来很浪费时间。第一个MyBatis的Mapper接口与XML文件路径不匹配。比如Mapper接口定义在com.example.mapper.FlightMapperXML文件却放在resources/mapper/FlightMapper.xml。如果你没有在pom.xml或application.yml里配置mapper-locationsMyBatis会“找不到方法”启动报Invalid bound statement (not found)。解决办法就是确保XML在classpath:mapper/下且application.yml里配置了正确的mapper-locations。第二个日期时间格式不一致。MySQL的dateTime字段和Java的LocalDateTime一般没问题但如果你用了Date返回给前端的时候会出现“2024-04-01T07:00:00.00000:00”这种带时区偏置的格式页面显示很难看。我上面配置了Jackson的date-format和time-zone能解决大部分问题但如果entity里用了LocalDateTime要注意Jackson的JavaTimeModuleSpringBoot会自动处理。演示的时候如果出现凌晨时差问题先检查数据库连接的serverTimezone和Jackson的time-zone。第三个事务失效。这是最隐蔽的坑。Spring的Transactional默认只能拦截RuntimeException如果你在Service方法里catch了异常又没抛出事务就不会回滚。比如扣库存成功但插入订单时报错你在Service里try { ... } catch (Exception e) { e.printStackTrace(); }数据库的库存已经被扣掉了订单却没有生成。正确做法是Service里不要捕获异常让Transactional监控到异常并回滚。如果有必然要捕获的业务异常则需要在catch块里throw new RuntimeException(e)。第四个跨域拦截器优先级问题。如果同时用了JWT拦截器和CORS配置容易发生预检请求被拦截器拦住导致前端报错。我在Interceptor里加了判断如果请求方法是OPTIONS直接放行。5.3 答辩时容易被问倒的高阶问题清单很多同学项目做出来了答辩反而紧张。下面这几个问题是基于这个系统面试官/答辩老师最常问的提前想好答案你会从容很多为什么不用数据库触发器来控制余票答触发器逻辑隐蔽不利于维护和复用而且在分布式环境下难以扩展事务性的业务逻辑放在Service层统一管理更清晰。创建订单时扣库存失败了怎么办答事务回滚接口返回“余票不足”此时不会留下脏数据。用户支付过程中关闭了浏览器或断网订单怎么处理答订单有一段时间的“待支付”窗口超时未支付的订单会被定时任务自动取消并把库存加回去用户也可在“我的订单”里手动取消未支付订单。如何提高系统性能答数据库连接池用HikariCP查询加入索引如航班号、出发时间数据量大时可引入Redis缓存常用航班查询结果但这些属于优化扩展毕设能实现基础性能即可。怎么保证数据安全性答密码BCrypt加密存储密码不落日志SQL使用预编译机制防注入JWT设置过期时间。还可以提到统一参数校验和敏感数据脱敏。其实这些问题的答案恰恰是你在写论文时考察核心模块设计的素材。你的毕设论文第二章和第四章完全可以围绕这些点展开这样论文和代码就是统一的一个故事而不是“代码是代码、论文是论文”。6. 项目扩展与升级思路选做内容如果你还想让毕设项目更有竞争力可以考虑这几个扩展方向。第一个方向是引入Redis缓存。航班查询是很典型的“读多写少”场景。把高频的航班查询结果缓存到Redis设置过期时间比如5分钟查询时先查缓存缓存不存在再查数据库。这个设计如果在答辩时提出来会显得你有缓存意识。实现时记得缓存更新策略航班新增、修改、取消时主动删除对应缓存避免脏数据。第二个方向是增加定时任务。SpringBoot里用Scheduled注解加一行EnableScheduling就能跑定时任务。比如每5分钟检查一次未支付订单超过15分钟自动取消并释放库存。这个功能写出代码来直接就是论文第四章的亮点而且代码量很小成本收益高。第三个方向是仪表盘统计。管理端放一个首页展示当日订单数、销售额、热门航线排行。用几条SQL group by就能完成前端用一个简单图表库ECharts展示折线图。这会让你的系统看起来“有数据支撑”不再像一个玩具。我见过太多学生把时间花在把前端页面弄得花里胡哨但后端逻辑一塌糊涂。航空机票预定系统真正值得投入精力的是业务逻辑和并发控制而不是界面炫酷程度。你把余票控制、订单状态机、事务边界这几个核心问题解决了无论论文还是答辩都能站得住脚。我在实际带项目时体会最深的一点是想通过这个题目锻炼自己一定不要止步于“能跑就行”。你多思考一层“为什么会并发超卖、为什么订单状态不能随便改”收获的远远不只是一份毕业设计而是一套对企业级应用的理解方式。希望这篇文章能帮你把这个经典项目做得既稳妥又有亮点。
返回列表