做这个电影订票系统最初是在一个练手周期里挑项目。选了一圈对比了图书管理、商城、博客这些常见题目之后我反而觉得电影订票最容易把技术深度体现出来——它的业务链路很完整从电影排期、影厅座位分布到选座下单、订单状态流转每一步都有明确的实体关系和状态切换做出来既不会太浅也不会像企业级中后台那样复杂到让人想放弃。最终落地的技术栈是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前后端分离源码和配套文档一起整理成了相对完整的Demo。如果你也正在准备类似的Java Web实战项目或者想找一套可以直接参考复现的订票系统源码这篇文章应该能帮你节约不少时间因为里面不光有实现思路还有我在实际开发中踩过的各种坑和对应的排查过程。1. 技术选型逻辑一套够用但不过度的组合1.1 为什么前后端分离从JSP时代的痛点说起很多课程项目到现在还在用JSPServlet那套。不是说不能做而是当你把前后端分离和Vue3SpringBoot写进简历的时候面试官天然会默认你已经具备了接口化开发和跨域处理的基本认知。电影订票系统尤其适合这种模式用户端要频繁切换电影列表、场次、选座、订单这些视图前端路由和组件化能显著降低维护成本后端只管暴露REST接口不需要关心页面渲染。我在开发时把前端独立为一个Vite项目后端独立为SpringBoot工程两者通过JSON交互这个分工让开发效率提升非常明显——特别是在调整选座交互的时候前端改完不用碰后端一行代码。当然前后端分离也带来了额外的复杂度。跨域、联调、部署都需要额外处理这些我在后面专门开了两节来讲这里先不展开。1.2 为什么用SpringBoot2而不是SpringBoot3这个问题我实际回答过很多遍。最大的原因在于生态兼容性。SpringBoot3强制要求JDK17及以上而很多练手环境和服务器上的JDK还停留在8或11SpringBoot2.7.x在JDK8上跑得非常稳。另外SpringBoot2配合MyBatis-Plus的兼容性最好网上踩坑资料也最多遇到问题更容易搜到解决方案。如果是求职或者演示用的项目SpringBoot2永远是那个不会出幺蛾子的选择。等到你真正需要虚拟线程、GraalVM这些特性时再升级到SpringBoot3也不迟。版本选择上也要注意SpringBoot的版本号要跟MyBatis-Plus的版本匹配。我用的是SpringBoot 2.7.x MyBatis-Plus 3.5.x这个组合比较成熟。如果用太老版本的MyBatis-Plus分页插件配置方式会不一样项目里很容易出现分页没有生效始终返回全量数据的问题。1.3 MySQL8.0不只是换个版本号数据库我选MySQL8.0一是因为它支持utf8mb4的默认字符集中文和特殊符号不会再出现乱码问题二是8.0在某些场景下可以利用窗口函数和JSON字段简化SQL逻辑。在这个项目里电影标签、影厅座位排布这类半结构化数据用JSON类型存储非常合适。相比5.78.0的连接方式也多了一些细节比如认证插件默认是caching_sha2_passwordJDBC连接串需要配置allowPublicKeyRetrievaltrue否则第一次连接会报Public Key Retrieval is not allowed。这个坑几乎每个人都会踩一次我后面也会再说一遍。2. 数据库建模订票系统最重要的地基工程2.1 核心实体关系电影、影厅、场次、座位、订单订票系统的数据模型核心是七张表我把每张表的职责列一下表名核心字段职责说明userid, username, password, nickname用户账号信息filmid, title, poster, duration, intro, status电影基础信息hallid, name, type, seat_layout影厅信息座位排布存JSONsessionid, film_id, hall_id, start_time, price某部电影在某个影厅的某一场次seatid, hall_id, row_no, col_no影厅座位的基础位置信息ordersid, order_no, user_id, session_id, total_amount, status订单主表order_itemid, order_id, session_id, seat_id, order_status订单与座位的关联同时标记座位占用状态从业务上理解用户选座的过程本质上是在一个场次下把N个座位的状态从未售变成已锁定然后生成订单记录。这个关系模型里最关键的外键是订单明细表的session_id和seat_id组合。2.2 座位数据的两种建模思路静态表 vs 冗余存储座位怎么存我见过两种主流的做法。第一种是每场电影都复制所有座位生成大量的场次座位记录比如一张影厅有100个座位10个场次就是1000条记录这种方案查询简单某个场次某个座位的状态直接查这张表就行但是数据冗余大。第二种是只有影厅维护一张座位表场次不复制座位而是通过订单明细来判断某个场次下哪些座位已被占。第二种方案更干净但查询某个场次的座位状态时需要把订单明细表和座位表关联起来稍复杂一点。我最终选的是第二种方案前端选座时通过接口返回当前场次已售/已锁定的座位ID集合再跟影厅的完整座位表做差集得到可选的座位。这样做的好处是当订单到期取消或支付完成时只需要更新订单状态并刷新已售集合而不用去批量修改场次座位表。如果你只是想快速交作业第一种方案更直接代码也更好理解但数据冗余会随着场次增长变得很吓人。2.3 防超卖的关键设计数据库唯一约束兜底两个用户同时选同一张票是订票系统最经典的问题。如果只在应用层面判断座位状态在高并发下一定会有漏网之鱼。我的做法是分层防护应用层查询座位状态时如果已售或存在未支付订单则直接返回不可选。这个判断能挡住绝大多数正常请求。事务层创建订单时开启事务先查再写。在服务层对场次记录加锁后面会细说。数据库兜底在订单明细表上建一个唯一索引。这是最关键的一层。订单明细表的建表SQL大致是这样CREATE TABLE order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL, session_id bigint NOT NULL, seat_id bigint NOT NULL, order_status tinyint NOT NULL DEFAULT 0 COMMENT 0占用中 1已支付 2已取消, PRIMARY KEY (id), UNIQUE KEY uk_session_seat_status (session_id, seat_id, order_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这个唯一索引包含了order_status。同一个座位在占用中或已支付状态下只允许存在一条记录订单取消时把明细的order_status改成2这样座位就能被重新购买。这个设计在压测时体现得特别好用JMeter模拟50个并发同时抢同一个座位应用层判断可能放过去好几个请求但数据库最终只允许一条成功插入其他请求会抛出DuplicateKeyException被全局异常处理器捕获后转成该座位已被选的提示。这一行索引比你在代码里写十个if都管用。3. 后端实现SpringBoot2 MyBatis-Plus 的高效开发3.1 从代码生成器到BaseMapper把CRUD省掉MyBatis-Plus在项目里的作用用一句话概括就是把单表CRUD和分页这类没有业务价值的工作量压到最低。它的代码生成器可以扫描数据库表自动生成实体类、Mapper接口和Service骨架我在搭建工程时只花了几分钟就把七张表的CRUD层全部生成了。之后真正需要手写的只有这些内容自定义SQL比如统计票房、复杂条件查询用LambdaQueryWrapper拼条件、批量插入订单明细。举一个实际例子场次列表查询需要关联电影表获取电影标题和海报同时按语种、档期筛选这时候直接用BaseMapper是不够的我会在Mapper里写一个自定义的关联SQL再用MyBatis-Plus的Page对象分页。分页插件的配置很简单Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }有了这个配置Service层调用page方法时拦截器会自动生成count查询和limit语句不用手写分页逻辑。3.2 选座下单接口事务、锁与异常处理的配合选座下单是整个项目中最值得细讲的一个接口。接口的入参是sessionId和一个座位ID列表返回值是订单号和总价。核心Service方法大概长这样Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long sessionId, ListLong seatIds) { // 1. 校验场次状态 Session session sessionMapper.selectById(sessionId); if (session null || session.getStartTime().isBefore(LocalDateTime.now())) { throw new BusinessException(该场次已停止售票); } // 2. 锁定场次行串行化同一场次的并发下单 Session lockSession sessionMapper.selectByIdForUpdate(sessionId); if (lockSession null) { throw new BusinessException(场次不存在); } // 3. 校验座位是否可售 ListSeat seats seatMapper.selectBatchIds(seatIds); ListLong occupiedSeatIds orderItemMapper.selectOccupiedSeatIds(sessionId); for (Seat seat : seats) { if (occupiedSeatIds.contains(seat.getId())) { throw new BusinessException(座位已被选请刷新后重试); } } // 4. 生成订单和明细 Order order buildOrder(userId, sessionId, seats); orderMapper.insert(order); ListOrderItem items buildOrderItems(order.getId(), sessionId, seats); orderItemMapper.batchInsert(items); // 可能触发唯一键冲突 return order; }这里有几个细节值得展开。第一场次锁定用的是selectByIdForUpdate这是我在Mapper里写的一个带FOR UPDATE的查询它在事务内锁定场次那一行同一场次的并发下单会被阻塞排队。第二第4步的批量插入如果碰上唯一键冲突会抛出DuplicateKeyException因为整个方法标注了Transactional事务会回滚订单主记录也不会残留。第三我把校验和插入全部包在同一个事务方法里如果一开始把校验放在事务外并发时就会出现两个请求同时通过校验然后竞争插入的窗口最终必然有一个请求在插入阶段失败但那已经太迟了——前端会看到莫名其妙的报错而不是友好的已被选提示。全局异常处理这里值得多说一句。用RestControllerAdvice把DuplicateKeyException、BusinessException、参数校验异常统一包装成响应结构前端只需要根据code字段判断成功或失败不需要解析后端异常堆栈。给用户看的message要中文友好比如该座位已被选请刷新后重试而不要直接把异常信息甩在页面上。3.3 订单取消与座位释放定时任务怎么处理待支付订单订单创建后会有一个待支付状态。为了模拟真实场景我加了一个5分钟未支付自动取消的逻辑。实现方式很简单用Spring的Scheduled注解写一个定时任务每30秒扫描一次待支付且创建时间超过5分钟的订单把它们的状态改成已取消同时把订单明细的状态改成已取消释放座位。这个定时任务在单机部署下完全够用如果要竞争同一个订单先到先得后面看到订单已取消就直接忽略。如果你希望更精细地控制可以在每次查询时只取出前100条待处理订单避免一次扫描太多。4. 前端落地Vue3 选座页面的交互设计4.1 Composition API把选座逻辑变成高内聚模块Vue3的Composition API在选座这个场景下优势非常明显。选座页面的核心状态包括影厅座位矩阵、已售座位集合、已选座位集合、当前场次信息、总价。如果用Options API这些状态会散落在data、computed、methods各个选项里当逻辑一多维护起来非常费劲。用Composition API后我把选座相关的状态和操作全部封装到一个useSeatSelector函数中组件里只负责调用和渲染。// useSeatSelector.js import { ref, computed } from vue export function useSeatSelector(session) { const seatMap ref([]) const selectedSeatIds ref(new Set()) const soldSeatIds ref(new Set()) const totalPrice computed(() { return selectedSeatIds.value.size * session.price }) function loadSeatMap() { // 请求后端获取座位矩阵和已售座位集合 } function toggleSeat(seat) { if (soldSeatIds.value.has(seat.seatId)) return const next new Set(selectedSeatIds.value) next.has(seat.seatId) ? next.delete(seat.seatId) : next.add(seat.seatId) selectedSeatIds.value next } return { seatMap, selectedSeatIds, soldSeatIds, totalPrice, loadSeatMap, toggleSeat } }注意这里有个小坑selectedSeatIds用的是ref(new Set())在toggleSeat里必须重新赋值一个新Set不能直接selectedSeatIds.value.add()。因为Vue3的响应式基于Proxy对Set内部的改动虽然能触发响应但在某些边界情况下不如整体赋值来得可靠。我在开发中就踩过一次直接往里add结果computed里的totalPrice没有更新排查了半天才发现是响应式依赖没有正确触发。4.2 座位图渲染用CSS Grid实现影院座位布局座位的可视化渲染我不用table布局而是用CSS Grid。每个座位是一个div根据状态切换class。座位状态有三种可选、已选、已售。渲染结构大概是template div classseat-grid div v-forseat in seatMap :keyseat.seatId :class[seat, seatClass(seat)] clicktoggleSeat(seat) {{ seat.row }}-{{ seat.col }} /div /div /templateCSS Grid的好处是只需要把行数和列数写到style里剩下的间距、对齐都由Grid处理不需要像table那样手动管理单元格。为了效果更好我给座位加了一个legend图例绿色可选、红色已售、黄色已选。选座页面的最上方还会显示当前场次的电影名、影厅名和播放时间这些数据在进入选座页时一次性从后端接口加载。还有一个体验细节点击已售座位时通常我直接忽略点击并弹一个轻提示该座位已被选。不要用alert用UI组件库的Message提示体验会好很多。4.3 选座完成后的下单与支付流程用户选好座位后点击确认选座按钮前端把sessionId和selectedSeatIds数组以POST方式提交到后端。后端返回订单号和总金额前端跳转到订单详情页面展示待支付状态。用户在订单页点击支付按钮实际上调用一个模拟支付接口支付成功后刷新页面显示已支付。这里要注意的是前端在请求选座接口时要把提交中状态处理好防止用户双击导致重复下单。我用的方案是按钮增加loading属性点击后立即置为不可用等请求返回后再恢复。这个看似简单但在演示时如果忘了做双击产生的重复订单会让观众印象深刻。4.4 用户端与管理端的页面划分整个前端除了选座流程还有用户端的电影列表页、场次列表页和订单列表页以及管理端的电影管理、影厅管理和排片管理。我把用户端用Vue Router做路由切换管理端在一个独立模块里通过菜单进入。两块共用一套axios实例只是在请求拦截器里配置了不同的token逻辑。没有引入复杂的Pinia状态管理库因为大多数状态依赖后端接口只有token和用户信息放在本地缓存里就够用了。5. 联调、部署与踩坑记录5.1 跨域配置开发环境和生产环境的两种姿势前后端分离第一步要解决的就是跨域。前端Dev服务器跑在5173端口后端跑在8080端口直接fetch一定会遇到CORS错误。我的处理是开发环境完全不配置后端CORS而是用Vite的代理转发// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样浏览器里的请求都指向当前站点由Vite把/api开头的请求转发到后端从根源上避免了跨域。生产环境更简单把前端的dist目录拷到SpringBoot的src/main/resources/static目录下由同一个后端服务托管同样不存在跨域问题。如果你确实需要前后端分开部署那就要在后端加一个CorsFilter把允许的域名配进去。5.2 MySQL8.0连接串里的时区坑MySQL8.0对时间字段的处理比5.7严格。我第一次连接时启动就报错报的是服务端时区无法识别。解决方案是连接串上明确指定jdbc:mysql://localhost:3306/film_ticket?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue这里除了serverTimezone之外allowPublicKeyRetrievaltrue也很关键。MySQL8.0默认的caching_sha2_password认证插件要求客户端在第一次连接时获取服务端的公钥如果不加这个参数某些版本的驱动会直接报Public Key Retrieval is not allowed。这个参数放在开发环境没问题生产环境如果你有安全顾虑可以在数据库里把用户的插件改成mysql_native_password两条路都可以走通。5.3 打包部署jar包与前端静态资源的合并SpringBoot项目打包成可执行jar是最省事的部署方式。用maven的spring-boot-maven-plugin打包把前端dist目录放进src/main/resources/static然后java -jar就能跑起来访问根路径直接看到前端页面。需要注意两点一是Vite的base要设置成/否则资源路径会带上一级目录导致404二是如果后端接口有上下文路径比如server.servlet.context-path/api那么Vite代理的target和路径都要对应调整否则打出来的包页面能打开、接口全挂。如果是部署在Linux服务器上建议用nginx反向代理80端口到8080前端资源走nginx接口走转发这样可维护性和性能都会好很多。5.4 其他值得记录的小坑再记几个零碎但让我花了不少时间的问题Lombok的Builder和MyBatis-Plus实体类混用时无参构造会被覆盖导致MyBatis反射创建实例报错解决办法是同时加NoArgsConstructor和AllArgsConstructor前端跨域带cookie时axios的withCredentials要开而且后端allowedOrigins不能用通配符必须精确指定域名如果发现数据库存的时间比北京时间少了8小时第一反应就是查连接串的serverTimezone而不是去改代码。6. 项目复盘最值得重新设计的几个方向6.1 从单机到分布锁与缓存的升级路径技术上的复盘我放在前面了最后想聊聊这个项目做完之后的一些想法。我第一次做完的时候觉得能跑就是胜利但回头看最值钱的反而是那些看似不起眼的细节唯一索引防超卖、全局异常处理、事务边界划分、前端状态逻辑的模块化。这些不是靠背概念学来的是真正跑了并发压测、联调过前端、部署过服务器之后才留下的体感。如果让我再做一遍第一件要改的就是把当前基于单机锁和数据库行锁的方案升级为Redis分布式锁。具体来说创建订单前先对场次座位维度设置一个带过期时间的Redis锁拿到锁的请求才允许进入下单逻辑拿不到锁的请求直接返回当前购票人数较多请重试。同时把已售座位集合缓存到Redis接口查询走缓存订单状态变化后主动刷新缓存。这样系统就从只能单实例部署变成了支持多实例横向扩展。6.2 产品化视角退票、限流与验票第二件要补的是业务完整度目前的状态机只有待支付、已支付、已取消三个状态缺少退票这个真实场景。增加已退票状态后退票接口要做的事就是把订单状态置为已退票、释放座位、按规则计算退款金额同时要把订单明细的order_status同步更新这块逻辑如果加上业务闭环才算完整。第三件是防刷和验票。订票入口加一个简单的令牌桶限流防止短时间内的重复请求用户端增加我的电影票二维码验票功能管理端做扫码核销。这三块都是真实产品里会遇到的典型问题做完之后整个项目的含金量会再上一个台阶。6.3 面试场景下的讲解重点最后再分享一个小经验如果你打算把这个项目作为求职面试中的演示项目别急着堆功能先把订单状态机和座位唯一约束这两个点讲清楚。面试官最喜欢问的恰恰是这两个点因为它们直接体现了你对并发和业务边界的理解。讲思路的时候能够顺手画一下表关系、说清楚锁的粒度和事务的边界比背十个框架名词都管用。