ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java电影院售票系统:Servlet+JSP+JDBC+MySQL实战解析

Java电影院售票系统:Servlet+JSP+JDBC+MySQL实战解析 简介一套电影院售票管理系统源码基于JavaServletJSPJDBCMySQL构建面向正在准备毕业设计、课程设计或期末大作业的高校学生也适合想通过完整项目练习Java Web分层开发的开发者。系统采用Servlet作为控制层、JSP作为视图层、JDBC负责数据库访问分层清晰功能覆盖电影与影院管理、排片场次、在线选座、订单支付、评论互动等典型票务场景代码注释详细整体完成度高界面简洁易用上手门槛低。压缩包共85个文件包含23份Java源代码、8份数据库脚本、10份配置文件、1个可运行jar包以及数据库表设计图解、需求分析文档和功能设计文档整包仅285KB非常轻量简单配置即可运行。包内还提供项目说明与数据库连接配置样例并附有模拟购票数据能帮助快速理解用户、电影、场次、订单、评论、影厅、电影院等核心表关系与项目结构。该资源已有518人学习参考足够作为高完成度课设或毕设模板也可作为经典Java Web项目实战的借鉴范例。1. 为什么“电影院售票系统”比普通商城更值得拆电影票比普通商品多出来的一样东西是“场次”。商品库存只需要一个数字票务库存则要同时回答三个问题哪个电影、哪个影厅、哪个时间卖出去的座位会不会重叠。很多人上网搜 Java 项目源码最后卡住的不是登录注册而是seats和orders怎么关联两个用户同时抢同一场次时为什么会出现负数。这里说的 Java项目-电影院售票管理系统javaServletJSPJDBCMySQL把用户表、电影表、影厅表、场次表、订单表、评论表以及完整代码放在一起表设计图解和 SQL 初始化脚本齐全适合拿去做毕业设计、期末大作业或者课程设计也适合当成理解 Servlet 控制器和 JDBC 事务的完整案例。2. Servlet JSP JDBC 的请求链路从参数到查询结果拆这类项目时先别急着跑起来而是把src/main/java下面的包结构过一遍。压缩包里能看到典型的/login、/film/list、/session/list、/buy这类请求路径每个路径对应一个WebServlet或web.xml映射。JSP 只负责渲染不直接持有PreparedStatement真正碰数据库的是 DAO 层。这个结构在面试里常被叫 Model 2实际上就是 Servlet 做控制器、JSP 做视图、普通 Java 类做业务和数据访问。后来再学 Spring MVC看到DispatcherServlet时能更快理解它本质上也是 Servlet只是把参数绑定、路径匹配、视图解析统一接管了。2.1 请求入口Servlet 不做业务只做参数解析和转发我拿到源码后的第一件事是看SessionListServlet因为查场次是门票业务的主流程。你会在里面看到类似下面的写法WebServlet(/session/list) public class SessionListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); int filmId Integer.parseInt(req.getParameter(filmId)); SessionDao sessionDao new SessionDao(); ListSession sessionList sessionDao.findByFilmId(filmId); req.setAttribute(sessionList, sessionList); req.getRequestDispatcher(/sessionList.jsp).forward(req, resp); } }这段代码有三个细节值得注意。第一req.setCharacterEncoding(UTF-8)必须放在读取参数之前否则中文电影名或用户名会乱码。第二Integer.parseInt直接转换参数演示项目可以接受但正规点要处理NumberFormatException否则访问/session/list?filmIdabc会直接 500。第三这里用的是forward而不是sendRedirect因为sessionList要放进request域给 JSP 使用redirect会产生第二次请求request里的属性会丢。以下是这个项目里常见的一批入口映射方便你对照源码定位请求路径Servlet 入口JSP 页面/loginLoginServlet登录成功跳转index.jsp/film/listFilmListServlet电影列表页/session/listSessionListServlet某部电影的场次列表/buyBuyServlet下单成功跳转订单详情页2.2 DAO 层用 JOIN 查场次而不是在 JSP 里拼 SQL接下来看SessionDao它决定了页面能显示哪些数据。查询场次时页面需要电影名、影厅名、影院名和价格这些字段分散在多张表里所以必须 JOINpublic ListSession findByFilmId(int filmId) { String sql SELECT s.session_id, f.title, r.room_name, c.cinema_name, s.show_date, s.start_time, s.price, s.available_seats FROM sessions s JOIN film f ON f.film_id s.film_id JOIN room r ON r.room_id s.room_id JOIN cinema c ON c.cinema_id r.cinema_id WHERE s.film_id ? AND s.status 1 ORDER BY s.show_date DESC, s.start_time ASC; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, filmId); try (ResultSet rs ps.executeQuery()) { ListSession list new ArrayList(); while (rs.next()) { Session s new Session(); s.setSessionId(rs.getInt(session_id)); s.setTitle(rs.getString(title)); s.setRoomName(rs.getString(room_name)); s.setPrice(rs.getBigDecimal(price)); list.add(s); } return list; } } catch (SQLException e) { throw new RuntimeException(查询场次失败, e); } }这段代码最值得学的是 JOIN 而不是子查询。查一场电影所有场次时只执行一条 SQL 就能拿到完整数据如果先在sessions里查出film_id再回表查film、room、cinema就是典型的 N1 查询页面数据量一大就会慢。代码里的try-with-resources会自动关闭Connection、PreparedStatement和ResultSet写毕设时能少写很多finally块。2.3 JDBC URL 和驱动先让数据库连接稳定打开压缩包里的config/jdbc.properties大概率长这样jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password123456这里的jdbc.url不是随便写的。useUnicodetruecharacterEncodingutf8解决中文乱码serverTimezoneAsia/Shanghai解决 MySQL 8 报时区错误useSSLfalse避免本地开发时 SSL 握手警告。如果项目里用的是 MySQL 5.x驱动类名通常是com.mysql.jdbc.Driver用 MySQL 8 则必须是com.mysql.cj.jdbc.Driver。换数据库版本却不换驱动类是“找不到驱动”最常见的来源。3. MySQL 表设计users、sessions、orders 怎么连成一张图这个项目的压缩包里专门放了一组“表设计图解.png”包括电影表、场次表、订单表、影厅表、评论表、用户表、电影院表。对写论文的人来说这些图可以直接贴到数据库设计章节对只想跑起来的人来说更值得关注的是*.sql文件之间的导入顺序和主外键关系。3.1 先看 SQL 脚本再决定执行顺序打开根目录能看到按表拆分的脚本users.sql、cinema.sql、room.sql、film.sql、sessions.sql、orders.sql、comments.sql最后是initData.sql。这些脚本不能随便拖进 Navicat 一股脑执行因为表之间会互相引用。如果先建orders再建sessions外键会直接报错。我一般会按“基础表 → 业务表 → 模拟数据”的顺序执行SQL 文件主要表关键作用users.sqlusers用户和管理员cinema.sqlcinema电影院信息room.sqlroom属于某个影院的影厅film.sqlfilm电影信息sessions.sqlsessions某电影在某影厅的场次orders.sqlorders用户购票订单comments.sqlcomments用户对电影的评价initData.sql上述各表预置登录用户和测试场次命令行导入可以这样写mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS cinema DEFAULT CHARACTER SET utf8mb4; mysql -uroot -p cinema users.sql mysql -uroot -p cinema cinema.sql mysql -uroot -p cinema room.sql mysql -uroot -p cinema film.sql mysql -uroot -p cinema sessions.sql mysql -uroot -p cinema orders.sql mysql -uroot -p cinema comments.sql mysql -uroot -p cinema initData.sql注意最后一步initData.sql。它里面除了管理员账号还会插入测试场次和模拟订单用来保证页面一打开就有数据。如果只导业务表不导它前端电影列表可能为空你会误以为程序有问题。3.2 sessions 表票务系统真正的“库存表”sessions是整个系统的枢纽。它的一行代表“某天某个时间某个影厅放映某部电影”这也是票价和余票的粒度所在。简化后的建表语句类似CREATE TABLE sessions ( session_id INT NOT NULL AUTO_INCREMENT, film_id INT NOT NULL, room_id INT NOT NULL, show_date DATE NOT NULL, start_time TIME NOT NULL, price DECIMAL(10,2) NOT NULL, available_seats INT NOT NULL COMMENT 当前剩余可售票数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可售 0停售, PRIMARY KEY (session_id), KEY idx_film_date (film_id, show_date), CONSTRAINT fk_sessions_film FOREIGN KEY (film_id) REFERENCES film(film_id), CONSTRAINT fk_sessions_room FOREIGN KEY (room_id) REFERENCES room(room_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;available_seats是一个冗余字段因为理论上用影厅容量减去已卖座位数能算出来。但售票场景里下单最频繁的操作就是“扣余票”所以几乎每个课程设计都会直接在sessions表维护这个数字。这里的外键fk_sessions_film和fk_sessions_room保证了不能插入一个不存在的电影或影厅但如果你的毕设数据量很大、要跑压测外键会带来额外检查开销正式项目里经常只保留索引不去掉外键。在演示项目里保留外键更容易解释表关系建议先别删。3.3 orders 和 comments订单快照与评论归属orders表的重点不是“订单号自动增长”而是订单里要存谁买、买哪场、买哪个座位、付了多少钱。简化版设计通常包含user_id、session_id、seat_no、total_price、status、create_time。其中seat_no建议存快照比如“3排6座”而不是只存座位 ID否则以后影厅重新排座历史订单会跟着变。评论表则要独立于订单表因为一个用户可以买多张票但只需要评一次。常见字段是comment_id、user_id、film_id、content、score、create_time。很多课程设计会把评论挂在订单下这在影院场景里不合理评论应该挂在电影维度而不是某一次场次。4. 售票核心实现行锁、事务和余票扣减如果答辩老师只问一个问题大概率是“两个用户同时买同一场最后一张票怎么保证不会超卖”。这个问题直接决定这个项目能不能从 60 分提到 90 分。4.1 先查余票再 UPDATE是超卖的最大来源错误写法是这样的SELECT available_seats FROM sessions WHERE session_id 1; // 两个并发请求同时读到 available_seats 1 // 两个请求都认为可以下单 UPDATE sessions SET available_seats 1 - 1 WHERE session_id 1;第二次UPDATE会把余票扣成负数因为第一次SELECT的结果在并发下是过期的。正确做法是把“判断余票大于 0”和“扣减 1”合并成一条 SQLpublic boolean decreaseStock(Connection conn, int sessionId) throws SQLException { String sql UPDATE sessions SET available_seats available_seats - 1 WHERE session_id ? AND available_seats 0; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, sessionId); return ps.executeUpdate() 1; } }这条UPDATE里available_seats 0是关键条件。MySQL InnoDB 执行更新时会对命中行加锁两个事务同时执行时后一个会等前一个提交然后看到余票已经变成 0于是更新影响行数为 0。业务层拿到false就知道该提示“已售罄”而不是继续生成订单。4.2 Service 层要把扣库存和插订单放进同一个事务光有原子扣减还不够如果扣库存成功、插入订单失败数据库里就会多出一个“扣了票但没订单”的记录。所以 Service 层必须手动控制事务边界代码如下public Order buyTicket(int userId, int sessionId, String seatNo) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); Session session sessionDao.findByIdForUpdate(conn, sessionId); if (session null || session.getAvailableSeats() 0) { throw new ServiceException(当前场次已售罄); } if (!sessionDao.decreaseStock(conn, sessionId)) { throw new ServiceException(手慢了座位已被抢); } String orderNo LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyyMMddHHmmssSSS)) String.format(%04d, userId % 10000); Order order orderDao.insert(conn, userId, sessionId, seatNo, orderNo, session.getPrice()); conn.commit(); return order; } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw new ServiceException(购票失败, e); } finally { DBUtil.close(conn); } }这段代码的逻辑顺序是先关闭自动提交再锁场次再扣余票再插订单最后统一提交。orderNo用时间戳加用户号拼出来虽然简单但已经能保证同一秒内不同用户不重号。真正重要的是catch里的rollback只要订单插入失败前面扣掉的余票也会回滚不会产生脏数据。4.3 FOR UPDATE 到底锁的是什么上面代码里的findByIdForUpdate是配合事务用的public Session findByIdForUpdate(Connection conn, int sessionId) throws SQLException { String sql SELECT session_id, price, available_seats FROM sessions WHERE session_id ? FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, sessionId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { Session s new Session(); s.setSessionId(rs.getInt(session_id)); s.setPrice(rs.getBigDecimal(price)); s.setAvailableSeats(rs.getInt(available_seats)); return s; } } } return null; }FOR UPDATE的意思是“把这行锁住直到当前事务提交或回滚”。这里的锁是 InnoDB 的行锁不是表锁所以同一时间还能查询其他场次。锁的时间很短只覆盖一次购票事务不会把整个影厅锁死。课程设计里用悲观锁完全够用而且答辩时最容易讲清楚。如果之后想更接近生产环境可以再看另一种方案方案核心语句适用场景悲观行锁SELECT ... FOR UPDATE毕业设计、单库小型系统原子扣减UPDATE ... SET available_seatsavailable_seats-1 WHERE available_seats0简单余票库存乐观锁UPDATE ... SET versionversion1 WHERE session_id? AND version?多副本、少冲突场景5. 部署与排错config/jdbc.properties、Maven 打包和 Tomcat这个项目拿到手以后最影响体验的不是代码而是能不能 10 分钟跑起来。压缩包里的 README 和config/jdbc.properties已经给了入口剩下的就是数据库、Maven、Tomcat 三件事。5.1 先建库导数据再改连接配置安装好 MySQL 后先确认能登录然后执行前面 3.1 里的建库和导入命令。导入完成后可以随便查一条SELECT s.session_id, f.title, r.room_name, s.show_date, s.start_time, s.price, s.available_seats FROM sessions s JOIN film f ON f.film_id s.film_id JOIN room r ON r.room_id s.room_id LIMIT 10;能查出数据说明 SQL 脚本没问题问题只会在应用层面。然后打开src/main/resources或config下的jdbc.properties把密码改成你自己 MySQL 的密码。如果 MySQL 版本是 8.0驱动类应该是com.mysql.cj.jdbc.Driver如果是 5.7写成com.mysql.jdbc.Driver也能跑。5.2 Maven 打包war 和 jar 决定 Tomcat 能不能启动项目根目录有pom.xml在 IDEA 里可以直接导入为 Maven 项目。命令行打包前先确认JAVA_HOME和MAVEN_HOME已配好执行mvn clean package -DskipTests打完后看target目录。如果你看到的是Movie_ticketing_system-1.0-SNAPSHOT.war直接丢到 Tomcat 的webapps下启动后访问项目路径即可。如果只看到.jar说明pom.xml里打包方式不是 warTomcat 不会把它当 Web 应用加载。此时要把pom.xml里这段改掉packagingwar/packaging同时检查javax.servlet-api和javax.servlet.jsp-api这两个依赖的scope是不是provided。如果是providedMaven 打包时不会把它们打进 warTomcat 启动时会用自己的实现这是正确做法如果你是本地用java -jar直接跑就会报NoClassDefFoundError: javax/servlet/http/HttpServlet。5.3 常见报错对照表在实际部署过程中下面的错误出现频率最高现象原因处理方式No suitable driver found for jdbc:mysql://...驱动没进WEB-INF/lib或 URL 有空格检查 dependenciesmvn clean package重新打 warThe server time zone value йʱ is unrecognizedMySQL 8 时区配置问题URL 末尾加serverTimezoneAsia/ShanghaiUnknown database cinema没执行建库命令CREATE DATABASE IF NOT EXISTS cinema DEFAULT CHARACTER SET utf8mb4;JSP 改了不生效Tomcat 缓存了旧 class停止 Tomcat删除work/Catalina/localhost/项目名目录再启动访问/buy返回 404Servlet 注解路径和表单 action 不一致检查WebServlet(/buy)和 JSP 里action${pageContext.request.contextPath}/buyJSP 改了不生效这个问题尤其常见。JSP 第一次访问时会被编译成 class 文件放在 Tomcat 的work目录下。如果 IDE 没做热部署旧 class 还在页面改多少次都不会变。删掉work对应目录比反复重启 Tomcat 更有效。6. 答辩演示现场可以做的三个加分验证6.1 登录接口的 SQL 注入对照很多课程设计的登录页面会直接拼接 SQLString sql SELECT * FROM users WHERE username name AND password pwd ;如果用户输入admin OR 11这段 SQL 就会永远成立这就是经典注入。项目中换成PreparedStatement后就会变成String sql SELECT * FROM users WHERE username ? AND password ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); }答辩时可以现场演示输入admin OR 11系统应当返回“用户名或密码错误”。解释时补一句?是通过 MySQL 二进制协议传值数据库会把它当成字符串数据而不是重新参与 SQL 语法解析。6.2 两段 SQL 验证超卖是否真的被拦住先查当前余票然后模拟两个终端同时买同一场次。最省事的办法是先用curl登录拿到 cookie再并发请求下单接口for i in seq 1 10; do curl -s -b cookie.txt \ -d sessionId5seatNo1排${i}座 \ http://localhost:8080/项目路径/buy done wait观察三个指标请求返回成功的数量、orders表新增记录数、sessions表剩余余票数。如果三个数一致事务和行锁就生效了。注意curl并发会触发真实的并发更新比在浏览器里点十次更有说服力。6.3 中文乱码的完整链路检查中文乱码通常出现在三个地方缺一不可。第一JSP 文件头部要写% page contentTypetext/html;charsetUTF-8 %第二Servlet 入口要写req.setCharacterEncoding(UTF-8)第三JDBC URL 要带useUnicodetruecharacterEncodingutf8。数据库建库时用utf8mb4而不是utf8否则生僻字和 emoji 会丢失。把这三件事在演示机上跑一遍登录框注入测试、两个用户抢同一场最后一张票、订单里的中文姓名或电影名不乱码。这三关过了这个毕业设计在答辩时基本就能拿得住追问。本文还有配套的精品资源点击获取
返回列表