简介这是面向数据库课程设计场景的C#火车售票系统完整工程包适用于课程设计、毕业设计、期末大作业或项目练手。压缩包共165个文件整体18.14MB核心包含C#源码cs、窗体资源resources/resx、编译可执行程序exe及SQL Server数据库文件mdf/ldf另有少量配置与说明文档目录结构清晰便于按模块查看与二次开发。项目功能已经过运行测试可复现复刻也能在现有基础上扩展改票管理、余票查询等功能。已有189人学习浏览适合具备一定C#与数据库基础、希望参考完整工程实现同类系统的学习者。设计报告也可借鉴下载后建议先阅读说明文件再结合自身需求修改。1. 数据库课程设计 火车售票系统.zip它到底能解决什么“数据库课程设计 火车售票系统.zip”这名字对很多计算机专业学生来说是第二道坎第一道是没思路第二道是有了代码也讲不清楚。这个压缩包通常装的是一个可运行的数据库课设工程核心解决三件事怎么把一张火车售票的 ER 图拆成不冲突不冗余的表怎么用 SQL 脚本在 MySQL 里一键建库建表并写入演示数据怎么把数据库操作嵌进代码让评委看到增删改查之外还有事务和并发意识。适合三类人第一次做课设不知道表怎么拆的补考重修需要在短时间内交付完整作品的以及想把课设当跳板改造成毕设雏形的。接下来我按自己带过的课设工程把拆包检查、表设计、代码跑通、避坑和答辩加分一次说清。2. 解压后的第一轮体检火车售票系统 zip 的目录结构与环境部署要点2.1 拿到 zip 先看文件结构解压命令与三类核心文件刚拿到压缩包别急着双击运行某个.exe课设 zip 里不是所有文件都能在 Windows 下直接执行。第一步永远是解压并列出文件树把项目结构看清。unzip 火车售票系统.zip -d railway cd railway tree -L 2 --charsetutf-8unzip -d railway是把解压内容全放进新建的railway目录避免解出来一堆散文件污染当前文件夹tree -L 2只显示两层目录防止源码文件一多就把清单刷满屏。Windows 下没有tree命令可以用dir /s /b替代但可读性差很多建议装一个 Git Bash 再跑这些命令。如果压缩包带了密码先用无密码版本跑一遍看结构实在解不开再想别的办法课设包绝大多数没有密码限制。接下来看三类文件。第一是.sql脚本这是整个项目的灵魂后面所有表结构和演示数据都由它定义第二是工程文件Java 工程看pom.xml和src目录Python 工程看requirements.txt和app.pyC 系工程则找main.c或.sln第三是课设报告和说明文档.docx或README.txt。优先级上先验证 SQL 脚本能不能在本地导入再验证代码能不能编译最后才是读报告——顺序反了会浪费时间在阅读上而环境问题一直没有暴露。另外一个容易忽略的动作是检查文件大小。一个正常的课设 zip 大约几 MB 上下如果某个.sql文件有几十上百 MB那可能不是课设而是运维备份导入进去会把机器卡死如果src目录为空那说明这个包只有文档没有代码这类包价值较低可用于参考表设计但别指望直接交作业。2.2 SQL 脚本体检字符集、存储引擎与建表顺序把 SQL 脚本直接灌进数据库是最常见的翻车点所以导入前先做静态检查。打开.sql文件优先看三处开头有没有指定字符集和排序规则、每张表引擎是不是 InnoDB、建表顺序是否遵循外键依赖。-- 建库脚本开头字符集决定中文存进去是不是乱码 CREATE DATABASE IF NOT EXISTS train_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE train_ticket; -- 先建主表再建关联表这个顺序不能乱 CREATE TABLE t_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(30) NOT NULL UNIQUE, id_card CHAR(18) NOT NULL UNIQUE, phone VARCHAR(11) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;utf8mb4和utf8的取舍很容易让人困惑。utf8在 MySQL 中最多只能存 3 字节的字符遇到生僻字或部分符号会报错或变成乱码而utf8mb4是完整的 Unicode 实现。课设不需要考虑空间成本直接用utf8mb4是最稳的选择。ENGINEInnoDB这一行如果缺失MySQL 8.x 默认 InnoDB 还看不出问题但 MySQL 5.7 的某些配置下可能落到 MyISAM后面写事务时才发现没有行锁就要到改表和重建数据之间做选择了——这属于那种改起来就要推倒重来的坑最好一开始就加好。建表顺序的问题在带外键的项目里尤其明显。如果t_order表引用了t_user而脚本里先写t_order后写t_user导入到中途就会报外键错误。解决方式有两个一是保持脚本本身的导出顺序不动二是在报告阶段说明“建表顺序按外键依赖排好”。检查脚本时可以手工模拟依赖但最快的办法还是先无脑导入一次把报错信息当调试指南。另外如果这个.sql是从 IntelliJ IDEA 或 Navicat 导出的文件头大概率带着DROP TABLE IF EXISTS和LOCK TABLES语句本地导入前建议把DROP留着、把LOCK删掉避免在一个已存在同名表的库里执行时误删数据。2.3 本地数据库部署用 MySQL zip 免安装包把环境跑起来课设环境大多是 Windows 本机用 MySQL 官方 zip 包做免安装部署比安装版更可控因为不需要管理员权限、不注册 Windows 服务、卸载时直接删目录即可。常见做法是从官网下载对应版本的mysql-8.x.x-winx64.zip解压到一个没有空格的路径比如C:\mysql。JDK 同理用 zip 解压后配JAVA_HOME环境变量不依赖系统安装程序以后换版本也更干净。cd C:\mysql\bin mysqld --initialize-insecure --basedirC:\mysql --datadirC:\mysql\data mysqld --console --skip-grant-tables--initialize-insecure会生成一个初始 root 密码为空的data目录本地调试够用--skip-grant-tables跳过权限验证适合第一次确认能不能启动。但注意跳过权限表意味着同一台机器上的任何进程都能连入公共机房里千万别带这个参数跑。验证启动成功后关掉窗口正常启动一次mysqld --console然后另开一个终端导入 SQLmysql -uroot -p --default-character-setutf8mb4 create.sql这里加--default-character-setutf8mb4是为了让导入客户端的字符集与数据库一致能直接绕开一半的乱码问题。导入完成后不要急着关终端执行SHOW TABLES;确认表数量再执行SELECT COUNT(*) FROM t_user;看看演示数据有没有真正进去。很多同学在这一步就发现了“导入没报错但实际没内容”的情况原因多半是 SQL 脚本里没有USE train_ticket;数据写进了默认库。这类问题越早发现越好。3. 火车售票系统的表结构设计从 ER 图到三范式之间的一次折中3.1 核心实体与字段边界用户、车次、订单怎么拆刚拿到题目时最容易犯的错是把“火车售票”理解成一张表车次、座位、票价、乘客、订单全塞一起字段越写越多到了写代码时发现一个查询要拆出六七个WHERE条件报告都无从下笔。正确的做法是先画 ER 图把实体分成四到五块用户、车次、站点、座位档位、订单。t_user是最简单的存用户 ID、姓名、身份证号、手机号。身份证号必须加UNIQUE约束否则同一个人可以用不同用户名反复注册这在答辩时会被直接问到。t_train存车次号、起点站、终点站、出发时间和到达时间、总票量。要注意“车次”和“列车”是两回事G1001 次是一趟车但可能经过多个站点如果课设要求支持中途站查询需要单独建站点表t_station和关系表t_train_station。不做中途站的话t_train里的start_station和end_station就够用。订单表t_order是实体关系最密集的地方它同时引用用户和车次还包含座位类型、价格、支付状态。推荐在同一张表里既有代理主键order_id又有业务主键order_no。order_id自增保证插入顺序order_no用 UUID 或时间戳生成这样两个含义分离后面答辩被问“为什么订单号不用 1、2、3 自增”时也有话讲。3.2 为什么课设不能用纯三范式订单表的冗余字段是刻意为之书本上的三范式要求订单表只存user_id和train_no剩下的信息全部通过外键关联去查。这在理论上是干净的但课设演示时有个现实问题订单列表页经常要展示车次名、发车时间、价格如果都靠 join每一行订单都要 join 两张表。数据量只有几十条时无所谓可答辩时评委可能现场改一条 SQL 要求你按发车时间筛选三范式就会暴露出连接开销的问题。折中方案是在t_order表里冗余保存train_name和depart_time。这样订单列表页的主查询变成单表查询只在一个场景里需要 join查看“某车次还有多少余票”。冗余带来的代价是更新同步但课设中车次信息基本不会变更这个代价根本不会触发。答辩时如果被问“这违反第三范式”准备好的回答是“订单表冗余了车次快照信息是为了避免历史订单因车次表变动而失真同时减少高频列表查询的 join 次数。”3.3 订单表建表语句与外键策略一份可直接改的模板订单表是整个课设中最值得逐字段解释的表。下面这份是可以直接当成模板的建表语句字段命名、类型选择、约束写法都按常见课设风格来CREATE TABLE t_order ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, train_no VARCHAR(10) NOT NULL, seat_type VARCHAR(20) NOT NULL DEFAULT hard_seat, price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, order_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, train_name VARCHAR(20) DEFAULT NULL, depart_time DATETIME DEFAULT NULL, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user(user_id), CONSTRAINT fk_order_train FOREIGN KEY (train_no) REFERENCES t_train(train_no), INDEX idx_order_time (order_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段值得专门说明。seat_type用VARCHAR而不是ENUM因为以后加商务座、一等座不需要改表结构直接改代码里的常量表就行。price用DECIMAL(10,2)而不是FLOAT小数点精度在钱的问题上不能妥协。status用TINYINT存 0/1/2分别对应待支付、已支付、已退票不要直接填中文文本否则统计和比较都要额外处理。外键策略上fk_order_user和fk_order_train故意不加ON DELETE CASCADE。这样用户删除会走逻辑删除流程车次删除也会被约束拦截住让开发者提前意识到要处理历史订单而不是让级联把订单一起删光。如果课设报告里能写出“物理删除受限时改用状态字段做逻辑删除”这一句话就能体现工程意识。3.4 索引怎么加联合索引列序与 ALTER TABLE 增量修改课设答辩时“索引”是个高频问题。很多同学把所有字段都加上索引这种做法不仅浪费存储还会拖慢写入。以t_order为例真正有用的索引有主键索引自动、order_no唯一索引、order_time普通索引以及一个(user_id, order_time)联合索引用来支撑“查某个用户的最近订单”这类高频查询。联合索引的列序跟查询条件的顺序有关。MySQL 在匹配联合索引时遵循“最左前缀”原则如果业务查询总是WHERE user_id ? ORDER BY order_time DESC那么(user_id, order_time)是正确顺序。如果将来有按时间查全局订单的需求再单独建一个order_time索引不要想着一个索引覆盖所有可能性。课设阶段用EXPLAIN SELECT ...查看执行计划type列出现ref或range就说明索引生效了这一步截图放进报告里非常加分。表结构后期如果要修改用ALTER TABLE t_order ADD COLUMN refund_time DATETIME NULL;这种方式做增量修改不要在原建表脚本上反复打补丁。增量修改的好处是生产环境不会因为重复执行建表语句而直接报错更重要的是它留下了明确的变更记录评审问“这字段什么时候加的”你还能说出具体操作。这里补一句实际经验如果原表里已经有重复的身份证号再执行ALTER TABLE ADD UNIQUE(id_card)会直接报Duplicate entry需要先用SELECT id_card, COUNT(*) FROM t_user GROUP BY id_card HAVING COUNT(*) 1;找出脏数据清理后再加约束。4. 让核心业务真正跑起来JDBC 事务、并发锁与演示数据流水线4.1 最小可运行工程连接串、驱动与编码参数有了表结构下一步就是写代码连库。课程设计用 Java JDBC 的组合最多也有用 Python Flask PyMySQL 的。如果你拿到的是 Java 工程先确认pom.xml或lib目录里有没有 MySQL 对应的驱动 JAR。MySQL 8.x 需要com.mysql.cj.jdbc.Driver同时连接串里要带serverTimezoneAsia/Shanghai少这个参数在部分 JDBC 版本上会直接抛时区异常。// 最小连接配置数据库连接串、用户名、密码、驱动类名 private static final String URL jdbc:mysql://localhost:3306/train_ticket?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4; private static final String USER root; private static final String PASSWORD 123456; public static Connection getConnection() throws SQLException { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new SQLException(缺少 MySQL 驱动请检查 lib 目录, e); } return DriverManager.getConnection(URL, USER, PASSWORD); }这段代码里characterEncodingutf8mb4会直接决定写入的中文是否正确。常见的翻车点是数据库和表都建成了 utf8mb4但连接串没指定编码驱动默认按系统编码传输导致插入的用户名在数据库里变成一串问号。遇到这种问题先别急着改表结构优先查连接串。Class.forName在高版本 JDBC 里可以省略但写上它有个好处驱动包没引入时报错信息会直接指向这一行排查成本低很多。4.2 售票核心方法SELECT FOR UPDATE 行锁与事务边界售票业务里最值钱的代码不是 CRUD而是那张“余票表”的扣减逻辑。最基本的错误是先SELECT total_tickets判断有没有票再UPDATE扣 1。这个顺序在单线程演示时没有任何问题但在两个终端同时下单时两个请求可能都读到余票为 1然后都做扣减最终数据库里余票变成 0却生成了两张订单。这就是超卖。正确做法是用事务把“检查余票 扣减 生成订单”做成一个原子操作并且用SELECT ... FOR UPDATE把涉及的车次行锁住public boolean buyTicket(int userId, String trainNo) { try (Connection conn getConnection()) { conn.setAutoCommit(false); // 事务从这里开始 conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); // 锁定该车次的行别的事务必须等这里提交 try (PreparedStatement ps conn.prepareStatement( SELECT total_tickets FROM t_train WHERE train_no ? FOR UPDATE)) { ps.setString(1, trainNo); try (ResultSet rs ps.executeQuery()) { if (!rs.next() || rs.getInt(total_tickets) 0) { conn.rollback(); return false; } } } // 余票足够扣减 1 try (PreparedStatement ps conn.prepareStatement( UPDATE t_train SET total_tickets total_tickets - 1 WHERE train_no ?)) { ps.setString(1, trainNo); ps.executeUpdate(); } // 生成订单 try (PreparedStatement ps conn.prepareStatement( INSERT INTO t_order(order_no, user_id, train_no, price) VALUES (?, ?, ?, ?))) { ps.setString(1, generateOrderNo()); ps.setInt(2, userId); ps.setString(3, trainNo); ps.setBigDecimal(4, new BigDecimal(50.00)); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); return false; } }逻辑说明generateOrderNo()可以用时间戳加随机数拼一个 32 位以内的字符串只要保证唯一就能满足order_no的UNIQUE约束。buyTicket方法把三步 SQL 放在一个事务里任何一步抛异常都会导致整体回滚不会出现“扣了票但没有订单”或反过来的情况。其中FOR UPDATE是 InnoDB 引擎下的行级排他锁事务未提交期间其他事务的FOR UPDATE会被阻塞而不是读到旧值。参数说明TRANSACTION_READ_COMMITTED读已提交隔离级别已经够用它能避免脏读同时比REPEATABLE_READ锁范围更小。在 MySQL 的默认隔离级别REPEATABLE_READ下如果你再加FOR UPDATE锁的行为会受索引影响没走到索引时可能退化为表锁带来并发性能损失。所以这一步背后的调参点其实是索引——t_train表主键是train_no按主键更新天然走行锁。如果你的工程里用自增 ID 当主键又用唯一业务号找记录一定确认查询走了索引再上锁。4.3 演示数据怎么造用 SQL 造出一个能讲完整故事的库演示时最忌讳的是空库跑程序或者只造几条“张三、李四、G1001”这种没有任何情节的数据。评审会看到的演示场景通常包括注册用户 → 查询余票 → 下单 → 查看自己的订单。为了让这些界面都有内容需要提前填充一些有故事性的演示数据三个测试用户、七八趟车次含一趟余票已经为 0 的以及若干已完成订单。-- 演示数据一个已支付订单、一个待支付订单还有一趟已售罄的车次 INSERT INTO t_train (train_no, train_name, start_station, end_station, depart_time, arrive_time, total_tickets) VALUES (G1001, 京沪高速铁路, 北京南, 上海虹桥, 08:00:00, 12:00:00, 0), (G1002, 京沪高速铁路, 上海虹桥, 北京南, 14:00:00, 18:00:00, 5); INSERT INTO t_user (user_name, id_card, phone) VALUES (王小明, 110101199001011234, 13800000001), (李小红, 110101199002024321, 13800000002); -- 注意这里先保证 t_order 引用的 t_train 和 t_user 已存在 INSERT INTO t_order (order_no, user_id, train_no, seat_type, price, status) VALUES (NO20240101001, 1, G1001, hard_seat, 50.00, 1);刻意留一条total_tickets 0的车次是为了让演示时能展示系统的“无票提示”分支也就是代码中的if (!rs.next() || rs.getInt(total_tickets) 0)分支。这类分支在程序正确性验证里同样重要能让评委相信你不是只写了喜悦路径。想更快造数据可以从 Excel 整理车站名单再导成 SQL 插值但注意 Excel 里的日期字符串要带引号且别用文本格式的小数传进DECIMAL字段否则会出现精度错乱。我自己习惯把 INSERT 语句按“主表→关联表”的顺序排在同一段 SQL 里这样即使删库重来也能一条命令恢复完整现场。4.4 退票逻辑与状态机让业务闭环多一环只有买票没有退票课设的完整度会打折扣。退票逻辑本质上是售票逻辑的反向操作同样需要事务修改订单状态 → 恢复余票 → 记录退票时间。这部分的思路仍然是一句UPDATE t_order SET status 2 WHERE order_id ? AND status 1如果UPDATE影响行数为 0说明订单状态不对直接视为非法操作返回。这里的关键是“状态机”思维同一个订单状态应该只能从待支付流转到已支付再流转到已退票不能随意跳变。把状态流转控制逻辑写在代码里而不是放任任意 SQL 直接改表是课设报告里值得单独写一节的点。5. 课设避坑指南并发扣票、SQL 导入与答辩追问的 5 条踩坑记录这 5 条是从历年课设翻车现场里提炼出来的最高频问题每一条都按现象、原因、解决三连写方便你在答辩前对号入座。5.1 现象并发下单导致余票变负原因没锁课设里最容易翻车的就是余票扣减。现象是打开两个终端同时对同一车次下单两边都查到余票 1 张然后各扣一次最终余票变成 0但生成了两张订单。原因很简单检查余票和扣余票之间没有锁两个会话并发读取了同一个快照。解决方法是把检查、扣减、插单放进同一个事务并对车次行加SELECT ... FOR UPDATE行锁。这里值得多提一句行锁不是免费的两个事务以不同顺序更新多行时可能互相等锁造成死锁MySQL 检测到死锁后自动回滚其中一个事务程序里捕获到死锁错误错误码 1213后重试即可。课设阶段只要保证每单操作始终按“先锁车次、再写订单”的统一顺序就能避开大多数死锁场景。如果你在演示时看到“余票变负数”这种结果不要急着怪数据库先检查代码里有没有真正用事务把两条 SQL 包在一起再检查连接池是不是把autoCommit又设回 true 了——这种低级问题我见过不止一次。5.2 现象SQL 文件导入报错或中文乱码原因字符集不一致现象是把课程设计里附带的.sql文件用mysql create.sql导入中途报ERROR 1064语法错误或者导入成功但表里中文全变问号。原因通常是文件编码和数据库字符集对不上Windows 记事本默认存成 ANSIGBK而数据库是 utf8mb4。解决分两步先把 SQL 文件用编辑器转存成 UTF-8 编码再用mysql -uroot -p --default-character-setutf8mb4 create.sql导入。如果用了图形客户端记得在连接选项中把编码改成utf8mb4。我见过最隐蔽的变体是文件声明了SET NAMES utf8但实际内容是 GBK 编码这种只能靠转存解决改声明没用。还有一个建议检查 SQL 脚本里是否写了charsetgbk之类的表选项如果有需要一并改成utf8mb4。导入后立刻用SHOW VARIABLES LIKE character_set%;查看当前会话的字符集配置六七个变量里只要character_set_database和character_set_client不一致就会出现“写入正常、查询乱码”的怪现象。5.3 现象连接报时区异常原因驱动与参数不匹配Java 工程连不上 MySQL 8 是高频故障典型报错是The server time zone value йʱ is unrecognized或者Public Key Retrieval is not allowed。原因分别对应连接串缺少serverTimezone参数以及 MySQL 8 的 caching_sha2_password 认证方式需要客户端显式允许获取公钥。解决是把连接串补全写成jdbc:mysql://localhost:3306/train_ticket?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8mb4驱动 JAR 也要保证是较新的mysql-connector-j旧版com.mysql.jdbc.Driver在连接串处理上和新版数据库不兼容。如果是 Maven 工程依赖坐标里把version提到当前网上的正式版本比手动往lib目录里塞 jar 稳妥。这一步排查顺序应该是先看驱动包版本再看连接串参数最后才怀疑数据库没配好因为数据库的问题通常报错会直接指向端口或用户名。5.4 现象删除车次被外键拦截原因没做逻辑删除在管理端删除一趟已产生订单的车次会直接报外键约束失败。很多同学的第一反应是给外键加ON DELETE CASCADE但这会连带把历史订单全删了属于数据灾难。更合理的解决是在t_train表加一个is_active状态字段比如 1 表示运营中、0 表示停运删除操作改为UPDATE t_train SET is_active 0 WHERE train_no ?所有查询在条件里加AND is_active 1。这样车次信息保留完整历史订单依然可以 join 到车次名和发车时间逻辑上浑然一体。这个改动看起来小波及面却很大前端查询余票、后台统计列表、订单详情显示车次名每一处SELECT都要同步加上状态过滤。如果项目里已经出现“删除后查询结果时有时无”的怪现象优先检查是不是这里混了物理删除和逻辑删除。课设答辩时主动说出“我用了逻辑删除而不是物理删除是为了保留订单快照”会比被评委追问才答出来体面得多。5.5 现象评委追问订单号为什么不用自增原因没分清业务主键与代理主键评委的典型问法是“你这个订单表的主键怎么生成”如果回答“就是自增 ID”后续大概率被追问“那如果有人知道你下一个订单号是不是 10087”笨办法是用order_id当主键自增再单独生成一个order_no业务编号。order_no可以用UUID转无横杠字符串也可以用日期时间加随机数拼接只要保证唯一性即可。这样order_id负责索引和关联order_no负责对外展示两个角色分离程序内部不会误把业务编号当数字处理。生成订单号时有个小坑如果用new Date().getTime()作为唯一来源同一毫秒内并发两个订单就会撞号我一般在时间戳后面拼四位随机数或者直接用UUID.randomUUID().toString().replace(-, )string 长度在 32 位以内完全够用。答辩时背熟一句话“order_id 是代理主键order_no 是业务主键两者解耦既保证数据库效率又避免前台下单号递增的泄露风险。”6. 把课设升级成能演示的完整作品验证方法与一个进阶技巧课时有限时优先做两件事一是用 SQL 验证整套数据的完整性二是把核心售票逻辑封装成存储过程让它变成 MySQL 里可重复调用和审计的接口。验证数据完整性最直接的方式是统计各车次的售票率。一条查询就能看出库里有没有重复订单或超卖SELECT t.train_no, t.total_tickets, COUNT(o.order_id) AS sold_count FROM t_train t LEFT JOIN t_order o ON t.train_no o.train_no AND o.status IN (0, 1) GROUP BY t.train_no, t.total_tickets HAVING sold_count t.total_tickets;这条 SQL 如果查出了sold_count total_tickets的记录说明存在超卖或重复订单需要回到事务逻辑排查如果没有任何结果说明当前数据没有明显违反“售出数 ≤ 车票总数”的约束可以当作一张干净的验证截图放进报告。进阶技巧是把售票逻辑下沉为存储过程DELIMITER $$ CREATE PROCEDURE sp_buy_ticket( IN p_user_id INT, IN p_train_no VARCHAR(10), IN p_seat_type VARCHAR(20) ) BEGIN DECLARE v_total INT DEFAULT 0; START TRANSACTION; SELECT total_tickets INTO v_total FROM t_train WHERE train_no p_train_no FOR UPDATE; IF v_total 0 THEN UPDATE t_train SET total_tickets total_tickets - 1 WHERE train_no p_train_no; INSERT INTO t_order(order_no, user_id, train_no, seat_type, price) VALUES (CONCAT(DATE_FORMAT(NOW(), %Y%m%d%H%i%s), p_user_id), p_user_id, p_train_no, p_seat_type, 50.00); COMMIT; ELSE ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT ticket sold out; END IF; END$$ DELIMITER ;调用命令为CALL sp_buy_ticket(1, G1002, hard_seat);。存储过程里同样用SELECT ... FOR UPDATE锁行所以它与 JDBC 版本的并发行为一致SIGNAL SQLSTATE 45000会抛出业务错误Java 端捕获SQLException后读取到的 message 就是ticket sold out不需要再额外查表判断。这层封装除了答辩加分也给了我一个习惯凡是核心写路径先交给存储过程或独立服务去管代码层只负责传入参数和接收结果不要让三条 SQL 散落在多个业务方法里。这样定位问题时能直接站在数据库层面看而不是翻开整个工程找 update。希望帮到你。本文还有配套的精品资源点击获取