基于Java的电影院购票系统:从SQL设计到事务并发实战解析
2026/9/10 15:26:24 网站建设 项目流程

简介:基于 Java 的电影院购票系统源码与数据库脚本整合包,面向 Java 学习者、课程设计及毕业设计人员。项目采用 MVC 分层设计,逻辑上分离模型与视图以降低耦合,既可采用 SSM 或 SpringBoot 等主流框架进行重构,也可基于 Servlet 自定义封装,帮助理解 JavaWeb 开发全流程。资源压缩包共 970 个文件,约 18.43MB,包含 jsp/servlet 页面、Java 类源码、数据库 SQL 脚本以及大量前端静态资源(js/css/gif/jpg 等),并附带 jar 依赖、XML 配置文件等,基本构成可直接运行的完整工程。文件类型覆盖 Java、JSP、HTML、XML、properties 及多种图片格式,便于对照学习界面布局、业务逻辑与持久层映射。资源已吸引 2792 人学习下载。通过源码可以研究购票系统中影片管理、场次安排、选座购票、订单生成等典型模块的实现思路;数据库脚本提供初始表结构与示例数据,减少环境搭建成本。整体适合用于项目实训、毕业设计二次开发,也可作为熟悉 MVC 架构与主流 Java 框架整合的参考案例。

1. 基于 Java 的电影院购票系统,源码和 SQL 脚本到底在讲什么

标题里的“基于java”不是空话。这类毕业设计与练手项目最常见的组合是 Java Swing/JavaFX 做客户端、MySQL 存数据、JDBC 连库,最终把源码工程加一份 cinema.sql 打成 rar 包分发。它要解决的并不是分布式架构或高并发中间件,而是最典型的信息系统闭环:用户能注册登录、查看电影排片、选座下单、生成票,管理员能维护影片和场次。能搜到并下载这个压缩包的人,多数不是缺思路,而是卡在“解压后怎么让代码和数据库对上”“为什么表结构和实体类对不上”“两个人同时选最后一个座位为什么会超卖”这些落地问题上。接下来按我整理这类项目时的顺序展开:先立表,再拆码,再谈事务,最后说排错和兜底。

2. 数据库 SQL 先行:场次、座位、订单与票的状态约束

拿到 rar 先别急着看 Java 代码,先打开 sql 文件读表结构。电影院购票最忌讳一张表存所有字段,常见做法拆成影片、场次、座位、订单、票五到六张表。表的数量不是越多越好,但要能回答一个问题:一个用户买一张票,数据从哪些表产生、哪些字段变化。

2.1 一张票房表拆成 film、session、seat、ticket 的原因

如果把“某部电影某天某厅某座卖了没”全部塞进一张表,字段会越加越多,最后连座位状态和订单号都混在一起。常规设计是让每张表只盯一个业务对象:film 存影片基础信息,session 存“什么时间在几号厅放哪部片”,seat 存某个场次下每个座位的状态,ticket 记录某张订单买了哪个座的票。下面是一份能直接执行的 MySQL 建表脚本:

CREATE DATABASE IF NOT EXISTS cinema DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE cinema; DROP TABLE IF EXISTS t_ticket; DROP TABLE IF EXISTS t_orders; DROP TABLE IF EXISTS t_seat; DROP TABLE IF EXISTS t_session; DROP TABLE IF EXISTS t_film; DROP TABLE IF EXISTS t_user; CREATE TABLE t_film ( film_id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, duration INT NOT NULL COMMENT '片长,分钟', price DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB; CREATE TABLE t_session ( session_id INT PRIMARY KEY AUTO_INCREMENT, film_id INT NOT NULL, hall_no VARCHAR(20) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, CONSTRAINT fk_sess_film FOREIGN KEY (film_id) REFERENCES t_film(film_id) ) ENGINE=InnoDB; CREATE TABLE t_seat ( seat_id INT PRIMARY KEY AUTO_INCREMENT, session_id INT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, seat_status TINYINT NOT NULL DEFAULT 0 COMMENT '0可用 1锁定 2已售', UNIQUE KEY uk_session_seat (session_id, row_no, col_no), CONSTRAINT fk_seat_sess FOREIGN KEY (session_id) REFERENCES t_session(session_id) ) ENGINE=InnoDB; CREATE TABLE t_orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, session_id INT 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, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB; CREATE TABLE t_ticket ( ticket_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, session_id INT NOT NULL, seat_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已出票', CONSTRAINT fk_ticket_order FOREIGN KEY (order_id) REFERENCES t_orders(order_id) ) ENGINE=InnoDB;

参数说明:t_seat 的UNIQUE KEY uk_session_seat (session_id, row_no, col_no)是数据库层面保证“同一场次同一行同列只有一个座位记录”,这一行会在第四章作为兜底条件再次出现。ticket 表没有在 session_id + seat_id 上建唯一索引,这是很多演示项目的通病,后面会用一条 ALTER 补上。ENGINE 统一用 InnoDB,因为后续购票依赖行锁和事务;MyISAM 不适合这类写多读多且要回滚的场景。

2.2 座位状态维护在 seat 表还是由 ticket 推导

两份常见设计,一份是 t_seat.seat_status 直接标记座位是否可用,另一份是不维护状态字段,每次查“某场次的已售座位”都去 t_ticket 里 count。演示项目绝大多数选前者,因为代码直白:选中座位时执行一条 UPDATE 把 0 改成 1,受影响行数为 0 说明座位刚被别人抢走。缺点是要时刻保证 seat_status 与订单、票一致。后者更“规范”,但查询可用座位要写子查询或关联,初学者在这上面容易绕晕。我的建议是:课设和内部项目用状态字段,等真的要考虑并发准确性时再叠加唯一约束兜底,而不是推翻表结构。

2.3 执行 SQL 脚本的固定动作:字符集、删表顺序、重复执行

解压 rar 后第一件事是把 sql 导进本地 MySQL。命令行导入时建议写成:

mysql -uroot -p --default-character-set=utf8mb4 < cinema.sql

加上--default-character-set=utf8mb4是为了避免 Windows 命令行默认 gbk 导致中文片名变成乱码。脚本开头的SET NAMES utf8mb4也要保留,它告诉服务端当前客户端发送来的字符集。另一个关键是 DROP 顺序:先删 ticket、orders,再删 seat、session,最后删 film 和 user,否则外键约束会报删除失败。脚本里用DROP TABLE IF EXISTS是为了让你可以反复执行,对“源码+数据库sql”这种交付物来说,可重复执行比一次性建表更能减少使用者的挫败感。

3. 拆开 Java 源码:实体、DAO、Service 与界面怎么分工

SQL 落地后,再看 Java 源码就不会一头雾水。Swing 项目最常见的分层是 entity、dao、service、ui、util 五个包。理解分层的意义不只是为了应付答辩,而是排查问题时有明确方向:SQL 报错去 dao 找,业务逻辑不对去 service 找,按钮没反应去 ui 找。

3.1 解压 rar 后的标准包结构,先对号入座

cinema/ ├── sql/ │ └── cinema.sql ├── src/ │ ├── com/cinema/entity/ │ │ ├── Film.java │ │ ├── Session.java │ │ ├── Seat.java │ │ ├── Order.java │ │ └── Ticket.java │ ├── com/cinema/dao/ │ │ ├── FilmDao.java │ │ ├── SessionDao.java │ │ ├── SeatDao.java │ │ └── OrderDao.java │ ├── com/cinema/service/ │ │ └── BookingService.java │ ├── com/cinema/ui/ │ │ ├── LoginFrame.java │ │ ├── MainFrame.java │ │ └── BookingDialog.java │ └── com/cinema/util/ │ └── DBUtil.java ├── lib/ │ └── mysql-connector-j-8.x.jar └── db.properties

各层职责在下表里分得很清楚:

类名示例职责常见错误
entityFilm.java与 t_film 字段一一对应日期用 String 而不是 java.util.Date
daoSeatDao.java只写 SQL,不写 if/else拼 SQL 字符串导致注入
serviceBookingService.java事务边界和业务校验在 dao 里开事务
uiMainFrame.java按钮、表格、弹窗在 UI 线程里做数据库查询
utilDBUtil.java获取连接、关资源连接不关闭导致 too many connections

dao 里只做数据访问,service 里只做逻辑。比如“选座前检查余额”是 service 的事,“把座位状态改成已售”是 dao 的事。很多源码包把这两层混在一起,后续把 Swing 换成 JavaFX 或控制台界面时,改动成本会非常大。

3.2 DBUtil 的三种写法,推荐用 Properties 配置

连接数据库的代码不能散落在每个 dao 里。最常见做法是写一个 DBUtil,启动时读取 db.properties。下面这个例子不依赖任何框架,所有 Java 基础阶段的项目都能直接套用:

package com.cinema.util; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; public class DBUtil { private static String url; private static String user; private static String password; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties props = new Properties(); props.load(in); url = props.getProperty("url"); user = props.getProperty("user"); password = props.getProperty("password"); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } }

对应 db.properties 内容:

driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/cinema?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai user=root password=123456

逻辑说明:静态代码块在类加载时执行一次,读取配置并注册驱动。getResourceAsStream("db.properties")要求文件在 classpath 下,IDE 里通常放 src 根目录,命令行编译时需要手动把它复制到输出目录。URL 里的serverTimezone=Asia/Shanghai是 MySQL 8 的硬性要求,缺了会报时区错误;characterEncoding=utf8配合数据库 utf8mb4 才能保证中文不乱码。

提示:MySQL 5.7 可以用com.mysql.jdbc.Driver,MySQL 8 必须换成com.mysql.cj.jdbc.Driver。如果 rar 里带的是旧版驱动类名,连不上库时第一个检查点就是这里。

3.3 从“查出排片”到“选座下单”的界面联动

Swing 的典型套路是 JTable 显示数据,选中行后把主键存在内存变量里,点击按钮再触发下一步。例如场次列表加载:

// ui/MainFrame.java 片段 DefaultTableModel model = (DefaultTableModel) sessionTable.getModel(); model.setRowCount(0); // 清空旧数据,避免重复加载 for (Session s : sessionDao.findByFilm(currentFilmId)) { model.addRow(new Object[]{ s.getSessionId(), s.getStartTime().toString(), s.getHallNo(), "¥" + s.getPrice() }); }

参数说明:setRowCount(0)是刷新 JTable 的常用手段,不调用它会导致每次切换电影后表格里出现两批场次。getSessionId()作为第一列虽然能看见,但它并不直接显示给用户,而是点击“购票”按钮时从sessionTable.getValueAt(selectedRow, 0)取出来传给下一个窗口。这里要留意一个坑:Swing 是单线程模型,数据库查询耗时较长时不能让界面卡死在事件线程里,至少要用一个线程池SwingWorker去做查询;不少源码包里直接在主线程查库,点按钮后会白屏几秒,这是 Java 基础里常被面试官追问的线程问题。

4. 购票事务与并发:两个用户同时选最后一张票时会发生什么

这是整份源码里最有含金量的地方,也是面试时爱从项目里往外挖的问题。数据库 SQL 设计得再好,如果 Java 代码忘了开事务,就会产生超卖。影院票务和普通商品秒杀不一样的是,座位是强一致资源:同一个场次的同一个座位只能属于一个用户。

4.1 “先查询再更新”为什么必然出错

很多初级版本是这样写的:查询座位状态,判断是 0,然后 UPDATE 成 1。两个线程同时查,看到同一行是 0,两个都执行 UPDATE,最后都提示购票成功。解决思路不是把判断放到 Java 的同步块里,因为应用只能控制自己的进程,另一个节点连的是同一套 MySQL。正确做法是让数据库在 UPDATE 时做原子判断,利用受影响行数。

4.2 一次购票要同时处理三张表:座位、订单、票

下面的方法把锁座位、建订单、插入票放在同一个数据库事务里,任何一步失败都回滚:

public void bookSeat(int sessionId, int seatId, int userId) throws Exception { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); PreparedStatement ps1 = conn.prepareStatement( "UPDATE t_seat SET seat_status = 1 " + "WHERE seat_id = ? AND session_id = ? AND seat_status = 0"); ps1.setInt(1, seatId); ps1.setInt(2, sessionId); int rows = ps1.executeUpdate(); if (rows == 0) { throw new RuntimeException("座位不可售"); } PreparedStatement ps2 = conn.prepareStatement( "INSERT INTO t_orders(order_no, user_id, session_id, total_amount, status) " + "VALUES(?, ?, ?, ?, 1)"); // 设置 order_no 为时间戳+随机数,或从序列生成器获取 PreparedStatement ps3 = conn.prepareStatement( "INSERT INTO t_ticket(order_id, session_id, seat_id, status) " + "VALUES(?, ?, ?, 1)"); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }

逻辑说明:关键的“原子判断”在 PS1 上。WHERE seat_status = 0让数据库在行锁范围内检查状态,两条并发请求同时执行时只有一条的executeUpdate()返回 1,另一条返回 0 并触发抛出异常。setAutoCommit(false)之后到commit()之前,这个连接上所有 SQL 同生共死。finally里的setAutoCommit(true)是恢复连接默认状态,避免连接被归还连接池后影响下一次使用。

参数说明:事务隔离级别设置了READ_COMMITTED,比 MySQL 默认的REPEATABLE_READ更宽松,本身不带间隙锁,对座位这一行的监控更简单。要不要升级成SERIALIZABLE完全没必要,因为座位锁的核心已经由带条件的 UPDATE 承担,级别只是控制读一致性。

4.3 悲观锁、乐观锁和唯一约束,在这个场景下谁更合适

方案实现优点缺点
悲观锁UPDATE ... WHERE seat_status=0实现简单,失败判断直观长事务会阻塞同一场次的其他座位写入
乐观锁表加 version 列,UPDATE ... WHERE version=?不阻塞读冲突后需要重试,代码多一层循环
唯一约束兜底对 session_id + seat_id 建唯一索引数据库终极防线报错信息不够友好,需要翻译成“座位被抢”

悲观锁适合座位这样的强竞争短事务,通常几毫秒内就提交,不会酿成大量阻塞。乐观锁适合读多写少、冲突概率低的场景,如果日常看电影上座率只有 30%,用它也没问题。看懂这里,再看 MyBatis 的@Version注解或相关源码解析,本质都是 version 列与更新行数配合。而不管选哪种,最后都应该加唯一约束,否则任何业务层的漏判都会直接造成重复出票。第五章会补上这条 ALTER 语句。

5. 部署、排错与从演示品变成可靠交付物

r ar 解压后能不能跑起来,七成问题集中在数据库驱动、字符集和 classpath 上。这一章按操作顺序过一遍,然后给出一个值得写进注释里的兜底技巧。

5.1 让 sql 和 Java 源码跑通的最小命令序列

# 导入数据库 mysql -uroot -p --default-character-set=utf8mb4 < sql/cinema.sql # 编译源码,假设 lib 下已有 mysql 驱动 javac -encoding UTF-8 -cp "lib/*" -d out \ src/com/cinema/entity/*.java \ src/com/cinema/dao/*.java \ src/com/cinema/service/*.java \ src/com/cinema/ui/*.java \ src/com/cinema/util/*.java # 把配置文件放到输出目录 cp src/db.properties out/ # 运行 java -cp "lib/*:out" com.cinema.ui.MainFrame

-cp "lib/*"会引入 lib 下所有 jar,注意双引号不可省,否则通配符被 shell 展开变成不存在的路径。Windows 下java -cp的分隔符是分号而不是冒号,把最后一句改成java -cp "lib/*;out" com.cinema.ui.MainFrame。对大多数下载者来说,用 IDEA 或 Eclipse 打开源码目录会更省事,但这种 IDE 自动处理 classpath 的方式会掩盖对编译过程的理解。

5.2 三个高频报错与对应的 Java 环境变量排查

报错现象常见原因处理方式
ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动 jar 没引入,或驱动类名旧lib 下确认有驱动,检查 db.properties 的 driver
Unknown database 'cinema'sql 脚本没执行,或执行时报错中断重跑脚本,再SHOW DATABASES;确认数据库存在
Public Key Retrieval is not allowedMySQL 8 认证方式导致的连接串问题URL 追加allowPublicKeyRetrieval=true&useSSL=false

如果命令行里javajavac均提示找不到命令,回到 Java 环境变量配置去检查 PATH 是否包含 JDK 的 bin 目录,这是 Java 基础排错的第一步。如果javac能运行但javaCould not find or load main class,多半是-d out之后的包路径和-cp设置不一致,比如直接写了com/cinema/ui/MainFrame而不是点分全限定名com.cinema.ui.MainFrame

5.3 最后一招:给 t_ticket 补唯一索引,并把 SQL 异常翻译成用户提示

哪怕业务层已经写了带条件的 UPDATE,也推荐在数据库上多设一道防线:

ALTER TABLE t_ticket ADD UNIQUE KEY uk_ticket_session_seat (session_id, seat_id);

这一段索引能拦截一切漏网之鱼。此时 Java 侧要改动捕获逻辑,不能把数据库异常直接抛到界面上:

try { conn.setAutoCommit(false); // 更新座位、插入订单、插入票 conn.commit(); } catch (SQLIntegrityConstraintViolationException e) { conn.rollback(); return "手慢了,该座位刚刚被选走,请重新选择"; } catch (SQLException e) { conn.rollback(); throw new RuntimeException("购票失败,请稍后重试", e); }

逻辑说明:SQLIntegrityConstraintViolationException是 JDBC 层对约束冲突的封装,比蛮力判断e.getMessage().contains("Duplicate")可靠得多。MySQL 原生的错误码是 1062,这条异常正是包裹了它。注意一个细节:事务内某条 SQL 抛出异常后,MySQL 会标记该事务为需要回滚的状态,所以rollback()必须放在 catch 里执行,不能在 finally 里省略。做完这一步,即使将来有人绕过 Service 层直接调用 dao,也没办法制造出同一场次同一座位的两张票。这个唯一索引,比任何 Java 同步块都稳。

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

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

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

立即咨询