ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

演唱会在线购票系统开发:Java并发控制与MySQL事务设计实战

演唱会在线购票系统开发:Java并发控制与MySQL事务设计实战 简介基于Java实现的演唱会在线购票系统设计源码适合Java学习者与毕业设计开发者参考可帮助理解在线购票流程中的用户登录、演唱会查询、在线选座及订单管理等核心模块。压缩包共37个文件包含13个Java源文件、10个class编译文件、6个XML配置、2个SQL脚本、2个properties配置及jar依赖等整体约2.1MB结构上按src、lib、out等目录组织便于对照阅读。资源已获91人学习可配合readme快速了解项目运行方式。源码覆盖数据持久化、配置管理及基础业务逻辑MVC分层清晰SQL文件提供建表与初始数据适合作为课程设计或二次开发的基础范例。1. 演唱会在线购票系统一门Java课程设计源码真正值钱的是并发控制演唱会门票的开售瞬间余票从100掉到0往往只需要几秒钟。演唱会在线购票系统这套源码表面看是用户、场次、订单的增删改查真正考验人的地方在并发控制几千个请求同时抢同一档票时系统要保证不超卖、不重复、不丢单还要在用户放弃支付后把库存还回来。对正在找Java课程设计题目的人来说这是把Java并发、MySQL事务、订单状态机一次串起来的经典练手项目对做小型票务或活动报名系统的开发者这套设计可以直接改造成秒杀场景的骨架。适合两类人读准备拿它交课设的学生和刚开始写高并发业务代码的后端工程师。看懂它比抄十套源码都管用。2. 先把数据模型立住从场次、票档到订单的六张核心表2.1 需求边界先聊清楚用户、场次、票档与座位演唱会购票和你熟悉的电商下单不太一样。电商的库存是挂在SKU上的一首歌的周边卖完就是卖完了演唱会的库存是按“演出场次 票档”维度管理的同一个歌手同一晚上在同一个场馆开一场内场票、看台票、VIP票各有各的库存互不干扰。动手建表之前必须先把两个业务规则问清楚。第一个是支不支持精确选座如果支持就要有一张seat表每个座位一行状态是“空 / 已占用”下单时锁具体行如果不支持只按票档卖那么票档表里存一个总余票数字就够了。第二个是支不支持退票支持的活订单状态机里就要有“已退票”这个终态库存回补的代码也要提前预留。这两个问题的答案直接决定表结构长什么样。我见很多课设翻车不是因为代码写得烂而是表结构第一天就没定对写到第三章发现库存没地方放回头改表、改mapper、改service连着改三天。先花半小时把边界写清楚后面能省出三倍时间。2.2 建表SQL六张核心表的一次成型写法这套系统里六张核心表分别是user用户表、concert演出场次表、ticket_type票档库存表、orders订单表、payment_record支付流水表和inventory_record库存流水表。前面两张表字段简单后面三张表才是业务核心直接看建表语句。票档库存表演唱会的“商品”就是它所有并发问题都围绕这张表展开CREATE TABLE ticket_type ( id bigint(20) NOT NULL AUTO_INCREMENT, concert_id bigint(20) NOT NULL COMMENT 演出场次ID, name varchar(30) NOT NULL COMMENT 票档名称内场票/看台票/VIP票, price bigint(20) NOT NULL COMMENT 票价单位分避免用double, stock int(11) NOT NULL DEFAULT 0 COMMENT 当前剩余库存, total_stock int(11) NOT NULL DEFAULT 0 COMMENT 初始总库存用于对账, sale_status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1在售 0停售, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_concert (concert_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT票档库存表;库存字段用int就够了一张票一个数字金额必须用bigint存“分”千万不要用double浮点数的精度问题在金额上是事故级别面试官看到double存钱印象分先扣一半。sale_status字段也不是多余的演出下架、票档停售都靠它控制不需要物理删除数据。订单表所有状态变化的落点CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号业务唯一, user_id bigint(20) NOT NULL COMMENT 下单用户ID, concert_id bigint(20) NOT NULL COMMENT 场次ID, ticket_type_id bigint(20) NOT NULL COMMENT 票档ID, quantity int(11) NOT NULL DEFAULT 1 COMMENT 购买张数, amount bigint(20) NOT NULL COMMENT 总金额单位分, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0已创建 1已支付 2已取消 3已退票, expire_time datetime NOT NULL COMMENT 支付截止时间, pay_time datetime DEFAULT NULL COMMENT 实际支付时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;order_no加唯一索引这是防重复下单的第一道物理约束就算代码里有并发漏洞数据库层面也能挡住一部分。expire_time是给第4章的库存回滚机制用的下单时写成create_time加15分钟或30分钟。支付流水表和库存流水表是辅助表字段设计如下表名关键字段作用payment_recordid, order_no, trade_no, channel, amount, status, callback_time记录支付回调order_no建唯一索引防幂等inventory_recordid, ticket_type_id, change_amount, order_no, create_time记录每次库存增减正数回补负数扣减inventory_record这张流水表很多人会忽略但它非常关键。扣库存、回补库存都往里面插一条记录演出结束后拿票档的总库存、订单数、流水数三方对账哪里出了问题一眼就能看出来。没有流水表库存数字变了只能靠猜那是纯黑匣子。2.3 为什么把库存放在票档上而不是座位表上精确选座模型下seat表一行为一个座位售出时执行的是“把某个座位状态从空改成已占用”。这个模型很直观但并发性能不理想一个订单买两张票可能要操作两行数据锁的范围分散用户下单前还要先选座多一次互动流程也更长。票档模型就简单得多一行票档记录一个余票数字扣库存时锁一行事务结束就释放。抢票场景的特点是“大量请求集中在少数几个票档上”行锁粒度越小、锁的行数越少系统吞吐越高。演唱会实际售票也确实是按档卖的普通用户买票时只关心内场还是看台、多少钱真正精确到座位号选座的场景占比不高。所以课设阶段我一般推荐票档模型理由就两条一是逻辑简单库存计算、余票展示都容易实现二是锁粒度好答得清楚“为什么这样设计”评委和面试官都认。这个选择本身就是答辩的得分点能说出InnoDB行锁粒度的人说明对数据库机制真的有理解。3. 用Spring Boot跑通购票主流程锁、事务与库存扣减怎么协同3.1 项目骨架与关键依赖最常见的实现组合是Spring Boot MyBatis MySQL。JDK 8或者17都行前提是本地环境装好JDK、环境变量配置正确、Maven能正常拉依赖。项目结构一般分四层controller接收请求service写业务逻辑mapper做数据访问domain放实体类。核心依赖只需要三个web、MyBatis和MySQL驱动dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyMyBatis的版本不用特意写让Spring Boot的依赖管理统一决定避免版本冲突。数据源的配置写在application.yml里连接池用Spring Boot默认的HikariCP就行不用额外引入别的池子。MyBatis的SQL可以写在XML里也可以直接写在mapper接口的注解上我个人建议课设项目用注解文件少逻辑集中代码评审的时候一眼能看全。3.2 购票主流程锁行、扣库存、建订单购票的核心方法就一个createOrder。整个方法必须在一个事务里三步操作要么全成功要么全失败。先看mapper层这是并发控制的命门Mapper public interface TicketTypeMapper { /** 悲观锁锁住这一行票档库存让并发请求在这里排队 */ Select(SELECT * FROM ticket_type WHERE id #{id} AND sale_status 1 FOR UPDATE) TicketType selectByIdForUpdate(Param(id) Long ticketTypeId); /** 原子扣减剩余库存必须大于等于购买数量否则影响行数为0 */ Update(UPDATE ticket_type SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}) int deductStock(Param(id) Long ticketTypeId, Param(quantity) Integer quantity); }这里有一个很容易被忽视的细节SQL参数一律用#{}不要用${}拼接前者是预编译占位符能防SQL注入后者是字符串替换业务系统里坚决不能出现。selectByIdForUpdate里的FOR UPDATE是普通select和悲观锁查询的分水岭只有加了它这一行才会在事务期间被锁住其他请求的同类查询会阻塞到当前事务提交。service层把三步串起来Service public class OrderServiceImpl implements OrderService { Autowired private TicketTypeMapper ticketTypeMapper; Autowired private OrderMapper orderMapper; Override Transactional(rollbackFor Exception.class, timeout 5) public Order createOrder(Long userId, Long ticketTypeId, Integer quantity) { // 1. 锁住票档行并发请求在这里排队 TicketType ticketType ticketTypeMapper.selectByIdForUpdate(ticketTypeId); if (ticketType null) { throw new BizException(票档不存在或已停售); } // 2. 校验剩余库存 if (ticketType.getStock() quantity) { throw new BizException(余票不足当前剩余 ticketType.getStock() 张); } // 3. 原子扣减影响行数为0说明库存刚好被抢完 int rows ticketTypeMapper.deductStock(ticketTypeId, quantity); if (rows 0) { throw new BizException(手慢了票被抢完); } // 4. 创建订单 Order order buildOrder(userId, ticketType, quantity); orderMapper.insert(order); return order; } }这个流程里有两道防线。第一道是FOR UPDATE锁行让同时到达的事务在数据库层排队第二道是deductStock里的stock #{quantity}条件就算某个环节锁失效了原子更新也会因为不满足条件而失败。两道防线缺一不可锁保证排队的有序性原子更新保证数据不被覆盖。controller层只做参数接收和结果返回RestController RequestMapping(/api/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/create) public ResultString create(RequestBody CreateOrderRequest request) { Order order orderService.createOrder( request.getUserId(), request.getTicketTypeId(), request.getQuantity()); return Result.ok(order.getOrderNo()); } }实际项目中userId应该从登录态里解析不能信任前端传过来的参数否则随便传一个别人的userId就能替别人下单。课设为了方便可以暂时在请求体里传但答辩时最好主动说清楚这一点显得你有安全意识。3.3 两个必调参数锁等待时间与数据库连接池抢票系统上线前有两个参数必须调不调就是等踩坑。第一个是MySQL的锁等待超时时间innodb_lock_wait_timeout默认值50秒。开票瞬间几百个请求同时来抢不到锁的事务会在数据库里傻等50秒连接池很快就被吃光后面的请求全部超时。-- 全局设置重启MySQL前临时生效 SET GLOBAL innodb_lock_wait_timeout 5; -- 持久化配置写入my.cnf的[mysqld]段 -- innodb_lock_wait_timeout5调到5秒以内配合事务注解上的timeout 5让请求快速失败用户1秒内看到“手慢了”的提示而不是转圈半分钟后才报错。第二个参数是HikariCP连接池的maximumPoolSize默认10在抢票场景偏小但也不要无脑调大。连接池大小不是越大越好数据库能同时处理的连接数有限池子开200数据库先扛不住。一般课设机器调到20到50足够配合限流效果更好。关于Transactional(rollbackFor Exception.class)这个参数网上很多人只写Transactional默认策略是只对运行时异常回滚受检异常不回滚。业务里如果自定义了受检异常库存扣了但订单没建数据就对不上了。显式声明rollbackFor逻辑才没有歧义这也是Java面试八股文里常被追问的点。4. 避坑实录超卖、锁失效、库存回滚与页面雪崩4.1 超卖库存变负数原因在“先查后扣”不是原子操作现象开票后查数据库stock变成了-5订单却卖出了120单初始库存只有100。用户投诉、对账失败、领导找谈话。原因写代码时用了先select再update的两步法——先查一下库存够不够够就update扣减。两个请求同时查到stock1都认为还有票都执行了扣减库存就变成了负数。这就是典型的“检查与扣减不是原子操作”时间窗口就在select返回结果和update执行之间。解决用前面代码里的两道防线。先加FOR UPDATE锁行让并发事务排队再用UPDATE ticket_type SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}做原子扣减影响行数为0就抛异常。有人会推荐乐观锁方案UPDATE ... SET stock stock - ?, version version 1 WHERE id ? AND version ?失败重试在读多写少的场景适用。但抢票是典型的写冲突场景悲观锁更直接重试逻辑也更好写课设推荐用悲观锁。4.2 锁失效synchronized只锁得住单个JVM拦不住集群现象本地测试跑了两个线程抢同一档票数据完全正常。打成jar包部署到两台服务器超卖问题又回来了库存照样变负数。原因synchronized和ReentrantLock锁的都是Java进程内的对象两台服务器是两个独立的JVM各锁各的互不相干。锁只对本进程内的线程生效跨进程、跨节点的请求根本拦不住。解决把锁上移到数据库层用第3章的FOR UPDATE方案就能解决集群问题数据库锁天然跨实例。如果觉得每次都锁数据库太奢侈可以引入Redis分布式锁但那是进阶方案后面单独讲。另有一个容易被忽略的坑即使单机部署synchronized锁住整个createOrder方法也未必安全——方法执行完锁就释放了但事务提交发生在方法返回之后、由Spring的事务代理完成这中间有个时间窗下一个线程拿到锁读到旧库存照样出问题。锁要放在事务边界之内或者干脆不用代码锁用数据库锁一了百了。提示谈到锁的时候先分清锁是谁的代码锁、数据库锁、Redis锁作用域完全不同混着谈必翻车。4.3 库存回滚问题只创建订单不支付库存被僵尸单占光现象开票五分钟后订单表里躺着几十条status0的未支付订单票档显示已售罄但这些人根本没付钱。真实用户买不到票只能等这批僵尸订单超时。原因业务缺少支付时限和超时关闭逻辑。下单时扣了库存但没有任何机制把未支付占用的库存还回来。解决建表时预留的expire_time字段派上用场了。下单时把expire_time设为当前时间加15分钟后台加一个定时任务每分钟扫描一次过期未支付订单把订单状态改成已取消同时回补库存。回补和改状态必须放在同一个事务里Scheduled(fixedDelay 60_000) Transactional(rollbackFor Exception.class) public void cancelExpiredOrders() { // 查出所有支付超时的已创建订单 ListOrder expired orderMapper.selectExpiredUnpaid(LocalDateTime.now()); for (Order order : expired) { // 先把订单置为取消 orderMapper.updateStatus(order.getId(), ORDER_STATUS_CANCELLED); // 再回补库存同时写一条库存变更流水 ticketTypeMapper.addStock(order.getTicketTypeId(), order.getQuantity()); inventoryRecordMapper.insert(Record.of(order.getTicketTypeId(), order.getQuantity(), order.getOrderNo())); } }注意定时任务要用fixedDelay而不是fixedRate前者是等上一次执行完再开始计时避免任务堆积。回补库存时记得写inventory_record流水不然以后排查“库存为什么多了”只能靠猜。正式系统里更优雅的方案是用延迟消息比如RabbitMQ的TTL队列但课设环境用定时扫描最稳妥也最好解释。4.4 页面雪崩热点票档把所有请求压进数据库连接池原地爆炸现象开票瞬间数据库CPU飙到100%应用日志大量报Connection pool exhausted连后台管理页面都打不开整个系统瘫痪。原因所有流量都直连数据库查询库存。一个热点场次几十万请求进来先查库再排队抢锁数据库连接池瞬间被占满后面的请求连连接都拿不到系统进入假死状态。解决读写分离思路。库存查询接口和下单接口分开处理查询接口先读RedisRedis没有再读数据库Redis里也没有就回源数据库并回填缓存下单接口加限流每台机器每秒只放行一定数量的请求超出的直接返回“排队人数过多”。数据库连接池不要开太大前面那个锁等待时间也要配合着调小让占不到连接的请求快速失败。这一套组合下来就算票已经卖完页面也能正常显示“已售罄”系统不会整个瘫掉。4.5 重复下单一个用户点了十次提交生成了十个订单现象用户手快连点十次“立即购买”订单表里出现了十笔相同场次、相同票档、不同订单号的记录库存被一个人占走十张。原因前端的按钮防抖只能拦住正常人拦不住网络重试和自动化脚本。后端没有做幂等约束每次请求都当新单处理。解决两层防护。第一层是数据库约束给orders表加一个uk_user_concert_type_status唯一索引同一个人在同一个场次同一票档只能有一笔未支付订单重复插入直接报DuplicateKeyException代码里捕获后查出来返回原订单号。第二层是业务判断service入口先查一遍是否存在status0的未支付订单存在就直接返回不再走建单流程。两层都有才算把重复下单堵死。5. 订单状态机与本地压测用数据说服自己系统能上线5.1 订单状态机状态迁移矩阵画清楚了再写代码订单表里的status字段只是一个int真正让订单不容易出错的是状态迁移规则。谁能在什么条件下把订单改成什么状态必须有一张明确的矩阵否则每个人都在自己的方法里乱改状态改到哪个状态、有没有回补库存完全失控。我常用的迁移矩阵就下面这张表当前状态触发事件目标状态配套动作已创建(0)支付成功回调已支付(1)写支付流水、记录pay_time已创建(0)超过expire_time未支付已取消(2)回补库存、停止支付已创建(0)用户主动取消已取消(2)回补库存已支付(1)用户申请退票已退票(3)回补库存、发起退款已取消(2) / 已退票(3)任何事件不允许抛业务异常这张表对应到代码里写一个枚举类统一管理状态和迁移校验public enum OrderStatus { CREATED(0, 已创建), PAID(1, 已支付), CANCELLED(2, 已取消), REFUNDED(3, 已退票); private final int code; private final String desc; /** 判断状态迁移是否合法 */ public static boolean canTransit(OrderStatus from, OrderStatus to) { switch (from) { case CREATED: return to PAID || to CANCELLED; case PAID: return to REFUNDED; default: return false; } } }状态校验必须在service层统一做不要让每个controller自己去判断否则异常路径会漏。支付回调也要做幂等同一个支付结果可能被推送多次payment_record表的order_no加唯一索引重复回调直接忽略或返回原结果。5.2 用JMeter做一次200并发压测线程组、参数和断言这样设写完代码先别急着交本地做一次并发压测看系统到底扛不扛得住。JMeter是免费工具步骤也不复杂第一步线程组设置200个线程Ramp-Up设为0秒循环1次意思是200个请求同时发射模拟开票瞬间的流量尖峰。第二步添加HTTP Request请求方法POST路径填/api/order/createBody填JSONuserId用${__threadNum}拼不同的值ticketTypeId固定quantity填1。第三步加一个JSON断言确保响应里带上了orderNo才算成功。第四步压测前手动把票档库存改成100。如果不习惯JMeter图形界面也可以写一个最简并发脚本原理一样int threads 200; CountDownLatch ready new CountDownLatch(threads); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threads); for (int i 0; i threads; i) { int userId i 1; new Thread(() - { ready.countDown(); try { start.await(); // 所有线程在这里等待发令枪 String resp httpPost(/api/order/create, {\userId\: userId ,\ticketTypeId\:1,\quantity\:1}); System.out.println(userId - resp); } catch (Exception e) { e.printStackTrace(); } finally { done.countDown(); } }).start(); } ready.await(); // 等200个线程全部就绪 start.countDown(); // 发令枪开始 done.await(); // 等全部请求结束这里的关键点在于CountDownLatch的配合ready.await()保证所有线程先创建完成、就绪start.countDown()是发令枪这样200个请求才是真正的同时起跑压测结果才有意义。如果把new Thread和httpPost写在一个循环里前几个请求已经跑完了后面的线程还没创建出来并发效果大打折扣。5.3 压测结果怎么读数量和库存对上了只是第一步压测跑完数据对上了也不能急着庆祝。三个地方必须逐一核对第一订单表的总行数是不是100成功订单数量不能超过初始库存。第二ticket_type表的stock最终为0inventory_record表里的扣减总数是100两边对得上账。第三失败请求的响应内容是什么如果清一色是“余票不足”或“手慢了”说明业务逻辑按预期返回了但凡出现一条DuplicateKeyException或者事务超时异常说明幂等或锁的配置还有问题。并发压测最容易出玄学一次数据对不代表次次对。把并发从50、100、200、500逐级压一遍观察成功订单数是不是一直等于初始库存。如果某一次冒出来多一笔订单立刻回头查锁和事务边界优先看那句FOR UPDATE是不是真的生效了、事务是不是真的包住了整个扣库存流程。别把压测工具当黑匣子能解释每一笔失败订单为什么失败才说明这套方案真的掌握到了。6. 三个进阶改造从课设demo到敢真卖票的准生产系统课设版本和准生产版本的分水岭在三个改造点上。第一用Redis分布式锁替换数据库行锁。FOR UPDATE能用但每次抢票都占用一条数据库连接连接池就是并发瓶颈。常见做法是把锁上移到Redis锁的粒度还是ticket_lock:{ticketTypeId}String lockKey ticket_lock: ticketTypeId; Boolean locked redis.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(3)); if (Boolean.TRUE.equals(locked)) { try { return doCreateOrder(userId, ticketTypeId, quantity); } finally { // 释放锁前先比较requestId防止误删别人的锁 redis.execute(RELEASE_LUA, List.of(lockKey), requestId); } }setIfAbsent必须带过期时间防止持有锁的线程崩溃导致死锁requestId用于标识锁的持有者释放时用Lua脚本做“比较并删除”避免删掉别人新拿到的锁。注意锁的过期时间如果比事务执行时间还短锁提前失效这时候数据库那层stock #{quantity}兜底条件就派上用场了。Redis锁不是银弹数据库扣减永远是最后防线。第二用消息队列做削峰。下单接口收到请求后先往队列里丢一条CreateOrderTask立刻返回“已进入排队”消费端再逐条执行扣库存、建订单、发支付链接。瞬时流量被摊平数据库承受的压力稳定在一个可预测的水平。第三多级缓存读库存。优先读本地缓存其次读Redis最后才查数据库开票前把热点票档的库存预热进缓存。余票展示允许秒级误差下单时以数据库为准这一点要在文档里写清楚避免后续同事纠结缓存一致性。我第一次做完这套售票系统时信心满满拿给室友抢票测试第一轮就在连接池上翻了车。后来才想明白售票系统的代码大部分是防御代码真正值钱的是你为那些异常路径想过多远。希望帮到你。本文还有配套的精品资源点击获取
返回列表