尧图精选

仿12306购票系统:Java原生JDBC+MySQL并发控制实战

🕒 发布时间:2026/9/2 18:30:44 📁 来源:尧图网络
简介一个使用Java语言与MySQL数据库实现的模拟12306窗口购票实践项目面向正在学习Java图形界面编程、数据库操作及软件工程综合设计的开发者和高校学生。压缩包共含61个文件具体包括12个Java源代码文件、39个编译后的class字节码文件、3个jar依赖库文件以及properties等配置资源整体体积约4.73MB其中Druid连接池、MySQL连接驱动等第三方依赖已随包提供导入集成开发环境即可运行。项目功能覆盖用户登录、车次查询、选择座位、下单支付等购票核心流程通过JDBC完成数据库增删改查使用多线程模拟多人同时抢票并包含事务控制、异常处理、连接池配置、数据表设计和索引优化等关键实践点。目前已有665人学习或下载适合通过阅读源码、运行调试以及二次开发深入理解Java与MySQL在真实业务场景中的配合方式也可作为课程设计或毕业设计的参考原型。1. 项目概述与核心设计思路1.1 为什么选“仿12306购票”这个题材火车票售票系统几乎是每个 Java 初学者绕不开的练手项目。原因很简单它的业务逻辑足够典型但又不至于复杂到让人望而却步。订单、库存、支付、座位分配、退改签……这些电商、出行领域最常见的能力都能在这个场景里得到训练尤其是在并发扣减余票这件事上稍微处理不好就会出现“超卖”。我这次做的 Demo 没有用 Spring Boot也没有引入 MyBatis 这类重量级框架而是用最朴素的Java 原生 JDBC MySQL 8.0来实现。目的很明确先把底层的数据库连接、SQL 编写、事务控制这些基本功练扎实再谈框架的封装和优化。很多同学一上来就怼着 Spring Boot 写结果出了问题连 SQL 都调不明白反而走了弯路。项目最终打包成一个可运行的myticket.zip里面包含完整源码、建表 SQL 脚本和一份简单的启动说明解压后按步骤操作就能跑起来。1.2 功能拆解与影响范围这个 Demo 覆盖了售票系统的核心闭环注册登录、车次查询、购票下单、支付模拟、退票处理、订单查询。它在设计上有意做了一个简化——单机部署、单数据库不涉及分布式事务和消息队列只把“一个用户买一张票”这个最基础的流程跑通。但别小看这个“简化版”它其实把互联网高并发场景下最核心的两个问题都带出来了余票的准确扣减多个用户同时买同一车次的票怎么保证不会卖超。座位与订单状态的一致性用户下单后要锁座支付失败或超时要释放座位。把这些逻辑吃透再去理解 Redis 分布式锁、消息队列削峰、分库分表这些进阶方案你会轻松得多。这个 Demo 适合三类人刚学完 Java 基础想找项目练手的学生、准备校招想补一个“拿得出手”的项目的同学、零基础转行想理解后端业务逻辑的开发者。2. 数据库设计与表结构规划2.1 五张核心表的结构与关系数据库设计是整个项目的地基。我最终规划了五张表每一张都有明确的职责边界-- 用户表 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) UNIQUE NOT NULL COMMENT 用户名, password VARCHAR(64) NOT NULL COMMENT 密码MD5加盐, real_name VARCHAR(20) NOT NULL COMMENT 乘车人姓名, id_card VARCHAR(18) NOT NULL COMMENT 身份证号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 车次表 CREATE TABLE t_train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) UNIQUE NOT NULL COMMENT 车次编号如G1024, start_station VARCHAR(30) NOT NULL COMMENT 始发站, end_station VARCHAR(30) NOT NULL COMMENT 终点站, start_time VARCHAR(10) NOT NULL COMMENT 发车时间 HH:mm, end_time VARCHAR(10) NOT NULL COMMENT 到达时间 HH:mm, seat_type VARCHAR(20) NOT NULL COMMENT 座位类型二等座/一等座/商务座, price DECIMAL(10,2) NOT NULL COMMENT 票价 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 余票表重点设计 CREATE TABLE t_stock ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL COMMENT 关联车次ID, train_date DATE NOT NULL COMMENT 发车日期, total_seats INT NOT NULL COMMENT 总票数, remaining_seats INT NOT NULL COMMENT 剩余票数, version INT DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_train_date (train_id, train_date), CONSTRAINT fk_stock_train FOREIGN KEY (train_id) REFERENCES t_train(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE NOT NULL COMMENT 订单编号, user_id INT NOT NULL COMMENT 下单用户ID, train_id INT NOT NULL COMMENT 车次ID, train_date DATE NOT NULL COMMENT 乘车日期, seat_type VARCHAR(20) NOT NULL COMMENT 座位类型, price DECIMAL(10,2) NOT NULL COMMENT 订单金额, status TINYINT DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消 3-已退票, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, KEY idx_user_id (user_id), KEY idx_train_date (train_id, train_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 余票表这么设计的原因这里要重点聊聊t_stock表。我见过很多新手做类似项目时直接把余票数写在t_train表里一个字段完事。这在单机低并发下没问题但一旦有并发请求问题立刻暴露。t_stock把车次和乘车日期拆成了业务维度同一趟 G1024今天买和下周买余票池是独立的。如果不拆就会出现“今天没票了但明天还有大量余票”却没法单独查询的问题。另外我加了一个version字段这是为并发控制准备的。后面会详细讲先记住一点不要直接用UPDATE t_train SET remaining remaining - 1这种方式在总表上扣减因为行锁会阻塞其他车次的更新。独立余票表让锁的粒度更细不同车次之间互不干扰。2.3 建表脚本的使用建议myticket.zip里的sql/init.sql文件包含了全部建表语句和演示数据。导入方式很简单mysql -u root -p sql/init.sql我在演示数据里预置了 3 个车次G1024北京南-上海虹桥、D311广州南-武汉、K102成都东-西安北每个车次每天的余票初始化为 100 张。这样方便后期测试并发场景你甚至可以写一个多线程脚本去抢同一个车次的票直接观察数据是否正确。3. 核心业务逻辑与实现细节3.1 用户登录与注册中的密码处理登录注册模块是最基础的 CRUD但密码存储不能明文。Demo 里我用了MD5 固定盐值的方式虽然生产环境推荐 BCrypt 这样的自适应哈希算法但 Java 原生 JDBC 项目里引入 BCrypt 需要额外依赖权衡之后我用了加盐 MD5并在代码注释里明确指出了升级方向。public static String encryptPassword(String rawPassword) { String salt myticket_salt_2024; String text salt rawPassword; for (int i 0; i 3; i) { text DigestUtils.md5Hex(text); } return text; }做三次 MD5 的目的不是增加安全强度MD5 本身就不安全而是让生成的密文与常见的“单次 MD5 彩虹表”不直接匹配属于用低成本的方案防君子不防小人。正式项目务必换成 BCrypt。登录成功之后用户信息会存到HttpSession里后续下单接口通过 Session 获取当前登录用户 ID。这一步是整个业务流的前提不登录不能买票。3.2 车次查询与模糊搜索查询模块用了一个支持三个条件的动态 SQL出发站、到达站、发车日期。最关键的一点是查询结果的余票数必须实时从t_stock表读取不能缓存车次表中过期的总票数。public ListTrainInfoVO queryTrains(String from, String to, String date) { String sql SELECT t.id, t.train_no, t.start_station, t.end_station, t.start_time, t.end_time, t.price, COALESCE(s.remaining_seats, 0) AS remaining FROM t_train t LEFT JOIN t_stock s ON t.id s.train_id AND s.train_date ? WHERE t.start_station LIKE ? AND t.end_station LIKE ?; // 参数设置与结果封装... }这里用LEFT JOIN而不是INNER JOIN是为了避免某天还没初始化余票数据时车次直接被过滤掉。COALESCE函数则确保NULL显示为 0前端渲染不会报错。3.3 购票流程的事务边界购票是这个 Demo 里最核心的代码也是面试官最爱问的部分。整个流程分五步必须放在同一个数据库事务里执行public boolean buyTicket(int userId, int trainId, String date) throws SQLException { Connection conn dataSource.getConnection(); // 关键点关闭自动提交开启手动事务 conn.setAutoCommit(false); try { // 1. 锁定余票行悲观锁 String lockSql SELECT remaining_seats FROM t_stock WHERE train_id ? AND train_date ? FOR UPDATE; PreparedStatement ps conn.prepareStatement(lockSql); // ... 设置参数并执行 ResultSet rs ps.executeQuery(); if (!rs.next()) { throw new RuntimeException(未找到该车次的余票信息); } int remaining rs.getInt(remaining_seats); // 2. 业务校验余票是否充足 if (remaining 0) { throw new RuntimeException(余票不足); } // 3. 扣减余票 String updateSql UPDATE t_stock SET remaining_seats remaining_seats - 1 WHERE train_id ? AND train_date ?; // ... 执行更新 // 4. 生成订单号并插入订单 String orderNo generateOrderNo(trainId, date); String insertOrderSql INSERT INTO t_order (order_no, user_id, train_id, train_date, seat_type, price, status) VALUES (?, ?, ?, ?, ?, ?, 0); // ... 执行插入 // 5. 提交事务 conn.commit(); return true; } catch (Exception e) { // 任何一个环节出错全部回滚 conn.rollback(); if (e instanceof RuntimeException) { throw (RuntimeException) e; } throw new RuntimeException(购票失败, e); } finally { conn.setAutoCommit(true); conn.close(); } }3.4 为什么扣减余票不走“先查再改”这是高频面试题我在代码注释里专门标注了。回想一下刚才的事务流程第 1 步通过SELECT ... FOR UPDATE锁住了目标行然后第 3 步才做更新。那为什么不直接执行UPDATE然后看影响行数呢其实两种方案都行但语义不同方案 ASELECT ... FOR UPDATEUPDATE先锁行在应用层做业务校验比如检查余票是否大于 0再执行更新。好处是流程直观符合人的思维习惯业务逻辑可以写得很丰富比如同时校验用户是否重复购票。方案 B条件更新UPDATE ... SET remaining remaining - 1 WHERE remaining 0一条 SQL 搞定数据库原生保证条件竞争的原子性性能更高。Demo 里选了方案 A核心考量是教学可读性。方案 B 的代码虽然更短但初学者容易忽略“条件更新为什么会避免超卖”这件事。两个方案建议都亲手写一遍理解其中差异面试时就稳了。4. 并发控制、购买流程演示与排查实录4.1 两种防超卖方案的对比余票扣减是高并发场景下的经典问题我在 Demo 里实现了两种策略默认用悲观锁但代码里保留了乐观锁的完整实现方便对比。悲观锁方案当前默认依赖SELECT ... FOR UPDATE对目标行加排他锁事务提交后锁才释放。相当于同一个车次、同一个日期的余票记录同一时刻只有一个事务能读取和修改其他请求等待。代价是并发性能下降但实现最不容易出错。乐观锁方案可切换不加锁直接执行条件更新用版本号或剩余票数本身做校验UPDATE t_stock SET remaining_seats remaining_seats - 1, version version 1 WHERE train_id ? AND train_date ? AND remaining_seats 0通过判断影响行数是否为 1 来确认是否抢票成功。如果影响行数为 0说明余票已经没了重新查询或者直接提示用户。这个方案并发性能高但需要应用层处理更新失败后的重试逻辑。下表是两种方案的特性对比对比维度悲观锁FOR UPDATE乐观锁条件更新实现难度低逻辑直白中需要处理重试并发性能低行锁会阻塞高无锁等待适用场景并发量中等、需要强一致性高并发、允许小概率重试超卖风险无无条件保证了原子性4.2 模拟并发抢票的实测结果写完这两个方案我用一个简单的多线程模拟器做了压测100 个线程同时抢同一车次的 100 张票每个线程模拟一个真实用户。悲观锁方案跑完数据库里剩余票数正好是 0成功创建的订单是 100 个一个不多一个不少。乐观锁方案跑完剩余票数也是 0但有大概 5% 的线程因为影响行数为 0 而抢票失败重试最终订单同样恰好 100 个。这个实验结果说明一点方案选型对结果正确性没影响影响的是用户体验和系统吞吐。如果做项目答辩建议现场演示这段压测过程顺手把原理讲清楚面试官会认为你是真的理解了而不是背出来的。4.3 用户维度的超售限制这里加了一个容易被忽视的业务规则同一个用户不能重复购买同一天、同一车次的票。如果不做限制用户理论上可以下单 N 次把余票全抢光。实现方式是在插入订单前加一个校验 SQLString checkSql SELECT COUNT(*) FROM t_order WHERE user_id ? AND train_id ? AND train_date ? AND status IN (0, 1);如果返回的COUNT(*)大于 0直接抛出“您已购买过该车次的票”阻止重复下单。值得一提的是这个校验必须放在第 1 步锁行之后否则两个并发请求同时通过校验还是会出现重复购买。4.4 购票全流程演示假设用户zhangsan已登录要买2024-06-15的G1024车次控制台输入每一步都有明确提示 请输入操作2 查询条件 - 出发站北京南 查询条件 - 到达站上海虹桥 查询条件 - 出发日期2024-06-15 结果输出 G1024 北京南(08:00) → 上海虹桥(13:20) 二等座 553.00元 余票100张 请输入操作3 输入车次ID1 输入乘车日期2024-06-15 输入乘车人姓名张三 系统提示扣减余票成功订单号 MYT2024061510001请尽快支付。 请输入操作4 输入订单号MYT2024061510001 系统提示订单支付成功状态已更新。这里要留意一个体验细节下单后订单状态是“待支付”我通过定时任务模拟了 15 分钟未支付自动取消。虽然 Demo 里这个定时任务只是简单的ScheduledExecutorService但它在真实系统里是一个非常关键的能力——下单后不锁死余票超时就释放避免黄牛大量占座。4.5 退票逻辑与余票回滚退票就是购票的逆操作但有一个特别容易踩坑的地方回滚余票时如果当前余票已经满了怎么办。比如一节车厢有 100 个座当前余票 100 张此时有人退票余票变成 101 张吗业务上当然不行。Demo 里的处理方式是退票时除了把订单状态改成“已退票”还需要检查该车次该日期的余票是否小于总票数小于才允许remaining_seats 1UPDATE t_stock SET remaining_seats remaining_seats 1 WHERE train_id ? AND train_date ? AND remaining_seats total_seats这个约束我在代码注释里标了TODO留给读者自己思考。顺便提一句真实 12306 的退票逻辑还要考虑改签优先级、候补队列等这里不展开但理解了基础模型进阶方案都能水到渠成。4.6 常见问题与排查记录问题 1明明在代码里加了事务但数据还是不一致排查思路先确认conn.setAutoCommit(false)是否在try块外面执行了其次要看conn是否是同一个连接。我曾经遇到过一个经典错误——在工具类里getConnection()每次返回新连接两个 SQL 操作各用各的连接事务自然无法生效。问题 2SELECT ... FOR UPDATE锁不住行原因通常有两个一是表引擎不是 InnoDBMyISAM 不支持行锁二是查询条件没走索引导致行锁升级为表锁。检查建表语句和EXPLAIN执行计划基本都能定位。问题 3多个请求同时扣减时重复读到相同的余票数这是典型的并发读取问题很可能是你在事务外先查了一次余票展示给用户看用户下单时又查了一次两次查询之间余票被别人扣掉了。解决思路是以事务内的锁查询为准页面展示的数据只做参考不要用它做最终校验。问题 4连接池使用不当导致连接泄漏JDBC 项目里最容易犯的低级错误是conn没关闭连接池资源被耗尽系统假死。在finally块里关闭连接是最基本的意识。如果用了连接池还要注意连接归还而不是关闭的逻辑差异。4.7 项目打包与运行指引myticket.zip解压后的目录结构如下myticket/ ├── sql/ │ └── init.sql # 建表脚本 演示数据 ├── src/ │ └── com/myticket/ │ ├── util/ # DBUtils 连接工具类、MD5工具类 │ ├── dao/ # 数据访问层UserDao、TrainDao、OrderDao │ ├── service/ # 业务层TicketService │ └── view/ # 控制台交互入口 Main ├── lib/ │ └── mysql-connector-java-8.0.33.jar └── README.md运行步骤三步走确保本地 MySQL 已启动执行mysql -u root -p sql/init.sql初始化数据库。修改DBUtils.java里的数据库连接地址、用户名、密码。用 IDEA 或命令行编译运行Main.java看到控制台菜单就算成功。如果你是用 Eclipse 打开工程记得把lib目录下的 JDBC 驱动添加到 Build Path否则运行时会报ClassNotFoundException: com.mysql.cj.jdbc.Driver。5. 进阶方向与个人建议这个 Demo 目前是控制台应用但如果你想在简历上写得更漂亮有两条非常推荐的升级路径。路径一加一个 Web 层。用 Servlet JSP 把控制台交互替换成浏览器页面再加上登录状态保持Cookie/Session、前端校验、Bootstrap 模板就变成了一个完整的 Web 项目。如果愿意再进一步把 JDBC 换成 MyBatis-Plus把 Servlet 换成 Spring MVC就非常接近企业级项目了。路径二给并发控制“上强度”。在mysql-connector之外引入 Spring Boot Redis把“15 分钟未支付自动取消”从定时任务改成 Redis 过期事件驱动再用 Redis 分布式锁替换数据库悲观锁项目含金量立刻上一个台阶。我在实际开发中反复体会到这种从零手写的小 Demo 才是理解框架的最好跳板。很多人抱怨自己只会“调包”对底层原理一问三不知缺的就是这一步——用原生 JDBC 把事务和锁亲手实现一遍。这个项目做完后再去看 MyBatis 的一级缓存、Spring 的事务传播机制会有一种“原来是这么回事”的顿悟感。最后再分享一个小技巧强烈建议在做完项目后打开 MySQL 的通用日志或者用SHOW ENGINE INNODB STATUS查看锁等待信息配合压测代码反复看几遍你对数据库事务和锁的理解会远超同龄人。这个 demo 看似简单但把每一行代码的“为什么”都吃透秋招面试里的数据库和并发题基本就稳了。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →