十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

JavaWeb电影院在线购票系统:数据库设计与并发锁座实践

JavaWeb电影院在线购票系统:数据库设计与并发锁座实践 简介基于JavaWebJSPServlet构建的电影院在线购票系统毕业设计资源面向计算机相关专业学生及需要快速搭建课设/毕设项目的人员。项目不依赖主流框架采用Bootstrap前端配合JSPServlet实现覆盖用户注册登录、个人信息维护、影片分类筛选按类型、国家/地区、影片信息展示、按价格/时间查询票源、热门影片推荐、影院房间座位选择已售座位自动锁定、五颗星评分、用户评价、下单购票、历史订单查询以及普通用户与会员差异化优惠等完整业务流程可直接作为毕设演示与论文撰写的功能蓝本。压缩包约46.69MB包含项目源代码、SQL数据库脚本及配套毕业论文等文档文件类型以源码和文档为主压缩包内目录结构清晰便于快速导入IDE运行与二次开发。已有80人学习下载项目从用户端到后台管理均有完整实现是课程设计或毕业设计的实用参考。1. 基于javaweb电影院在线购票系统的边界与难点“基于javaweb电影院在线购票系统”这个名字在毕业设计里出现频率很高但它并不是“增删改查套模板”就能交差的项目。一个能通过答辩、敢写进简历的购票系统真正的难点集中在两块一是把电影、影厅、场次、座位这些实体之间的关系设计对二是在“同一场次、同一排、同一个座位”被两个人同时选中时系统还能保证不超卖、不重复出票。其余像用户注册、电影列表、订单查询本质上都是在为这两块核心逻辑做外围支撑。这篇文章按我搭这类系统时的顺序推进先定技术栈和架构再建数据库然后实现选座与下单流程最后落到本地部署、打包和论文主线组织。适合正在做毕设、或者第一次接触完整 javaweb 全链路项目的同学也适合已经能写简单 Servlet 但没处理过并发和事务的开发者。2. 为什么选传统 JavaWeb 而不是 Spring Boot2.1 三层架构与 MVC 在票务系统里的落位“javaweb”这个词在毕设语境里通常指的不是 Spring Boot而是 Servlet JSP JDBC 这套基础组合。它的价值在于把整个请求链路完全暴露在你面前浏览器把 HTTP 请求发给 TomcatTomcat 根据web.xml里的映射找到对应的 ServletServlet 调用 Service 层的方法Service 通过 DAO 访问 MySQL最后返回 JSP 渲染页面。这个链路里任何一环出问题都能直接看到不需要在一堆自动配置的注解背后猜。我一般会把项目切成分层结构严格遵循 MVCcontroller只做参数接收、调用 service、设置响应不写业务判断service承载下单、锁座、退票这类核心业务dao只执行 SQL返回数据对象filter统一编码、登录状态校验util数据库连接池、JSON 转换、时间处理JSP 可以出现在两个位置一种是直接用 JSP 渲染整个页面另一种是 JSP 里通过 Ajax 调 ServletServlet 返回 JSON由 JavaScript 动态更新页面。购票系统的选座页面建议走第二种因为座位图需要根据后端数据实时刷新纯服务端渲染会让页面交互显得很生硬。2.2 版本搭配与依赖清单传统 JavaWeb 项目最常见的坑在第一行依赖就埋好了。比如从网上复制一份 pom 或 lib 目录结果 Tomcat 10 配了javax.servlet一启动就报NoClassDefFoundError。这里的关键是Tomcat 10 之后包名从javax换成了jakarta而绝大多数毕设教程用的是 Tomcat 8.5/9 javax.servlet。我做这个系通时优先选这套组合踩坑少、教程多、兼容性最稳。组件建议版本选型原因JDK1.8毕业设计环境最通用Tomcat 8/9 完美支持Tomcat8.5 或 9.0使用 javax.servlet不存在包名迁移问题Servlet API4.0.xscope 设为 provided由 Tomcat 提供MySQL5.7 或 8.0utf8mb4 字符集支持完好MySQL Connector/J8.0.xdriverClass 写 com.mysql.cj.jdbc.DriverDruid1.2.x自带监控页面排错比 DBCP 直观Jackson2.13.x处理选座接口的 JSON 序列化如果用 Maven 管理pom.xml里最需要看清楚的依赖就这么几个dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.30/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.8/version /dependencyServlet API 必须用provided它的作用是编译时能找到HttpServlet但打包时不把 Tomcat 自己的类塞进 WAR否则部署时容易和容器自带的类冲突。MySQL Connector/J 8.x 的驱动类名是com.mysql.cj.jdbc.Driver连接串需要带时区和 SSL 参数例如jdbc:mysql://localhost:3306/cinema?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4characterEncodingutf8mb4比utf8更严谨它能存下 emoji 这类四字节字符电影海报备注别里偶尔会出现这种内容提前在 JDBC 层配好能少一次页面乱码排查。2.3 不用 SSM 的理由有同学会问为什么不直接用 Spring Boot 或 SSM对这个题目来说不是不能用而是传统 JavaWeb 更能体现你对底层机制的理解。SSM 帮你做了 IoC 和事务管理但毕设答辩老师经常追问“Service 层事务是怎么保证的”“HTTP 请求是怎么被分发到方法的”如果用传统 Servlet 手动提交事务这些问题的答案就在你的代码里一翻就能讲明白。另外一个实际原因是很多毕业设计源码给的确实就是“Servlet JSP JDBC MySQL”这套你按这个路线做遇到问题时能找到的参考资料和同学案例最多。Spring Boot 的自动配置对这个题目属于“过度设计”它解决的大多是微服务场景下的问题对一台 Tomcat 跑完的小系统没有本质收益。3. 数据库建模排片、场次与座位状态机3.1 核心表字段设计与建表 SQL购票系统的表结构比普通增删改查项目多一层“排片”概念。电影表和用户表比较简单真正决定系统上限的是schedule场次和seat座位这两张表。注意order是 MySQL 保留字直接用会报语法错误这也是为什么数据库表名普遍加t_前缀的原因之一。下面是核心表的建表脚本按此顺序执行可以避免外键检查干扰CREATE TABLE t_movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 电影名称, duration_min INT NOT NULL COMMENT 片长分钟, release_date DATE NOT NULL COMMENT 上映日期, cover VARCHAR(255) COMMENT 海报路径, description TEXT COMMENT 剧情简介, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上映 0下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_hall ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 影厅名如1号厅, row_count INT NOT NULL COMMENT 排数, col_count INT NOT NULL COMMENT 每排列数 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0, start_time DATETIME NOT NULL COMMENT 开场时间, end_time DATETIME NOT NULL COMMENT 散场时间, KEY idx_movie_start (movie_id, start_time), KEY idx_start_time (start_time), CONSTRAINT fk_schedule_movie FOREIGN KEY (movie_id) REFERENCES t_movie(id), CONSTRAINT fk_schedule_hall FOREIGN KEY (hall_id) REFERENCES t_hall(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_num INT NOT NULL, col_num INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1已售 2锁定, UNIQUE KEY uk_schedule_seat (schedule_id, row_num, col_num), CONSTRAINT fk_seat_schedule FOREIGN KEY (schedule_id) REFERENCES t_schedule(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expire_time DATETIME NOT NULL COMMENT 未支付订单过期时间, KEY idx_user (user_id), KEY idx_schedule (schedule_id), KEY idx_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_order_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, UNIQUE KEY uk_order_seat (order_id, seat_id), UNIQUE KEY uk_seat_order (seat_id, order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_seat表是按schedule_id生成一份快照每个场次有一份自己的座位状态。这样设计的原因很直接同一天同一个影厅可能排了三场不同的电影同一把座椅在不同的场次里互不影响用户名下不需要全局“座位号”而是“场次 座位”的组合。uk_schedule_seat唯一索引从数据库层面杜绝了同一场次里插入重复坐标的脏数据。3.2 座位 status 值0 可售 / 1 已售 / 2 锁定你可能好奇为什么座位状态需要三个值而不是“有/无”。原因在真实购票流程用户点了选座但还没下单或者下单后没付款这时座位既不能算“已售出”也不能放给其他人否则会出现两个人同时买到同一个座位的冲突。所以引入了“锁定”这个中间态。整个状态流转是一个小型的有限状态机0 可售 - 2 锁定用户选座或下单未支付 2 锁定 - 1 已售支付回调成功 2 锁定 - 0 可售订单超时取消或用户主动放弃status用TINYINT存不要用字符串一是减小数据量二是索引效率更高。代码里用常量类统一管理public final class SeatStatus { public static final int AVAILABLE 0; public static final int SOLD 1; public static final int LOCKED 2; }3.3 按场次查询可售座位的 SQL前端展示座位图时需要把某个场次的全部座位一次性查出来由前端根据状态值渲染颜色。SQL 写起来很简单但要注意带schedule_id条件避免串场SELECT id, row_num, col_num, status FROM t_seat WHERE schedule_id ? ORDER BY row_num ASC, col_num ASC;这个查询每次进选座页都会执行频繁点击时也没有必要加缓存数据量小、MySQL 查询速度在毫秒级加了缓存反而要处理“座位刚被锁定但页面还显示可售”的脏读问题。真正需要加索引的是idx_schedule用于快速过滤出场次下的座位集合。4. 核心实现选座接口、下单事务与并发处理4.1 前端座位图JSP 渲染与点击交互选座页面的渲染思路是Servlet 查询出场次信息和座位列表后转发到seat_select.jspJSP 用 JSTL 循环生成座位节点。每个座位用一个div表示带有>c:forEach varseat items${seatList} div classseat seat-status-${seat.status} >function toggleSeat(dom) { if (parseInt(dom.dataset.seatStatus) ! 0) { return; // 已售或已锁定的座位不可点 } let seatId dom.dataset.seatId; let idx selectedSeats.indexOf(seatId); if (idx 0) { selectedSeats.splice(idx, 1); dom.classList.remove(selected); } else { selectedSeats.push(seatId); dom.classList.add(selected); } document.getElementById(selectedInfo).textContent 已选 selectedSeats.length 个座位; }这段代码只负责与本地交互不直接改变后端状态。真正把座位“锁住”的动作发生在点击“去结算”按钮之后由 Ajax 请求/seat/lock完成。前端应该区分“选中”和“锁定”两个概念选中只是本地临时状态锁定才是对后端数据产生作用。4.2 条件 UPDATE 锁座位避免超卖锁座位是整个系统最核心的并发点也是答辩时最容易出彩的地方。常见的错误写法是先查再更SELECT id, status FROM t_seat WHERE id ? AND status 0; -- 判断 status 0然后执行 UPDATE UPDATE t_seat SET status 2 WHERE id ?;这种做法在单线程测试下完全正常但两个请求同时执行 SELECT 时都能查到status 0随后都执行 UPDATE最终两个订单对应同一张座位这就是超卖。解决思路是把“判断状态”和“修改状态”放到一条 UPDATE 里用行锁来保证原子性UPDATE t_seat SET status 2 WHERE schedule_id ? AND id IN (?, ?, ?) AND status 0这条 SQL 的语义是只在座位状态仍为 0 时才把它改成 2。MySQL InnoDB 在执行 UPDATE 时会锁住匹配的行第二个请求必须等第一个提交后才继续等它拿到锁时行的状态已经变成 2status 0条件不成立受影响行数为 0。通过对比受影响行数和请求锁定的座位数量就能判断是否发生冲突。对应的 DAO 方法如下public int lockSeats(Connection conn, Long scheduleId, ListLong seatIds) throws SQLException { StringBuilder sql new StringBuilder( UPDATE t_seat SET status 2 WHERE schedule_id ? AND status 0 AND id IN (); for (int i 0; i seatIds.size(); i) { sql.append(?); if (i seatIds.size() - 1) sql.append(,); } sql.append()); try (PreparedStatement ps conn.prepareStatement(sql.toString())) { int idx 1; ps.setLong(idx, scheduleId); for (Long id : seatIds) { ps.setLong(idx, id); } return ps.executeUpdate(); } }调用方拿返回值与seatIds.size()比较不相等就说明有座位被其他人抢先锁定这时直接回滚事务并提示用户刷新重选。这里不再需要 synchronized 或分布式锁数据库的行锁已经替我们做了最可靠的控制。4.3 下单事务先锁座位再插订单锁座位不能单独存在必须和创建订单放在同一个事务里。否则会出现座位锁了但订单没生成或者订单生成了但座位状态没变的中间态。Servlet 层的核心逻辑WebServlet(/seat/lock) public class SeatLockServlet extends HttpServlet { private final SeatDao seatDao new SeatDao(); private final OrderDao orderDao new OrderDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { Long scheduleId Long.valueOf(req.getParameter(scheduleId)); String[] seatIdArr req.getParameter(seatIds).split(,); ListLong seatIds new ArrayList(); for (String s : seatIdArr) seatIds.add(Long.valueOf(s)); Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); int rows seatDao.lockSeats(conn, scheduleId, seatIds); if (rows ! seatIds.size()) { conn.rollback(); writeJson(resp, Result.fail(有座位已被他人锁定请刷新后重试)); return; } String orderNo OrderNoGenerator.next(); BigDecimal total scheduleDao.getPrice(conn, scheduleId) .multiply(BigDecimal.valueOf(seatIds.size())); orderDao.insert(conn, orderNo, userId, scheduleId, total); conn.commit(); writeJson(resp, Result.ok(orderNo)); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } log.error(lock seats failed, e); resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } finally { DBUtil.close(conn); } } }流程拆开看是四个动作开事务 - 条件锁座 - 插入订单 - 提交。锁座失败直接回滚不会留下半张订单插入订单失败回滚也会把已经锁住的座位恢复为可售。这里有一个要注意的地方应用层拿到Connection后必须手动setAutoCommit(false)如果忘记这一步每条 SQL 都是独立提交锁座和插订单之间一旦抛异常数据库里就会出现“座位锁了但没订单”的脏数据。4.4 支付回调的幂等处理用户支付成功后的回调处理也是容易出问题的点。实际项目中支付平台可能因为网络重试把同一个回调通知发送两次甚至多次。如果回调处理逻辑没有做幂等第二次回调会把订单状态从已支付改成其他值或者把座位状态重复回滚结果就是用户付了钱但座位没了。传统 JavaWeb 里最简单也最可靠的幂等写法是用条件 UPDATEUPDATE t_order SET status 1, paid_time NOW() WHERE order_no ? AND status 0受影响行数为 1说明本次回调正常完成了“待支付 - 已支付”的流转受影响行数为 0说明订单已经不是待支付状态可能是重复回调也可能是订单已取消。两种情况都不需要再执行业务逻辑直接返回成功即可。配合座位状态更新时再把订单里的座位从 2 改为 1整个过程都在同一个事务内完成保证“订单已支付”和“座位已售出”永远同时成立。5. 本地部署、WAR 包与毕业设计论文的组织顺序5.1 Tomcat 部署路径与编码参数毕业设计源码拿到手后最常见的问题不是代码写错而是环境不一致导致跑不起来。先看两个最容易翻车的点JDK 和 Tomcat 版本必须匹配javax.servlet与jakarta.servlet不能混用MySQL 驱动版本必须与连接串里的driverClass一致。如果你用 IDEA直接在 Run Configuration 里配 Tomcat ServerDeployment 里添加war exploded以这种方式运行不需要打 WAR 包启动速度快适合调试。如果最后要交付一个可部署的包用 Maven 在项目根目录执行mvn clean package -DskipTests构建产物在target/下文件名类似cinema.war。把这个文件复制到 Tomcat 的webapps/目录启动 Tomcat 后它会自动解压部署。Windows 下启动startup.batLinux 下一般是bin/catalina.sh start访问路径是http://localhost:8080/cinema/其中cinema就是 WAR 包的前缀也就是应用的 context path。如果觉得带前缀不美观可以把 WAR 包改名为ROOT.war或者在conf/server.xml里给 Host 加一个 Context 配置但毕业设计通常没必要多这一步。中文乱码是另一个高频问题。JSP 文件本身要保证pageEncodingUTF-8同时建议在 Tomcat 的bin/catalina.sh里加上JAVA_OPTS$JAVA_OPTS -Dfile.encodingUTF-8Windows 下对应set JAVA_OPTS-Dfile.encodingUTF-8。这一步能避免控制台日志和输出文件乱码但它不能解决 HTTP 请求参数的编码问题后者需要在 Filter 里统一处理这个 Filter 一般在源码包里已经有现成的。5.2 数据库初始化命令源码包里的 SQL 文件通常叫cinema.sql或init.sql导入时要注意先建库还是直接用脚本里的建库语句。稳妥的做法是登录 MySQL 后先建库再指定字符集导入mysql -uroot -p --default-character-setutf8mb4进入命令行后执行CREATE DATABASE cinema DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE cinema; SOURCE /path/to/cinema.sql;--default-character-setutf8mb4很重要。Windows 命令行默认可能是 GBK如果不指定字符集SQL 文件里的中文备注导入后会变成问号页面显示的全是????排查起来非常容易绕弯路。另外如果你的源码 SQL 文件是 UTF-8 编码导入前确认文件本身没有 BOM 头否则第一行建表语句可能因为 BOM 字符而报语法错误用编辑器另存为无 BOM 的 UTF-8 即可解决。5.3 论文里的技术主线与测试用例表论文部分不需要写成项目使用说明书而是要沿着“需求分析 - 总体设计 - 数据库设计 - 详细设计 - 系统实现 - 系统测试”这条线把技术决策讲清楚。我见过很多论文在这部分失效原因是大量截图放功能介绍却没有回答“为什么这么设计”。有一个核心技巧把并发控制写进“系统实现”章。比如条件 UPDATE 锁座位的代码配上执行过程说明比十张页面截图都更能体现工作量。测试部分则不要写“功能正常”而是给有验证价值的用例表用例编号场景前置条件操作预期结果TC-01座位并发锁定两个账号同一场次同时锁定同一座位一个成功另一个提示座位已被锁定TC-02订单超时释放订单状态为待支付且已过期等待定时任务执行座位状态由锁定恢复为可售TC-03支付回调重复订单已支付模拟回调发送两次第二次不影响订单状态座位不重复回滚这组用例既覆盖了系统最核心的业务规则又能让答辩老师快速理解你解决的技术难点。哪怕系统页面做得朴素一点用例里的“并发锁定”“幂等处理”这些关键词也足以把项目的技术含金量撑起来。6. 进阶技巧订单超时自动释放锁定座位很多同学做到“锁座 下单 支付”就停了但一个完整可用的购票系统还需要处理一个真实业务场景用户锁定了座位、生成了待支付订单然后关掉页面不付款。如果系统不做超时释放这个座位会被一直占着其他用户永远买不到。常见的处理方式是定时任务扫描未支付订单发现超时就把座位恢复为可售同时把订单置为已取消。实现上不需要引入 QuartzJDK 自带的ScheduledExecutorService足够public class ExpireOrderTask implements Runnable { Override public void run() { try (Connection conn DBUtil.getConnection()) { conn.setAutoCommit(false); // 查出所有已过期的待支付订单并把状态改为取消 ListLong expiredOrderIds orderDao.findExpiredIds(conn); if (expiredOrderIds.isEmpty()) { conn.rollback(); return; } orderDao.cancelByIds(conn, expiredOrderIds); // 一次性把对应座位的状态改回可售 int rows seatDao.releaseByOrderIds(conn, expiredOrderIds); conn.commit(); log.info(released {} seats, orders: {}, rows, expiredOrderIds.size()); } catch (Exception e) { log.error(release expired orders failed, e); } } }定时任务在 ServletContextListener 里启动WebListener public class ExpireTaskListener implements ServletContextListener { private ScheduledExecutorService scheduler; Override public void contextInitialized(ServletContextEvent sce) { scheduler Executors.newScheduledThreadPool(1); scheduler.scheduleWithFixedDelay(new ExpireOrderTask(), 30, 30, TimeUnit.SECONDS); } Override public void contextDestroyed(ServletContextEvent sce) { scheduler.shutdownNow(); } }scheduleWithFixedDelay的第一个参数是第一次执行的延迟第二个是每次任务结束到下次开始的间隔。对于毕设系统30 秒扫一次足够了不需要配置更小的间隔。释放座位的 SQL 也是单条完成避免先查订单再循环更新座位UPDATE t_seat s JOIN t_order_seat os ON s.id os.seat_id JOIN t_order o ON o.id os.order_id SET s.status 0 WHERE o.status 0 AND o.expire_time NOW()这里不需要像锁座那样带AND s.status 2条件吗安全起见加上更好。这能防止同时有其他流程已经把座位改成已售的情况下定时任务又把已支付的座位改回可售。所以上面的 DAO 实现里releaseByOrderIds的 SQL 应该额外加AND s.status 2保证状态只能从“锁定”回到“可售”。验证这个功能不需要真的等 15 分钟直接把某条订单的expire_time改成过去时间然后观察日志即可UPDATE t_order SET expire_time NOW() - INTERVAL 1 MINUTE WHERE id ?;下一轮定时任务执行时对应座位会从锁定恢复为可售前端重新读取座位列表就能看到变化。调试时建议在 ExpireOrderTask 里打一行日志输出本次释放的座位数和订单数这样能快速确认定时任务有没有被正确调度。如果日志没输出先检查web.xml或WebListener注解是否被 Tomcat 扫描到再看代码里有没有因为编码问题导致类加载失败。这个经验对线上系统同样适用订单超时释放的逻辑一定要独立于用户请求链路不能在“支付”接口里顺手做否则高并发下用户付款时会因为排队而体验很差。把释放动作交给后台任务用户请求只做最轻量的状态检查才是这个功能该有的姿态。本文还有配套的精品资源点击获取
返回列表