每年到这个时间点,我的私信里就会出现同一个问题:“航班订票系统用SSM怎么写?”或者更直白一点:“能不能给我一套完整能跑的SSM航班订票系统?”说实话,这个题目确实是高校课设和毕设里的常青树,几乎人手一份。但大多数人只关心代码能不能跑通、页面长什么样,很少有人认真想过:为什么这个“看起来简单”的项目,真正做到一半才发现处处是坑。
SSM不是“Spring+SpringMVC+MyBatis”三个框架堆在一起就完事,航班订票系统也不只是“查航班、下订单”的增删改查。我在实际带人做完整个项目之后发现,这个题目真正难住人的地方集中在几个特定环节:框架配置的整合顺序、数据库表结构的设计取舍、订票与余票扣减时的事务和并发控制,以及项目部署时的一堆环境问题。这篇文章就把我实际完成这个项目的全过程拆给你看,从选型逻辑到表结构,从核心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文件里写SQL,MyBatis会自动完成参数绑定和结果集映射,把数据库记录转成Java对象。它最核心的思路是“把SQL写在XML里”,所以SQL和Java代码分离,维护起来很清爽。
这三个框架连在一起的请求链路是这样走的:浏览器发起请求,SpringMVC接收并找到Controller,Controller调用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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;航班表是核心,包含班次信息、出发到达城市与时间、三档舱位价格,以及关键的余票数字段:
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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表承载业务流转,状态字段用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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;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 id="queryFlights" resultType="com.ssm.flight.entity.Flight"> SELECT * FROM flight <where> <if test="departureCity != null and departureCity != ''"> AND departure_city = #{departureCity} </if> <if test="arrivalCity != null and arrivalCity != ''"> AND arrival_city = #{arrivalCity} </if> <if test="departureDate != 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 id="decreaseRemainingSeatsByVersion"> UPDATE flight SET remaining_seats = remaining_seats - 1, version = version + 1 WHERE id = #{id} AND version = #{version} AND remaining_seats > 0 </update>Java方法里在扣减后判断受影响行数,如果返回值是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里,必须判断当前订单状态是否为0:
@Transactional(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 id="increaseRemainingSeats"> 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里一定要加上characterEncoding=UTF-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.RELEASE),MyBatis用好3.4.x,别混着latest乱用。
- 配置Tomcat,Document 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来练这个题目,答案已经在你的动手过程里了。