
引导段做演唱会购票系统这个选题老实说在毕业设计里算是一个经典且性价比很高的方向。它几乎覆盖了 Java 后端开发的全套知识点权限控制、库存管理、订单状态机、座位图渲染、支付回调、并发防超卖……这些全是企业里真实在用的东西。你只要把 Spring Boot 这套跑通了简历上写“独立设计并实现了一个线上选座购票平台”面试官很难不感兴趣。这篇文章我会从零开始把这套系统怎么做完整地捋一遍。不是把 Controller、Service、Mapper 抄一遍就完事而是把“为什么这么做”讲清楚——座位状态怎么管、锁票怎么锁、超时释放怎么实现、高并发下怎么防止同个座位被卖两次。无论是打算直接用这个题目做毕设还是只是想把票务类项目的核心逻辑吃透这篇都能给你省下不少踩坑的时间。1. 需求拆解与功能边界1.1 这个系统的三个核心痛点先说结论演唱会购票系统在业务上可以有很多花活比如会员等级、优惠券、粉丝团特权但真正决定系统好坏的就三件事。第一是“座位和库存的一致性”。一张票对应一个座位座位卖了就不能再卖。这和普通电商的库存完全不同电商库存是一件商品有 1000 件你只要把数量扣对就行演唱会票务是一张座位图上有几千甚至几万个不同的座位每个座位都是一个独立库存这就引入了“行、列、区域、票价档位”这些额外维度。第二是“锁票和超时释放”。用户选完座不会立刻付款系统得给他留几分钟时间。这个“留”就是锁票。锁太久会浪费票源锁太短用户来不及付款而且锁票期间座位要在其他用户端实时变灰。这里涉及状态超时、定时任务、以及并发下防止重复锁票。第三是“防恶意占座和刷票”。热门演唱会一开票黄牛脚本能在一秒内发出成千上万的请求。要是系统不做任何限制数据库连接会被打爆真实用户根本抢不到。所以限流、接口防刷、队列削峰这些东西在票务系统里不是加分项而是必需品。这三件事对应到项目里就是座位状态管理、订单状态机、高并发处理三块核心代码。我把这三块单独拎出来讲其余像后台管理、用户中心这些 CRUD 功能反而简单。1.2 用户角色与用例清单做毕设别上来就画一堆用例图然后开始写代码。先把角色和核心流程定下来后面的表结构和接口就都有依据了。这套系统的角色分三类游客只能浏览演出列表和场次信息不能选座、不能下单。有些毕设把注册也省了直接强制登录下单我觉得不太好因为缺少了“游客临时选座登录后合并”这个真实场景但考虑到毕设工作量强制登录是可以接受的简化。注册用户核心操作是选座、锁定座位、下单、支付、查看订单、退票。管理员管理演出场馆、场次、座位图、票价档位查看所有订单处理退票。核心用例简单梳理如下角色用例关键约束游客浏览演出列表、查看场次无需登录用户在线选座、锁定座位一个用户最多同时锁 5 个座位用户提交订单、模拟支付锁票超时未支付自动释放用户查看订单、申请退票已支付订单可退管理员维护场馆与座位座位图可配置管理员管理场次与票价每个场次独立座位库存管理员查看订单列表、标记出票出票后座位不可退这里有一个经验用户用例里的“锁定座位”和“提交订单”很多人会混在一起做觉得选完座直接生成订单就行。真实系统中这两个动作是分开的。选座只产生“座位锁”不产生订单因为用户可能选了好几个座位犹豫一下要不要提交或者选座后发现自己没登录先去登录回来座位还在。把这个分离之后状态机和超时释放就有了抓手。1.3 功能模块拆分模块划分是直接对应到代码目录和数据库表的我建议按这样的方式拆用户模块注册登录、个人信息、我的订单、我的退票记录。基于 Spring Security 或 Sa-Token 做鉴权别自己手写 session 管理除非你想复习一遍 Filter 机制。演出模块演出列表、详情、场次筛选。场次和演出是分开的一个演出可以有多场比如北京场、上海场、加场。场馆与座位模块场馆信息、座位区域、每个区域的票价档位。座位数据可以一次初始化成模板场次创建时复制一份。选座与订单模块锁座、解锁、下单、支付回调、订单超时关闭。这是整个项目最复杂的部分。后台管理模块场馆 CRUD、演出 CRUD、场次上架下架、订单查询、退票审核。很多人会纠结要不要做前端。我的建议是如果有时间做一个 Vue 3 Element Plus 的管理后台再做一个移动端适配的购票 H5 页面用 Axios 对接接口。演唱会购票本身就偏向移动端场景前端选座图用 Canvas 或者 SVG 实现都可以别用太多图表库不然答辩时被问到底层实现很容易卡壳。整个项目的模块关系是前端选座图通过接口拿到座位列表用户点击某个座位时调用后端锁座接口后端先查座位状态再写 Redis 锁和数据库状态成功后前端把座位标记为“已选”用户点击提交订单后端把这些已选座位转成订单明细同时启动超时计数器。2. 技术选型与项目架构2.1 为什么选 Spring Boot 而不是 SSH 或 SSM这个题目挂在 Java 分类下实际上绝大多数人用的都是 Spring Boot。我直接说下我的判断Spring Boot 的自动配置和 starter 机制能极大减少配置量一个spring-boot-starter-web就把 Web 容器、JSON 转换、异常处理都带上了比起早年 SSH 时代写一堆 XML 要省下至少两三天时间。毕设答辩老师基本默认你会用 Spring Boot。你要是在 2025 年的项目里还用 SSM 手动配置反而容易被质疑技术栈过时。Spring Boot 生态对 Redis、MQ、Elasticsearch 都有现成 starter后面做高并发处理直接引入就行。版本选择上我建议 Spring Boot 2.7.x 或者 3.x 搭配 Java 8 或 Java 17。如果你用的是 JDK 8就选 2.7.18如果装了 JDK 17 或者 21就直接上 Spring Boot 3.2。别为了追新选一个刚出的小版本万一遇到依赖兼容问题排查起来会非常痛苦。2.2 数据库表结构设计表结构是整个项目的骨架设计好了后面写代码能省一半劲。我列出核心的几张表以及每张表的要点。用户表t_user字段类型说明idbigint主键usernamevarchar(64)登录名唯一passwordvarchar(128)BCrypt 加密phonevarchar(20)手机号statustinyint1 正常0 禁用create_timedatetime注册时间演出表t_show字段类型说明idbigint主键namevarchar(128)演出名称cover_urlvarchar(255)封面图descriptiontext演出介绍statustinyint1 未上架2 已上架3 已下架场次表t_session字段类型说明idbigint主键show_idbigint关联演出venue_idbigint关联场馆start_timedatetime开场时间sale_start_timedatetime开票时间sale_end_timedatetime截止时间statustinyint1 未开票2 售票中3 已售罄4 已结束场次和演出分离之后同一个演出就能复用一套数据比如“巡演”就非常合适。另一个关键点是sale_start_time开票时间不到的时候接口要直接拒绝抢票这个在压测时很容易被忽略。座位表t_seat字段类型说明idbigint主键venue_idbigint归属场馆session_idbigint场次 ID区分不同场的同一座位area_codevarchar(32)区域编码如 A 区row_noint排号col_noint列号pricedecimal(10,2)票价statustinyint0 可售 1 锁定 2 已售 3 不可售这里要特别注意座位表要不要按场次复制一份我的答案是复制。因为同一个场馆的同一个座位在不同场次中状态完全独立这一场卖了下一场还能卖。如果不复制你就得在座位状态上额外加一个场次维度查询和锁定的 SQL 都会复杂很多。用空间换逻辑简单对毕设来说非常划算。2.3 项目分层与目录结构一个清晰的目录结构答辩时能直接展示给老师看。我建议这样组织com.example.ticket ├── TicketApplication.java ├── common │ ├── Result.java │ ├── exception │ └── constant ├── config │ ├── RedisConfig.java │ ├── WebMvcConfig.java │ └── AsyncConfig.java ├── controller │ ├── user │ ├── show │ ├── order │ └── admin ├── service │ ├── impl │ └── UserService.java ├── mapper │ └── xml ├── entity ├── dto ├── vo └── task └── OrderTimeoutTask.javaController 层只做参数接收和结果封装不写业务代码Service 层是业务逻辑的核心尤其是锁座、下单相关的代码Mapper 层用 MyBatis-Plus每个方法对应一个 SQL。别把业务逻辑写在 Controller 里这种代码自己调试都费劲。3. 选座与票务状态机项目最核心的难点3.1 座位状态流转设计说了这么多终于到最核心的部分了。座位状态是整个系统的灵魂我把它定义为四种状态0 可售用户可以点击并锁定1 锁定已有用户选中但还没付款处于倒计时状态2 已售订单已支付座位不能再被任何人锁定3 不可售可能是被管理员维护、遮挡区、或者是保留座位状态流转的规则是可售 用户点击选座 → 锁定同时记录锁座时间启动超时任务锁定 用户提交订单 → 已售在支付回调成功后才改锁定 超时未支付 → 可售释放座位已售 用户退票 → 可售把售票记录标记为取消这里最容易掉进去的坑是把订单状态和座位状态混在一个字段里。我见过有人用订单状态去推座位状态结果一套状态机里既有待支付又有已锁定看起来差不多实际关系很乱。我的做法是订单管订单自己的状态座位管自己的状态两者通过t_order_seat表关联支付回调成功后同时更新订单和座位用事务保证一致性。3.2 锁票与超时释放机制锁票的接口设计要考虑几个问题同一用户不能重复锁同一个座位不同用户不能锁同一个座位锁座后要在 15 分钟内完成支付。代码层面我推荐这样实现Transactional public boolean lockSeat(SeatLockRequest request) { Long userId request.getUserId(); Long sessionId request.getSessionId(); Long seatId request.getSeatId(); // 乐观锁实现只有当前状态为 0 可售时才更新为 1 锁定 int updated seatMapper.compareAndSetStatus( seatId, sessionId, SeatStatus.AVAILABLE.getCode(), SeatStatus.LOCKED.getCode() ); if (updated 0) { throw new SeatLockedException(座位已被锁定或售出); } // 记录锁座缓存设置过期时间为 15 分钟 String lockKey seat:lock: sessionId : seatId; redisTemplate.opsForValue().set(lockKey, userId.toString(), 15, TimeUnit.MINUTES); return true; }compareAndSetStatus对应的 SQL 是UPDATE t_seat SET status #{newStatus}, lock_user_id #{userId}, lock_time NOW() WHERE id #{seatId} AND session_id #{sessionId} AND status #{oldStatus}这个 SQL 的精髓在于把“判断状态 更新状态”合并成一条原子操作。在并发情况下数据库行锁会保证同一条记录只有一个事务能改成功其他事务 update 影响行数为 0就知道自己抢失败了。我强调一下不要先查询状态再 update不要在 Java 代码里用 if 判断那样在高并发下一定会出问题。超时释放用定时任务扫描。每 30 秒执行一次把锁定超过 15 分钟且没有订单的座位释放掉Scheduled(fixedDelay 30000) public void releaseExpiredSeats() { ListSeat expiredSeats seatMapper.selectExpiredLocks(30, TimeUnit.MINUTES); for (Seat seat : expiredSeats) { // 释放前再检查一次是否已有订单 int cnt orderSeatMapper.countBySeatId(seat.getId()); if (cnt 0) { seatMapper.releaseSeat(seat.getId()); } } }这里有个细节释放前一定要再查一次订单否则很可能出现“用户正好在最后一秒提交订单订单表已经插入了关联记录但座位还没改成已售结果定时任务把座位释放了”的极端情况。重复检查不是黑客行为而是很常见的边界问题。3.3 防超卖与分布式锁有些同学会觉得上面那个compareAndSetStatus已经解决了并发问题但实际还不够。因为业务流程是“用户选座 → 锁座 → 提交订单 → 支付”其中锁座和下单之间隔了十几分钟如果两个用户同时锁同一个座位数据库行锁会保证只有一个成功。真正复杂的是“批量锁座”比如用户一次选了 5 个座位可是其中第 3 个座位被别人锁了这时应该怎么办我的方案是批量锁座时逐个执行compareAndSetStatus如果中间有失败就回滚前面已经锁成功的座位。回滚的逻辑很简单把刚刚锁成功的座位状态改回可售同时删除 Redis 锁记录。这个回滚操作本身也要用事务包起来确保原子性。分布式锁在这套系统里用在两个地方一个是防超卖的下单接口一个是定时任务执行。用 Redis 的 SETNX 就行参考 schema 如下String lockKey ticket:order: userId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (!locked) { throw new FrequentRequestException(操作太频繁请稍后重试); }下单接口加这个锁可以防止同一个人连续点两次“提交订单”导致同一个座位生成两个订单。虽然数据库层有重复校验但提前在应用层挡住一批无效请求数据库压力会小很多。4. 实操项目搭建与核心配置4.1 Maven 依赖与 application.yml 配置直接贴一份我实测用的 pom 依赖版本比较稳parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.25/version /dependency /dependenciesapplication.yml里的几个关键配置项server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ticket_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: truemap-underscore-to-camel-case一定要开不然数据库字段lock_user_id映射到 Java 的lockUserId会失败这是新手最常见的问题之一。4.2 场馆座位数据的初始化座位表数据怎么初始化也是一大痛点。手写几千行 INSERT 不现实我建议写一个 CommandLineRunner启动时检测座位表为空就自动生成。比如一个容纳 2000 人的中小型场馆划分成 A、B、C 三个区域A 区 500 座B 区 700 座C 区 800 座每个区域票价不同。生成逻辑就是四层嵌套循环区域 → 排 → 列 → insert。Bean public CommandLineRunner initSeatData() { return args - { if (seatMapper.countByVenueId(1L) 0) { return; } ListSeat seats new ArrayList(); // A 区 10 排 x 50 列 500 个座位 for (int row 1; row 10; row) { for (int col 1; col 50; col) { seats.add(buildSeat(1L, A, row, col, new BigDecimal(1280.00))); } } // B 区 14 排 x 50 列 700 // C 区 16 排 x 50 列 800 seatMapper.batchInsert(seats); }; }批量插入用 MyBatis 的foreach循环拼接几千条数据一次插入的耗时基本在几百毫秒内。这种自动化初始化方式配合前端选座图调试起来能省太多事了。要生成一个场次时就在已有场馆座位模板的基础上复制一份到t_seat表。复制时给session_id填上新的场次 ID状态初始化为 0 可售。SQL 大概长这样INSERT INTO t_seat (venue_id, session_id, area_code, row_no, col_no, price, status) SELECT venue_id, #{newSessionId}, area_code, row_no, col_no, price, 0 FROM t_seat WHERE session_id #{templateSessionId}这招特别实用一场演唱会临时加场只需要复制一次座位数据新场次的票务就全部就绪了。4.3 下单流程完整代码示例下单是整个项目的核心链路我把它拆成几个步骤校验用户登录态查询锁座记录确认座位还在锁定状态且属于该用户计算订单总价插入订单主表状态为待支付批量插入订单座位关联表调用支付服务毕设可以用 mock关键代码Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListLong seatIds) { ListSeat lockedSeats seatMapper.selectForUpdate(seatIds); if (lockedSeats.size() ! seatIds.size()) { throw new BusinessException(部分座位状态异常请重新选座); } for (Seat seat : lockedSeats) { if (!userId.equals(seat.getLockUserId()) || seat.getStatus() ! SeatStatus.LOCKED.getCode()) { throw new BusinessException(座位 seat.getId() 不属于当前用户或已失效); } } BigDecimal totalPrice lockedSeats.stream() .map(Seat::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); Order order new Order(); order.setUserId(userId); order.setTotalPrice(totalPrice); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setCreateTime(LocalDateTime.now()); orderMapper.insert(order); for (Seat seat : lockedSeats) { orderSeatMapper.insert(new OrderSeat(order.getId(), seat.getId())); } return buildOrderVO(order); }这里selectForUpdate用的是悲观锁它会锁住查询到的行直到事务提交。虽然前面锁座用的是乐观锁的compareAndSetStatus但在下单这种关键事务里再用一次悲观锁可以确保当前事务内数据不被外部修改。两种锁策略不冲突乐观锁用于抢占悲观锁用于最后的数据保护。5. 高并发场景优化与压测5.1 Redis 缓存与预减库存选座图中每个场次的座位状态用户会非常频繁地刷新查看。如果每次刷新都查 MySQL一个热门场次同时几万人在线MySQL 根本扛不住。所以要用 Redis 做一层缓存。我的做法是场次开票后把座位列表按区域缓存到 Rediskey 形如session:seat:list:{sessionId}value 是一个 JSON 数组包含座位 ID、区域、排、列、价格、状态。前端选座时直接读这个缓存只有点击选座时才走后端接口。锁座成功后不仅要更新 MySQL还要更新 Redis 里的座位状态保证其他用户能看到座位变灰。这里用 Redis 的 hash 结构会比较方便HSET session:seat:map:{sessionId} {seatId} {status:1, lockUserId:123}当用户刷新座位图时直接从 hash 里取状态不用查数据库响应时间能控制在 10ms 以内。预减库存是另一个优化点。每个场次维护一个 Redis 计数器锁座前先DECR返回值小于 0 就说明没票了直接返回“已售罄”不用去动数据库。锁座成功后再把数据库状态更新了。这样能挡住绝大多数无效请求。5.2 接口限流与削峰限流有两个层面单用户限流和全局限流。单用户限流可以用 Redis 计数器每个用户每秒最多请求 N 次。比如选座接口限 5 次/秒普通查询限 20 次/秒String key rate:user: userId : requestMethod; Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, 1, TimeUnit.SECONDS); } if (count 5) { throw new FrequentRequestException(请求过于频繁); }全局限流我推荐用 Guava 的 RateLimiter 或者 Redis 的令牌桶。其实毕设用到单用户限流就够了全局限流可以作为一个加分点写进文档里代码实现一下也很快。关于削峰很多毕设根本用不到 MQ。如果你的场次规模是一万张票、五万人抢那确实需要消息队列把下单请求转成异步处理如果只是 1000 张票你用 Redis 预减库存加数据库锁座已经完全足够了。别为了秀技术硬上 RabbitMQ答辩时被问到消息丢失、重复消费反而给自己挖坑。5.3 压测结果怎么看我拿 JMeter 简单压过这个项目的锁座接口开 200 个线程循环发请求MySQL 和 Redis 都跑在本机。给大家一个直观的数据参考不加缓存不加限流直接在锁座接口查 MySQLQPS 大约 800 左右数据库 CPU 占满。加了 Redis 预减库存后锁座接口 QPS 能到 1800 左右原因是无效请求在 Redis 层就被拦截了。再加单用户限流后接口 QPS 稳定在 1500但数据库负载明显下降因为重复刷接口的请求被挡掉了。这个数据给不了绝对参考毕竟是本机环境但能看出一个趋势Redis 在票务系统里的作用是拦截和降载真正写库的动作仍然要控制在最低频率。做压测时一定记得把日志关掉Logback 默认打印 SQL 日志对性能影响很大压测前把mybatis-plus.configuration.log-impl改成NoLoggingImpl。6. 常见问题与排错实录6.1 座位重复卖出怎么办这是最容易出现的并发问题。根源基本都是没有做原子更新而是先 select 再 update。排查步骤打开 MySQL 慢查询日志看是否有大量慢 SQL。检查锁座的更新语句是否带了status 0条件。如果只是UPDATE t_seat SET status1 WHERE id那并发请求会互相覆盖直接导致超卖。确认Transactional是否生效。同一个类内部调用事务方法Spring 代理不会拦截也会导致锁失效。我在踩过这个坑之后把锁座接口的更新 SQL 固定写成compareAndSetStatus就是以 status 作为条件更新影响行数只有 0 和 1 两种结果。这是代码评审时最重要的一个审查点。6.2 订单超时了座位没有释放超时释放跑完一遍发现座位状态没变很正常。最常见的原因是定时任务里没有加“释放前检查订单”的逻辑。还有另一个场景用户锁座后超时释放和用户提交订单刚好同时发生SELECT count(*) FROM order_seat WHERE seat_id查出来订单已经存在于是座位不被释放但订单也一直处于待支付状态形成一个脏数据。我的解决办法是订单创建时给订单本身也加一个过期时间字段超时定时任务除了释放座位还要把对应订单改成已取消双管齐下。6.3 前端选座图座位对不上座位图渲染出来跟场馆实际座位对不上。这个多数不是后端的问题而是前端画图时用了绝对坐标而后端只返回了排号和列号。我给前端的数据结构里增加了x和y坐标字段这个坐标可以在座位初始化时生成也可以在查询时根据区域动态计算。比如 A 区是一个横向 50 座的矩形第 1 排第 1 列的坐标就是 (0,0)每往右一列 x 加 26座位间距加座位宽度每往下一排 y 加 26。不同区域之间的起始 x 坐标要错开防止图形重叠。6.4 答辩时的高频问题做这个题目答辩时大概率会被问到这些问题提前准备答案为什么选 Redis 做锁票缓存答Redis 的SETNX和过期时间天然支持分布式锁且原子操作避免了并发问题。锁座和下单之间断了怎么办答锁座记录带超时时间定时任务自动释放订单表也有状态机兜底。数据库表怎么设计的答按照场景把用户、演出、场次、座位、订单、订单座位关联表分开设计重点说明座位表按场次复制的原因。如果用户支付成功但回调没有返回怎么办答订单表设置一个支付超时时间定时任务主动查询第三方支付结果毕设模拟支付时可以直接改成支付后 call 接口回调。这些答案不需要背得一字不差但一定要真的操作过、掉过坑回答起来才会有底气。我记得当时做这个项目时最失败的一次是压测时把 MySQL 锁表了所有请求全卡住排查了一个多小时发现是事务里一个查询用了select * for update把整张表锁住了。从那以后我凡是遇到for update都先看有没有索引、锁的范围是不是精确到了行。这个项目的扩展性其实很好。做完基础版之后可以再加一个座位分区管理、会员折扣、团购功能每一块都是可以写进简历的亮点。如果时间充裕建议把高并发部分做得再细一点比如用 Redisson 替换手写的 SETNX 分布式锁用 RocketMQ 替换定时轮询做订单超时关闭这些升级都能让你在答辩里多聊十分钟的深度内容。最后说一点个人体会做毕设最忌讳的不是不会而是怕做不完就一直拖。先把选座、下单、支付这三条最要紧的链路端到端跑通再回头补后台管理的 CRUD心态会完全不一样。票务系统的核心就是座位状态和订单状态这两套状态机它们跑稳了这个项目就已经成功了一大半。