SSM框架机票火车票预定系统:架构、事务与并发扣减实战拆解
简介面向软件工程课程设计场景提供一套基于SSM框架的机票/火车票预定系统完整项目包适合需要完成类似选题的高校学生及正在练习Spring、SpringMVC、MyBatis整合开发的开发者。该压缩包共1651个文件体量约50.16MB涵盖636个js前端交互脚本、354个png界面素材、204个css样式、51个java源码、36个jsp动态页面以及xml配置、jar依赖、sql数据库脚本等前端、后端、配置与部署文件一应俱全。已有273人学习使用可作为课程设计模板或SSM进阶练习。项目中实际包含用户登录注册、余票查询、车次/航班预订、订单管理、后台管理等功能模块可据此理解Spring的IoC依赖注入与事务管理、SpringMVC的请求分发、MyBatis的动态SQL映射也可参考war包部署及前后端分离设计为独立完成毕设或工程化开发积累经验。1. 软件工程课程设计里的常客 SSM 框架下的机票/火车票预定系统到底能做什么拿到这份源码之前我先猜了一轮软件工程课设里的交易类系统大概率又是“能登录、能查询、能下单”的 CRUD 换皮。翻完才发现这套基于 SSM 框架的机票/火车票预定系统把交易类系统最值钱的部分做了——余票扣减用数据库行锁兜底订单创建和库存变更待在同一个事务里机票和火车票共用一张数据表而不是写两套重复的查询接口。这比绝大多数停留在“增删改查演示”的课设工程完整得多。它能覆盖的是一条完整业务链路用户注册登录、出发地和目的地查询、按出发日期筛选车次、下单、余票扣减、订单查询、后台对车次数据维护。适合两类人看正在准备软件工程课程设计的学生拿这套代码改改页面和字段就能交刚入门 Java Web、想搞明白 Spring、SpringMVC、MyBatis 三个框架怎么在一个工程里协同的开发者把它当解剖样本正合适。它想解决的核心问题也一句话能说清在中小规模 Java Web 项目里请求处理、业务逻辑、数据访问三层怎么各干各的活余票这类热点数据又如何避免被并发打爆。这套代码把答案都摆在了明面上接下来我按架构、核心链路、数据库、部署踩坑、二次开发五个方向拆开讲。2. 架构先拆SSM 三层的职责边界与工程骨架2.1 三个框架在项目里分别管了哪一段SSM 项目跟 Spring Boot 最大的区别是配置全部显式化没有自动装配帮你省事所以第一步不是读业务代码而是先把三个框架的分工理清楚。从浏览器里一次最简单的查询请求出发链路大概是JSP 表单发起 GET 请求DispatcherServlet 拦截Controller 方法接收参数调用 Service 里的业务方法Service 再通过 Mapper 接口走 MyBatis 的 XML SQL 访问 MySQL查询结果再一层层往回传最后由视图解析器渲染 JSP。这段链路里Spring 管的是对象关系和事务Service 实例由它创建Mapper 接口代理由它注入方法上的 Transactional 由它拦截。SpringMVC 管的是 Web 层请求怎么映射到 Controller 方法、参数怎么绑定、返回的字符串如何解析成 JSP 视图。MyBatis 管的是数据访问Mapper 接口跟 XML 里的 SQL 如何对应、查询结果如何自动装进实体类。三个框架看似各管一段实际在工程配置里有两个最容易打架的点——扫描包的边界和容器父子关系偏偏大部分课设报错都出在这。2.2 web.xml 与双容器加载顺序先扫包再谈业务一个标准 SSM 工程的配置文件有四件套web.xml、applicationContext.xml、spring-mvc.xml、mybatis-config.xml再加 jdbc.properties 做数据库连接。web.xml 是整个应用的启动总闸里面同时挂了 Spring 根容器和 SpringMVC 子容器两个容器的加载顺序决定了很多奇怪报错。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 !-- Spring 根容器负责加载 applicationContext.xml -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- SpringMVC 子容器负责加载 spring-mvc.xml -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping /web-app逻辑说明ContextLoaderListener 先创建根容器DispatcherServlet 再创建自己的子容器子容器可以拿父容器的 Bean父容器拿不到子容器的。所以 Service 和 Mapper 必须放在根容器里Controller 放在子容器里。如果 Service 的组件扫描和 Controller 的组件扫描写在同一个 XML 里或者两个 XML 的扫描范围重叠就会出现 Bean 重复定义或者注入不到。url-pattern 用/而不是*.do意味着所有请求都进 DispatcherServletcss、js、图片这类静态资源也得在 spring-mvc.xml 里额外放行不然页面全是裸 HTML。项目里通常配了静态资源映射我看完配置后第一反应是这块在部署到线上时最容易变成“本地正常、服务器 404”的源头。接着看 spring-mvc.xml 里的关键配置context:component-scan base-packagecom.example.ticket.controller/ mvc:annotation-driven/ mvc:resources mapping/static/** location/static// bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean参数说明component-scan 只扫 controller 包Service 和 DAO 的扫描留给 applicationContext.xml。/static/**的映射把静态资源请求交给容器默认 Servlet避免 DispatcherServlet 全部拦截。InternalResourceViewResolver 的 prefix 和 suffix 拼出真实的 JSP 路径Controller 里返回 ticket_list实际渲染/WEB-INF/views/ticket_list.jsp。提示改完这两个 XML 之后务必检查 target/classes 里是否同步了最新配置。Maven 增量构建偶尔会漏掉 resources 目录里的文件我见过不止一次控制台跑的是旧配置改了等于白改。3. 核心模块拆解一次下单请求从进入到订单落库的完整链路3.1 Controller 层参数绑定与提交后的重定向整套系统里最值得读的是下单路径它把 Controller、Service、DAO 三层全部串起来了。先看 OrderController 的创建订单方法Controller RequestMapping(/order) public class OrderController { Autowired private OrderService orderService; RequestMapping(value /create, method RequestMethod.POST) public String create(RequestParam(ticketId) int ticketId, RequestParam(userId) int userId, RequestParam(value quantity, defaultValue 1) int quantity, RedirectAttributes attr) { try { Order order orderService.createOrder(ticketId, userId, quantity); attr.addFlashAttribute(orderNo, order.getOrderNo()); return redirect:/order/success; } catch (RuntimeException e) { attr.addFlashAttribute(errorMsg, e.getMessage()); return redirect:/order/fail; } } }逻辑说明RequestParam 把请求参数绑定到方法入参quantity 不传时默认 1。方法内部不写任何业务判断拿到参数直接交给 Service。成功和失败都走 302 重定向订单号和错误信息通过 RedirectAttributes 的 flash 机制传到下一个请求里这样用户刷新页面不会触发表单重复提交——这个 PRG 模式在日常开发里很常用。参数说明ticketId 是车次或航班的主键userId 是当前登录用户 IDquantity 是一次购买的票数。这里有个细节Controller 里没有做表单校验比如 quantity 大于 0、ticketId 存在之类的检查都放在 Service 层因为 Controller 只负责接参数、转发结果业务规则下沉才能保证同样的下单逻辑在不同入口复用。3.2 Service 层事务边界下的行锁与余票扣减OrderService 的 createOrder 是整条链路的命门Transactional public Order createOrder(int ticketId, int userId, int quantity) { // 1. 锁定车次记录阻止并发修改 Ticket ticket ticketDao.selectTicketForUpdate(ticketId); if (ticket.getRemainCount() quantity) { throw new RuntimeException(该车次余票不足剩余 ticket.getRemainCount() 张); } // 2. 扣减余票返回受影响行数做二次校验 int affected ticketDao.decreaseStock(ticketId, quantity); if (affected 0) { throw new RuntimeException(抢票失败请重新尝试); } // 3. 生成订单号并插入订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTicketId(ticketId); order.setQuantity(quantity); order.setTotalPrice(ticket.getPrice().multiply(new BigDecimal(quantity))); order.setStatus(0); orderDao.insertOrder(order); return order; }逻辑说明Transactional 保证这段方法里的查、扣、插三个操作要么全部成功要么全部回滚。第一步的 selectTicketForUpdate 用的是 SELECT ... FOR UPDATE把这一行数据锁住另一个并发请求来查同一张票时会被阻塞从源头避免两个用户同时读到余票 1然后都通过校验。第三步 new BigDecimal(quantity) 做乘法是为了避免 Double 类型在金额计算上的精度玄学问题。参数说明decreaseStock 返回的是 UPDATE 影响的行数。如果返回 0说明车票记录不存在或者出了别的问题直接抛异常让事务回滚。你会在后面数据库章节看到这个 UPDATE 的 SQL 还带了 remain_count #{quantity} 的条件也就是说库存不足这道防线在数据库层面又拦了一次Service 的预检查只是提前给用户一个友好提示。3.3 Mapper 层动态 SQL 与查询结果映射查询车次列表的部分在 TicketMapper.xml 里用的是动态 SQL这也是 MyBatis 相对 JDBC 最省事的点select idqueryTickets parameterTypemap resultTypeTicket SELECT * FROM ticket where if teststartStation ! null and startStation ! AND start_station LIKE CONCAT(%, #{startStation}, %) /if if testendStation ! null and endStation ! AND end_station LIKE CONCAT(%, #{endStation}, %) /if if testdepartDate ! null and departDate ! AND DATE_FORMAT(depart_time, %Y-%m-%d) #{departDate} /if if testticketType ! null AND ticket_type #{ticketType} /if /where ORDER BY depart_time ASC /select逻辑说明用 标签比老写法 where 11 干净它会自动去掉第一个条件前的 AND。四个 if 按条件拼接 SQL用户只填出发地就只按出发地过滤全不填就查全表按出发时间排序。LIKE CONCAT(%, #{startStation}, %) 做的是包含匹配比如输北京能匹配北京南北京西。参数说明ticketType 用来区分机票和火车票1 是机票、2 是火车票。这里有个小坑DATE_FORMAT(depart_time, %Y-%m-%d) #{departDate} 写法直观但给 depart_time 列建的索引会在函数计算后失效。数据量小无感真上线的话应该改成用 depart_time 当天零点 AND depart_time 次日零点 的范围查询。课设场景不必纠结但面试被问到索引优化时能说出这一条很加分。4. 数据库设计车次表、订单表与库存扣减的建模细节4.1 核心表结构机票和火车票怎么共用一张表项目里用的是一张 ticket 表同时承载机票和火车票区别靠 ticket_type 字段。看核心建表语句CREATE TABLE ticket ( id INT PRIMARY KEY AUTO_INCREMENT, ticket_type TINYINT NOT NULL COMMENT 1机票 2火车票, flight_no VARCHAR(20) NOT NULL COMMENT 航班号/车次号, start_station VARCHAR(50) NOT NULL COMMENT 出发城市或车站, end_station VARCHAR(50) NOT NULL COMMENT 到达城市或车站, depart_time DATETIME NOT NULL COMMENT 出发时间, arrive_time DATETIME NOT NULL COMMENT 到达时间, price DECIMAL(10, 2) NOT NULL, remain_count INT NOT NULL DEFAULT 0 COMMENT 余票数量, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_depart (depart_time), KEY idx_route (start_station, end_station) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_info ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, ticket_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, passenger_info VARCHAR(500) COMMENT 乘车人姓名证件号逗号分隔, total_price DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), CONSTRAINT fk_order_ticket FOREIGN KEY (ticket_id) REFERENCES ticket(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计说明合表的收益是搜索接口只查一张表按出发地、到达地、出发日期过滤的逻辑完全统一代价是差异化字段只能退化到通用字段比如飞机没有座位等级火车也没有登机口。课设项目这么设计省掉大量重复代码真要扩展常见做法是加一张 ticket_ext 扩展表或者拆成航班、车次两张表但那样 Controller 和 Service 都得跟着改工程量立刻翻倍。order_no 设置 UNIQUE 约束是数据库层的防重兜底。应用层生成单号时先查一遍是否重复但并发下仍有重复风险唯一索引直接让第二次插入报错。passenger_info 以逗号分隔保存多个乘车人信息单表搞定实名制需求。4.2 订单列表联查与分页最常用的两条 SQL用户中心里的我的订单需要同时展示车次信息和订单状态查法很直接SELECT o.id, o.order_no, o.total_price, o.quantity, o.status, o.create_time, t.flight_no, t.start_station, t.end_station, t.depart_time FROM order_info o JOIN ticket t ON o.ticket_id t.id WHERE o.user_id #{userId} ORDER BY o.create_time DESC LIMIT #{offset}, #{pageSize}逻辑说明JOIN ticket 就是为了补全车次号、路线和时间信息列表页一次查完不需要再去循环查第二张表。LIMIT 的 offset 从 0 开始pageSize 固定 10前端传页码换算一下即可。这种物理分页在数据量几百条时完全没有性能问题。4.3 库存扣减剩余票数字段怎么在并发下不超卖扣减余票的 SQL 是往下单接口里最关键的一条语句UPDATE ticket SET remain_count remain_count - #{quantity} WHERE id #{ticketId} AND remain_count #{quantity}逻辑说明这个 UPDATE 把余票是否充足的判断下推到了数据库。remain_count #{quantity} 条件不成立时影响行数为 0Service 层拿到 affected 0 就抛异常回滚。相比先 SELECT 再 UPDATE 的写法这条 SQL 本身是原子操作不需要额外加锁也能保证不超卖。项目代码里 Service 层先 SELECT ... FOR UPDATE 锁行做了一次预检查再走这条 UPDATE属于双保险虽然第一次判断在逻辑上有点冗余。注意要让事务和行锁真正生效所有表引擎必须用 InnoDB。MyISAM 不支持行级锁和事务回滚下单扣库存这种场景坚决不能用。导入数据库脚本后可以先执行 SHOW TABLE STATUS 检查一遍。5. 运行部署与避坑从建库到浏览器出现首页的四道坎5.1 第一次启动完整步骤与命令含义本地跑这个项目需要四样东西JDK 8、Maven 3.6、Tomcat 8.5、MySQL 5.7。版本太新或太旧都可能出幺蛾子比如 JDK 11 跑老版本 CGLIB 代理会报错Tomcat 10 把 javax 包改成 jakarta 直接编译不过。先把数据库建好mysql -u root -p init.sql # init.sql 里包含建库、建表、插入样车次数据的语句然后改 jdbc.properties 里的数据库账号密码确认连接串类似 jdbc:mysql://localhost:3306/xxx?useUnicodetruecharacterEncodingutf8。接着用 Maven 打包并丢给 Tomcatmvn clean package -DskipTests cp target/xxx.war $TOMCAT_HOME/webapps/ cd $TOMCAT_HOME/bin ./startup.sh命令说明clean 清理旧的 target 目录package 打 war 包-DskipTests 跳过测试类避免课设里没写好的测试阻塞打包。war 复制到 webapps 下后Tomcat 启动时会自动解压浏览器访问 http://localhost:8080/xxx/ 就能看到首页。如果本地还在用 IDE 调试IDEA 里配置 Tomcat 后直接以 war exploded 方式运行也可以好处是改了 JSP 不用重启坏处是要多等几秒热部署。我一般建议命令行打包先验证一遍因为线上部署走的就是这个流程。5.2 四类典型踩坑记录从报错到恢复的全过程现象一Tomcat 启动报错 No qualifying bean of type OrderService原因Controller 上的 Autowired 找不到 Service 实例。最常见的是 spring-mvc.xml 的 component-scan 把 controller 和 service 包一起扫了两个容器里各创建了一份 Service或者 applicationContext.xml 里根本没扫 service 包。解决先看报错信息里的 bean 名称确定这个类注册在哪个容器里。保持一条铁律spring-mvc.xml 只扫 controller 包applicationContext.xml 只扫 service 和 dao 包两边扫描范围不要重叠。现象二启动时正常一调用查询接口就报 Invalid bound statement (not found)原因Mapper 接口的类文件在但对应的 XML 没有被 Maven 打进 classpath。很多同学把 UserMapper.xml 顺手放在 src/main/java 的 mapper 目录下Maven 默认不打包 java 目录里的 XML还有一种是 namespace 写成别的接口全限定名MyBatis 对不上号。解决打开 target/classes 目录确认有没有 XML 文件。没有就在 pom.xml 的 build 里加 resources 配置把**/mapper/*.xml纳入打包范围。然后核对 XML 根节点的 namespace 和 Mapper 接口的全限定名是否完全一致。现象三页面显示中文正常但查询条件传中文时变成问号原因GET 请求的中文参数在 Tomcat 层面没有按 UTF-8 解码Tomcat 8 默认 URIEncoding 是 UTF-8但老版本 Tomcat 是 ISO-8859-1响应页面的 charset 如果没设置也可能乱。解决在 server.xml 的 Connector 上配置 URIEncodingUTF-8JSP 顶部加% page contentTypetext/html;charsetUTF-8 %连接串里带 characterEncodingutf8三层全堵上才不会再出乱码。现象四订单创建成功但余票没扣或者余票扣了订单回滚没了原因事务没有真正生效。两种情况最常见——spring 配置里少了 tx:annotation-driven注解没被识别或者表引擎是 MyISAM连回滚都没法回滚。解决确认 applicationContext.xml 里有 DataSourceTransactionManager 和tx:annotation-driven再用 SHOW TABLE STATUS 检查表引擎全部改成 InnoDB。另外注意不要在 Service 方法内用 try-catch 把 RuntimeException 吞掉事务要靠异常触发回滚一吞就失效。5.3 本地与服务器的部署差异换环境时容易忽略的隐藏配置本地 IDEA 跑通了不代表服务器上没问题。最常见的是访问路径带不带项目名本地访问 /xxx/服务器上如果想直接 www 域名打开war 包要改名为 ROOT.war 部署否则 nginx 转发还得重写 rewrite 规则。其次是数据库连接localhost 要改成云数据库的内网地址同时确认服务器的 3306 端口白名单放行了应用所在的内网 IP。还有个隐蔽点本地开发是 Windows 或 Mac服务器多半是 Linux日志路径、文件上传临时目录的写法要兼容。用相对路径或者项目内路径最稳绝对路径换环境必挂。部署完先去 Tomcat 的 catalina.out 看启动日志别直接打开浏览器很多错误在日志里写得比浏览器页面清楚得多。6. 二次开发改造从课设交付变成能用的系统先动这三处6.1 用最少改动支持往返查询现在查询只能按单段行程过滤要支持往返只需要在 Service 里把两次单程查询的结果折叠起来public MapString, ListTicket searchRoundTrip(String from, String to, String goDate, String backDate) { MapString, ListTicket result new HashMap(); result.put(go, ticketDao.queryTickets(from, to, goDate)); result.put(back, ticketDao.queryTickets(to, from, backDate)); return result; }逻辑说明出发地和目的地互换再查一次就能拼出返程结果前端按 go/back 分组渲染数据库表结构完全不用动。唯一要额外处理的是返程日期为空时的提示以及往返同时选座时的联动校验。6.2 给热点车次加 Redis 缓存课设的访问量不大但买票场景天然有热点数据一趟热门车次的余票可能被几百人同时查。顺手加一层短时缓存提前体验一下真实系统常用的降负载手段public ListTicket queryTicketsWithCache(String from, String to, String date) { String key ticket: from : to : date; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseArray(cached, Ticket.class); } ListTicket tickets ticketDao.queryTickets(from, to, date); redisTemplate.opsForValue().set( key, JSON.toJSONString(tickets), 5, TimeUnit.MINUTES); return tickets; }参数说明缓存 key 由查询条件拼接精确命中同样的搜索。过期时间设 5 分钟均衡数据新鲜度和数据库压力。注意下单扣减余票后一定要主动删掉对应 key否则缓存里的余票是旧的。Spring 环境里引入 spring-data-redis 就能用 RedisTemplate不需要额外写工具类。6.3 守住三层边界改造才不会越改越乱我在动这套代码时坚持三条底线Controller 里不出现业务判断只做参数接收和结果转发Service 里不做多表查询的 SQL 拼接数据访问全部走 MapperMapper 里不写业务逻辑和事务控制只负责 SQL 与结果映射。这套代码本身分层比较标准大多数改造在 Service 层完成就够。扫码登录、支付回调这些新能力先加 Controller 方法再补 Service 接口最后落表字段和 Mapper顺序不乱后面接手的同学也不会骂人。之前我帮一个同学排查这个项目时发现他把支付回调的逻辑写进了 Controller结果回调重试时不改状态接口硬是多写了三张表才圆回来。从那以后我每次拿到分层代码都会先画一遍请求链路强制自己每层只干自己的活改完再跑一遍验收路径登录、搜索、下单、查订单外加重新部署一次验证配置文件没打丢。希望这份拆解能帮到你照着这个思路把课设跑通、吃透再动手改比你从头写一个省太多时间。本文还有配套的精品资源点击获取