ARTICLE DETAIL

资讯详情

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

电影院售票系统:JavaWeb并发选座与事务回滚实战解析

电影院售票系统:JavaWeb并发选座与事务回滚实战解析 简介这是一份面向Java学习者与毕业设计场景的电影院售票管理系统完整源码包。系统采用JavaServletJSPJDBCMySQL技术栈基于B/S架构实现电影信息管理、场次排期、座位选择、订单生成、用户管理及后台数据统计等核心功能覆盖从购票到票务处理的全流程适合用于课程设计、期末大作业或JavaWeb入门实践。资源包共103个文件约2.31MB包含23个Java源文件、23个class文件、10个XML配置、8个SQL数据库脚本及项目说明文档其中源码附有详细注释方便对照理解分层结构SQL脚本可直接初始化数据库XML与properties等配置有助于掌握项目部署。目前已有86人学习下载。通过该项目可系统练习Servlet与JSP交互、JDBC数据库操作、会话管理及权限控制等关键知识点也能参考其界面设计与功能模块划分快速搭建同类管理系统。1. 电影院售票管理系统JavaServletJSPJDBCMysql一个被高估又常被做砸的经典 JavaWeb 项目很多人把这部电影院售票管理系统当成 JavaServletJSPJDBCMysql 的入门 CRUD真正动手才发现翻车点全在并发选座和订单状态流转上同一场次的同一个座位被两个人同时锁定、订单支付超时后座位迟迟不释放、凌晨场次的排片算到了前一天。它解决的其实是一个完整的售票闭环——排片管理、选座锁座、订单生成、支付状态回写缺一环都会让系统在真实场景里没法用。这个项目适合刚学完 Java 基础、想通过 Servlet/JSP 验证分层能力的学生也适合需要快速搭业务系统 demo 的工程师。下面从数据模型开始把它拆成一个能照着复现的完整方案先建表再手写 JDBC 连接与事务用 Servlet 收口请求最后补上并发和状态流转的坑。2. 电影院售票管理系统的数据模型四张核心表如何支撑排片、锁座与结算2.1 为什么座位状态挂在“场次”上而不是挂在“影厅”上很多新手会把 seat 表设计成 hall 表的子表一个影厅 200 个座位然后纠结“选没选过怎么记”。问题在于同一个座位在 14:00 这场是可售的在 19:00 这场可能已经卖掉了。座位本身是静态资源动态的是“场次 座位”这个组合。常见做法是把影厅座位做成一维编号表再建一张 schedule_seat 关系表用场次 ID 座位 ID 做联合主键状态字段单独放。CREATE TABLE film ( id int NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 片名, duration int NOT NULL DEFAULT 120 COMMENT 时长(分钟), release_date date DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE hall ( id int NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 影厅名, row_count int NOT NULL COMMENT 排数, col_count int NOT NULL COMMENT 每排座位数, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seat ( id int NOT NULL AUTO_INCREMENT, hall_id int NOT NULL, row_no int NOT NULL, col_no int NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_hall_seat (hall_id, row_no, col_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE schedule ( id int NOT NULL AUTO_INCREMENT, film_id int NOT NULL, hall_id int NOT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, price decimal(8,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), KEY idx_film (film_id), KEY idx_hall_time (hall_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE schedule_seat ( id int NOT NULL AUTO_INCREMENT, schedule_id int NOT NULL, seat_id int NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, PRIMARY KEY (id), UNIQUE KEY uk_schedule_seat (schedule_id, seat_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id int NOT NULL, schedule_id int NOT NULL, seat_ids varchar(255) NOT NULL COMMENT 座位快照, amount decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个容易被忽略的参数所有表都用 InnoDB才能支持后面要讲的行锁和事务字符集用 utf8mb4 而不是 utf8是因为 MySQL 的 utf8 实际只存三字节遇到 emoji 或生僻字会直接报错。schedule_seat 用 schedule_id seat_id 做唯一键这是并发场景的兜底约束靠代码判空是不够的。orders 表没有把每个座位拆成子表而是用 seat_ids 存逗号分隔的座位快照简化对账逻辑这是课程设计和真实小规模项目中都能接受的方案。2.2 订单状态为什么只用三个值常量类与状态流转约定订单只有待支付、已支付、已取消三种状态够不够够的。取票、退款、改签这些业务在这个系统里不会被实现状态越多出 bug 的概率越大。常见误区是把状态含义写进数据库注释代码里到处用数字字面量比如 if (status 1)半年后没人知道 1 是什么。我一般会在 common 包里放一个订单状态常量类页面和 Service 都引用它public final class OrderStatus { public static final int PENDING_PAY 0; // 待支付座位锁定 public static final int PAID 1; // 已支付座位售出 public static final int CANCELED 2; // 已取消座位释放 private OrderStatus() {} }这里要定两个约定锁座发生在订单创建时所以 PENDING_PAY 意味着 schedule_seat.status 必须是 1锁定只有当订单变成 PAID座位才是 2已售。取消订单时要把订单改成 CANCELED 并把座位改回 0这两个操作必须在同一个事务里。如果只在代码里随手改一处就会出现“订单已取消、座位永远锁死”的脏数据后面会专门讲这个坑。2.3 预置排片与座位数据一条 INSERT…SELECT 刷全场的做法没有管理员后台时排片数据怎么进去手工一条一条 INSERT 太容易错。常见做法是写一个 Java 的初始化方法启动时生成未来三天的排片再批量把影厅座位复制成 schedule_seat。生成排片时要注意同一影厅时间不能重叠最简单的规则是新场次的 start_time 必须大于该影厅上一场已排场次的 end_time中间留 30 分钟保洁。生成完 schedule 之后把座位复制到 schedule_seat 用一条 SQL 就能完成-- 为所有已生成的场次初始化座位记录状态一律为 0可售 INSERT INTO schedule_seat (schedule_id, seat_id, status) SELECT s.id, seat.id, 0 FROM schedule s JOIN hall h ON s.hall_id h.id JOIN seat ON seat.hall_id h.id;注意这条 SQL 的幂等性schedule_seat 上有联合唯一键重复执行会报重复键错误所以只能跑一次。我的习惯是把它放在一个大事务里任何一步失败就全部回滚避免出现“排片生成了一半、座位只刷了一半”的半成品数据。联调阶段这个方案比反复登录后台造数据省事得多。3. 用 JDBC 手写 DAO 层连接池参数、防注入与事务提交3.1 连接池用哪家Druid 配置与三个必调参数JDBC 裸写的第一课是永远不要自己 new Connection。常见做法是用 Druid它的监控面板对排查“连接池打满”很有帮助这也是 java 面试题里经常被追问的点。在 src 或 resources 下放一个 druid.properties再写一个静态工具类读取它# 连接池基础配置 driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse usernameroot password123456 # 初始连接数 空闲时保持 5 条 initialSize5 # 最大连接数 maxActive20 # 获取连接超时时间 maxWait3000 # 心跳校验 SQL validationQuerySELECT 1参数里最值得调的是三个initialSize 是启动时预先建立的连接数5 足够maxActive 是池子上限课程设计 20 就够别贪大开 200 只会让 MySQL 压力变大maxWait 是排队拿连接的超时时间3000 毫秒比较合理超过就抛异常避免请求无限挂起。url 里的 serverTimezone 必须设否则高版本 MySQL 驱动会报时区错误useSSLfalse 是因为本地开发没有证书配置后能少一个警告。注意 MySQL 8 的驱动类名是 com.mysql.cj.jdbc.Driver旧版 com.mysql.jdbc.Driver 只是兼容转发不建议再写。3.2 从 PreparedStatement 到参数绑定查询接口怎么才算干净DAO 层最常见的脏代码是字符串拼接 SQL。既然标题明确用了 JDBC 而不是 MyBatis那 PreparedStatement 的预编译和 ? 占位符就是必须守住的底线。下面是一个查询场次列表的典型写法public ListSchedule findValidSchedules(int filmId, Date date) { String sql SELECT id, film_id, hall_id, start_time, end_time, price FROM schedule WHERE film_id ? AND DATE(start_time) ? ORDER BY start_time; // try-with-resources 自动关闭三层资源避免连接泄漏 try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, filmId); ps.setDate(2, new java.sql.Date(date.getTime())); try (ResultSet rs ps.executeQuery()) { ListSchedule list new ArrayList(); while (rs.next()) { Schedule s new Schedule(); s.setId(rs.getInt(id)); s.setPrice(rs.getBigDecimal(price)); list.add(s); } return list; } } catch (SQLException e) { throw new RuntimeException(查询排片失败, e); } }这里用了 try-with-resourcesConnection、PreparedStatement、ResultSet 三层的关闭都不用手动写 finally这在 JDK 7 之后是标准做法也是避免连接泄漏最省心的手段。日期参数不要拼成 WHERE start_time LIKE 2025-01-01%用 DATE(start_time) ? 交给驱动按日期类型绑定MySQL 能走索引如果表量大性能敏感再改成 start_time ? AND start_time DATE_ADD(?, INTERVAL 1 DAY) 的范围写法。对列做函数包裹会让索引失效这是常见误用。3.3 一个事务方法选座落库为什么必须手动 commit页面里点击“选座并提交订单”后端至少要做三件事锁定座位、生成订单、返回订单号。这三件事有前后依赖中间绝不能夹着一次隐式提交否则座位锁了订单却没建成。下面这个方法把事务边界定在一个 JDBC 方法里public String lockSeatsAndCreateOrder(int userId, int scheduleId, int[] seatIds) { String orderNo C System.currentTimeMillis(); Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 一个事务从这开始 String lockSql UPDATE schedule_seat SET status 1 WHERE schedule_id ? AND seat_id ? AND status 0; try (PreparedStatement ps conn.prepareStatement(lockSql)) { for (int seatId : seatIds) { ps.setInt(1, scheduleId); ps.setInt(2, seatId); ps.addBatch(); } int[] rows ps.executeBatch(); for (int row : rows) { if (row ! 1) throw new RuntimeException(座位已被他人锁定); } } String orderSql INSERT INTO orders(order_no, user_id, schedule_id, seat_ids, amount, status) VALUES (?, ?, ?, ?, (SELECT price * ? FROM schedule WHERE id ?), 0); try (PreparedStatement ps conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, orderNo); ps.setInt(2, userId); ps.setInt(3, scheduleId); ps.setString(4, joinSeatIds(seatIds)); ps.setInt(5, seatIds.length); ps.setInt(6, scheduleId); ps.executeUpdate(); } conn.commit(); // 锁座 下单一起提交 return orderNo; } catch (Exception e) { conn.rollback(); throw new RuntimeException(锁定座位失败, e); } finally { conn.setAutoCommit(true); conn.close(); } }这个方法的要点在 UPDATE 语句的条件里带了 status 0MySQL 在 InnoDB 下执行这条更新时会对命中的行加排他锁第二个请求更新同一行会阻塞第一个事务提交后第二个请求发现不满足 WHERE 条件影响行数为 0于是回滚。这就是不依赖先查后改也能防并发超卖的方式也是 mysql 锁的分类里最常用的“条件更新”手段。注意金额不能放在订单表上让前端传而是在 INSERT 里用子查询从 schedule 表取单价再乘座位数这是防篡改的基本功。4. Servlet 当控制器从 HttpServlet 到前端控制器的路由演进4.1 Servlet 怎么接到请求注解映射与生命周期的关系Servlet 在传统 JavaWeb 里有三层职责接参、调 Service、转发页面。很多人第一次跑 servlet demo 只会在 doGet 里打印一行 Hello那不算入门真正能用的是搞明白 doGet 和 doPost 的分工。下面的 FilmServlet 按 method 参数区分查询和下单两个动作WebServlet(/film/*) public class FilmServlet extends HttpServlet { private ScheduleService scheduleService new ScheduleService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String method req.getParameter(method); if (list.equals(method)) { ListSchedule list scheduleService.findTodaySchedules(); req.setAttribute(schedules, list); // 服务端跳转数据放在 request 里 req.getRequestDispatcher(/WEB-INF/jsp/schedule_list.jsp).forward(req, resp); } else { resp.sendError(400); } } }用 WebServlet 注解可以省掉 web.xml 里的 和 两段配置路径 /film/* 表示匹配 /film、/film/list 这一类请求method 参数再细分业务。req.setCharacterEncoding(UTF-8) 只对 POST 请求体有效GET 参数的中文乱码要靠 Tomcat 的 URIEncoding 配置常见做法是在 server.xml 的 Connector 上加 URIEncodingUTF-8。注意 Servlet 是单例多线程的成员变量里只放 Service 这类无状态对象绝对不要放每个请求自己的数据否则并发时数据会互相串。4.2 用一个 DispatcherServlet 统一收口action 参数与反射分发项目大了以后每个模块建一个 Servlet 会形成几十个类维护成本高。常见做法是写一个 DispatcherServlet 做前端控制器action 参数指定处理类用反射调方法。这是从 servlet demo 过渡到项目化组织的关键一步WebServlet(/action/*) public class DispatcherServlet extends HttpServlet { private MapString, Object actions new HashMap(); Override public void init() { // 启动时注册业务 Action避免每次请求都拼类名反射 actions.put(scheduleList, new ScheduleAction()); actions.put(lockSeat, new OrderAction()); } Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String actionName req.getParameter(action); Object action actions.get(actionName); if (action null) { resp.sendError(404); return; } try { Method m action.getClass().getMethod(execute, HttpServletRequest.class, HttpServletResponse.class); m.invoke(action, req, resp); } catch (Exception e) { req.setAttribute(error, e.getMessage()); req.getRequestDispatcher(/WEB-INF/jsp/error.jsp).forward(req, resp); } } }这里用 actions 这个 Map 做注册表把 action 名映射到处理类避免每次请求都走反射再拼一次类名。统一收口的好处是过滤器、字符编码、异常处理都只写一遍坏处是如果以后要接 Spring MVC路由思想要从“前端控制器 action 参数”转向“注解 方法映射”理解这套分发逻辑后迁移会更快。注意覆盖的是 service 方法它会拦截所有请求方法doGet/doPost 的区分可以让具体的 Action 自己处理这样 JSP 里表单和 AJAX 都能走同一个入口。4.3 转发还是重定向售票页刷新为什么不能重复下单选座成功后跳转页面用 forward 还是 sendRedirect初学者经常混淆。forward 是服务端内部跳转地址栏不变刷新页面会再次执行下单逻辑结果就是 F5 一下又生成一单sendRedirect 是浏览器重新发起 GET 请求地址栏变成新地址刷新只重刷结果页。所以订单这类写操作成功后必须重定向try { String orderNo orderService.lockSeatsAndCreateOrder(userId, scheduleId, seatIds); // 下单成功重定向到查询详情避免刷新重复提交 resp.sendRedirect(req.getContextPath() /action?actionorderDetailorderNo orderNo); } catch (Exception e) { req.setAttribute(error, e.getMessage()); req.getRequestDispatcher(/WEB-INF/jsp/order_fail.jsp).forward(req, resp); }重定向之后订单详情页是幂等的怎么刷都是查订单不会再锁座。失败页面则用 forward因为失败信息是一次性提示刷不刷都不影响业务如果用重定向就得把 error 拼进 URL难看又容易泄露内部细节。这个取舍是所有 Web 框架里 POST-Redirect-GET 模式的核心理解了它后面学 Spring MVC 的 RedirectAttributes 会非常顺。5. 售票系统避坑排查并发超卖、状态回滚、日期边界与脏数据5.1 两个人同时锁同一个座位都显示成功现象两个浏览器同时点同一个座位后端校验“未售出”都通过最后两人都收到订单超卖。原因最常见的写法是先 SELECT 查 status再在内存里判断最后 UPDATE。两个请求先后查到 status0判断都通过于是都去 UPDATE没有条件约束谁也拦不住谁。解决把判断下沉到 UPDATE 的 WHERE 条件里UPDATE schedule_seat SET status1 WHERE schedule_id? AND seat_id? AND status0影响行数为 0 即视为失败同时保留 schedule_id seat_id 唯一索引兜底。这是 3.3 里事务写法的前提顺序不能反先拿连接设 autoCommit(false)再执行 UPDATE。5.2 订单超时未支付座位一直锁死现象用户锁了座位不付款锁座永远不释放能卖的座位越来越少。原因锁座只在创建订单时做了没有“超时释放”机制。有人会在应用里起一个后台线程定时清看起来很省事但 Web 应用重启线程就没了多节点部署会重复扫容易把别人刚锁的座位误释放。解决给 orders 表加一个 create_time 索引起一个定时任务课程设计用 Timer 或 Spring 的 Scheduled 都可以每 30 秒扫描一次 status0 且 create_time 超过 15 分钟的订单改成 CANCELED并把对应 schedule_seat 改回 0。真正线上系统会用延迟队列或订单关闭服务原理一样都是“到期关单并回滚资源”。5.3 DATETIME 的日期边界凌晨场次的排片算到前一天现象排片那天下午生成的数据在页面看不到检查数据库发现 start_time 正常但列表查询用字符串匹配日期凌晨场次被漏掉。原因DATE(start_time) 与字符串比较在不同时区、不同连接串下表现不一致尤其是 serverTimezone 没配置时驱动会把 DATETIME 转成本地时区再比较。解决统一用参数化日期范围避免对列做函数计算后和字符串拼接SELECT ... WHERE start_time ? AND start_time DATE_ADD(?, INTERVAL 1 DAY)参数用 java.sql.Date。同时在 url 里加上 serverTimezoneAsia/Shanghai就算服务器时区是 UTC 也不会错位。5.4 订单取消后座位释放不在同一个事务里现象用户主动取消订单一分钟后又来买同一场次发现座位还是锁着到数据库查 schedule_seatstatus 还停在 1。原因取消订单的代码分散在两层先改了 orders 再单独 UPDATE schedule_seat中间抛异常没有回滚或者两次操作用了不同的 Connection。解决把“改订单状态 释放座位”放进同一个事务方法上只允许一个 DAO 方法执行完再执行另一个不让 JSP 或 Servlet 直接跨表改数据。排查这个坑最快的办法是把事务隔离级别设为已提交读再在日志里确认两个 UPDATE 之间没有隐式提交。5.5 连接数打满页面等待后数据库全部超时现象并发测试只有 50 个用户系统整体卡死日志全是 wait timeout。原因某个 DAO 方法里忘记 close或者 catch 里没关Druid 的连接池被占光后续请求全部等在 maxWait 上。解决排查看 Druid 监控页的活跃连接数和 SQL 执行时间找出没释放连接的方法代码层面统一改用 try-with-resources把 Connection 放在 try 的小括号里异常路径也会自动关闭。血泪经验是连接泄漏在低并发时完全看不出一旦上线流量一起来就爆。6. 用 JSP EL JSTL 做售票页AJAX 查座与短缓存更新的一个落地技巧6.1 2 秒缓存座位状态读接口的平衡点JSP 是这套系统唯一的视图层。排片页用 c:forEach 渲染场次列表很简单真正麻烦的是选座页点击场次要把座位状态实时拉下来又不能每点一下都打一次 MySQL。我的习惯是给查座接口加一个“2 秒本地缓存”用 ConcurrentHashMap 记录 scheduleId 到座位状态和缓存时间2 秒内命中直接返回 JSON超过 2 秒才查库。技巧的关键是读接口可以稍微滞后写接口绝不能走缓存否则会出现界面显示可售、提交却失败的体验问题。// 点击场次后加载座位状态数据为 0/1/2 数组 function loadSeats(scheduleId) { fetch(/action?actionseatMapscheduleId scheduleId) .then(resp resp.json()) .then(data renderSeats(data)); }AJAX 拿到 JSON 数组后前端根据值渲染成可点击、灰锁、红色已售三种样式提交时把 seatIds 拼成逗号串 POST 给下单接口失败则通过 action 统一错误页回显。这个方案在课程设计里够用且好解释2 秒缓存几乎不会带来用户可感知的延迟却能让座位状态查询的数据库压力下降一个量级。6.2 验证这套系统的三个步骤做完以后别急着交按下面三步自测第一并发锁座验证开两个浏览器同时点同一个座位断言只有一个请求能成功下单另一个提示“座位已被他人锁定”第二超时释放验证手工把某条订单的 create_time 改到 16 分钟前等定时任务跑完查 schedule_seat 是否恢复为 0第三日期边界验证生成一场 00:30 的场次查当天排片列表是否包含它。这三步能覆盖这个项目 80% 的隐藏 bug。我最后一次做这个项目时把缓存时间设成 5 秒就翻过车——用户付完款马上刷新界面还是“已锁定”被误以为没买到后来固定成 2 秒才平衡了体验与性能。希望帮到你。本文还有配套的精品资源点击获取
返回列表