☰
电影院售票系统:JavaWeb并发选座与事务回滚实战解析
2026/10/7 20:44:05 网站建设 项目流程

简介:这是一份面向Java学习者与毕业设计场景的电影院售票管理系统完整源码包。系统采用Java+Servlet+JSP+JDBC+MySQL技术栈,基于B/S架构实现电影信息管理、场次排期、座位选择、订单生成、用户管理及后台数据统计等核心功能,覆盖从购票到票务处理的全流程,适合用于课程设计、期末大作业或JavaWeb入门实践。资源包共103个文件,约2.31MB,包含23个Java源文件、23个class文件、10个XML配置、8个SQL数据库脚本及项目说明文档,其中源码附有详细注释,方便对照理解分层结构;SQL脚本可直接初始化数据库,XML与properties等配置有助于掌握项目部署。目前已有86人学习下载。通过该项目可系统练习Servlet与JSP交互、JDBC数据库操作、会话管理及权限控制等关键知识点,也能参考其界面设计与功能模块划分,快速搭建同类管理系统。

1. 电影院售票管理系统(Java+Servlet+JSP+JDBC+Mysql):一个被高估又常被做砸的经典 JavaWeb 项目

很多人把这部电影院售票管理系统当成 Java+Servlet+JSP+JDBC+Mysql 的入门 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个容易被忽略的参数:所有表都用 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,再写一个静态工具类读取它:

# 连接池基础配置 driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/cinema?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=123456 # 初始连接数 空闲时保持 5 条 initialSize=5 # 最大连接数 maxActive=20 # 获取连接超时时间 maxWait=3000 # 心跳校验 SQL validationQuery=SELECT 1

参数里最值得调的是三个:initialSize 是启动时预先建立的连接数,5 足够;maxActive 是池子上限,课程设计 20 就够,别贪大,开 200 只会让 MySQL 压力变大;maxWait 是排队拿连接的超时时间,3000 毫秒比较合理,超过就抛异常,避免请求无限挂起。url 里的 serverTimezone 必须设,否则高版本 MySQL 驱动会报时区错误;useSSL=false 是因为本地开发没有证书,配置后能少一个警告。注意 MySQL 8 的驱动类名是 com.mysql.cj.jdbc.Driver,旧版 com.mysql.jdbc.Driver 只是兼容转发,不建议再写。

3.2 从 PreparedStatement 到参数绑定:查询接口怎么才算干净

DAO 层最常见的脏代码是字符串拼接 SQL。既然标题明确用了 JDBC 而不是 MyBatis,那 PreparedStatement 的预编译和 ? 占位符就是必须守住的底线。下面是一个查询场次列表的典型写法:

public List<Schedule> 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()) { List<Schedule> 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-resources,Connection、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 = 0:MySQL 在 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;charset=UTF-8"); String method = req.getParameter("method"); if ("list".equals(method)) { List<Schedule> 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 上加 URIEncoding="UTF-8"。注意 Servlet 是单例多线程的,成员变量里只放 Service 这类无状态对象,绝对不要放每个请求自己的数据,否则并发时数据会互相串。

4.2 用一个 DispatcherServlet 统一收口:action 参数与反射分发

项目大了以后,每个模块建一个 Servlet 会形成几十个类,维护成本高。常见做法是写一个 DispatcherServlet 做前端控制器,action 参数指定处理类,用反射调方法。这是从 servlet demo 过渡到项目化组织的关键一步:

@WebServlet("/action/*") public class DispatcherServlet extends HttpServlet { private Map<String, 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?action=orderDetail&orderNo=" + 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。两个请求先后查到 status=0,判断都通过,于是都去 UPDATE,没有条件约束,谁也拦不住谁。

解决:把判断下沉到 UPDATE 的 WHERE 条件里,UPDATE schedule_seat SET status=1 WHERE schedule_id=? AND seat_id=? AND status=0,影响行数为 0 即视为失败;同时保留 schedule_id + seat_id 唯一索引兜底。这是 3.3 里事务写法的前提,顺序不能反:先拿连接设 autoCommit(false),再执行 UPDATE。

5.2 订单超时未支付,座位一直锁死

现象:用户锁了座位不付款,锁座永远不释放,能卖的座位越来越少。

原因:锁座只在创建订单时做了,没有“超时释放”机制。有人会在应用里起一个后台线程定时清,看起来很省事,但 Web 应用重启线程就没了,多节点部署会重复扫,容易把别人刚锁的座位误释放。

解决:给 orders 表加一个 create_time 索引,起一个定时任务(课程设计用 Timer 或 Spring 的 @Scheduled 都可以)每 30 秒扫描一次 status=0 且 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 里加上 serverTimezone=Asia/Shanghai,就算服务器时区是 UTC 也不会错位。

5.4 订单取消后座位释放不在同一个事务里

现象:用户主动取消订单,一分钟后又来买同一场次,发现座位还是锁着;到数据库查 schedule_seat,status 还停在 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?action=seatMap&scheduleId=' + 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 秒才平衡了体验与性能。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询