☰
基于JavaFX与MySQL的民航票务系统数据库设计与事务实践
2026/10/13 11:27:34 网站建设 项目流程

1. 项目缘起与整体设计思路

1.1 为什么选民航票务这个场景来做数据库实验

数据库这门课,光看课本上的范式理论、事务隔离级别、索引原理,考试能过,但真让你动手做一个能跑的系统,很多人是懵的。我当时做这个实验的时候,导师给的要求很明确:必须有一个完整的小型MIS,必须用到数据库的增删改查、事务、视图、存储过程,前端不限。我选民航票务,原因很直接——这个场景的数据关系足够复杂,但又不会复杂到做不完。

民航票务系统天然涉及几个核心实体:航班、飞机、航线、机场、乘客、订单、支付记录、座位。这些实体之间的关系有强耦合也有一对多、多对多的典型结构。比如一个订单可以包含多个乘客,一个航班对应一架飞机和一条航线,一条航线涉及出发和到达两个机场。这种结构拿来练数据库设计,比学生管理系统那种“学生-课程-成绩”三张表走天下的模式有意思得多,也更接近真实业务。

另一个原因是,票务系统对事务的要求非常直观。你买一张票,背后至少要做三件事:扣减座位库存、生成订单记录、写入支付流水。这三步必须在一个事务里完成,要么全成功,要么全回滚。这种场景在实验报告里写“事务的ACID特性”时,你是有真实代码可以贴的,不是空谈理论。

1.2 技术选型:JavaFX + JDBC + MySQL 的组合逻辑

前端用JavaFX,不是因为它比Web前端好,而是因为这门课是数据库实验,不是Web开发实验。JavaFX的好处是:它是Java标准库的一部分(从JDK 11开始需要单独引入模块),不需要额外搭Node环境、不需要配Nginx、不需要处理跨域。你写一个Application子类,用FXML或者纯代码搭界面,直接打包成jar就能跑。对于数据库实验来说,界面能看、能点、能输入就够了,重点在后台的数据流转。

数据库选MySQL,理由更简单:免费、资料多、JDBC驱动成熟、学校机房大概率已经装了。我用的是MySQL 8.0,因为8.0对窗口函数和CTE的支持更好,后面做统计查询的时候会方便很多。如果你用的是5.7,大部分功能也能实现,只是某些复杂查询要改写。

连接方式用原生JDBC,没有上MyBatis或Hibernate。这是故意的。数据库实验的核心目的之一是让你亲手写SQL,理解PreparedStatement和Statement的区别,理解连接池的必要性。如果你用ORM框架,SQL都被框架生成了,实验报告里“SQL语句设计”那一章你就没东西可写。所以我的建议是:实验阶段用原生JDBC,把SQL写透,以后做项目再用框架。

1.3 系统模块划分与数据库表结构概览

整个系统我分了五个模块:航班管理、订单管理、乘客管理、统计报表、系统管理。对应到数据库,核心表有八张:

表名作用关键字段
airport机场信息airport_id, airport_name, city, code
airline航空公司airline_id, airline_name, code
route航线route_id, departure_airport, arrival_airport, distance
aircraft飞机aircraft_id, model, seat_count, airline_id
flight航班flight_id, route_id, aircraft_id, departure_time, arrival_time, price, status
passenger乘客passenger_id, name, id_card, phone
orders订单order_id, flight_id, passenger_id, seat_no, order_time, status
payment支付记录payment_id, order_id, amount, pay_time, pay_method

这八张表通过外键关联,形成了完整的业务闭环。比如你要查某个乘客的所有订单,就从passenger出发,经过orders,再关联到flight和route,最后拿到出发到达机场信息。这条查询链在实验报告里可以作为“多表联合查询”的典型案例。

注意:表名用orders而不是order,因为order是SQL关键字,直接用会导致语法错误。这是我在建表时踩的第一个坑,当时报错信息指向FROM order,我盯着看了十分钟才反应过来。

2. 数据库设计中的关键细节与避坑要点

2.1 表结构设计:从ER图到物理表的映射

画ER图的时候,我用的是Chen氏表示法,实体用矩形,属性用椭圆,关系用菱形。这个阶段最重要的是把多对多关系找出来。比如“订单”和“航班”之间,一个订单对应一个航班,但一个航班可以有很多订单,这是一对多。但“乘客”和“航班”之间,一个乘客可以坐很多航班,一个航班有很多乘客,这是多对多,需要通过orders表来拆解。

映射到物理表的时候,有几个决策点需要说明:

第一,主键用自增整数还是业务主键?我全部用了自增整数(AUTO_INCREMENT),因为业务主键(比如身份证号、航班号)可能会变,而且字符串做主键在索引效率上不如整数。但身份证号我加了UNIQUE约束,保证业务唯一性。

第二,外键要不要加ON DELETE CASCADE?我的选择是:订单表的外键不加级联删除,因为订单是财务记录,不能因为删了一个乘客就把订单也删了。但航班和航线之间的外键可以加,因为航线删了,航班确实没有存在的意义。这个决策在实验报告里可以写成“参照完整性约束的设计考量”。

第三,时间字段用DATETIME还是TIMESTAMP?我用了DATETIME,因为TIMESTAMP有2038年问题,而且受时区影响。航班起降时间用DATETIME更稳妥。

2.2 索引设计:哪些字段该加索引,哪些不该加

索引是数据库实验里容易被忽略但实际影响巨大的部分。我在flight表的departure_time和route_id上加了索引,因为查询航班时最常用的条件就是“某条航线某天的航班”。在orders表的passenger_id和flight_id上也加了索引,因为查订单时经常按乘客或航班过滤。

但索引不是越多越好。passenger表的name字段我没加索引,因为姓名重复率高,索引选择性差,加了反而拖慢写入。id_card加了UNIQUE索引,因为身份证号唯一且查询频繁。

这里有个经验:你可以用EXPLAIN命令看查询执行计划。比如:

EXPLAIN SELECT * FROM flight WHERE route_id = 1 AND departure_time BETWEEN '2024-01-01' AND '2024-01-02';

如果type列显示ALL,说明全表扫描,需要加索引;如果显示ref或range,说明索引生效了。这个命令在实验报告里可以作为“查询优化”的证据。

2.3 事务与并发控制:买票场景下的锁机制

买票是典型的高并发场景。两个人同时买同一个航班的最后一张票,如果不做并发控制,就会出现超卖。我在代码里用了两种方式处理:

第一种是悲观锁。在查询座位库存时加FOR UPDATE:

SELECT seat_count FROM aircraft WHERE aircraft_id = (SELECT aircraft_id FROM flight WHERE flight_id = ?) FOR UPDATE;

这样在事务提交前,其他事务无法修改这行记录,保证了库存扣减的原子性。

第二种是乐观锁。在flight表加一个version字段,更新时检查版本号:

UPDATE flight SET seat_count = seat_count - 1, version = version + 1 WHERE flight_id = ? AND version = ?;

如果返回的受影响行数为0,说明版本号变了,需要重试。

我最终用了悲观锁,因为实验环境下并发量不大,悲观锁实现简单,不容易出错。但如果你要做真实系统,乐观锁在高并发下性能更好。

提示:MySQL默认的隔离级别是REPEATABLE READ,在这个级别下,普通的SELECT不会加锁,只有FOR UPDATE或LOCK IN SHARE MODE才会加锁。这一点在实验报告里要写清楚,不然老师会问你“为什么加了FOR UPDATE就能防超卖”。

3. 核心功能实现与实操过程

3.1 环境搭建:从零到能跑的第一步

环境搭建这一步,我踩的坑最多。首先是JDK版本,JavaFX从JDK 11开始被移出了标准库,需要单独下载OpenJFX的SDK。我一开始用JDK 17,发现javafx.application.Application找不到,后来在VM options里加了:

--module-path /path/to/javafx-sdk/lib --add-modules javafx.controls,javafx.fxml

才解决。如果你用Maven,可以在pom.xml里加javafx-maven-plugin,这样就不用手动配module-path了。

MySQL这边,我建了一个专门的数据库用户,只给了这个库的权限,没有用root。这是好习惯,实验报告里可以写“遵循最小权限原则”。建库语句:

CREATE DATABASE aviation_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'ticket_user'@'localhost' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON aviation_ticket.* TO 'ticket_user'@'localhost';

字符集用utf8mb4,因为乘客姓名可能有生僻字,utf8在MySQL里是阉割版,存不了四字节的字符。

JDBC驱动我用的是mysql-connector-j-8.0.33.jar,直接放在项目的lib目录下,在IDE里Add to Build Path。如果你用Maven,加依赖:

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

3.2 数据库连接池:为什么不用DriverManager直接连

一开始我用DriverManager.getConnection()每次操作都新建连接,测试的时候没问题,但连续点几次查询按钮之后,程序就卡死了。原因是每次新建连接都要经历TCP握手、认证、初始化会话,开销很大,而且连接没及时关闭会导致MySQL的max_connections被耗尽。

后来我加了一个简单的连接池,用的是HikariCP。配置如下:

HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/aviation_ticket?useSSL=false&serverTimezone=Asia/Shanghai"); config.setUsername("ticket_user"); config.setPassword("your_password"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); HikariDataSource dataSource = new HikariDataSource(config);

maximumPoolSize设10,是因为实验环境并发不高,10个连接足够。connectionTimeout设3000毫秒,意思是如果3秒内拿不到连接就抛异常,避免界面卡死。这个配置在实验报告里可以作为“连接池参数调优”的素材。

注意:JDBC URL里的serverTimezone参数很重要。MySQL 8.0的驱动如果不指定时区,可能会报“The server time zone value is unrecognized”错误。我设成Asia/Shanghai,因为航班时间都是国内时间。

3.3 航班查询与动态条件拼接

航班查询是用户用得最多的功能。界面上有出发城市、到达城市、日期三个输入框,用户可能只填其中一个,也可能全填。这就涉及动态SQL拼接。

我一开始用字符串拼接:

String sql = "SELECT * FROM flight WHERE 1=1"; if (departureCity != null) sql += " AND departure_city = '" + departureCity + "'";

这种写法有两个问题:一是SQL注入风险,二是日期格式处理麻烦。后来改用PreparedStatement加条件列表:

List<String> conditions = new ArrayList<>(); List<Object> params = new ArrayList<>(); if (departureCity != null && !departureCity.isEmpty()) { conditions.add("departure_city = ?"); params.add(departureCity); } if (arrivalCity != null && !arrivalCity.isEmpty()) { conditions.add("arrival_city = ?"); params.add(arrivalCity); } if (date != null) { conditions.add("DATE(departure_time) = ?"); params.add(date); } String sql = "SELECT f.*, r.departure_airport, r.arrival_airport, a.model " + "FROM flight f " + "JOIN route r ON f.route_id = r.route_id " + "JOIN aircraft a ON f.aircraft_id = a.aircraft_id " + (conditions.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditions)) + " ORDER BY f.departure_time";

这样既安全又灵活。DATE()函数的作用是只取日期部分,忽略时间,因为用户选的是日期,不是具体时刻。

3.4 下单流程:一个完整的事务案例

下单是整个系统最核心的功能,也是事务教学的最佳案例。我的实现分四步:

第一步,检查航班是否存在且有余票。用SELECT ... FOR UPDATE锁住航班记录。

第二步,检查该乘客是否已经买过同一航班的票,防止重复下单。

第三步,插入订单记录,状态设为“待支付”。

第四步,更新航班的剩余座位数,减一。

这四步放在一个事务里:

Connection conn = dataSource.getConnection(); conn.setAutoCommit(false); try { // 步骤1:锁航班记录 PreparedStatement ps1 = conn.prepareStatement( "SELECT remaining_seats FROM flight WHERE flight_id = ? FOR UPDATE"); ps1.setInt(1, flightId); ResultSet rs1 = ps1.executeQuery(); if (!rs1.next() || rs1.getInt("remaining_seats") <= 0) { throw new RuntimeException("余票不足"); } // 步骤2:检查重复订单 PreparedStatement ps2 = conn.prepareStatement( "SELECT COUNT(*) FROM orders WHERE flight_id = ? AND passenger_id = ? AND status != '已取消'"); ps2.setInt(1, flightId); ps2.setInt(2, passengerId); ResultSet rs2 = ps2.executeQuery(); rs2.next(); if (rs2.getInt(1) > 0) { throw new RuntimeException("该乘客已购买此航班"); } // 步骤3:插入订单 PreparedStatement ps3 = conn.prepareStatement( "INSERT INTO orders (flight_id, passenger_id, seat_no, order_time, status) VALUES (?, ?, ?, NOW(), '待支付')"); ps3.setInt(1, flightId); ps3.setInt(2, passengerId); ps3.setString(3, seatNo); ps3.executeUpdate(); // 步骤4:扣减余票 PreparedStatement ps4 = conn.prepareStatement( "UPDATE flight SET remaining_seats = remaining_seats - 1 WHERE flight_id = ?"); ps4.setInt(1, flightId); ps4.executeUpdate(); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }

这段代码在实验报告里可以直接作为“事务的原子性”示例。如果步骤3失败,步骤4不会执行;如果步骤4失败,步骤3会回滚。FOR UPDATE保证了在事务提交前,其他事务无法修改这条航班记录,防止超卖。

提示:conn.close()在连接池环境下不是真的关闭连接,而是归还给池。所以不用担心频繁关闭连接的性能问题。

3.5 统计报表:用视图和存储过程简化复杂查询

统计报表模块要求展示“每条航线的总订单数、总营收、平均票价”。这个查询涉及orders、flight、route三张表,而且需要分组聚合。我一开始在Java里写SQL,后来发现这个查询太复杂,不如直接在数据库里建一个视图:

CREATE VIEW v_route_stats AS SELECT r.route_id, r.departure_airport, r.arrival_airport, COUNT(o.order_id) AS total_orders, SUM(f.price) AS total_revenue, AVG(f.price) AS avg_price FROM route r LEFT JOIN flight f ON r.route_id = f.route_id LEFT JOIN orders o ON f.flight_id = o.flight_id AND o.status = '已支付' GROUP BY r.route_id, r.departure_airport, r.arrival_airport;

视图的好处是,Java代码里只需要SELECT * FROM v_route_stats,逻辑清晰,而且视图的定义在数据库里,多个地方可以复用。实验报告里可以写“视图的作用是封装复杂查询,提供逻辑独立性”。

另外我还写了一个存储过程,用于批量更新航班状态(比如把已过起飞时间的航班状态改为“已起飞”):

DELIMITER // CREATE PROCEDURE update_flight_status() BEGIN UPDATE flight SET status = '已起飞' WHERE departure_time < NOW() AND status = '计划中'; UPDATE flight SET status = '已到达' WHERE arrival_time < NOW() AND status = '已起飞'; END // DELIMITER ;

这个存储过程可以定时调用,也可以手动执行。实验报告里可以写“存储过程的优势是减少网络传输,逻辑在数据库端执行”。

4. 常见问题与排查技巧实录

4.1 中文乱码:从数据库到界面的全链路排查

中文乱码是数据库实验里最常见的问题。我的排查顺序是:数据库字符集 → 表字符集 → 连接字符集 → Java文件编码 → 界面字体。

首先确认数据库和表的字符集:

SHOW VARIABLES LIKE 'character_set%'; SHOW CREATE TABLE passenger;

如果character_set_database不是utf8mb4,需要改。表字符集在建表时指定DEFAULT CHARSET=utf8mb4。

然后检查JDBC URL有没有加characterEncoding=utf8。MySQL 8.0的驱动默认就是utf8mb4,但显式写上更保险。

Java文件编码要在IDE里设成UTF-8,编译时加-encoding UTF-8。JavaFX的Label和TextField默认字体可能不支持中文,需要在CSS里指定字体:

.root { -fx-font-family: "Microsoft YaHei", "SimHei", sans-serif; }

这一套下来,乱码基本能解决。如果还有问题,用System.out.println打印从数据库读出来的字符串,看是在哪一步出的问题。

4.2 界面卡死:JavaFX的线程模型与数据库操作

JavaFX的UI更新必须在JavaFX Application Thread里执行,而数据库查询是耗时操作,如果直接在按钮的setOnAction里写查询,界面会卡死。我一开始就犯了这个错误,点查询按钮后界面直接无响应,鼠标变成转圈。

正确的做法是用Task或Service把数据库操作放到后台线程:

Task<List<Flight>> task = new Task<>() { @Override protected List<Flight> call() throws Exception { return flightDao.search(departureCity, arrivalCity, date); } }; task.setOnSucceeded(e -> { List<Flight> flights = task.getValue(); flightTable.getItems().setAll(flights); }); task.setOnFailed(e -> { task.getException().printStackTrace(); showAlert("查询失败", task.getException().getMessage()); }); new Thread(task).start();

call()方法在后台线程执行,setOnSucceeded在UI线程执行,这样界面就不会卡。这个模式在实验报告里可以作为“JavaFX并发编程”的案例。

4.3 事务不回滚:自动提交的陷阱

我写下单功能的时候,测试发现即使抛了异常,订单还是插进去了。排查了半天,发现是conn.setAutoCommit(false)没生效。原因是我的连接是从连接池拿的,HikariCP默认的autoCommit是true,我在代码里设了false,但异常处理里没有正确回滚。

后来我改成在获取连接后立即设autoCommit为false,并且在finally块里恢复:

Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // ... 业务逻辑 conn.commit(); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw e; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) { ex.printStackTrace(); } } }

关键点是:rollback必须在catch里执行,而且rollback本身也可能抛异常,要单独try-catch。另外,连接归还给池之前要把autoCommit恢复成true,否则下一个拿到这个连接的代码会以为自己在事务里。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
中文显示为问号字符集不匹配SHOW VARIABLES LIKE 'character_set%'统一用utf8mb4
界面点击无响应数据库操作在UI线程看线程堆栈用Task放后台线程
事务不回滚autoCommit未关闭或rollback未执行打印autoCommit状态显式设false并在catch里rollback
连接耗尽连接未关闭SHOW PROCESSLIST用连接池并确保close
日期查询不准时区或格式问题打印SQL和参数URL加serverTimezone,用DATE()函数
外键约束失败插入顺序错误看错误信息里的外键名先插主表再插从表
主键冲突自增主键被手动指定检查INSERT语句去掉主键字段让数据库自增

提示:SHOW PROCESSLIST可以看当前有哪些连接在执行,如果发现大量Sleep状态的连接,说明连接泄漏了。这个命令在调试连接池问题时非常有用。

4.5 几个让我印象深刻的坑

第一个坑是ORDER BY和LIMIT一起用的时候,如果排序字段有重复值,分页结果可能不稳定。比如按价格排序,价格相同的航班在不同页可能重复出现或漏掉。解决方案是加一个唯一字段作为次级排序,比如ORDER BY price, flight_id。

第二个坑是COUNT(*)和COUNT(column)的区别。COUNT(*)统计所有行,COUNT(column)只统计该列非NULL的行。我在统计订单数时用了COUNT(payment_id),结果未支付的订单没算进去,后来改成COUNT(*)才对。

第三个坑是JavaFX的TableView绑定数据时,如果实体类的getter命名不规范(比如getDepartureTime写成getDeparturetime),表格列会显示空白。JavaFX的属性绑定依赖反射,方法名必须符合JavaBean规范。

第四个坑是MySQL的GROUP BY在ONLY_FULL_GROUP_BY模式下,SELECT的列必须在GROUP BY里出现或者是聚合函数。我一开始写SELECT route_id, departure_airport, COUNT(*) FROM flight GROUP BY route_id,报错说departure_airport不在GROUP BY里。解决方案是要么把departure_airport加进GROUP BY,要么用ANY_VALUE(departure_airport)。我选择了前者,因为逻辑上更清晰。

5. 项目扩展与个人实操体会

5.1 如果要做成真实系统,还需要补什么

这个实验项目离真实票务系统还有距离,但作为学习载体已经够了。如果要继续扩展,我建议从三个方向入手:

第一是加缓存。航班查询是读多写少的场景,可以用Redis缓存热门航线的查询结果,减少数据库压力。但缓存和数据库的一致性是个问题,需要设计合理的过期策略。

第二是加分库分表。当订单量大了之后,单表查询会变慢。可以按时间分表,比如orders_2024、orders_2025,或者按乘客ID哈希分片。但这会带来跨分片查询的问题,复杂度陡增。

第三是加消息队列。下单后发消息通知乘客、更新积分、写日志,这些操作可以异步化,用消息队列解耦。这样主流程只需要保证订单和库存的一致性,其他操作最终一致即可。

5.2 我在这个项目里学到的三件事

第一件事:数据库设计不是画完ER图就完了。物理设计阶段要考虑索引、分区、字符集、存储引擎。我一开始用MyISAM,后来发现它不支持事务,赶紧换成InnoDB。这个教训让我记住了:MySQL的默认存储引擎从5.5开始就是InnoDB了,但如果你手动指定了MyISAM,事务会静默失效。

第二件事:SQL写得好不好,EXPLAIN说了算。我一开始觉得查询能跑就行,后来发现一个没加索引的查询在数据量大了之后慢了100倍。学会看执行计划,是数据库从业者的基本功。

第三件事:事务的边界要尽可能小。我一开始把整个下单流程包括发送邮件都放在一个事务里,结果邮件发送超时导致整个事务回滚。后来把邮件发送移到事务外,事务只包含数据库操作,问题就解决了。事务里不要做网络调用、不要做文件IO,这是铁律。

5.3 给做同类实验的同学几个建议

如果你也要做这个实验,我的建议是:先把数据库表建好,数据插进去,用命令行或者Navicat把每条SQL都跑一遍,确认逻辑正确,再写Java代码。很多人一上来就写界面,结果界面写完了发现数据库查不出数据,回头改表结构,界面也要跟着改,返工成本很高。

另外,实验报告里不要只贴代码,要贴执行结果。比如你写了事务回滚的代码,就要贴一张图:正常下单后数据库里的记录,以及异常回滚后数据库里没有记录。这样老师才能确认你的代码真的跑通了。

最后,版本控制很重要。我用Git管理代码,每完成一个功能就commit一次。这样即使改崩了,也能回退到上一个可用版本。实验期间我至少回退了五次,如果没有版本控制,每次都要重写,心态会崩。

这个项目我前前后后做了三周,中间踩了无数坑,但做完之后对数据库的理解上了一个台阶。课本上的ACID、索引、视图、存储过程,在项目里都有了具体的对应物。如果你也在做类似的实验,希望这些经验能帮你少走点弯路。

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

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

立即咨询