ARTICLE DETAIL

资讯详情

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

SpringBoot+MySQL电影选座订票系统:从库表设计到并发锁实践

SpringBoot+MySQL电影选座订票系统:从库表设计到并发锁实践 有个现象挺有意思每年计算机毕业设计的高频选题里电影选座与订票系统绝对排得上前五。你去GitHub搜springboot影院能翻出上千个仓库但其中能称得上能跑、能讲、能答辩的可能不到三分之一。这个题目之所以被反复选是因为它刚好卡在一个特别微妙的位置——既有基础的CRUD操作撑场面又有真实的业务难点座位锁、订单状态、超时释放可以做文章不至于像图书管理那样被评委一句话问死也不至于像秒杀系统那样超出毕设的范畴。我手头刚好完整做过一版基于SpringBootMySQL的影院在线售票与座位预订平台从数据库设计到座位锁实现到前后端联调踩坑整个过程拿出来聊聊。这篇的内容定位很明确如果你正准备做或正在做这个毕设选题想搞清楚核心表怎么设计、选座并发怎么处理、预约超时怎么实现、以及答辩时会被问哪几个刁钻问题那这篇可以直接帮你省掉一大半自己摸索的时间。1. 项目整体拆解这个系统的核心业务链路是什么先把这个系统的边界画清楚。一个电影选座与订票系统表面上是用户选个座位、付个钱、生成订单但如果把它拆成业务链路至少涉及六个环节用户管理、影片和场次管理、选座与锁定、订单生成、支付回调或模拟支付、以及后台的排片管理。其中最容易翻车的是座位锁定和订单超时释放这两个环节。因为影院座位是典型的有限共享资源两个用户同时点同一个座位系统必须保证只有一个人能锁成功。这个需求和秒杀系统的核心逻辑非常像区别在于秒杀是先扣库存再付款选座系统是先锁座位再付款锁要过期付款要限时。这一进一出就决定了整个系统的表结构设计和接口设计思路。再说技术栈。这个项目最稳妥的组合就是SpringBoot做后端、MySQL存数据、Vue或Thymeleaf做前端页面。我的建议是如果你Java基础一般就老实选Thymeleaf服务端渲染省掉前后端联调的麻烦如果你对Vue还比较熟那就用前后端分离接口文档写好答辩时也显得完整。我这次做的是前后端分离版SpringBoot负责纯接口前端用Vue3Element Plus因为毕设评委其实挺吃前后端分离独立接口文档这一套的。工程的目录结构建议按功能模块分包不要全塞在controller里。我用的分包方式是com.example.cinema ├── config // 配置类跨域、拦截器、MyBatis-Plus配置 ├── controller // 接口层电影、场次、订单、座位、用户 ├── service // 业务层核心逻辑在这里 ├── mapper // 数据访问层MyBatis-Plus的mapper接口 ├── entity // 实体类 ├── dto // 入参出参对象 ├── common // 统一返回值、异常处理 ├── utils // 工具类JWT、座位状态计算等这个分包方式不算新奇但它好在职责边界清晰答辩时无论评委问哪个层你都能讲清楚这个类为什么出现在这里。还有个容易忽略的点统一返回值。很多毕设项目接口返回格式乱七八糟有的直接返回实体有的返回Map前面的人这么做也糊弄过去了但一旦做到订单、支付回调这种交互环节没有统一返回结构会非常痛苦。我的做法是定义一个Result类泛型结构如下public class ResultT { private Integer code; // 200成功500失败 private String message; private T data; // 对应的静态方法 success() / error() }所有接口统一返回这个结构前端只写一次axios拦截器就搞定全部请求的状态处理。这个设计本身不花钱但会让你的代码看起来规范一个档次。2. 数据库设计五张核心表之间的关系与建表思路数据库设计是整个系统最见功底的部分。我见过太多毕设电影系统的库表设计是一张电影表、一张场次表、一张订单表草草了事结果做到订单详情的时候发现座位信息不知道该存哪做到票根的时候发现没有关联字段反复改表改到崩溃。核心表我最终定的是五张加上用户表一共六张基本覆盖所有业务场景表名核心职责关键字段movie影片信息id, title, duration, poster_url, release_dateschedule场次信息id, movie_id, hall_id, start_time, end_time, pricehall影厅信息id, name, seat_rows, seat_cols, seat_layoutseat座位锁定记录id, schedule_id, seat_row, seat_col, status, lock_time, expire_timeorders订单信息id, schedule_id, user_id, seat_ids, total_price, status, create_timeuser用户信息id, username, password, phone这里值得展开说三个点这三个点也是答辩时的高频提问位置。第一个是影厅座位数据的存储方式。hall表里有一个seat_layout字段我存的是JSON格式的座位矩阵比如ABBBBA这样的字符串数组或者二维数组的JSON。这个设计的好处是你可以在建影厅的时候自由定义哪些位置是过道、哪些是双人座、哪些不可售。选座页面渲染时直接把干净的layout数据返回给前端前端动态绘制座位图。这个方案比在数据库里为每个座位建一行记录要轻量得多而且排片换厅时不需要重建座位数据。第二个是座位锁定的记录方式。seat表不是预先给影厅建好所有座位而是用户尝试锁定某个座位时才插入一条记录。这个思路要特别习惯因为它和直觉相反。刚动手的时候很容易往为每个场次生成全量座位的方向走但那样数据量会非常难看一个20排×15座的影厅一天5个场次光座位锁记录就1500条/天这里面绝大多数座位根本没有交互。按需插入的话只有用户真正点击座位时才有数据落库数据库干净查询也快。第三个是订单表和座位锁的关系。关键决策是订单表里用seat_id字段逗号拼接来存用户最终锁定的座位同时座位锁记录的status字段来标记这笔锁对应的状态。为什么不搞张中间表因为一个人一场最多买5张票逗号拼接完全够用省去联表查询订单详情也一目了然。当然做联表中间表也不能算错只是毕设体量下没这个必要这是典型的合理简化。我建表的实际SQL示例精简版CREATE TABLE schedule ( id bigint NOT NULL AUTO_INCREMENT, movie_id bigint NOT NULL COMMENT 影片ID, hall_id bigint NOT NULL COMMENT 影厅ID, start_time datetime NOT NULL, end_time datetime NOT NULL, price decimal(10,2) NOT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_movie_start (movie_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这里的联合索引idx_movie_start就是为了支撑查某部电影有哪些未开场次这个高频查询。MySQL建索引这件事在毕设答辩中经常被问到能主动讲出我给哪张表加了什么索引、为什么加是很好的加分项。3. 选座模块的并发控制座位锁定的核心实现方案这个模块是整个系统技术含量最高的地方也是答辩时最容易被深挖的地方。我先把问题描述清楚用户A和用户B同时打开同一个场次的选座页面同时看到9排8座是空的两人同时点击选这个座。如果系统不做任何锁定两个人都会锁成功最后都下单造成超卖。解决思路和库存扣减一模一样就是一次只有一个请求能修改这条数据。我用了最简单可靠的方式在seat表中插入记录时利用排他约束。插入之前先用for update锁行或者直接依赖MySQL唯一索引。具体来说流程是两个接口先调用锁定接口再调用下单接口。锁定接口的逻辑Override Transactional public synchronized boolean lockSeats(LockSeatRequest request) { // 1. 校验场次和座位是否有效 // 2. 检查座位是否已被锁定或已售出 ListSeat existing seatMapper.selectByScheduleAndSeat( request.getScheduleId(), request.getSeatRows(), request.getSeatCols() ); if (existing ! null !existing.isEmpty()) { // 如果有记录且status不是已取消说明座位不可用 return false; } // 3. 插入锁定记录status0(锁定中)写入锁定时长比如15分钟 Seat seat new Seat(); seat.setScheduleId(request.getScheduleId()); seat.setSeatRow(request.getSeatRow()); seat.setSeatCol(request.getSeatCol()); seat.setStatus(0); seat.setLockTime(new Date()); seat.setExpireTime(Date.from(LocalDateTime.now().plusMinutes(15).toInstant(ZoneId.systemDefault()))); seatMapper.insert(seat); return true; }注意这里我加了synchronized关键字加锁粒度是整个方法。这个方法虽然在并发高的时候性能一般但它的好处是简单、绝对安全、能讲清楚对毕设来说绰绰有余。真实工业级方案当然是引入分布式锁比如基于Redis的Redisson但一般硕士论文都不要求这个本科毕设更不用说。如果你真想让答辩更有亮点可以把为什么不用Redis锁这个问题准备一下诚实地说本系统单体部署synchronized足够应对局部并发但若要集群化部署需要引入Redis分布式锁这反而是非常得体的答案。还有个小细节锁定记录在同一场次同一排号同一列号下必须唯一。怎么保证给seat表加一个唯一索引ALTER TABLE seat ADD UNIQUE KEY uk_schedule_seat (schedule_id, seat_row, seat_col);这样即使两个并发请求都通过了代码层面的是否已存在检查数据库层面的唯一约束会拦下第二个插入请求达到双保险的效果。这个属于数据库兜底的经典思想做的时候记得加。座位状态的流转我用三个数字表示0锁定中已选未付、1已售出已支付、2已释放超时或主动取消。查询座位状态时前端请求一个接口后端返回整个座位矩阵每个位置的数字用前端映射成不同颜色绿色可售、黄色锁定、红色已售。后面这个查询接口千万别每次实时扫数据库因为性能不好还容易超时。我实际用的方案是查询时先看当前时间是否超过锁定的expireTime超过就把status批量更新为2释放。也就是所谓懒更新下单用户请求时如果发现锁过期了锁就被自动作废不需要搞定时任务去清理过期锁。这个思路很多教材不会写但真跑系统的时候特别实用。4. 订单模块下单、超时释放与支付状态流转选座锁成功之后用户进入订单页此时系统要生成一条订单记录状态是待支付。这里有一条非常关键的规则订单的待支付时间必须框死超时后订单作废座位释放回大厅。这个规则既是业务要求也是技术亮点很多毕设的项目不做这一层订单永远挂着座位永远锁着演示的时候全是死座位就非常假。我当时实现超时释放用了两种方案的组合方案A下单时在内存里放一个定时任务15分钟后检查订单是否仍处于待支付状态是则取消并把对应的座位锁标记释放。这个方案在单体部署下可行实现简单。方案B依赖用户下次访问时的懒检查查询订单时判断createTime和当前时间的差值超过15分钟自动更新状态。两种方案我都实现了一遍。方案A负责主动释放方案B负责被动兜底组合起来双保险。如果答辩时被问定时任务挂了怎么办这个兜底逻辑就是很好的回答。订单表的核心逻辑代码Override Transactional public OrderVO createOrder(CreateOrderRequest request) { // 1. 查询当前座位锁记录必须存在且未过期 // 2. 计算订单总价从schedule表中取单张票价 × 座位数 // 3. 建立订单记录状态0(待付款) // 4. 开启定时任务OrderTimeoutTask // 5. 返回订单信息给前端携带过期时间戳供前端做倒计时 }支付这块毕设不可能真正接支付宝微信支付涉及商户号和资质但不接支付又显得业务不完整。我的做法是做一个模拟收银台页面点击模拟支付按钮调用后端pay接口直接修改订单状态从0变为1同时把seat表的status从0更新为1。这套流程完全复刻了真实支付回调的交互逻辑但没有任何外部依赖。代码里有个贴士所有涉及订单金额的计算必须使用BigDecimal不能使用Double。原因很简单Double在十进制运算中有精度误差比如0.10.2不等于0.3这在金额计算中是致命的。这个细节虽然低级但每年都有学生踩坑答辩时被问为什么用BigDecimal不用Double这是一个标准的送分题。5. 关键技术选型前后端分离、JWT鉴权与接口设计这个项目的技术选型逻辑我需要展开讲一下因为选型本身就有考察价值。后端SpringBoot 2.7.x MyBatis-Plus。为什么选MyBatis-Plus不选原生MyBatis因为Youre写毕设不是akka开发MP的单表CRUD能力能帮你省掉至少30%的样板代码再加上分页插件列表接口基本十分钟搞定。用原生MyBatis的学生多半会在mapper.xml里面写一大堆重复SQL毫无加分项。但记住用MP也要注意一点核心业务的SQL还是要自己写比如座位锁的查询和订单状态更新这些SQL比较绕自动生成的条件构造器容易写出隐藏bug。前端Vue3 Vite Element Plus Axios。说句实在话如果毕设周期紧Vue3的setup语法需要一点时间适应但这也是目前面试官眼里的标配值得花这个时间。Elelment Plus的Dialog、Table、Form组件覆盖了后台管理的绝大多数场景座位图我自己用div网格画的不要引第三方组件库因为那些组件库的座位图不灵活自定义布局的时候改起来很痛苦。鉴权JWT。这个也是答辩高频问题。我选JWT的原因是后端无状态不需要在Redis里维护Session适合前后端分离架构。JWT在毕设系统里的使用方式很简单登录成功后服务端签发一个token前端存储并用axios拦截器附加到每个请求的header里后端通过拦截器校验token有效性和过期时间。JWT拦截器的一个关键设计点是白名单。登录注册接口不能拦但选座、下单、查订单这些接口必须拦。我实现时专门维护了一个PermitAllUrl数组放在配置类里Controller方法不需要额外写注解非常清爽。接口设计我习惯用RESTful风格但实际上很多毕设项目根本管不住URL风格一会儿/api/cinema/movie/list一会儿/getMovieById。我的建议是不追求过度的RESTful规范约定大于规范统一一套自己的URL风格让人能看懂就行。核心接口清单如下GET /api/movie/list // 电影列表 GET /api/schedule/list?movieId? // 某电影的场次列表 GET /api/hall/layout/{scheduleId} // 某场次的座位图 POST /api/seat/lock // 锁定座位选座提交 POST /api/order/create // 创建订单 POST /api/order/pay // 模拟支付 GET /api/order/detail/{orderId} // 订单详情每个接口我都用统一Result类包装同时配合错误码机制。比如座位锁定失败返回2001座位已被选中超时返回2002座位锁定已过期订单不存在返回2003。前端拿到code后统一弹出message不需要每个组件单独写异常处理。6. 前端选座的实现细节座位图渲染与交互响应前端选座页是这个项目的门面也是演示效果最直观的地方。这部分的交互要做得顺滑因为评委一定会点开选座页面试试手感。座位图的渲染逻辑div classseat-map div v-for(row, rowIndex) in layout :keyrowIndex classseat-row div v-for(col, colIndex) in row :keycolIndex !-- 根据座位状态渲染不同样式 -- /div /div /divlayout数据就是hall表中那个JSON解析出来的二维数组定位方式很直观layout[row][col]的值为0表示过道1表示普通座位。座位状态则来自接口返回的statusMap格式是r_c: status前端通过拼接${row}_${col}去map里查状态然后映射样式状态0可售绿色点击后变为选中状态蓝色状态1已锁浅黄色禁用点击状态2已售灰色禁用点击状态3选中/待购蓝色这是前端本地状态提交前才向后端校验这里有一个所有选座系统都绕不开的问题前端认为可选的座位点击确认时后端可能会拒绝。因为在你浏览页面的这段时间里这个座位可能已经被别人锁走了。所以锁定接口的响应一定要处理已被他人锁定的分支提示用户刷新座位图。这个体验细节如果演示时出现了不要慌这是正常现象你反而可以借此讲解并发控制机制评委反而会对你有好感。下单后的倒计时体验也别忽略。订单创建成功后前端要拿到订单过期时间戳然后启动一个定时器做剩余时间展示。倒计时归零时同步调用后端的取消接口把订单状态改为4已取消同时座位状态释放回可售态。这里要注意的是前端定时器只是体验层真正的状态控制永远以服务端为准前端就算把倒计时卡住不触发后端定时任务一样会释放座位这是安全性兜底。7. 踩坑记录运行不起来和答辩翻车的典型问题这个项目在我开发和帮别人调试的过程中有几个坑反复出现在这里集中记录下来大概率你也会遇到。第一个坑MySQL 8.0的驱动依赖和时区问题。SpringBoot 2.7.x自带的MySQL驱动是mysql-connector-java 8.0.x连接字符串必须带serverTimezoneAsia/Shanghai否则启动直接报时区错误。另外驱动类名要写com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver。这个问题运行一次就知道但几乎每个新手都会卡一晚上。第二个坑MyBatis-Plus的LogicDelete逻辑删除会影响座位唯一索引。如果你给实体加了TableLogic注解MP会在删除操作时自动把update变为update set deleted1但deleted字段不在唯一索引里的话同一个座位删除后再插入唯一索引会冲突。我实际解决方式是seet表不做逻辑删除只做物理删除或状态更新status置为2。这也是一个看你理解有多深的提问点。第三个坑跨域配置在前后端分离时必须显式声明。SpringBoot开发环境默认情况下8080端口的前端调8081的后端接口浏览器CORS会拦截。需要写一个CorsFilter配置类允许跨域。千万别只加一个CrossOrigin注解就完事因为加了跨域配置之后请求会先走preflight的OPTIONS请求如果你的拦截器没有放行OPTIONS依然会报跨域错误。正确的处理方式是配置类里对OPTIONS请求直接放行。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; // 放行预检请求 } // 其余请求校验JWT逻辑 }第四个坑是关于nginx部署和打包的如果你最后要把前端打包放到SpringBoot的resources/static下一起部署记得前端的publicPath要设置成相对路径./否则打包后所有静态资源和路由请求全是绝对路径一访问就404。8. 答辩准备评委最可能问的问题与回答思路最后聊聊评审。毕设项目的代码是死的人是活的很多同学代码写完了答辩时却支支吾吾讲不清最后分数被拉低。我整理了几个这个题目答辩时出现概率极高的提问每个都附上回答思路。座位锁定的并发问题你是怎么解决的答同一场次同一座位在数据库层有唯一索引同时代码层面锁定接口使用synchronized保证同一时间只有一个线程处理同一场次的座位锁定请求数据库和代码双重保险。这里有一个进阶版本的答法如果系统要部署多个实例单机的synchronized会失效需要引入Redis分布式锁或数据库乐观锁这是未来可扩展的方向。——这句话能把你的层次从做了一个毕设拉升到理解生产环境中的系统瓶颈。如果用户下单后一直不支付座位什么时候释放答创建订单时后端启动定时任务15分钟后检查订单状态如果是待支付就自动取消并释放座位锁同时查询订单详情时也会做懒检查双保险机制保证座位不会死锁。你的数据库为什么这么设计有没有考虑索引答先讲表拆分的原则再明确说出schedule表的联合索引、seat表的唯一索引以及订单表的user_id索引。然后还可以补一句除了连接查询外尽量避免在索引列上做函数运算。这句话一出懂行的评委就知道你不是背的。JWT和Session有什么区别为什么用JWT答JWT是无状态的用户会话信息放在token里服务端不需要存储会话数据适合前后端分离架构。Session需要服务端存储sessionId扩展性不如JWT。JWT的缺点是注销困难、无法主动失效但毕设场景完全够用。你的支付是模拟的怎么保证安全答模拟支付后端直接改状态真实支付需要接入正规支付渠道并通过签名验证回调。本项目的安全设计在于核心的业务逻辑座位锁、订单状态都在服务端控制前端无法绕过并且JWT鉴权保证所有订单操作和用户身份绑定。还有两个常见翻车点需要特别留意一是不要让前端页面太简陋选座系统的首页如果没有漂亮的电影海报和详情排版评委第一眼印象会打折扣二是演示前一定要准备几个测试账号和真实数据电影排片、场次时间要提前设置好别现场打开是空的。9. 一点后续扩展的想法这个系统做完之后如果时间富裕值得扩展的方向其实不少。最简单的切入点是加一个排片管理后台让管理员能录入电影、创建影厅、设置场次和票价。这正好把系统从只有C端选座扩展成C端管理端的完整闭环。很多学校对毕设的最低要求是有前台有后台把这一点补上覆盖率就高了很多。如果还想再多一点技术含量可以再考虑把座位锁定方案换成Redis的分布式锁或者引入消息队列做订单超时延迟触发这些扩展在论文后续研究里都是很好写的话点。按我个人的经验这个题目最值得花时间的不是写代码而是把座位锁定和订单超时这套核心逻辑理解透。代码是表现业务建模能力才是评委真正想看到的东西。把这套系统的每个状态流转都摸透了答辩舞台上的那个你会底气完全不同。
返回列表