电影院售票系统JavaWeb课设:Servlet+JSP+JDBC全流程实战
发布时间:2026/10/7 10:42:00 作者:尧图编辑部 阅读量:1,286

简介一套完整的电影院售票管理系统面向Java初学者及需要毕业设计、期末大作业参考的高校学生。系统采用JavaServletJSPJDBCMySQL构建B/S架构包含用户选座购票、电影排片、订单管理、后台销售统计等核心功能界面友好、操作流程直观。资源包共103个文件约2.31MB以23个java源码、23个class编译文件、8个sql数据库脚本为主另含xml配置、png界面截图和docx说明文档便于直接导入IDE结合数据库运行调试。代码注释详细对登录鉴权、JDBC连接、购票事务等关键模块均有清晰实现适合新手对照学习分层开发思路。目前已有86人学习下载。该项目功能完备、管理便捷既可作为课程设计或毕业设计的交付成果也能帮助读者掌握Servlet/JSP与MySQL整合开发的标准流程。1. 电影院售票管理系统一个 JavaWeb 课设的完整骨架电影院售票管理系统标题里四个技术词——Java、Servlet、JSP、JDBC——再加一个 MySQL是很多计算机专业学生第一次接触的“完整业务系统”。它看起来只是课程设计背后却同时涉及表单提交、数据展示、座位状态更新、订单生成和并发控制能跑通它JavaWeb 才算真正入门。这套系统适合谁正在准备课程设计、毕业设计或者想从“只会写 SQL 和简单增删改查”跨向“完整 Web 应用”的人都可以照着做一遍。管理员维护电影和排片用户选场次、选座位、下订单一个业务闭环正好把 Servlet 和 JSP 的协作关系练出来。下面用 6 个部分把建表、JDBC 连接、购票事务、页面渲染到常见翻车点整个梳理一遍并给出可以直接复制的代码和参数照着跑通问题不大。重点是先理解为什么这样搭再去改代码而不是把代码粘完就算结束。2. 技术选型先想明白ServletJSPJDBC 到底在替谁干活2.1 Servlet、JSP、JDBC 的分工与边界很多同学上来就问“为什么不直接用 Spring Boot”我的回答是框架越高级越容易把底层 HTTP 处理过程变成黑匣子。Spring Boot 里一个RestController加一句RequestMapping就能接请求可请求从 Tomcat 到 Servlet 再到浏览器这一路发生了什么被封装得干干净净。你背会了注解却理解不了 Servlet 的生命周期。这个项目里Servlet 是唯一的 HTTP 入口。浏览器发出请求Tomcat 负责创建 Servlet 实例并调用doGet或doPost你在方法里拿到表单参数转给业务层处理最后决定是转发页面还是重定向。JSP 是视图层本质上是 Servlet 容器帮你编译成 Servlet 的模板负责输出 HTML。JSP 可以直接写 Java 代码这既是便利也是陷阱一旦把 SQL 写进 JSP你的业务逻辑就全散落在页面里维护起来非常痛苦。JDBC 是 Java 访问数据库的标准接口MySQL 是存储层。JDBC 的工作只有三件事拿数据库连接、执行 SQL、处理结果。它虽然啰嗦但让你清楚每一张表的读写发生在哪个方法里出了问题一根线能追到底。网上搜“servlet demo”看着简单真正把它们组合成一个售票系统才涉及选座、状态变更、订单生成这一串动作也正是这个项目值得做的原因。技术组件在这个系统里的责任边界提醒Servlet接收请求、解析参数、分配业务操作不要写 SQL不要直接拼页面JSP渲染数据、展示表单、提示错误只读Servlet放进去的数据JDBC建立连接、执行 SQL、管理事务连接用完必须关闭MySQL持久化电影、排片、座位、订单表结构和索引决定性能2.2 模块划分与数据表总览买票系统的核心动作是“锁座”和“生成订单”其余都是辅助。所以数据库设计要围绕这两个动作展开不能只做一张“电影表”加一张“订单表”应付交差。我习惯把系统切成三个大模块第一是基础数据包括电影、场次、影厅第二是售票包含选座、命中状态、锁定座位第三是订单记录谁在什么时候买了哪一场的哪个座位。管理员操作用在电影和场次上普通用户操作落在座位和订单上。对应到表结构最少需要四张表数据表核心字段作用movieid, title, duration, price, on_date, status电影基础信息scheduleid, movie_id, hall, start_time, end_time某部电影的某一场排片seatid, schedule_id, row_num, col_num, status, user_id某一场的座位状态t_orderid, order_no, user_id, schedule_id, seat_id, status, create_time订单信息座位表必须带着schedule_id维度同一影厅不同场次是不同座位记录。每场排片生成时可以按影厅规格一次性插入几十条座位记录状态初始为 0。这个设计在第四部分写购票逻辑时会特别有用。2.3 目录结构Maven 还是普通 Web 工程两种都可以但强烈建议用 Maven哪怕你还不习惯写依赖坐标。普通 Web 工程要自己把mysql-connector-java的 jar 包拖进WEB-INF/lib换机器部署时经常忘记拷包Tomcat 启动直接报ClassNotFoundException。Maven 只需要在pom.xml里声明依赖构建时统一拉取不会出现“我本机行你机器不行”的玄学问题。一个典型的工程目录长这样cinema-ticket/ ├── pom.xml ├── sql/ │ └── cinema.sql └── src/main/ ├── java/com/cinema/ │ ├── util/DBUtil.java │ ├── model/Movie.java │ ├── model/Schedule.java │ ├── model/Seat.java │ ├── model/Order.java │ ├── dao/MovieDao.java │ ├── dao/SeatDao.java │ ├── service/OrderService.java │ └── web/MovieServlet.java │ └── web/OrderServlet.java └── webapp/ ├── WEB-INF/web.xml ├── css/ ├── js/ ├── admin/ │ ├── movieManage.jsp │ └── scheduleManage.jsp └── index.jsputil放数据库连接工具model放实体类dao放 SQL 操作service放事务和业务规则web放 Servlet。这样分层的好处是以后想把JDBC换成 MyBatis只改dao层想把 JSP 换成前端页面只改webapp下的文件业务逻辑不用动。3. MySQL 建表与 JDBC 连接先把数据层踩实3.1 建库建表场次、座位、订单的字段取舍在动手写 Servlet 之前先确保数据库表能支撑完整业务。下面是建库建表的基础脚本字段命名尽量简单但语义清晰。CREATE DATABASE IF NOT EXISTS cinema DEFAULT CHARACTER SET utf8mb4; USE cinema; CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 电影名称, duration INT NOT NULL COMMENT 时长单位分钟, price DECIMAL(6,2) NOT NULL COMMENT 基础价格, on_date DATE COMMENT 上映日期, status TINYINT DEFAULT 1 COMMENT 1上映 0下架 ); CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT NOT NULL, hall VARCHAR(20) NOT NULL COMMENT 影厅名称, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, KEY idx_movie_id (movie_id), CONSTRAINT fk_schedule_movie FOREIGN KEY (movie_id) REFERENCES movie(id) ); CREATE TABLE seat ( id INT PRIMARY KEY AUTO_INCREMENT, schedule_id INT NOT NULL, row_num INT NOT NULL, col_num INT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0可选 1锁定 2已售, user_id INT COMMENT 当前操作人ID, UNIQUE KEY uk_schedule_seat (schedule_id, row_num, col_num), CONSTRAINT fk_seat_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, schedule_id INT NOT NULL, seat_id INT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_schedule_id (schedule_id) );这里有几个参数值得说明。价格字段用DECIMAL(6,2)不要用FLOAT浮点数算金额会产生不可预期的小数误差订单金额这类数据必须精确。时间字段用DATETIME不要用字符串存否则后面按时间排序、筛选场次时很难写 SQL。座位表上的UNIQUE KEY uk_schedule_seat (schedule_id, row_num, col_num)是防止同一个影厅同一排同一列出现两条重复记录这是锁座逻辑的一道数据层保险。外键在这个项目里可以保留因为它业务量不大外键能保护数据一致性。但在真实高并发系统中外键会影响批量插入和删除性能而且分布式架构里外键没法跨库使用。学这个项目时先留着理解其作用就行。3.2 JDBC 连接工具类一次封装全项目复用最朴素的做法是写一个DBUtil类把驱动加载、连接获取和资源关闭统一起来。package com.cinema.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/cinema ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }这里的URL参数要重点解释。characterEncodingutf8保证向数据库写入中文时不会乱码useSSLfalse是本地开发、没有启用 SSL 证书时避免多余握手serverTimezoneAsia/Shanghai在 MySQL 8.0 驱动下几乎是必填项否则连接时间字段会直接抛The server time zone value的异常。Class.forName用来加载驱动类如果你用的是 MySQL 5.x 时代的驱动包驱动名是com.mysql.jdbc.Driver注意区分。PASSWORD写成固定值只是课设做法实际项目应该把数据库配置放到db.properties文件里并加入版本管理忽略清单。这一步不做不影响运行但值得知道。3.3 PreparedStatement 占位符别把参数拼进 SQL很多初学 JDBC 的人习惯写字符串拼接“select * from movie where id id”。这在本地测试没问题但一旦有用户输入通过这种 SQL 进到数据库就存在 SQL 注入风险。这个项目虽然规模小但一定要用PreparedStatement。public Movie findMovieById(Connection conn, int movieId) throws SQLException { String sql SELECT id, title, duration, price FROM movie WHERE id ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, movieId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { Movie m new Movie(); m.setId(rs.getInt(id)); m.setTitle(rs.getString(title)); m.setDuration(rs.getInt(duration)); m.setPrice(rs.getBigDecimal(price)); return m; } } } return null; }PreparedStatement使用?占位符参数索引从 1 开始setInt(1, movieId)把第一个问号替换成整型参数。这样一方面避免了字符串拼接带来的注入风险另一方面也省去手动处理单引号的麻烦。try (PreparedStatement ps...)和try (ResultSet rs...)是自动关闭资源的写法如果这里的连接是从外面传进来的千万别在这个方法里把conn也关掉否则事务就没法继续了。4. 核心购票流程的实现Servlet 控制层 JSP 视图层4.1 Servlet 路由和参数处理一个入口分发多个操作电影列表、场次列表、订单创建、取消订单这些动作都靠 Servlet 分支处理。常见做法是把同一个资源的操作放在一个 Servlet 中用action参数区分。package com.cinema.web; import com.cinema.service.OrderService; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/order) public class OrderServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String action req.getParameter(action); if (create.equals(action)) { int userId Integer.parseInt(req.getParameter(userId)); int scheduleId Integer.parseInt(req.getParameter(scheduleId)); int seatId Integer.parseInt(req.getParameter(seatId)); boolean ok new OrderService().buyTicket(userId, scheduleId, seatId); if (ok) { resp.sendRedirect(orderSuccess.jsp); } else { req.setAttribute(msg, 该座位已被他人购买请重新选座); req.getRequestDispatcher(buy.jsp).forward(req, resp); } } else if (list.equals(action)) { // 查询当前用户订单转发到订单列表 } } }WebServlet(/order)是 Servlet 3.0 之后的注解式映射省去在web.xml里逐条配置。doGet里调用doPost是为了让通过GET方式访问/order时也能得到合理处理否则浏览器直接输入地址会报 405。req.setCharacterEncoding(UTF-8)必须放在读取任何参数之前否则 POST 请求里的中文参数会乱码。这里有个细节跳转页面时错误场景用forward成功场景用sendRedirect。区别在于forward是服务端内部跳转浏览器 URL 不变用户刷新页面会重复提交同一个订单sendRedirect让浏览器重新发起一个 GET 请求可以避免表单重复提交。课设里体会到这个区别面试被问“forward 和 redirect 的区别”时就能用自己的例子讲清楚。4.2 选座与锁座事务边界必须放在 Service 层买票最核心的逻辑不是“插一条订单”而是“在并发情况下同一张座位只能被一个人买走”。如果把检测座位状态和创建订单拆成两个独立方法两个用户同时查到“座位可选”就会同时下单这就是典型的超卖。下面的代码把两步动作放进同一个事务并且用一条带条件判断的UPDATE完成锁座package com.cinema.service; import com.cinema.util.DBUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.text.SimpleDateFormat; import java.util.Date; public class OrderService { public boolean buyTicket(int userId, int scheduleId, int seatId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); // 1. 条件更新座位只有 status 0 时才能修改为已售 String lockSql UPDATE seat SET status 2, user_id ? WHERE id ? AND status 0; PreparedStatement lockPs conn.prepareStatement(lockSql); lockPs.setInt(1, userId); lockPs.setInt(2, seatId); int affected lockPs.executeUpdate(); if (affected ! 1) { conn.rollback(); return false; } // 2. 插入订单 String orderNo new SimpleDateFormat(yyyyMMddHHmmss).format(new Date()) userId seatId; String insertSql INSERT INTO t_order (order_no, user_id, schedule_id, seat_id, status, create_time) VALUES (?, ?, ?, ?, 0, NOW()); PreparedStatement insertPs conn.prepareStatement(insertSql); insertPs.setString(1, orderNo); insertPs.setInt(2, userId); insertPs.setInt(3, scheduleId); insertPs.setInt(4, seatId); insertPs.executeUpdate(); conn.commit(); return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (Exception ignored) {} } return false; } finally { if (conn ! null) { try { conn.close(); } catch (Exception ignored) {} } } } }这段代码的要点是UPDATE seat SET status 2 ... WHERE id ? AND status 0。MySQL 执行这条更新时会给对应行加行锁第一个请求改了状态并提交第二个请求即使同时进入也只能等待锁释放然后再执行更新。但这时它的条件status 0已经不满足影响行数是 0于是事务回滚并返回 false。这也是 mysql 事务处理里最典型的“原子更新”手法而不是先SELECT再UPDATE。SELECT 和 UPDATE 之间有时间差并发下必然出问题。setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED)是设置隔离级别为读已提交避免读到未提交的脏数据。订单号和座位号拼在一起主要是课设里方便观察不需要纠缠这个命名方式。4.3 JSP 渲染列表、座位格和错误提示JSP 里最怕满屏幕% ... %写 Java 代码。用 JSTL 标签可以让页面清晰很多。% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body h2当前热映/h2 c:forEach items${movieList} varmovie div span${movie.title}/span span${movie.price} 元/span a href${pageContext.request.contextPath}/order?actionschedulemovieId${movie.id}选场次/a /div /c:forEach c:if test${not empty msg} p stylecolor:red${msg}/p /c:if /body /html${movieList}是 Servlet 通过req.setAttribute(movieList, list)放进去的数据。${movie.title}会自动调用Movie类的getTitle()方法。这个语法本身不是 JSP 内置的而是 EL 表达式JSTL 标签配合 EL 才能这样写。如果没引入 JSTL 相关 jar页面会直接报错这在第五章会提到。选座页可以简单演算成二维座位网格。常见做法是Servlet查出某场次所有座位再按行号列号放到一个LinkedHashMap里JSP 里做两层循环输出。不要用 JSP 去查数据库页面只负责把已经传进来的数据渲染出来。4.4 完整的请求链路从浏览器到数据库把前面三段串起来一次购票的请求链路是这样的用户点击选座页的“确认购买”浏览器提交POST /order?actioncreateuserId1scheduleId2seatId5。Tomcat 根据注解映射找到OrderServlet调用doPost。doPost先设置请求编码再从request中取参数。OrderService.buyTicket拿到参数后开启事务先锁座、再插入订单。返回结果后Servlet 决定sendRedirect到成功页或者forward回选座页并携带错误提示。排查问题时按这条链路往前往后追先看浏览器提交的参数对不对再看 Servlet 是否执行到了业务方法最后看 SQL 和事务提交状态。很多“页面点了没反应”的问题其实卡在第一步或第二步跟数据库一点关系都没有。5. 电影院售票系统常见问题排查五个必踩的坑5.1 数据库连不上Access denied 和 Unknown database 同时出现现象Tomcat 启动后第一次访问页面Java 后台直接抛java.sql.SQLException: Access denied for user rootlocalhost或者Unknown database cinema。原因第一种是用户名密码不对或者 MySQL 服务本来就没启动第二种是 URL 里写的库名还没创建。MySQL 8.0 的密码加密方式和旧版驱动也可能冲突导致认证失败。解决先确认 MySQL 服务在运行Windows 下可以用服务管理器或net start mysql查看状态。然后确认cinema库已经执行过建库脚本。最后确认连接 URL 里的库名、用户名密码和驱动版本三者匹配。本地课设环境如果装了 MySQL 8驱动就用对应的mysql-connector-java 8.x版本不要拿 5.x 的 jar 硬凑。5.2 Tomcat 10 下 Servlet 类名全红javax 还是 jakarta现象把别人给的源码导入 IDEA 后HttpServlet、ServletRequest这些类全部报错项目无法编译。原因Tomcat 10 开始把 Java EE 的javax.servlet包迁移成了jakarta.servlet老项目代码里的javax.servlet.*已经不存在了。解决两个思路。要么把环境换成 Tomcat 9老代码不用改要么把代码里的javax.servlet全部替换成jakarta.servlet。如果这是课程要求或者正在看老教程直接用 Tomcat 9 最省事很多线上的 servlet demo 和 jsp 教程都基于 Tomcat 8/9别再被版本差异卡时间。5.3 中文乱码页面、请求、数据库三级不一致现象电影名称在数据库里正常页面上却是问号或者搜索“阿凡达”查不到任何结果但数据确实存在。原因编码没对齐。MySQL 表用了utf8mb4但 JDBC 连接 URL 没有加characterEncodingutf8JSP 页面charset写成了GBKServlet 读参数时没有设置request.setCharacterEncoding(UTF-8)。其中任何一环断开中文就乱。解决统一成 UTF-8没有例外。JSP 第一行用contentTypetext/html;charsetUTF-8Servlet 读参数前执行setCharacterEncoding(UTF-8)JDBC URL 里带useUnicodetruecharacterEncodingutf8数据库连接字符集也改成utf8mb4。做完这四件事乱码基本绝迹。5.4 同一座位被两个人同时买到现象两个人同时选中同一个座位数据库里出现了两条订单但座位只有一场一个。原因业务代码走了“先查状态再更新状态”的路线两个请求都查到座位可售然后各自下单中间没有锁的概念。解决按第四章的写法用一条UPDATE ... WHERE id? AND status0做条件更新然后根据影响行数判断是否真的锁住了座位。这不是 SQL 技巧本身多高深而是把误判逻辑交给了数据库的行锁机制。同时确保Connection的setAutoCommit(false)在事务开头commit在两条语句都成功后执行任何一步失败都要rollback。5.5 表单提交后 404 或 405现象点击表单“提交”按钮后浏览器地址变了页面却报 404 Not Found或者报 405 Method Not Allowed。原因404 通常是请求路径写错了。部署后上下文路径是/cinema-ticket完整路径应该是/cinema-ticket/order表单里写成了/order。405 则是 Servlet 写了doPost但表单用GET提交或者反过来。解决表单和链接里统一使用${pageContext.request.contextPath}拼路径比如${pageContext.request.contextPath}/order?actioncreate。Servlet 里如果希望GET和POST都支持就在doGet里直接调用doPost或者在两个方法里分别处理。这样既不会 404也不会 405。6. 从能跑到能用三个验证技巧和一个重构方向6.1 用 curl 快速验证购票接口页面还没写完的时候可以用 curl 直接验证 Servlet 和 Service 能不能工作。启动 Tomcat 后执行curl -s -X POST \ -d actioncreateuserId1scheduleId2seatId5 \ http://localhost:8080/cinema-ticket/order \ -w HTTP状态码:%{http_code}\n如果返回 302说明sendRedirect生效如果返回 200 并带出了错误提示说明 Service 里锁座失败。这个办法能让你把前端因素抛开直接看业务层和数据库交互是否正确。注意这里的路径要按你的部署上下文调整不要照抄。6.2 下单后验证座位和订单状态验证事务最直接的方式是买完票后查两张表SELECT * FROM seat WHERE id 5; SELECT * FROM t_order WHERE seat_id 5;如果订单存在但座位状态还是 2 以外的值说明事务没有提交成功如果座位变成 2 却没有订单说明回滚逻辑有问题。把这个查询写进你的本地测试 SQL 片段里比盯着控制台日志更直观。6.3 重构方向连接池与统一响应课设跑通以后下一步是把DBUtil里的DriverManager.getConnection()换成 Druid 或 HikariCP 连接池。连接池的好处是复用连接不用每次请求都经历“建立连接、执行 SQL、关闭连接”的完整过程。改起来不复杂核心就是让DBUtil从一个独立配置的数据源拿连接而不是每次都新建。至于“能不能直接进化到 Spring Boot”我的建议是等这个项目里 Servlet、JSP、JDBC 之间的边界都清楚了再跨。Spring Boot 的 Controller 本质上还是 ServletMyBatis 内部也还是 JDBC只是被封装得更精致。基础没走通就跳过去遇到问题还是得回来看这一层。我自己最初做类似项目时把锁座和插入订单拆成两个独立 DAO 方法测试时明明看到订单创建成功座位状态却原封不动。后来养成一个习惯凡是设计到多条 SQL先把前后状态查出来打印一遍再写业务代码。这个习惯后来帮我排查过不少同事的订单问题。希望这条血泪经验能让你在电影院售票管理系统上少翻几次车希望帮到你。本文还有配套的精品资源点击获取