ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SSM航班订票系统开发全解析:数据库设计与并发控制实战

SSM航班订票系统开发全解析:数据库设计与并发控制实战 每年到这个时间点我的私信里就会出现同一个问题“航班订票系统用SSM怎么写”或者更直白一点“能不能给我一套完整能跑的SSM航班订票系统”说实话这个题目确实是高校课设和毕设里的常青树几乎人手一份。但大多数人只关心代码能不能跑通、页面长什么样很少有人认真想过为什么这个“看起来简单”的项目真正做到一半才发现处处是坑。SSM不是“SpringSpringMVCMyBatis”三个框架堆在一起就完事航班订票系统也不只是“查航班、下订单”的增删改查。我在实际带人做完整个项目之后发现这个题目真正难住人的地方集中在几个特定环节框架配置的整合顺序、数据库表结构的设计取舍、订票与余票扣减时的事务和并发控制以及项目部署时的一堆环境问题。这篇文章就把我实际完成这个项目的全过程拆给你看从选型逻辑到表结构从核心Service代码到压测翻车现场都会讲到。适合正在做SSM相关课设、毕业设计的人阅读也适合想用这个项目练手、准备面试框架原理问题的同学参考。1. SSM还能稳坐高校项目榜首的真实原因1.1 SSM到底是什么它们各自管哪块很多人一上来就背框架名但真被问到“这三个框架分别解决什么问题”就卡住了。我用最朴素的话说清楚SSM是一个典型的分层Java Web开发架构三个框架各管一个层次组合起来就是一套完整的业务系统骨架。Spring是核心容器负责管理系统里所有的Java对象。比如你的UserService、OrderService这些类传统方式是自己new但在SSM里头对象的创建和依赖注入全部交给Spring容器你需要用的时候直接声明Autowired让它把对象“递”给你。Spring还有一个重要能力是AOP面向切面编程后面会讲到的事务管理、日志记录利用AOP可以在不修改业务代码的情况下统一加上这些横切逻辑。SpringMVC管的是Web层也就是你打开浏览器输入网址、点按钮之后请求是怎么被接收和响应的。它的核心机制是从请求进来开始先经过DispatcherServlet这个总入口再由HandlerMapping找到对应的Controller方法方法执行完返回ModelAndView最后由视图解析器渲染成JSP页面返回给前端。简单说就是“请求分发和视图控制”。MyBatis管的是持久层也就是数据库访问。它比传统的JDBC方便太多你用Mapper接口定义一个方法然后在XML文件里写SQLMyBatis会自动完成参数绑定和结果集映射把数据库记录转成Java对象。它最核心的思路是“把SQL写在XML里”所以SQL和Java代码分离维护起来很清爽。这三个框架连在一起的请求链路是这样走的浏览器发起请求SpringMVC接收并找到ControllerController调用Service完成业务逻辑Service调用Mapper操作数据库数据返回后再一级一级回传最终由SpringMVC渲染页面。用餐厅点菜来类比SpringMVC是服务员接单传菜Service是厨房真正炒菜MyBatis是仓库管理员取食材而Spring是这家店的总调度管着服务员、厨师、仓管之间的协作关系。1.2 既然Spring Boot都出来了为什么还要选SSM这是我被问得最多的问题。每次我都会反问一句如果你的目标是“最快速度做出来一个能用的系统”那选Spring Boot加MyBatis-Plus肯定更省事因为Spring Boot帮你把大量配置都自动完成了甚至内嵌了Tomcat点击启动就能跑。但如果你想要的是“理解Java Web框架到底在做什么”SSM反而是更好的学习材料。原因是这个Spring Boot替你省掉的东西恰恰是SSM里需要你手动写配置的部分。你在SSM项目里要自己配置数据源、自己扫描Mapper、自己写事务管理配置文件、自己配置视图解析器。这些配置每一行你都得搞明白是干什么用的否则起不来。当你在Spring Boot里用一行注解就搞定事务时你不会知道背后接的是什么、默认回滚规则是什么但在SSM里你亲手配过事务管理器这种理解是长在脑子里的面试时讲IoC和AOP都有真实案例可以支撑。另外一个现实因素很多学校的课程体系还停留在以SSM为主线官方要求你用SSM完成毕业设计。同时企业里也存在大量历史遗留的SSM项目你入职后可能还要维护它们。所以我不建议一概而论说“别学SSM了”关键看你的目标为了求职突击直接上Spring Boot为了应付课设并想真正弄懂框架原理SSM是非常扎实的选择。航班订票系统这种业务复杂度适中、模块边界清晰的项目正好把SSM的各个能力都用上所以才会被一届一届选中。2. 航班订票的需求边界功能拆解与版本规划2.1 先定需求边界别一上来就想着做全套做这个项目的人里十个有八个是一开始把功能想得特别满要有会员等级、积分兑换、短信通知、航班动态推送、在线选座、退改签甚至还要接入真实支付接口。然后写着写着就烂尾了连最基础的功能都没跑通。我的建议是分版本推进。第一版MVP版本只做核心闭环用户注册登录、航班条件查询、下单订票、模拟支付、取消订单、我的订单列表、后台航班信息管理。这个版本跑通后整个项目的主干流程就完整了。第二版再考虑锦上添花的功能比如乘机人管理、历史订单统计、图表报表等。你拿着第一版去交课设已经超过大多数人的完成度了。做需求拆解时可以按“用户角色 功能模块”来划分。系统分成三个视角游客、登录用户、管理员。游客能看首页和航班列表登录用户能下单、支付、取消订单、查看自己的订单管理员能维护航班的基础信息和余票初始值。这个划分画清楚之后页面跳转关系也就出来了打开首页-登录/注册-进入航班查询列表-点击预订-生成订单-确认支付-在“我的订单”里看到状态并操作。我见过很多同学在没想清楚之前就直接写Controller结果页面URL混乱、参数对不上后面调试得想哭。强烈建议先把功能清单列成表格尤其是关键路径上的动作和页面这样不管写代码还是写文档都清晰。2.2 功能清单与页面流转下面是我最终确定下来的MVP版本功能表供你参考用户模块注册、登录、退出登录密码加密保存登录状态用Session维护航班模块按出发城市、到达城市、出发日期查询航班展示班次、起飞到达时间、舱位价格、余票订票模块选择航班和舱位生成订单同一航班余票扣减必须原子操作支付模块模拟支付点击后把订单状态从待支付改成已支付记录支付时间订单模块展示当前用户订单列表待支付状态可取消取消后余票回补后台管理管理员登录后维护航班表新增航班、修改时间、调整价格页面流转主线是index.jsp首页/登录入口 - flightList.jsp航班查询结果 - bookConfirm.jsp订票确认 - orderList.jsp我的订单。后台单独分一套admin_login.jsp和admin_flight.jsp。2.3 非功能性需求并发、安全、幂等除了功能需求做这个项目时必须提前考虑三个非功能问题否则后面会踩大坑。并发一个航班的余票是固定的但多个用户可能同时下单。如果你不做并发控制就会出现“两个人同时买到最后一个座位”的超卖结果。这个问题在第四章我会专门讲方案。安全用户密码不能明文存数据库至少要用MD5加盐最好用BCrypt这种不可逆算法。登录接口要考虑SQL注入MyBatis里尽量用#{}而不是${}去拼接参数。幂等用户不小心连续点两次“下单”按钮要避免生成两个同样的订单。一个简单方案是前端在点击后立刻禁用按钮后端再做一层校验比如很短时间内同一用户、同一航班的未支付订单只允许存在一张。3. 数据库设计表结构是一切功能的底座3.1 核心表结构与SQL脚本这个项目我最终使用了四张核心表用户表user、航班表flight、订单表orders。有些同学会再加一张乘机人表MVP阶段可以先不做把乘机人信息直接冗余在订单里比如存一个passenger_name字段后面要扩展再拆表。用户表设计如下CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL COMMENT 登录名, password varchar(64) NOT NULL COMMENT 加密后的密码, real_name varchar(32) DEFAULT NULL COMMENT 真实姓名, id_card varchar(32) DEFAULT NULL COMMENT 身份证号, phone varchar(20) DEFAULT NULL COMMENT 手机号, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;航班表是核心包含班次信息、出发到达城市与时间、三档舱位价格以及关键的余票数字段CREATE TABLE flight ( id int(11) NOT NULL AUTO_INCREMENT, flight_no varchar(10) NOT NULL COMMENT 航班号, departure_city varchar(50) NOT NULL, arrival_city varchar(50) NOT NULL, departure_time datetime NOT NULL, arrival_time datetime NOT NULL, seat_count int(11) NOT NULL DEFAULT 180 COMMENT 总座位数, remaining_seats int(11) NOT NULL DEFAULT 180 COMMENT 剩余座位数, price_ec decimal(10,2) NOT NULL COMMENT 经济舱价格, price_bc decimal(10,2) NOT NULL COMMENT 商务舱价格, price_fc decimal(10,2) NOT NULL COMMENT 头等舱价格, airline varchar(50) DEFAULT NULL COMMENT 航空公司, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), KEY idx_departure_city (departure_city), KEY idx_arrival_city (arrival_city), KEY idx_departure_time (departure_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表承载业务流转状态字段用int表示不需要复杂的枚举表CREATE TABLE orders ( id int(11) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id int(11) NOT NULL, flight_id int(11) NOT NULL, passenger_name varchar(32) DEFAULT NULL COMMENT 乘机人姓名, seat_class varchar(10) NOT NULL COMMENT 舱位EC经济舱 BC商务舱 FC头等舱, price decimal(10,2) NOT NULL COMMENT 成交价格, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_flight_id (flight_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 为什么要把余票数冗余在航班表里有些同学看到这里会问“余票不是应该实时去订单表里统计已支付订单数、然后用总数减掉么”理论上可以但实际性能很差。每次用户查询航班如果都要去COUNT一遍订单表里该航班已支付的订单数量查询压力会非常大而航班列表页面用户往往要一次看几十个航班这个统计量就爆炸了。所以我在航班表里直接维护一个remaining_seats字段下单时扣减取消时加回。这是一种以空间换时间、以冗余换性能的常见做法。但注意冗余带来的副作用就是一致性问题订单插入和余票扣减必须是一个原子操作不能出现“订单插进去了但余票没减”这种脏数据。这就引出了后面事务和并发控制的内容。3.3 订单号生成策略订单表的主键用的是自增id但对外暴露时应该使用业务订单号order_no。不能直接把自增id给用户看因为很容易被遍历。我采取的方案是时间戳加用户信息加随机数public String generateOrderNo(Integer userId) { String time LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)); String userPart String.format(%04d, userId % 10000); String randomPart String.format(%04d, new Random().nextInt(10000)); return time userPart randomPart; }这样生成的订单号是28位以内的字符串足够唯一也包含了生成时间信息排查问题时一眼能看出是哪天下单的。4. 核心业务实现查询、预订、订单处理的完整链路4.1 航班查询MyBatis动态SQL的典型应用查询功能最常用的场景是用户选择出发城市、到达城市、出发日期要能组合筛选。因为有多种条件组合的可能这里就用到了MyBatis的动态SQL标签。select idqueryFlights resultTypecom.ssm.flight.entity.Flight SELECT * FROM flight where if testdepartureCity ! null and departureCity ! AND departure_city #{departureCity} /if if testarrivalCity ! null and arrivalCity ! AND arrival_city #{arrivalCity} /if if testdepartureDate ! null AND DATE(departure_time) #{departureDate} /if /where AND remaining_seats 0 ORDER BY departure_time /select标签很智能它会自动把第一个多余的AND去掉。如果你只填了到达城市那么实际SQL就变成WHERE arrival_city ?不会出现语法错误。另外注意我加了AND remaining_seats 0这个硬条件这一行可以让前端在查询时直接过滤掉无票航班用户体验好很多。4.2 订票核心Service并发扣票问题的两种解法订票是整个系统的心脏也是并发问题最集中的地方。先看一个最朴素、也是最危险的写法// 错误示例并发下绝对会超卖 public Order createOrder(Integer userId, Integer flightId, String seatClass) { Flight flight flightMapper.selectById(flightId); if (flight.getRemainingSeats() 0) { flightMapper.decreaseRemainingSeats(flightId); orderMapper.insert(order); } }这个写法在两个用户同时下单时可能两个人都在执行if (remainingSeats 0)的瞬间看到余票为1然后各自都执行了扣减结果一张票卖了两次。解决方式有两条路悲观锁和乐观锁。悲观锁的思路是我在判断余票之前先把这一行数据锁住其他事务只能等待我提交后才能读取最新数据。SQL长这样SELECT * FROM flight WHERE id #{flightId} FOR UPDATE;在Java代码里对应这种写法Transactional(rollbackFor Exception.class) public Order createOrderByPessimistic(Integer userId, Integer flightId, String seatClass) { Flight flight flightMapper.selectFlightForUpdate(flightId); if (flight null || flight.getRemainingSeats() 0) { throw new BizException(航班不存在或余票不足); } Order order buildOrder(userId, flight, seatClass); orderMapper.insert(order); flightMapper.decreaseRemainingSeats(flightId); return order; }注意FOR UPDATE锁必须在事务里才有意义事务提交后锁才会释放所以方法上必须有Transactional注解。悲观锁的优点是逻辑直观、写起来简单缺点是并发大时同一航班的订票请求会串行排队吞吐量会降低。乐观锁的思路是不加锁但在更新时带上版本条件谁先更新成功谁赢。核心在SQL上update iddecreaseRemainingSeatsByVersion UPDATE flight SET remaining_seats remaining_seats - 1, version version 1 WHERE id #{id} AND version #{version} AND remaining_seats 0 /updateJava方法里在扣减后判断受影响行数如果返回值是0说明版本已经被别人改过了你就丢了这次竞争需要提示用户“余票被别人抢了请重试”Transactional(rollbackFor Exception.class) public Order createOrderByOptimistic(Integer userId, Integer flightId, String seatClass) { Flight flight flightMapper.selectById(flightId); if (flight null || flight.getRemainingSeats() 0) { throw new BizException(航班不存在或余票不足); } Order order buildOrder(userId, flight, seatClass); orderMapper.insert(order); int rows flightMapper.decreaseRemainingSeatsByVersion(flightId, flight.getVersion()); if (rows 0) { throw new BizException(余票已被抢占请重新选择航班); } return order; }在我实测中这个项目用乐观锁就够了因为航班的订票并发量通常不会特别夸张乐观锁不会阻塞其他事务逻辑也更简洁。4.3 订单状态机状态流转不能随便改订单状态我设计了三个值0待支付1已支付2已取消。状态流转规则必须写清楚不然会出现“已支付的订单还能重复取消、取消后还能支付”的混乱场景。合法的状态流转路径就三条创建订单时状态为0待支付状态下用户支付成功变成1待支付状态下用户取消变成2。已支付订单不能直接取消因为涉及退款MVP阶段可以不做但要注意很多同学把按钮“取消订单”无条件暴露在页面上用户点已支付订单的取消结果代码直接把状态改成已取消了这是错的。取消订单的Service里必须判断当前订单状态是否为0Transactional(rollbackFor Exception.class) public void cancelOrder(Long orderId, Integer userId) { Order order orderMapper.selectById(orderId); if (order null || !order.getUserId().equals(userId)) { throw new BizException(订单不存在); } if (order.getStatus() ! 0) { throw new BizException(当前订单状态不允许取消); } orderMapper.updateStatus(orderId, 2); flightMapper.increaseRemainingSeats(order.getFlightId()); }同时把余票加回去update idincreaseRemainingSeats UPDATE flight SET remaining_seats remaining_seats 1 WHERE id #{id} /update注意这里我只在订单状态为0时可取消所以回补余票时不需要再判断航班状态。事务把“改订单状态”和“回补余票”绑在一起了所以即使中途出错两个操作都会回滚不会出现“订单取消了但余票没回来”。5. 最容易翻车的三个地方并发、事务、隐藏的坑5.1 实测压测暴露的超卖问题完整排查链路我第一次写完这个项目用JMeter模拟100个用户并发抢同一个航班的180个座位结果数据库里出现了182条待支付订单。当时第一反应是SQL写错了于是把SQL单独拿去数据库执行每次扣减都正常怎么程序里就出错排查过程我建议按这个顺序走第一检查Transactional注解有没有真正生效。当时我的一个Service方法用了Transactional但同一个类里另一个方法调用了它形成了自调用Spring的代理根本没拦截事务等于没有。Spring事务是基于AOP代理的只有在代理对象上调用方法才会触发事务拦截器你在类内部通过this.method()调用是不经过代理的。解决方式是拆成两个独立的Service或者用注入自身的方式调用另一个代理方法。第二检查事务配置里有没有开启事务管理器。SSM中你定义了DataSource和事务管理器还不够必须显式配置注解驱动扫描如果少了这一步所有Transactional注解都会静默失效这是最坑的一处。第三检查数据库表和引擎。MySQL的MyISAM引擎不支持事务即使Java层配置全对底层也回滚不了。确保表是InnoDB引擎。这三步查完超卖问题大概率就能解决。5.2 事务失效的其他经典场景刚才说的自调用是事务失效最常见的场景但还有几种情况同样会让你抓狂。第一种方法不是public。Spring的Transactional默认使用JDK动态代理或CGLIB代理但要求目标方法必须可被代理private方法无法被外部代理拦截事务自然无效。第二种rollbackFor设置不正确。Spring默认只对RuntimeException运行时异常回滚如果业务代码里抛出的是自定义的受检异常比如数据库约束异常、自定义BizException继承自Exception默认情况下事务不会回滚数据就会停留在半成品状态。所以统一做法是写Transactional(rollbackFor Exception.class)让所有异常都触发回滚。第三种数据库连接没走事务管理器。如果你的数据源配置了多个或者MyBatis的SqlSessionFactory没接到同一个事务管理器上也会出现“看起来配了事务但实际没生效”的诡异问题。SSM项目里要确保sqlSessionFactory的dataSource属性和transactionManager的dataSource是同一个对象。5.3 两个容易忽略的安全与线程问题第一个是MyBatis里的#{}和${}。我见过很多新手在动态排序时图方便直接写ORDER BY ${sortField}这个写法是直接把字符串拼接进SQL如果sortField来自前端用户输入就是经典的SQL注入漏洞。正确做法是排序字段在Java代码里做一个白名单映射比如传入的key对应到具体的实体字段名再以固定值拼接进SQL。所有参数的值传递一律用#{}。第二个是SimpleDateFormat的线程安全问题。很多老项目习惯在工具类里写一个全局的SimpleDateFormat变量然后到处调用。但SimpleDateFormat是线程不安全的多线程并发解析日期时会出现错误结果甚至抛异常。我在这个项目里改用Java 8的LocalDateTime加DateTimeFormatter后者是线程安全的用法更清爽private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); String formatTime(LocalDateTime time) { return time.format(FORMATTER); }另外数据库连接URL里一定要加上characterEncodingUTF-8否则插入中文姓名时会出现乱码。6. 部署与验证让系统真正跑起来6.1 环境准备与部署步骤这个项目我使用的技术栈是JDK 1.8、Maven 3.6可选、Tomcat 8.5、MySQL 5.7。SSM对版本的包容度比较高只要这几个版本不要过于极端都没问题。部署步骤我给你理一遍照着操作基本不会卡壳在MySQL中建库执行上述的表结构和样例数据SQL脚本。修改jdbc.properties把数据库地址、用户名、密码改成你自己的环境。在IDEA里导入项目等待Maven把依赖下载完。这里容易遇到的坑是拉不到依赖或版本冲突建议Spring相关依赖统一版本比如4.3.30.RELEASEMyBatis用好3.4.x别混着latest乱用。配置TomcatDocument Base指向项目启动后浏览器访问http://localhost:8080/项目名。启动时如果报404先看控制台有没有完整输出“Mapped to xxxController#yyy()”这样的映射日志如果没有就是包扫描路径配置不对Spring容器根本没扫描到Controller。6.2 验收测试清单写完之后别急着交付至少跑一遍下面的测试清单注册新用户密码保存到数据库是否加密不应该是明文。用错误密码登录是否被正确拦截并提示。输入出发城市、到达城市、日期查询航班组合条件查询是否正确。对同一个航班发起两个并发订票请求在余票只有1张时是否只有一个成功。创建订单后不支付执行取消再查航班余票是否回补。支付成功后该订单是否还能被取消。管理员新增航班后用户端能否立刻查到。这些测试千万别靠肉眼在浏览器里手动点几下就算完至少要用JMeter跑一次并发场景。很多同学的项目单机操作一切正常一到并发测试就露馅原因就是没提前做过验证。6.3 演示数据准备为了让流程能完整跑起来我在库里放了几条演示数据。注意时间字段如果用的是datetime可以用未来的日期方便测试。INSERT INTO flight (flight_no, departure_city, arrival_city, departure_time, arrival_time, seat_count, remaining_seats, price_ec, price_bc, price_fc, airline) VALUES (CA1234, 北京, 上海, 2025-06-10 08:00:00, 2025-06-10 10:30:00, 180, 180, 800.00, 1500.00, 2200.00, 中国国航), (MU5678, 上海, 广州, 2025-06-11 14:00:00, 2025-06-11 16:20:00, 200, 200, 950.00, 1800.00, 2600.00, 东方航空), (CZ9012, 广州, 成都, 2025-06-12 09:30:00, 2025-06-12 11:50:00, 150, 150, 700.00, 1300.00, 2000.00, 南方航空);这些数据的出发和到达城市、时间、价格都有差异登录后随便搜一个城市组合就能看到测试效果。这个项目做完之后我自己有个很深的感受SSM航班的代码量其实不多但真正花时间的全在配置细节和并发一致性上。如果你也是第一次写这类系统建议把上面的测试清单从头到尾执行一遍特别是并发场景最值得跑。把这一套吃透你以后再切换到Spring Boot的项目会发现很多概念都是相通的只不过框架帮你做了刚才那些繁琐的配置。到时候再回头想想为什么当初要选SSM来练这个题目答案已经在你的动手过程里了。
返回列表