基于JavaFX与JDBC的民航票务管理系统:数据库课程设计实战与并发事务解析
1. 项目缘起与整体设计思路数据库这门课光啃理论不动手考试一过全还给老师。当年我在学数据库原理时范式、事务隔离级别、索引结构背得滚瓜烂熟可一到要自己设计一个能跑起来的系统脑子里全是浆糊。后来带学弟学妹做课程设计发现大家卡住的地方惊人地一致不是不会写SQL而是不知道怎么把一堆零散的SQL语句、实体关系、界面逻辑串成一个完整的、能演示、能答辩、还能写进简历的项目。这个《小型MIS的开发》——民航票务管理系统就是在这种背景下被反复验证过的一个经典选题。它麻雀虽小五脏俱全覆盖了数据库设计、JDBC编程、事务控制、界面交互、并发处理等核心知识点而且业务逻辑贴近生活谁没买过机票呢理解起需求来几乎没有门槛。这个系统本质上是一个信息管理系统MIS的微缩版核心功能围绕“航班—乘客—订单—支付”这条主线展开。它要解决的真实问题是如何用程序化的方式管理航班信息的增删改查、处理乘客的订票退票请求、保证余票数量在并发操作下不出错、并且提供一个还算友好的图形界面让非技术人员也能操作。适合谁来参考如果你是正在做数据库课程设计的学生或者刚学完Java基础想找个项目练手JDBC和JavaFX的开发者再或者你是面试官想考察候选人对事务和并发的理解这个项目都是一个极佳的载体。我见过太多人把课程设计做成“界面单表增删改查”的玩具答辩时被老师问一句“如果两个人同时买最后一张票怎么办”就哑口无言。所以这篇博文我会把重点放在设计决策背后的思考和实际编码中踩过的坑上而不是贴一堆CRUD代码了事。技术选型上为什么是JavaFX而不是Swing为什么数据库选MySQL而不是SQLite为什么用原生JDBC而不是MyBatis或Hibernate这些选择在课程设计场景下都有非常务实的理由。JavaFX相比SwingFXML分离界面和逻辑Scene Builder拖拽布局CSS美化样式做出来的界面至少看起来像这个年代的东西答辩时第一印象分就上去了。MySQL胜在普及率高学校机房大概率装好了遇到问题网上解决方案多而且支持完整的事务和行级锁这对票务系统至关重要。至于不用ORM框架是因为课程设计的核心目的是展示你对数据库原理的理解手写JDBC能让你直面Connection、PreparedStatement、ResultSet、事务提交回滚这些底层细节用MyBatis当然快但老师一眼就看穿你是在调API而不是在设计系统。当然如果你时间充裕用MyBatis也不是不行但事务控制那块你得自己补回来。整体架构我采用的是经典的三层结构表示层JavaFX界面、业务逻辑层Service、数据访问层DAO。这个分层不是为了炫技而是为了解耦和可测试。比如“订票”这个操作界面层只负责收集用户选择的航班号和输入的乘客信息然后调用Service层的bookTicket方法Service层负责开启事务、检查余票、插入订单记录、更新航班余票、提交或回滚DAO层则封装了具体的SQL执行。这样分层的好处是如果哪天要把界面从JavaFX换成Web业务逻辑和数据库访问代码几乎不用动。我在实际开发中甚至写了一套简单的单元测试直接调用Service层完全绕开界面验证并发订票时余票不会变成负数。这种可测试性是分层架构带来的最大红利。数据库设计是整个项目的基石我把它放在最前面讲。很多同学一上来就打开Navicat建表字段随便起名外键也不加最后写查询时各种JOIN写得想哭。正确的做法是先画E-R图理清实体和关系。这个系统里核心实体有四个航班Flight、乘客Passenger、订单Order、用户User。航班和乘客通过订单形成多对多关系一个航班可以有多个乘客订票一个乘客也可以订多个航班。用户表则是为了登录和权限控制普通乘客只能查票订票管理员可以管理航班信息。这里有个容易忽略的点订单和乘客的关系。很多人会把乘客信息直接冗余到订单表里比如订单表里存乘客姓名和身份证号。这样做查询方便但违反了第三范式而且乘客信息修改时会产生数据不一致。我的做法是乘客表独立订单表只存乘客ID作为外键。当然如果考虑到历史订单需要保留乘客当时的姓名乘客后来改名了可以在订单表里冗余一个“下单时姓名”字段这是有意为之的反范式设计为了业务准确性。表结构设计有几个关键决策点。航班表的主键我用的是自增整数flight_id而不是航班号。为什么因为航班号可能会因为航空公司调整而改变而且不同日期的同一航班号其实是不同的航班实例。所以航班号只是一个普通属性真正唯一标识一个航班的是“航班号日期起飞时间”的组合但为了简化我用自增ID做主键在业务层保证唯一性。余票数量字段remaining_seats是并发控制的重点后面会详细讲。订单表的状态字段status用枚举值表示待支付、已支付、已取消、已退票。这个状态机设计很重要它决定了哪些操作是合法的。比如“已取消”的订单不能再支付“已支付”的订单才能退票。很多同学的系统里状态字段形同虚设导致业务逻辑混乱。用户表的密码存储我强烈建议至少做一次MD5或SHA-256哈希虽然课程设计不要求但这是基本的安全意识答辩时提一句“密码做了哈希存储”是加分项。别存明文真的我见过太多存明文的系统了。2. 核心细节解析与实操要点2.1 数据库表结构设计与字段类型选择先看航班表flight的设计。字段包括flight_idINT主键自增、flight_numberVARCHAR(10)航班号如CA1234、departure_cityVARCHAR(50)、arrival_cityVARCHAR(50)、departure_timeDATETIME、arrival_timeDATETIME、priceDECIMAL(10,2)、total_seatsINT、remaining_seatsINT。这里有几个细节值得展开。价格用DECIMAL而不是FLOAT因为浮点数有精度问题0.10.2不等于0.3这种坑在金额计算上是致命的。DECIMAL(10,2)表示总共10位其中2位小数最大能存99999999.99足够机票价格了。时间用DATETIME而不是TIMESTAMP因为TIMESTAMP有2038年问题而且受时区影响DATETIME更直观。余票字段我单独设了remaining_seats而不是每次查询时用total_seats - COUNT(订单)来计算。为什么因为计算余票需要JOIN订单表并GROUP BY在高并发下性能很差而且无法利用数据库的行锁来保证一致性。冗余一个余票字段虽然增加了更新逻辑的复杂度但换来了查询性能和并发控制的便利。这是典型的空间换时间和一致性换性能的权衡。乘客表passengerpassenger_idINT主键自增、nameVARCHAR(50)、id_cardVARCHAR(18)唯一索引、phoneVARCHAR(20)。身份证号加唯一索引防止同一乘客被重复录入。这里有个实操心得身份证号不要用INT存储18位数字远超INT范围而且前导零会丢失必须用VARCHAR。手机号同理用VARCHAR因为可能带国际区号或分隔符。订单表ticket_orderorder_idINT主键自增、flight_idINT外键、passenger_idINT外键、order_timeDATETIME、statusVARCHAR(20)、seat_numberVARCHAR(10)。外键约束我建议加上ON DELETE RESTRICT防止误删航班导致订单变成孤儿记录。status字段用VARCHAR存枚举值比用TINYINT可读性好调试时一眼就能看懂。seat_number可以简单生成比如“12A”这种格式课程设计不要求真实的座位图但有个座位号会让系统看起来更完整。用户表sys_useruser_idINT主键自增、usernameVARCHAR(50)唯一、password_hashVARCHAR(64)、roleVARCHAR(20)值为ADMIN或PASSENGER。密码哈希用SHA-256输出64位十六进制字符串所以字段长度设64。角色字段控制权限管理员登录后能看到航班管理菜单普通乘客只能看到查询和订票。建表SQL我习惯加上ENGINEInnoDB和DEFAULT CHARSETutf8mb4。InnoDB支持事务和行级锁这是票务系统的刚需。utf8mb4支持emoji和生僻字虽然民航系统用不到emoji但养成好习惯没坏处。下面是一个简化的建表示例CREATE TABLE flight ( flight_id INT PRIMARY KEY AUTO_INCREMENT, flight_number VARCHAR(10) NOT NULL, departure_city VARCHAR(50) NOT NULL, arrival_city VARCHAR(50) NOT NULL, departure_time DATETIME NOT NULL, arrival_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, total_seats INT NOT NULL, remaining_seats INT NOT NULL, INDEX idx_flight_number (flight_number), INDEX idx_departure_arrival (departure_city, arrival_city) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;索引的设计也有讲究。flight_number加索引是为了按航班号查询。departure_city和arrival_city建联合索引因为最常见的查询是“从A城市到B城市有哪些航班”。注意联合索引的顺序把选择性高的字段放前面但这里两个字段选择性差不多所以顺序影响不大。订单表的flight_id和passenger_id都要加索引因为经常需要按航班查订单、按乘客查订单。2.2 JavaFX界面布局与FXML分离策略JavaFX的界面开发我强烈建议用FXML而不是纯Java代码。纯Java代码写界面改一个按钮位置就要重新编译而且代码和逻辑混在一起后期维护想死。FXML把界面描述成XML文件用Scene Builder可视化编辑改完保存直接生效不用重启应用。Controller类通过FXML注解绑定控件逻辑清晰。主界面我设计成一个TabPane三个标签页航班查询、我的订单、航班管理仅管理员可见。航班查询页顶部是搜索栏出发城市、到达城市、日期三个输入框加一个查询按钮。中间是TableView展示航班列表列包括航班号、出发到达城市、时间、价格、余票。底部是订票按钮。TableView的数据绑定用ObservableListFlight当列表数据变化时界面自动刷新这是JavaFX的观察者模式带来的便利。这里有个实操细节TableView的列绑定。用PropertyValueFactory可以快速绑定但要求实体类的字段有getter方法。比如new PropertyValueFactory(flightNumber)会去找getFlightNumber()方法。如果字段名是flightNumbergetter是getFlightNumber()没问题。但如果字段是isAvailable这种布尔类型getter应该是isAvailable()PropertyValueFactory也能识别。我踩过的坑是字段名首字母大写比如FlightNumbergetter是getFlightNumber()PropertyValueFactory会找不到因为它默认字段名首字母小写。所以实体类字段命名一定要遵循JavaBean规范首字母小写。FXML文件里控件要设置fx:idController里用FXML注解注入。比如TableView fx:idflightTable layoutX20 layoutY80 prefWidth760 columns TableColumn text航班号 prefWidth100 cellValueFactoryPropertyValueFactory propertyflightNumber//cellValueFactory /TableColumn !-- 其他列省略 -- /columns /TableViewController里FXML private TableViewFlight flightTable; FXML private TableColumnFlight, String flightNumberCol;注意泛型TableColumnFlight, String表示这一列展示Flight对象的String类型属性。如果泛型写错运行时会报ClassCastException。我见过有人写成TableColumnFlight, Object结果排序功能失效因为Object没有实现Comparable。界面美化用CSS。JavaFX支持类似Web的CSS语法可以设置背景色、字体、边框、阴影。比如给按钮加个圆角.button { -fx-background-radius: 5; -fx-background-color: #4a90d9; -fx-text-fill: white; }把CSS文件放在resources目录下在FXML根节点加stylesheets../css/style.css即可。注意路径是相对于FXML文件的。这个细节坑了我半小时一直以为CSS没生效其实是路径写错了。2.3 JDBC连接管理与事务控制核心逻辑JDBC连接管理新手最容易犯的错是每个方法都DriverManager.getConnection()用完也不关跑一会儿就报“Too many connections”。正确的做法是用连接池但课程设计里手写一个简单的连接池有点超纲我通常建议用一个工具类封装获取和释放连接并且用try-with-resources确保关闭。public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/airline_ticket?useSSLfalseserverTimezoneUTC; private static final String USER root; private static final String PASSWORD your_password; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable res : resources) { if (res ! null) { try { res.close(); } catch (Exception e) { e.printStackTrace(); } } } } }注意URL里的serverTimezoneUTC不加这个参数MySQL 8.0以上版本会报时区错误。useSSLfalse是关闭SSL警告本地开发无所谓。事务控制是票务系统的灵魂。订票操作的伪代码逻辑是开启事务 - 查询航班余票加行锁- 如果余票大于0插入订单记录 - 更新航班余票减1 - 提交事务如果余票不足回滚事务并提示用户。这里的关键是查询余票时必须加锁否则两个线程同时查到余票为1都认为可以订然后都去更新余票最后余票变成-1。这就是经典的并发超卖问题。在MySQL InnoDB里用SELECT ... FOR UPDATE加排他锁。注意FOR UPDATE必须在事务中才生效而且锁的是索引扫描到的行。所以查询条件必须走索引否则会锁表。我们的查询条件是WHERE flight_id ?flight_id是主键走主键索引只锁这一行没问题。public boolean bookTicket(int flightId, int passengerId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 加行锁查询余票 String checkSql SELECT remaining_seats FROM flight WHERE flight_id ? FOR UPDATE; PreparedStatement checkStmt conn.prepareStatement(checkSql); checkStmt.setInt(1, flightId); ResultSet rs checkStmt.executeQuery(); if (!rs.next() || rs.getInt(remaining_seats) 0) { conn.rollback(); return false; } // 插入订单 String insertSql INSERT INTO ticket_order (flight_id, passenger_id, order_time, status) VALUES (?, ?, NOW(), 待支付); PreparedStatement insertStmt conn.prepareStatement(insertSql); insertStmt.setInt(1, flightId); insertStmt.setInt(2, passengerId); insertStmt.executeUpdate(); // 更新余票 String updateSql UPDATE flight SET remaining_seats remaining_seats - 1 WHERE flight_id ?; PreparedStatement updateStmt conn.prepareStatement(updateSql); updateStmt.setInt(1, flightId); updateStmt.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码有几个要点。第一setAutoCommit(false)开启事务所有操作要么全成功要么全失败。第二FOR UPDATE加锁确保查询和更新之间没有其他事务插入。第三rollback()在异常和余票不足时调用保证数据一致性。第四finally块里恢复自动提交并关闭连接防止连接泄漏。我见过有人忘了恢复setAutoCommit(true)连接放回池里后下一个使用者以为在自动提交模式结果事务一直不提交数据莫名其妙丢失。注意FOR UPDATE锁的释放时机是事务提交或回滚时。所以事务要尽量短不要在事务里做网络请求或等待用户输入否则锁持有时间过长其他用户订票会阻塞。2.4 并发场景下的余票扣减方案对比余票扣减是票务系统最核心的并发问题。除了上面说的SELECT ... FOR UPDATE悲观锁方案还有几种常见方案各有优劣我在这里做个对比方便你根据场景选择。方案实现方式优点缺点适用场景悲观锁SELECT FOR UPDATE强一致性实现简单锁等待吞吐量低并发量不高一致性要求高乐观锁版本号或余票字段CAS无锁等待吞吐量高冲突时需重试实现复杂并发量高冲突少数据库原子操作UPDATE flight SET remaining_seats remaining_seats - 1 WHERE flight_id ? AND remaining_seats 0无需显式锁一条SQL搞定无法在扣减前做其他检查简单扣减场景分布式锁Redis或ZooKeeper跨服务一致引入外部依赖复杂度高分布式系统课程设计里我推荐悲观锁或原子操作。原子操作方案最简洁UPDATE flight SET remaining_seats remaining_seats - 1 WHERE flight_id ? AND remaining_seats 0;然后检查executeUpdate()的返回值如果返回1表示扣减成功返回0表示余票不足。这个方案利用了数据库的原子性和行锁不需要显式开启事务单条UPDATE语句本身就是原子的。但缺点是如果扣减成功后还需要插入订单这两步不在同一个事务里可能扣了余票但订单插入失败。所以更稳妥的做法是把UPDATE和INSERT放在同一个事务里UPDATE用原子操作INSERT正常插入最后一起提交。conn.setAutoCommit(false); // 先扣余票 String updateSql UPDATE flight SET remaining_seats remaining_seats - 1 WHERE flight_id ? AND remaining_seats 0; PreparedStatement updateStmt conn.prepareStatement(updateSql); updateStmt.setInt(1, flightId); int affected updateStmt.executeUpdate(); if (affected 0) { conn.rollback(); return false; // 余票不足 } // 再插订单 String insertSql INSERT INTO ticket_order ...; // ... conn.commit();这个方案比SELECT FOR UPDATE少了一次查询性能更好而且逻辑更简洁。我实测下来在课程设计演示场景下并发量个位数两种方案都没问题但原子操作方案代码更少不容易出错。实操心得无论用哪种方案都建议在测试阶段用多线程模拟并发订票验证余票不会变成负数。我写过一个简单的测试开10个线程同时订同一航班的最后5张票预期结果是5个成功5个失败余票为0。这个测试跑通答辩时老师问并发问题你就稳了。3. 实操过程与核心环节实现3.1 开发环境搭建与项目骨架创建环境准备这块我列一个清单照着装就行。JDK版本建议JDK 11或17JavaFX从JDK 11开始不再内置需要单独下载JavaFX SDK。如果你用JDK 8JavaFX是内置的但JDK 8太老了建议升级。IDE用IntelliJ IDEA社区版就够Scene Builder单独下载安装IDEA里配置一下路径就能在FXML文件上右键用Scene Builder打开。MySQL装8.0版本Navicat或DBeaver作为数据库客户端。Maven管理依赖在pom.xml里加MySQL Connector和JavaFX的依赖。dependencies dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.openjfx/groupId artifactIdjavafx-controls/artifactId version17.0.2/version /dependency dependency groupIdorg.openjfx/groupId artifactIdjavafx-fxml/artifactId version17.0.2/version /dependency /dependencies项目骨架按三层架构建包com.airline.ui放FXML和Controllercom.airline.service放业务逻辑com.airline.dao放数据访问com.airline.entity放实体类com.airline.util放工具类。resources目录下建fxml和css子目录。这个结构清晰答辩时老师一看就知道你懂分层。数据库连接配置我建议写在db.properties文件里而不是硬编码在Java代码中。这样换数据库环境时只改配置文件不用重新编译。db.urljdbc:mysql://localhost:3306/airline_ticket?useSSLfalseserverTimezoneUTC db.userroot db.passwordyour_password然后用Properties类加载public class DBUtil { private static Properties props new Properties(); static { try (InputStream in DBUtil.class.getClassLoader().getResourceAsStream(db.properties)) { props.load(in); } catch (IOException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(props.getProperty(db.url), props.getProperty(db.user), props.getProperty(db.password)); } }3.2 航班查询与多条件动态SQL拼接航班查询是用户最常用的功能。界面上有三个条件出发城市、到达城市、日期。用户可能只填其中一两个也可能全填。这就涉及动态SQL拼接。用PreparedStatement可以防止SQL注入但拼接条件时需要小心处理参数索引。我的做法是用StringBuilder拼SQL用Listpublic ListFlight searchFlights(String depCity, String arrCity, String date) { StringBuilder sql new StringBuilder(SELECT * FROM flight WHERE 11); ListObject params new ArrayList(); if (depCity ! null !depCity.isEmpty()) { sql.append( AND departure_city ?); params.add(depCity); } if (arrCity ! null !arrCity.isEmpty()) { sql.append( AND arrival_city ?); params.add(arrCity); } if (date ! null !date.isEmpty()) { sql.append( AND DATE(departure_time) ?); params.add(date); } sql.append( ORDER BY departure_time); ListFlight flights new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement stmt conn.prepareStatement(sql.toString())) { for (int i 0; i params.size(); i) { stmt.setObject(i 1, params.get(i)); } ResultSet rs stmt.executeQuery(); while (rs.next()) { flights.add(mapRowToFlight(rs)); } } catch (SQLException e) { e.printStackTrace(); } return flights; }WHERE 11是个小技巧这样后续每个条件都可以用AND开头不用判断是不是第一个条件。DATE(departure_time) ?会把日期字段的函数作用于列上导致索引失效。如果数据量大应该改成departure_time ? AND departure_time ?传入当天的起始和结束时间。课程设计数据量小用DATE()没问题但面试时被问到索引优化你要知道这个点。mapRowToFlight方法把ResultSet的一行映射成Flight对象这是DAO层的标准操作。注意rs.getTimestamp(departure_time)返回的是java.sql.Timestamp可以转成LocalDateTimeprivate Flight mapRowToFlight(ResultSet rs) throws SQLException { Flight f new Flight(); f.setFlightId(rs.getInt(flight_id)); f.setFlightNumber(rs.getString(flight_number)); f.setDepartureCity(rs.getString(departure_city)); f.setArrivalCity(rs.getString(arrival_city)); f.setDepartureTime(rs.getTimestamp(departure_time).toLocalDateTime()); f.setArrivalTime(rs.getTimestamp(arrival_time).toLocalDateTime()); f.setPrice(rs.getBigDecimal(price)); f.setTotalSeats(rs.getInt(total_seats)); f.setRemainingSeats(rs.getInt(remaining_seats)); return f; }3.3 订票流程的完整实现与界面联动订票流程从用户点击“订票”按钮开始。界面层获取TableView中选中的Flight对象弹出对话框让用户输入乘客姓名和身份证号。这里有个设计决策是让用户先登录再订票还是订票时临时输入乘客信息我选择后者因为课程设计里用户体系可以简化订票时直接录入乘客信息更直观。如果要做完整可以先查乘客表如果身份证号已存在就复用否则新建乘客记录。FXML private void handleBookTicket() { Flight selected flightTable.getSelectionModel().getSelectedItem(); if (selected null) { showAlert(请先选择一个航班); return; } if (selected.getRemainingSeats() 0) { showAlert(该航班余票不足); return; } // 弹出输入对话框 TextInputDialog nameDialog new TextInputDialog(); nameDialog.setTitle(订票); nameDialog.setHeaderText(请输入乘客姓名); OptionalString nameResult nameDialog.showAndWait(); if (!nameResult.isPresent() || nameResult.get().trim().isEmpty()) return; TextInputDialog idDialog new TextInputDialog(); idDialog.setTitle(订票); idDialog.setHeaderText(请输入乘客身份证号); OptionalString idResult idDialog.showAndWait(); if (!idResult.isPresent() || idResult.get().trim().isEmpty()) return; // 调用Service层 boolean success ticketService.bookTicket(selected.getFlightId(), nameResult.get().trim(), idResult.get().trim()); if (success) { showAlert(订票成功); refreshFlightTable(); // 刷新余票显示 } else { showAlert(订票失败余票不足或系统异常); } }Service层的bookTicket方法接收航班ID、乘客姓名、身份证号内部先查乘客是否存在不存在则插入然后执行前面讲的事务扣减余票和插入订单。这里有个细节乘客表的唯一索引。如果两个线程同时用同一个身份证号订票都查不到乘客记录都去插入第二个会因为唯一索引冲突而失败。所以插入乘客的代码要捕获SQLIntegrityConstraintViolationException然后重新查询乘客ID。public boolean bookTicket(int flightId, String name, String idCard) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 查或建乘客 int passengerId getOrCreatePassenger(conn, name, idCard); // 扣余票 String updateSql UPDATE flight SET remaining_seats remaining_seats - 1 WHERE flight_id ? AND remaining_seats 0; PreparedStatement updateStmt conn.prepareStatement(updateSql); updateStmt.setInt(1, flightId); if (updateStmt.executeUpdate() 0) { conn.rollback(); return false; } // 插订单 String insertSql INSERT INTO ticket_order (flight_id, passenger_id, order_time, status) VALUES (?, ?, NOW(), 待支付); PreparedStatement insertStmt conn.prepareStatement(insertSql); insertStmt.setInt(1, flightId); insertStmt.setInt(2, passengerId); insertStmt.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { rollbackQuietly(conn); e.printStackTrace(); return false; } finally { closeQuietly(conn); } } private int getOrCreatePassenger(Connection conn, String name, String idCard) throws SQLException { String selectSql SELECT passenger_id FROM passenger WHERE id_card ?; PreparedStatement selectStmt conn.prepareStatement(selectSql); selectStmt.setString(1, idCard); ResultSet rs selectStmt.executeQuery(); if (rs.next()) { return rs.getInt(passenger_id); } // 不存在则插入 String insertSql INSERT INTO passenger (name, id_card) VALUES (?, ?); PreparedStatement insertStmt conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS); insertStmt.setString(1, name); insertStmt.setString(2, idCard); insertStmt.executeUpdate(); ResultSet keys insertStmt.getGeneratedKeys(); keys.next(); return keys.getInt(1); }Statement.RETURN_GENERATED_KEYS是获取自增主键的关键很多人插完记录不知道怎么拿ID就是漏了这个参数。3.4 退票与订单状态流转的实现退票比订票复杂因为涉及状态检查和余票回滚。业务规则是只有“已支付”的订单才能退票退票后订单状态变为“已退票”航班余票加1。如果订单是“待支付”状态应该走取消订单流程而不是退票。public boolean refundTicket(int orderId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 查订单状态并加锁 String checkSql SELECT flight_id, status FROM ticket_order WHERE order_id ? FOR UPDATE; PreparedStatement checkStmt conn.prepareStatement(checkSql); checkStmt.setInt(1, orderId); ResultSet rs checkStmt.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } String status rs.getString(status); int flightId rs.getInt(flight_id); if (!已支付.equals(status)) { conn.rollback(); return false; // 状态不允许退票 } // 更新订单状态 String updateOrderSql UPDATE ticket_order SET status 已退票 WHERE order_id ?; PreparedStatement updateOrderStmt conn.prepareStatement(updateOrderSql); updateOrderStmt.setInt(1, orderId); updateOrderStmt.executeUpdate(); // 回滚余票 String updateFlightSql UPDATE flight SET remaining_seats remaining_seats 1 WHERE flight_id ?; PreparedStatement updateFlightStmt conn.prepareStatement(updateFlightSql); updateFlightStmt.setInt(1, flightId); updateFlightStmt.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { rollbackQuietly(conn); e.printStackTrace(); return false; } finally { closeQuietly(conn); } }这里对订单记录加了FOR UPDATE锁防止两个线程同时退同一张票导致余票加2。状态检查必须在锁内进行否则可能出现“检查时是已支付更新时已经被另一个线程退票了”的情况。订单状态流转图用文字描述待支付 - 已支付支付操作- 已退票退票操作待支付 - 已取消取消操作。已支付不能直接取消必须先退票。已退票和已取消是终态不能再变。这个状态机在Service层用if-else或switch实现确保非法状态转换被拒绝。4. 常见问题与排查技巧实录4.1 数据库连接与驱动加载问题排查问题一ClassNotFoundException: com.mysql.cj.jdbc.Driver。这是新手最常见的问题原因是MySQL驱动jar包没加到classpath。如果你用Maven检查pom.xml里有没有mysql-connector依赖版本号对不对。如果手动管理jar包确认jar文件在项目的lib目录并且被添加到Build Path。MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver5.x版本是com.mysql.jdbc.Driver别搞混了。问题二The server time zone value ???ú±ê×??± is unrecognized。这是MySQL 8.0的时区问题解决方案是在JDBC URL后面加?serverTimezoneUTC或?serverTimezoneAsia/Shanghai。我推荐用Asia/Shanghai因为UTC会导致时间显示差8小时。问题三Public Key Retrieval is not allowed。MySQL 8.0默认使用caching_sha2_password认证插件JDBC连接时需要允许公钥检索。在URL加allowPublicKeyRetrievaltrue即可。或者把用户的认证插件改成mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY your_password;问题四连接泄漏导致Too many connections。检查所有getConnection()的地方确保在finally块里关闭。用try-with-resources最保险try (Connection conn DBUtil.getConnection(); PreparedStatement stmt conn.prepareStatement(sql)) { // 操作 } catch (SQLException e) { e.printStackTrace(); }try-with-resources会自动关闭conn和stmt不用手写finally。4.2 JavaFX界面卡死与线程安全问题问题点击查询按钮后界面卡住不动。这是因为数据库查询是耗时操作如果在JavaFX Application ThreadUI线程里执行会阻塞界面刷新。解决方案是把耗时操作放到后台线程用Task或Service类。FXML private void handleSearch() { TaskListFlight task new Task() { Override protected ListFlight call() { return flightService.searchFlights(depCityField.getText(), arrCityField.getText(), dateField.getText()); } }; task.setOnSucceeded(e - { flightTable.setItems(FXCollections.observableArrayList(task.getValue())); }); new Thread(task).start(); }Task的call()方法在后台线程执行setOnSucceeded的回调在UI线程执行可以安全更新界面。这个模式是JavaFX异步编程的标准写法务必掌握。问题NullPointerException在Controller初始化时。原因是FXML里的fx:id和Controller里的字段名不一致或者忘了加FXML注解。检查FXML文件确认每个需要绑定的控件都有fx:id且值和Controller字段名完全一致大小写敏感。另外Controller类必须在FXML根节点用fx:controller指定否则FXML加载器不知道怎么注入。问题修改了FXML但界面没变化。Maven项目里FXML文件放在src/main/resources下编译后会被复制到target/classes。如果你直接改target里的文件重新编译会被覆盖。正确做法是改src/main/resources下的源文件然后重新编译。IDEA里可以设置自动编译或者手动Build Project。4.3 事务不回滚与并发超卖排查问题订票失败后余票没有恢复。检查事务是否真的开启了。conn.setAutoCommit(false)必须在执行任何SQL之前调用。如果先执行了查询再设置之前的操作已经自动提交了。另外rollback()必须在catch块里调用而且要在conn不为null且未关闭的情况下调用。问题并发测试时余票变成负数。这是超卖问题说明扣减余票的SQL没有加锁或没有原子性。检查是否用了SELECT ... FOR UPDATE或者UPDATE ... WHERE remaining_seats 0。如果用了SELECT查询余票然后UPDATE扣减但中间没有锁就会超卖。另外检查事务隔离级别MySQL默认是REPEATABLE READFOR UPDATE会加锁但如果隔离级别是READ COMMITTED行为可能不同。课程设计用默认级别就行。问题死锁Deadlock found when trying to get lock。两个事务互相等待对方持有的锁。比如事务A锁了航班1要更新订单1事务B锁了订单1要更新航班1。解决方案是统一加锁顺序比如所有事务都先锁航班再锁订单。或者降低事务粒度尽快提交。课程设计里死锁不常见但如果并发测试时遇到检查一下加锁顺序。4.4 常见问题速查表问题现象可能原因排查方法解决方案ClassNotFoundException驱动jar未加载检查classpath和依赖添加mysql-connector依赖时区错误MySQL 8.0时区未设置看异常信息URL加serverTimezone界面卡死耗时操作在UI线程看是否用了Task用Task后台执行余票负数并发扣减未加锁多线程测试用FOR UPDATE或原子UPDATE事务不回滚autoCommit未关闭检查setAutoCommit在SQL前调用密码明文未做哈希看数据库SHA-256哈希存储FXML加载失败路径错误或fx:id不匹配看异常堆栈检查路径和字段名自增主键获取不到未指定RETURN_GENERATED_KEYS看prepareStatement参数加Statement.RETURN_GENERATED_KEYS独家避坑技巧在开发阶段把JDBC的SQL日志打开能看到实际执行的SQL和参数排查问题效率翻倍。用Log4jdbc或p6spy或者简单点在DAO层手动打印SQL和参数。我习惯在PreparedStatement执行前打印stmt.toString()虽然格式不完美但能看到参数值。5. 项目扩展与个人经验分享这个系统做完基础功能后还有很多可以扩展的方向。比如支付模拟加一个支付按钮把订单状态从“待支付”改成“已支付”模拟支付流程。航班管理管理员可以增删改航班注意删除航班时要检查是否有未完成的订单有的话不能删。报表统计按航线统计售票量、按日期统计收入用GROUP BY和聚合函数实现。登录注册用户表加密码哈希和角色字段登录后根据角色显示不同菜单。这些扩展不需要改架构在现有分层上加Service方法和界面即可。我个人在实际操作中的体会是课程设计最容易被忽视的不是功能多少而是数据一致性和异常处理。我见过太多系统功能演示时一切正常但一按错按钮就崩溃或者数据库里出现脏数据。所以我在写每个Service方法时都会问自己三个问题这个操作涉及几张表需不需要事务异常了怎么办把这三个问题想清楚代码质量就上了一个台阶。另外界面上的每个用户输入都要做校验身份证号格式、日期格式、必填项这些细节在答辩时都是加分项。最后再分享一个小技巧用Git管理代码。哪怕是一个人开发也建个本地仓库每完成一个功能就commit一次。这样改崩了可以回滚而且commit记录本身就是开发过程的证明答辩时展示Git log老师能看到你的开发思路和迭代过程。 commit信息写清楚“完成订票事务控制”“修复并发超卖问题”比任何PPT都有说服力。这个习惯我从课程设计保持到工作受益无穷。