资讯动态

JSP+Servlet+MySQL车票预订系统:事务与并发控制实战拆解

发布时间:2026/10/21 8:26:02 来源:尧图企业网站定制
简介基于WEB的车票预订信息系统设计与实现项目源码面向Java方向毕业生、课程设计学习者及希望提升Web开发能力的学生覆盖Servlet、JSP、MySQL数据库等核心技能适合用来完成车次查询、余票管理、在线预订、订单记录等模块的设计与编码。压缩包共1337个文件约55.82MB除JSP动态页面、Java源码、CSS样式、JavaScript脚本、图片素材和SQL数据库脚本外还包含jar依赖包、Eclipse/MyEclipse工程配置、XML配置与少量操作演示视频目录结构完整可按功能分类快速定位。已有1105人学习下载项目配有视频讲解能帮助读者从环境搭建、项目导入到数据库连接和调试运行逐步走通流程。通过反复研读源码和实际调试还能深入理解Java Web分层架构、Servlet请求处理机制、JSP页面交互以及JDBC数据访问方式为毕业设计、课程报告或求职作品提供一套可直接改造复用的完整方案。1. 从抢票软件聊起的反直觉选型为什么这套 JSP 车票预订系统值得拆一个每天都在刷票软件里抢票的人可能不会想到课程设计级别的车票系统里藏着事务、并发、Session 这些 Java Web 最核心的东西。这套基于 WEB 的车票预订信息系统技术栈是 JSP Servlet MySQL没有 Spring Boot没有 MyBatis连 Maven 都不需要。打开入口页面注册、登录、查票、下单、出票链路完整顺着代码往下翻JDBC 原生事务、行级锁、PreparedStatement 防注入这些基本功全都能找到。适合刚学完 Java Web 想梳理请求链路的人也适合要做课程设计、想快速跑通再二次开发的人后端熟手还能在它身上验证自己对 Servlet 生命周期和事务边界的理解。2. 技术架构拆解JSP Servlet MySQL 三件套的工程落地2.1 技术选型裸写 Java Web 为什么比框架更适合拆解启动这个车票预订系统之后你会发现它完全没有框架痕迹。没有 Spring 容器没有 MyBatis依赖就是一个web.xml里声明的 Servlet 和一个放在WEB-INF/lib下的 mysql 驱动 jar。整个系统就是 JSP 做页面渲染、Servlet 做控制器、JDBC 直连 MySQL 的经典三层结构。我从首页index.jsp入手摸代码的时候先看到${loginUser}这个 session 属性顺着它走到了UserLoginServlet再往下一层是UserDao。整个请求链路没有框架帮你跳转所有路径都是开发者自己写的转发和重定向。对新手来说这是好消息你能看到一次 HTTP 请求从浏览器到服务器、再到数据库、最后回页面的完整路径。对熟手来说因为框架藏起来的细节全暴露在外面反而适合验证自己对 Filter 顺序、事务边界、编码处理这些基础概念是不是真懂。有人会问为什么不直接上 Spring Boot答案要看使用场景。教学演示和课程项目这个层级评判重点通常是业务闭环能不能讲清楚而不是框架有多新。一个RestController就完成的查询接口在这个项目里要拆成 Servlet 接收参数、DAO 拼 SQL、JSP 用 JSTL 渲染三个步骤。正是这种裸写让每个环节都没地方躲。2.2 工程目录结构src、WebRoot、sql 三块各自承担什么把 rar 解开后项目是三个核心目录的结构src放 Java 源码WebRoot是 Web 应用根目录sql放初始化脚本。最常见的包结构长这样TicketSystem/ ├── src/com/ticket/ │ ├── entity/ # 实体类 User.java / Train.java / Order.java │ ├── dao/ # 数据访问层 UserDao / TrainDao / OrderDao │ ├── service/ # 业务层封装订票、退票等事务逻辑 │ ├── servlet/ # 控制器层接收请求并跳转页面 │ ├── filter/ # LoginFilter 登录拦截、EncodingFilter 编码过滤 │ ├── util/ # DBUtil 连接管理、MD5Util 密码加密 │ └── listener/ # 启动时加载配置的监听器 ├── WebRoot/ │ ├── WEB-INF/ │ │ ├── web.xml # 核心配置Servlet 映射、Filter、欢迎页 │ │ └── lib/ # mysql-connector-java.jar / jstl.jar │ ├── css/ js/ fonts/ │ ├── index.jsp # 首页和入口登录 │ ├── search.jsp # 车次查询 │ ├── order_list.jsp # 我的订单 │ └── admin/ # 后台管理页面 └── sql/ ├── ticket_system.sql # 建库建表 └── init_data.sql # 示例车次数据web.xml值得多看两眼。虽然是 Servlet 3.1 规范了项目仍然用 XML 声明了LoginFilter的映射和各个 Servlet 的路径。登录拦截是这么配的filter filter-nameLoginFilter/filter-name filter-classcom.ticket.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/*/url-pattern /filter-mappingURL 匹配/*意味着所有请求都会过过滤器在 Java 代码里判断 session 是否存在不存在就重定向回login.jsp。这种写法比注解直观特别是你要给别人讲“请求进来先过谁、再进哪个 Servlet”的时候直接在 XML 里按顺序看就够了。2.3 数据库设计三张核心表如何支撑整个订票闭环整个系统只有三张表t_user管用户t_train管车次和余票t_order管订单。初始化脚本里最有价值的字段是t_train.remaining和t_order.status前者是后续并发控制的关键后者是订单状态机的锚点。表名核心字段作用t_userid, username, password, id_card, phone用户注册与登录密码存的是 MD5 值t_trainid, train_no, depart_station, arrive_station, depart_time, arrive_time, price, remaining, seat_total车次基本信息remaining 表示实时余票t_orderid, order_no, user_id, train_id, ticket_count, total_price, status, create_time订单表status 用 0 待支付、1 已支付、2 已取消订单设计上order_no是生成的唯一流水号user_id和train_id外键关联另两张表。注意t_order.ticket_count和t_train.remaining之间没有数据库级约束余票到底够不够完全靠程序判断。这个设计在并发量上来之后会翻车第 4 章会讲具体解决思路。初始化脚本里的示例数据很重要。跑起来之后至少要有 4 到 5 条不同车次最好是同一条线路不同时段的排班方便测查询、订票和后续取消逻辑。执行完init_data.sql就能用预置的普通用户登录后台能看到这些数据。3. 核心模块拆解从登录到出票的业务链路逐段走读3.1 登录模块Session 怎么把一次登录状态串起来登录是第一个要读的 Servlet。login.jsp把表单 POST 到UserLoginServletdoPost做了三件事取参数、查库、决定去留。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password MD5Util.md5(request.getParameter(password)); UserDao userDao new UserDao(); User user userDao.findByUsernameAndPassword(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, user); response.sendRedirect(index.jsp); } else { request.setAttribute(errorMsg, 账号或密码错误); request.getRequestDispatcher(login.jsp).forward(request, response); } }这段代码有三个关键点。第一密码不是明文比对入库前用 MD5 算过一次数据库里存的是 32 位哈希值。第二登录成功把user对象塞进session.setAttribute后续页面通过${sessionScope.loginUser.username}就能拿到当前用户。第三失败时用forward转发回登录页errorMsg参数通过${requestScope.errorMsg}在 JSP 里渲染。sendRedirect和forward的区别在这里体现得很明显成功用重定向地址栏变成index.jspURL 不暴露会话信息失败用转发request 里的属性还能继续用。很多新手把这两个混用结果报错信息永远传不过去。3.2 车次查询模块PreparedStatement 拼接多条件查询查询页search.jsp提供出发站、到达站、发车日期三个输入框提交后由TrainSearchServlet接收再丢给TrainDao.search。核心 DAO 方法是这份代码里最值得抄的一段public ListTrain search(String from, String to, String date) { String sql SELECT * FROM t_train WHERE 1 1; ListObject params new ArrayList(); if (from ! null !.equals(from.trim())) { sql AND depart_station LIKE ?; params.add(% from.trim() %); } if (to ! null !.equals(to.trim())) { sql AND arrive_station LIKE ?; params.add(% to.trim() %); } if (date ! null !.equals(date.trim())) { sql AND depart_time ? AND depart_time DATE_ADD(?, INTERVAL 1 DAY); params.add(date 00:00:00); params.add(date); } // 用 PreparedStatement 执行占位符 ? 逐个 setObject }WHERE 1 1看起来像玄学其实是给动态拼接省判断。每个条件参数都塞进params列表最后统一preparedStatement.setObject(i, params.get(i - 1))。这样完全避开字符串拼接 SQL防住注入的关键一步。日期查询没用匹配而是取一天的开始到第二天的开始中间用DATE_ADD做闭区间。因为depart_time是DATETIME只比日期会丢掉时间精度。查询结果ListTrain放进 request 的trainList属性转发到search_result.jsp由c:forEach渲染成表格。3.3 预订车票模块事务边界与行锁保证不超卖订票是整个项目最核心也最容易出问题的模块。业务上要同时做两件事扣减t_train.remaining插入一条t_order。这两步必须在一个数据库事务里否则会出现“订单生成了但余票没扣”或“票扣了订单没生成”。public void book(Connection conn, Order order, int count) throws Exception { conn.setAutoCommit(false); try { // 行级锁定查询当前余票 Train train trainDao.findByIdForUpdate(conn, order.getTrainId()); if (train.getRemaining() count) { throw new RuntimeException(余票不足); } trainDao.decreaseRemaining(conn, order.getTrainId(), count); orderDao.insert(conn, order); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }findByIdForUpdate内部执行的是SELECT * FROM t_train WHERE id ? FOR UPDATE。这是 MySQL 的悲观锁SQL 执行后 InnoDB 锁住对应行其他事务的更新必须等当前事务提交或回滚。两个用户同时抢同一车次时第二个请求会阻塞在FOR UPDATE上第一个提交后再读到最新的remaining。这段逻辑放在 service 层而不是 Servlet 层是因为事务边界必须覆盖 DAO 的多个操作。直接在BookTicketServlet里写后面再加“锁定订单”“账务流水”这些步骤事务会越写越乱。我把这当作这个项目给的最大设计经验事务从 service 方法开始到 service 方法结束不放进 Web 层。count参数是购票张数decreaseRemaining执行UPDATE t_train SET remaining remaining - ? WHERE id ?。有了前面的FOR UPDATE判断一定准确。如果哪天项目改成高并发存储过程就要换乐观锁方案用WHERE remaining ?作为更新条件靠受影响行数判断是否失败。4. 部署运行与常见问题避坑Tomcat 环境下让项目跑起来4.1 本地部署五步走从建库到浏览器出首页跑这个项目不需要复杂环境JDK、Tomcat、MySQL 三件套就够。部署顺序我建议严格按下面来。第一步建库打开 MySQL执行sql/ticket_system.sql里的建库建表语句再执行init_data.sql灌入示例车次。注意顺序不能反过来先有表结构才能写数据。第二步改连接配置找到src/util/DBUtil.java或db.properties把连接串里的账号密码改成你本机的。常见写法是jdbc:mysql://localhost:3306/ticket_system?useSSLfalsecharacterEncodingutf8useSSLfalse能省掉 MySQL 5.7 在本地环境不必要的 SSL 握手警告。第三步编译打包如果手编在src目录用javac -cp把所有 java 文件编译到WEB-INF/classes更省事的做法是在 IDEA 或 Eclipse 里打成 war 包扔到 Tomcat 的webapps目录下。第四步启动 Tomcat到bin目录执行startup.bat或startup.sh看到日志出现Server startup就说明启动成功。第五步访问浏览器打开http://localhost:8080/项目名/先注册一个普通用户再登录完成一次完整的查票订票操作。前端页面能走通说明配置基本没问题。4.2 高频问题排查记录五条具体踩坑实录问题一首页永远出不来直接跳到 404现象访问项目路径直接给 Tomcat 的默认 404 页面。原因大多出在 war 包没放对位置或者web.xml里没有配置welcome-file-list。解决先确认 war 解压出来的目录名和访问路径一致再检查WEB-INF/web.xml里 welcome-file 是不是index.jsp。两者都对了页面就能出来。问题二页面中文乱码数据库里存成问号现象登录后用户名在页面上显示一串??。原因有两层POST 请求的正文编码没有指定Tomcat 默认用 ISO-8859-1 解码连接串没带characterEncodingutf8JDBC 按 latin1 写入。解决写一个EncodingFilter对所有 request 和 response 设成 UTF-8同时在连接 URL 末尾补characterEncodingutf8。两处都改完再重启 Tomcat。问题三ClassNotFoundException: com.mysql.jdbc.Driver现象一跑查询就报找不到驱动类。原因驱动 jar 没进WEB-INF/lib。大多数 IDE 调试时依赖由 IDE 自己挂到 classpath部署到 Tomcat 后它只认WEB-INF/lib下的包手打 war 包里缺了这层保障。解决把mysql-connector-java-x.x.x.jar直接拷贝到WEB-INF/lib目录重新打包发布。问题四余票变成负数现象同一条线路两个浏览器同时下单最后余票字段出现负数。原因DAO 里“先查再改”的读写不是原子的两个线程同时读到remaining1各自扣 1。解决按第 3 章的方案在查询后加FOR UPDATE或者把扣减 SQL 改成UPDATE ... SET remaining remaining - ? WHERE id ? AND remaining ?判断受影响行数为 0 就报余票不足。两种方案我都在本地验证过后者并发性能更好。问题五JSP 页面里的 ${value} 原样输出现象${sessionScope.loginUser.username}在页面上直接显示成字符串。原因web.xml头部声明的是 Servlet 2.3 旧规范JSP 2.0 里 EL 默认关闭。解决把 web.xml 版本声明改成 Servlet 3.1 的 xsdl同时引入 jstl.jar 和 standard.jar 到WEB-INF/lib在 JSP 顶部加% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %。5. 页面交互拆解JSP 渲染与异步刷新的两种写法5.1 查询结果页用 JSTL 渲染车次表格search_result.jsp是典型的服务端渲染。TrainSearchServlet把trainList放进 requestJSP 用c:forEach循环输出每一行车次每行带一个“预订”按钮按钮值传车次 ID。form actiontrainSearch methodpost input typetext namefrom placeholder出发站 input typetext nameto placeholder到达站 input typedate namedate button typesubmit查询/button /form table c:forEach vartrain items${trainList} tr td${train.trainNo}/td td${train.departStation} → ${train.arriveStation}/td td${train.departTime} - ${train.arriveTime}/td td¥${train.price}/td td${train.remaining} 张/td tda hrefbook.jsp?trainId${train.id}预订/a/td /tr /c:forEach /table这段 JSP 的细节在于把列表展示和跳转入口放在同一个表格里。${train.remaining}显示当前余票但前端没有做售罄拦截余票为 0 时“预订”按钮依然可以点超卖检查完全靠后端第 3 章的事务逻辑扛。实战中我会先加一层余票为 0 时把“预订”换成“已售罄”用c:if test${train.remaining 0}条件渲染就能实现。表格里“出发站 → 到达站”用的是 HTML 字符串拼接。这里没转义风险因为值来自初始化脚本的固定数据。如果以后改成用户自定义车次名就要小心 XSS输出前需要转义。5.2 局部异步刷新少整页跳转多 Ajax 联动首页最近的热门线路和订单状态这块是用 Ajax 做的局部刷新不经过TrainSearchServlet单独暴露了一个HotRoutesServlet返回 JSON 数组前端用 jQuery 的$.get拉取后拼到 DOM 上。$.get(hotRoutes, { city: Shanghai }, function (data) { var list $(#hot-routes); data.forEach(function (item) { list.append(li item.depart 到 item.arrive /li); }); }, json);Servlet 返回 JSON 的常见做法是手动拼字符串配合response.setContentType(application/json;charsetUTF-8)。这里有个容易翻车的点手拼 JSON 必须自己处理引号转义和 null 值一旦字段里带双引号就会解析失败。本项目是演示用所以没引 JSON 库你要是想再加接口建议直接上 fastjson 或 Gson一行JSON.toJSONString(list)就能序列化整个车次列表。5.3 订单列表的状态回显与按钮联动订单页order_list.jsp读取当前用户的全部订单每一行显示车次、票数、金额、下单时间和状态。状态字段status是数字页面要做一次中文映射0 显示“待支付”1 显示“已支付”2 显示“已取消”。常见做法是在循环里套c:choose。c:choose c:when test${order.status 0}待支付/c:when c:when test${order.status 1}已支付/c:when c:otherwise已取消/c:otherwise /c:choose不同状态还会触发不同的操作按钮已支付订单显示“取消订票”待支付订单显示“去支付”已取消订单按钮置灰。这套联动逻辑全靠status一个字段判断所以后端在更新状态时一定要保证事务一致性否则页面会渲染出“已取消订单还能取消”这种自相矛盾的按钮。6. 二次开发实战给车票系统加上“取消订票”并回补余票6.1 需求与接口一个最小的状态变更闭环最常见的扩展需求是取消订票、改签、退票本质上都是对t_order.status做流转同时回补或调整t_train.remaining。这里以取消订票为例讲一个最小闭环的改法。先在order_list.jsp的已支付订单后面加一个“取消”按钮点击后 POST 到新增的CancelOrderServlet携带orderNo。Servlet 内部做三件事按订单号查订单、把status从 1 改成 2、把余票加回去。这三个动作必须在一个事务里否则会出现“余票加了订单还是已支付”的脏数据。protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String orderNo request.getParameter(orderNo); Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); Order order orderDao.findByOrderNo(conn, orderNo); if (order null || 2.equals(order.getStatus())) { throw new RuntimeException(订单不存在或已取消); } orderDao.updateStatus(conn, orderNo, 2); trainDao.increaseRemaining(conn, order.getTrainId(), order.getTicketCount()); conn.commit(); response.sendRedirect(order_list.jsp?msgcancel_success); } catch (Exception e) { conn.rollback(); request.setAttribute(errorMsg, e.getMessage()); request.getRequestDispatcher(order_list.jsp).forward(request, response); } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }这段代码里容易漏掉的是取消前的状态校验。订单已经取消过再走一次取消逻辑会把余票重复加回去。这个幂等检查在真实项目里叫状态机守卫很多接口的翻车事故都出在这里。6.2 验证方法与回归要点改完先别急着点按钮。我的验证流程分三步第一步用两个账号各下一单确认订单数和余票数落在初始化脚本的基线值上第二步对同一订单连续点两次取消第二次必须报“订单不存在或已取消”同时数据库里remaining只增加了一次第三步开两个浏览器同时取消同一笔订单看事务锁能不能拦住后进入的那个请求。三步都过了再补页面提示取消成功后把按钮置灰用c:if test${order.status eq 1}控制渲染让用户知道状态已经不可逆。从那以后我每次给带库存属性的业务加接口都强制自己走一遍“先查状态机、再改数、再回归并发”的流程。这种裸写的 JSP 项目没有框架兜底每一步都得自己画清楚反而逼着人把事务边界和幂等性想明白。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑