☰
Spring Boot演唱会购票系统:座位锁票与防超卖核心实现
2026/10/4 13:15:24 网站建设 项目流程

(引导段)

做演唱会购票系统这个选题,老实说,在毕业设计里算是一个经典且性价比很高的方向。它几乎覆盖了 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.java

Controller 层只做参数接收和结果封装,不写业务代码;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() { List<Seat> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency> </dependencies>

application.yml里的几个关键配置项:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ticket_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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: true

map-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; } List<Seat> 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 下单流程完整代码示例

下单是整个项目的核心链路,我把它拆成几个步骤:

  1. 校验用户登录态
  2. 查询锁座记录,确认座位还在锁定状态且属于该用户
  3. 计算订单总价
  4. 插入订单主表,状态为待支付
  5. 批量插入订单座位关联表
  6. 调用支付服务(毕设可以用 mock)

关键代码:

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, List<Long> seatIds) { List<Seat> 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 做一层缓存。

我的做法是:场次开票后,把座位列表按区域缓存到 Redis,key 形如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 都跑在本机。给大家一个直观的数据参考:

  • 不加缓存不加限流,直接在锁座接口查 MySQL:QPS 大约 800 左右,数据库 CPU 占满。
  • 加了 Redis 预减库存后:锁座接口 QPS 能到 1800 左右,原因是无效请求在 Redis 层就被拦截了。
  • 再加单用户限流后:接口 QPS 稳定在 1500,但数据库负载明显下降,因为重复刷接口的请求被挡掉了。

这个数据给不了绝对参考,毕竟是本机环境,但能看出一个趋势:Redis 在票务系统里的作用是拦截和降载,真正写库的动作仍然要控制在最低频率。做压测时一定记得把日志关掉,Logback 默认打印 SQL 日志对性能影响很大,压测前把mybatis-plus.configuration.log-impl改成NoLoggingImpl。

6. 常见问题与排错实录

6.1 座位重复卖出怎么办

这是最容易出现的并发问题。根源基本都是没有做原子更新,而是先 select 再 update。

排查步骤:

  1. 打开 MySQL 慢查询日志,看是否有大量慢 SQL。
  2. 检查锁座的更新语句是否带了status = 0条件。如果只是UPDATE t_seat SET status=1 WHERE id=?,那并发请求会互相覆盖,直接导致超卖。
  3. 确认@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,心态会完全不一样。票务系统的核心就是座位状态和订单状态这两套状态机,它们跑稳了,这个项目就已经成功了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询