简介:一套面向广东工业大学数据库课程设计的车站售票管理系统个人方案,适合正在选题或实现课设的学生参考。系统覆盖车票购买与退票、车次与时刻表查询、售票情况统计,并实现数据安全管理、操作员管理和权限设置等系统维护功能,功能模块清晰,数据库设计思路完整。资源包共100个文件,压缩后仅4.65MB,包含21个Java源码、72个class编译文件、1个SQL数据库脚本,以及安装说明doc和工程配置文件。文件类型以Java源码和class文件为主,配合SQL脚本可快速还原数据库结构,安装说明书则指导从零配置环境,便于直接导入运行或对照学习。目前已有1155人浏览学习。对于需要快速完成数据库课设或掌握售票系统开发流程的读者,这份带完整源码和说明书的方案,能帮助完成环境配置、读懂表结构设计,并参照实现购票退票、查询统计和权限控制等核心功能,无论是课程设计答辩还是后续功能扩展,都能提供切实参考。
1. 车站售票管理系统课设:一门课把关系型数据库真正用起来
车站售票管理系统是数据库课设里最常见也最容易做砸的题目。表面看只是查余票、下订单、管账号,好像几张表就能收工;实际做下去,你会撞上 E-R 建模、范式取舍、事务边界、并发扣票这些只在课本里见过的东西。它的价值在于:你第一次用一个完整业务场景把关系型数据库的核心机制串起来,做完之后对 MySQL 的理解会从「会写 SELECT」升级到「敢说自己设计过业务系统」。这里就从建表开始,讲到 JDBC 实现、并发控制,最后是我自己踩过的一串坑,照做就能复现,答辩时也能把每个设计决定讲清楚。
2. 车站售票系统的核心表设计:E-R 模型到 7 张表的落地
2.1 先画 E-R 图:把车站、车次、旅客、订单的边界定清楚
很多同学上手就写 CREATE TABLE,写到一半发现字段对不上又回头改表,这是课设里最浪费时间的事。我的习惯是先画 E-R 图,再翻译成表结构。车站售票系统里,最基本的实体是旅客(账号)、车站、车次,外加订单这个核心业务实体。这里有一个关键区分:车次是「模板」,比如 G100 次从 A 站开往 B 站,每天都跑;真正被售卖的是「某年某月某日的 G100 次」,这必须单独建一张运行计划表(schedule),否则你没法表达「今天的 G100 余票 3 张,明天同车次余票 50 张」。
旅客与订单是一对多;订单与车次是多对多,因为一张订单可能同时买 G100 和 D101 两张票,所以要在中间拆出订单明细表。车站与车次之间是「途经」关系,最简单做法是在车次表里用一个字段存途经站 ID 串。把这些关系在纸上画清楚,建表就不会返工。
2.2 建表 SQL:7 张核心表的字段选择与主外键设计
我一般建这几张表:t_user(旅客账号)、t_station(车站)、t_train(车次模板)、t_schedule(运行计划)、t_order(订单主表)、t_order_item(订单明细),再加一张 t_refund(退票记录)。前六张覆盖主流程,退票表是答辩时的加分项。以下 SQL 按 MySQL 8.0、InnoDB、utf8mb4 编写,可以直接跑:
CREATE DATABASE railway_ticketing DEFAULT CHARACTER SET utf8mb4; USE railway_ticketing; CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, -- 存 MD5/SHA-256 哈希值,不存明文 phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_station ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, city VARCHAR(50) ); CREATE TABLE t_train ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_no VARCHAR(20) NOT NULL UNIQUE, -- 车次号,如 G100 train_type VARCHAR(10) NOT NULL, -- 高铁/动车/普快 dep_station_id BIGINT NOT NULL, arr_station_id BIGINT NOT NULL, stop_station_ids VARCHAR(500), -- 途经站 ID 串,逗号分隔 FOREIGN KEY (dep_station_id) REFERENCES t_station(id), FOREIGN KEY (arr_station_id) REFERENCES t_station(id) ); CREATE TABLE t_schedule ( id BIGINT AUTO_INCREMENT PRIMARY KEY, train_id BIGINT NOT NULL, run_date DATE NOT NULL, dep_time TIME NOT NULL, arr_time TIME NOT NULL, total_seats INT NOT NULL, remain_seats INT NOT NULL, price DECIMAL(10,2) NOT NULL, version INT DEFAULT 0, -- 乐观锁版本号,第 4 章会用到 UNIQUE KEY uk_train_date (train_id, run_date), FOREIGN KEY (train_id) REFERENCES t_train(id) );几个设计点我解释一下。主键统一用 BIGINT AUTO_INCREMENT,演示数据量根本到不了 int 上限,用 BIGINT 是养成习惯。t_user.username、t_train.train_no 加 UNIQUE,这是业务上不可能重复的字段。票价用 DECIMAL(10,2) 而不是 FLOAT/DOUBLE,浮点数的精度问题在票务系统里不能出现。t_schedule 上加 UNIQUE KEY uk_train_date(train_id, run_date),保证同一车次同一天只有一条运行计划,这是数据完整性的硬约束。至于 version 字段,现在先留着,第 4 章讲乐观锁时直接复用,提前设计能避免事后 ALTER TABLE 改结构。
订单相关两张表如下:
CREATE TABLE t_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, -- 业务订单号,程序生成 user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已退票 total_price DECIMAL(12,2) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, seat_no VARCHAR(10), price DECIMAL(10,2) NOT NULL, -- 下单时票价快照 FOREIGN KEY (order_id) REFERENCES t_order(id), FOREIGN KEY (schedule_id) REFERENCES t_schedule(id) );order_no 是程序生成的业务订单号(时间戳加随机数),和自增主键 id 分开。原因是主键不应携带业务含义,而订单号要展示给用户、要支持查询。status 用 TINYINT 加注释,比字符串省空间,改状态就是改数字,Java 端用常量对照读取。订单明细表把 order_id 和 schedule_id 关联起来,注意它带了 price 快照——订单价格以下单时为准,之后车次调价不影响已生成的订单,这就是「快照」字段的意义。
2.3 范式与冗余的取舍:课设别把自己绕进 5NF
范式是答辩一定会被问到的东西。最安全的回答是:所有表满足第三范式(3NF),主键单一,非主属性完全依赖主键且不存在传递依赖。但完全遵循范式在真实系统里反而不顺手,比如 t_train.stop_station_ids 存逗号分隔的途经站 ID 串,严格说违反第一范式(属性不可再分),但它让「查一趟车经过哪些站」变成一次字符串拆分,而不是一次多行 JOIN。课设里这是可以接受的取舍,答辩时主动讲「这里我做了反范式设计,原因是减少一次关联查询」,这是加分项。
另一个常见取舍:要不要把出发站、到达站名称冗余到 t_schedule?我的建议是不冗余,保持 JOIN。MySQL 对外键列有索引支撑,演示数据量下性能差异根本感知不到;把 name 冗余进去,反而要面对「站改名后所有冗余都要同步」的维护问题。记住一句话:范式是底线,冗余是手段,只有当你明确说出「我要减少哪一次查询成本」时,冗余才成立。
3. 用 JDBC + MySQL 跑通售票主流程:登录、余票查询与下单
3.1 技术选型:为什么课设选 JDBC + Swing 而不是 Spring Boot
很多组一上来就搭 Spring Boot + MyBatis + Vue,结果一大半时间花在框架配置和环境问题上,数据库课设最该展示的 SQL 和事务反而没篇幅讲。我的建议是:用纯 JDBC 连接 MySQL 8.0,界面用 Java Swing 或 JavaFX 完成。原因有三点:第一,JDBC 让你亲手写每一条 SQL,事务、锁、连接管理全部可见,答辩时老师问什么你都能答到代码层;第二,Swing 不需要任何前端知识,一个人一周能写完界面;第三,数据库课设的评分重心是 E-R 图、建表、SQL 和事务设计,框架只是外皮。如果老师明确要求 Web 形式,用 Servlet + JSP 也一样,核心思路不变。
3.2 数据库连接管理:一个 DBUtil 类走遍全项目
连接管理是整个项目的底座,我习惯用一个 DBUtil 类统一负责拿连接,避免每个类都写一遍 DriverManager:
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/railway_ticketing" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的数据库密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL 驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这段代码里的三个参数是血泪经验换来的。serverTimezone=Asia/Shanghai 解决第 5 章第一个坑;characterEncoding=utf8 解决中文乱码;useSSL=false 是因为本地连接不需要加密握手,加上能省掉一堆告警日志。MySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver,5.x 时代是 com.mysql.jdbc.Driver,如果你从网上抄到旧代码,加载驱动那行会直接报 ClassNotFoundException——先查驱动 jar 版本和类名是否匹配。
课设阶段不需要连接池(Druid、HikariCP 都可以不加),一个简单的 DBUtil 足够,还能让老师看清楚你懂 Connection 的生命周期。如果建的是普通 Java 项目,把 mysql-connector-j 的 jar 放进项目里并 Add to Build Path 即可;用 Maven 就加对应依赖坐标。
3.3 登录与余票查询:带参数预处理的两段基础 SQL
登录和余票查询是整个系统的门面,也是两段必须用 PreparedStatement 的 SQL。登录示例:
String sql = "SELECT id, username FROM t_user WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, md5(password)); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { // 登录成功,把 rs.getLong("id") 存到当前会话 } else { // 用户名或密码错误 } } }注意两点。第一,password 字段不存明文,MD5 就可以,想再扎实一点用 SHA-256 加盐,答辩时这是安全意识的体现。第二,用 PreparedStatement 的 ? 占位符,而不是把变量拼进 SQL 字符串,否则你会掉进第 5 章的最后一个坑。
余票查询是列表页的核心,要 JOIN 三张表把展示字段凑齐:
SELECT s.id, t.train_no, st1.name AS dep_name, st2.name AS arr_name, s.run_date, s.dep_time, s.arr_time, s.remain_seats, s.price FROM t_schedule s JOIN t_train t ON s.train_id = t.id JOIN t_station st1 ON t.dep_station_id = st1.id JOIN t_station st2 ON t.arr_station_id = st2.id WHERE s.run_date BETWEEN ? AND ? AND s.remain_seats > 0 ORDER BY s.run_date, s.dep_time;这段查询用 INNER JOIN 把运行计划、车次、车站串起来,st1 和 st2 是同一张 t_station 表的两个别名,分别代表出发站和到达站。WHERE 里用 run_date BETWEEN 和 remain_seats > 0,对应「按日期筛选」和「只显示有余票的车次」两个演示时最常点的功能。ORDER BY 按日期和发车时间排序,贴近真实购票页的展示习惯。
3.4 下订单:事务里完成插入订单、扣减余票两步
订票是整个系统里唯一必须开事务的地方,因为它涉及两张以上表的写入。完整结构如下:
Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 第1步:锁住这条运行计划,防止并发扣票(第4章详解) String lockSql = "SELECT remain_seats FROM t_schedule WHERE id = ? FOR UPDATE"; try (PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setLong(1, scheduleId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next() || rs.getInt("remain_seats") < ticketCount) { throw new RuntimeException("余票不足"); } } } // 第2步:插入订单主表,拿到自增主键 String insertOrder = "INSERT INTO t_order(order_no, user_id, status, total_price) " + "VALUES (?, ?, 0, ?)"; long orderId; try (PreparedStatement ps = conn.prepareStatement(insertOrder, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, orderNo); ps.setLong(2, userId); ps.setBigDecimal(3, totalPrice); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { keys.next(); orderId = keys.getLong(1); } } // 第3步:插入订单明细 String insertItem = "INSERT INTO t_order_item(order_id, schedule_id, seat_no, price) " + "VALUES (?, ?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(insertItem)) { ps.setLong(1, orderId); ps.setLong(2, scheduleId); ps.setString(3, seatNo); ps.setBigDecimal(4, price); ps.executeUpdate(); } // 第4步:扣减余票,affected rows = 0 说明余票不够,触发回滚 String updateSeat = "UPDATE t_schedule SET remain_seats = remain_seats - ? " + "WHERE id = ? AND remain_seats >= ?"; try (PreparedStatement ps = conn.prepareStatement(updateSeat)) { ps.setInt(1, ticketCount); ps.setLong(2, scheduleId); ps.setInt(3, ticketCount); if (ps.executeUpdate() == 0) { throw new RuntimeException("余票不足"); } } conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }这段流程的顺序有讲究:先锁行,再检查余票,然后插入订单,最后扣减余票。锁必须在检查之前,否则两个窗口同时读到余票 1,检查都通过,后面就全乱套。UPDATE 语句里的 AND remain_seats >= ? 是第二道保险,即使前面检查漏了,这里 affected rows 为 0 也能让程序感知到并回滚。commit 之后才算真正卖出一张票;任何一步失败都必须 rollback,否则会出现有订单没扣票、或扣了票没有订单的脏数据。
4. 两个窗口同时卖最后一张票:并发扣票的三种解法
4.1 先把问题复现出来:丢失更新到底丢在哪
把系统同时开两个窗口(或开两个终端连同一个库)去卖同一天同一车次的最后一张票,你可能看到两个订单同时成功,余票变成负数。这不是程序写错了,是数据库并发下的经典问题——丢失更新。两个事务同时读到 remain_seats=1,都认为还有票,各自往下执行扣减,最终把一张票卖了两次。理解它的最好方式是在两个终端手动模拟:
-- 终端 A:开启事务,查询余票 BEGIN; SELECT remain_seats FROM t_schedule WHERE id = 1; -- 读到 1,还有票 -- 终端 B:同时查同一张票 BEGIN; SELECT remain_seats FROM t_schedule WHERE id = 1; -- 也读到 1,还有票 -- 终端 A:下单并扣减,提交 UPDATE t_schedule SET remain_seats = remain_seats - 1 WHERE id = 1; COMMIT; -- 终端 B:也下单并扣减,提交 —— 余票变成 -1,一张票卖出两次 UPDATE t_schedule SET remain_seats = remain_seats - 1 WHERE id = 1; COMMIT;先亲眼看一次这个结果,后面三种解法你才能真正理解它们在解决什么。很多课设做到这里就停了,但把并发问题解决了,项目档次瞬间不一样,答辩老师也最爱问这个点。
4.2 悲观锁:SELECT ... FOR UPDATE 把行锁住再处理
悲观锁的思路是:查余票之前先把这行锁住,别人想碰必须等我提交。具体做法就是第 3 章第 4 节里的余票查询,从普通 SELECT 改成:
SELECT remain_seats FROM t_schedule WHERE id = ? FOR UPDATE;这条 SELECT 在事务内执行时会给命中行加排他锁,直到事务提交或回滚才释放。注意它必须出现在 setAutoCommit(false) 之后、余票检查之前。锁住之后再查 remain_seats、判断够不够、插入订单、扣减余票,整个流程串行化。两个窗口同时操作时,第二个窗口的 SELECT ... FOR UPDATE 会阻塞,直到第一个窗口提交——它的体验是「稍微卡了一下,然后提示余票不足」。
悲观锁适合票少、抢票冲突多的场景,实现最简单,课设里首选。代价是持有锁期间其他查询会被挡住,所以事务体要尽量短,别在锁内做耗时操作,比如发短信、写日志都不能放进去。
4.3 乐观锁与触发器兜底:version 字段和数据库层防线
乐观锁假设冲突很少,不锁行,靠版本号在更新时校验。建表时预留的 version 字段现在派上用场:
// 读取时带上版本号 String selectSql = "SELECT remain_seats, version FROM t_schedule WHERE id = ?"; // 更新时用版本号做条件 String updateSql = "UPDATE t_schedule SET remain_seats = remain_seats - ?, " + "version = version + 1 " + "WHERE id = ? AND version = ? AND remain_seats >= ?"; int rows = ps.executeUpdate(); if (rows == 0) { conn.rollback(); // 版本冲突或余票不足,让用户重试 } else { conn.commit(); }关键在于 UPDATE 的 WHERE 条件带上了 version 和 remain_seats 两个约束。affected rows = 0 说明这行在你读取之后被改过,此时回滚并提示用户「余票发生变化,请重试」。课设里可以两种都实现,然后对比说明:悲观锁胜在简单可靠,乐观锁胜在无阻塞、适合读多写少的场景。
最后再加一道数据库层的兜底防线。MySQL 8.0.16 之后 CHECK 约束会真正生效,也可以直接建触发器:
DELIMITER // CREATE TRIGGER trg_schedule_check BEFORE UPDATE ON t_schedule FOR EACH ROW BEGIN IF NEW.remain_seats < 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余票不足'; END IF; END// DELIMITER ;这个触发器在 UPDATE 时检查新余票,只要是负数就 SIGNAL 抛错,整个事务回滚。它是最后一道闸门:就算应用层有 bug 绕过所有检查,数据库也不会让余票变成负数。三层防线——应用检查、乐观锁/悲观锁、数据库约束——讲出来,分数就稳了。
5. 车站售票课设踩坑排查:时间区、乱码、外键与未提交
5.1 启动就报 server time zone 错误
现象:程序第一次连数据库就抛 java.sql.SQLException: The server time zone value '�й���ʱ��' is unrecognized。 原因:MySQL 8.0 驱动要求连接串里明确时区,而系统时区在驱动看来是乱码。 解决:JDBC URL 里加 serverTimezone=Asia/Shanghai。如果部署到其他时区的机器,改成对应时区。少数情况下还要在 MySQL 端执行 SET GLOBAL time_zone = '+8:00',但课设环境一般不需要。这个报错出现频率极高,基本每个连 MySQL 8.0 的新手都会撞一次,看到乱码时区别慌,就是少了这一个参数。
5.2 查到中文全是乱码
现象:数据库里数据正常,Java 界面里显示的却是 ?? 一串问号。 原因:连接层字符集没指定,MySQL 用了默认的 latin1 做传输。 解决:URL 加 characterEncoding=utf8,建库时用 DEFAULT CHARACTER SET utf8mb4,建表时也用 utf8mb4。三处对齐基本不会再乱码。如果还乱,检查代码文件本身是不是 UTF-8 保存的,Windows 记事本另存为时会悄悄改成 GBK,这个玄学问题我帮别人调过好几次。
5.3 删除车次报错:外键约束挡在前面
现象:执行 DELETE FROM t_train WHERE id=1; 报错 Cannot delete or update a parent row: a foreign key constraint fails。 原因:t_schedule.train_id 是外键引用 t_train.id,MySQL 默认的 RESTRICT 规则禁止删除被引用的父表行。 解决:先查关联子表,确认没有未完成订单再删;或者建表时在外键上加 ON DELETE CASCADE。课设里我推荐显式两步删除,逻辑清楚,答辩时讲「我先删子表数据,再删父表数据,保证引用完整性」比一句 CASCADE 更有说服力:
-- 先查出引用了该车次的运行计划 SELECT id FROM t_schedule WHERE train_id = 1; -- 确认没有未完成订单关联后再删除 DELETE FROM t_schedule WHERE train_id = 1; DELETE FROM t_train WHERE id = 1;5.4 订单插进去了,界面却查不到
现象:程序里 insert 返回成功,但重新打开查询界面看不到这条订单。 原因:JDBC 手动 setAutoCommit(false) 之后忘了 commit,程序退出时连接关闭,未提交事务被回滚。 解决:凡手动开启事务,commit 和 rollback 必须成对出现,finally 块中做回滚兜底。排查时可以执行 SHOW PROCESSLIST,看有没有 Sleep 状态的连接长时间挂着,那就是没提交的事务。血泪经验:写完事务代码后,故意在 commit 前抛个异常,确认数据没有被写进去,再恢复正常逻辑,这样能验证 rollback 真的生效。
5.5 登录框输入一个单引号就报错:SQL 注入要堵上
现象:用户名输入 admin' or '1'='1,程序直接抛 SQL 语法异常,甚至可能绕过密码登录。 原因:用 Statement 做了字符串拼接,单引号破坏了 SQL 语句结构。 解决:全部改用 PreparedStatement 占位符,参数化查询下以上问题不复存在。这个问题答辩老师大概率会问,答得上来是安全加分项,答不上来等于告诉老师你的系统有后门。除了登录,余票查询、订单查询所有涉及用户输入的地方都要走同样的写法。
6. 答辩前冲刺:演示数据生成、索引验证与两个自测场景
6.1 演示数据:用存储过程批量生成一个月的运行计划
演示时最尴尬的事是点开余票查询全是空表,手动 INSERT 几十行又浪费时间。我一般写一个存储过程批量生成数据:
DELIMITER // CREATE PROCEDURE generate_schedules(IN days INT) BEGIN DECLARE i INT DEFAULT 0; DECLARE d DATE; SET d = CURDATE(); WHILE i < days DO INSERT INTO t_schedule(train_id, run_date, dep_time, arr_time, total_seats, remain_seats, price) SELECT id, DATE_ADD(d, INTERVAL i DAY), '08:00', '10:30', 500, 500, 280.00 FROM t_train WHERE train_no = 'G100'; SET i = i + 1; END WHILE; END// DELIMITER ; CALL generate_schedules(30);这个存储过程按车次模板批量往 t_schedule 插入 days 天的运行计划,一次 CALL 生成一个月的数据。演示时重点准备:至少 5 个车站、3 个车次(高铁/动车/普快各一)、未来 30 天每天都有班次,余票有满票、个位数、售罄三种状态。这三种状态能覆盖你所有功能演示点,尤其「售罄」可以直接演并发那一段。
6.2 用 EXPLAIN 验证索引 + 两个必测场景
答辩前用 EXPLAIN 检查最常用的查询:
EXPLAIN SELECT * FROM t_schedule WHERE run_date = '2025-06-15' AND remain_seats > 0;如果 type 是 ALL 全表扫描、rows 很大,说明索引没生效。t_schedule 上的 uk_train_date(train_id, run_date)覆盖了大多数查询;如果按 run_date 单独筛选,执行下面这条补一个普通索引:
ALTER TABLE t_schedule ADD INDEX idx_run_date (run_date);最后两个自测场景,演示前必须跑一遍。场景一:两个窗口同时买最后一张票,看是否只有一个成功且余票不为负;场景二:支付后刷新订单列表、发起退票、再买同一车次,走完整个生命周期。这两个场景能跑通,你的课设就立住了。
我自己做课设那年,因为偷懒把扣票逻辑写在界面层,演示时两个窗口一开就翻车,全场看着我反复刷新。那次以后我养成了习惯:凡是涉及库存、余额、扣减的 SQL,一律先把并发场景想清楚再写代码,锁在哪一行、事务边界在哪、失败怎么回滚。后来工作上写订票接口时直接复用了这套思路——交出去的作业不只是为了学分,它值得你认真对待。希望帮到你。
本文还有配套的精品资源,点击获取