简介:基于 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各层职责在下表里分得很清楚:
| 层 | 类名示例 | 职责 | 常见错误 |
|---|---|---|---|
| entity | Film.java | 与 t_film 字段一一对应 | 日期用 String 而不是 java.util.Date |
| dao | SeatDao.java | 只写 SQL,不写 if/else | 拼 SQL 字符串导致注入 |
| service | BookingService.java | 事务边界和业务校验 | 在 dao 里开事务 |
| ui | MainFrame.java | 按钮、表格、弹窗 | 在 UI 线程里做数据库查询 |
| util | DBUtil.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 allowed | MySQL 8 认证方式导致的连接串问题 | URL 追加allowPublicKeyRetrieval=true&useSSL=false |
如果命令行里java和javac均提示找不到命令,回到 Java 环境变量配置去检查 PATH 是否包含 JDK 的 bin 目录,这是 Java 基础排错的第一步。如果javac能运行但java报Could 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 同步块都稳。
本文还有配套的精品资源,点击获取