
我做了那么多套前后端分离的练手项目电影订票这类题材一直是刚需。它不像电商那样一堆促销规则要处理也没有社交产品那么复杂的推荐逻辑但恰好能把 SpringBootMyBatisVue3 这套主流技术栈里最关键的知识点串起来表结构设计、接口分层、跨域联调、并发控制、状态管理、部署上线。这篇文章就把这整套系统的搭建思路完整拆开讲透。说句实在话我现在回头看自己最早写的那版订票系统光是同一个人同一场次能不能重复下单这种问题就踩过大坑。希望这篇内容能让你绕过这些坎直接做出一个能拿去面试、能写进简历、甚至能真正上线跑的项目。1. 项目定位与技术选型为什么是SpringBootVue3MyBatis这套组合现在只要搜Java 项目十有八九都是 SpringBoot。这套组合不是赶时髦而是它确实把后端开发里那堆繁琐的配置工作几乎清零了。以前用 SSH 那套框架光 XML 配置就能写几十行现在 SpringBoot 一个注解就能起一个 Web 服务。做电影订票这种业务逻辑并不复杂的系统SpringBoot 的优势在于你能把精力放在业务本身而不是去折腾框架。Vue3 作为前端这一侧选它的理由也很直白。Vue2 时期我们写组件逻辑都是用 Options API数据在 data 里、方法在 methods 里一个组件稍微复杂一点代码散得跟没整理过一样。Vue3 的 Composition API 用 setup 函数把相关的逻辑聚在一起写订票流程的时候选座、确认订单、支付状态这几个功能点的代码放在一起维护逻辑清晰了不是一星半点。再加上现在 Vue3 已经出了好几年生态上该有的库基本都兼容了上手资料也多。MyBatis 在持久层这一层跟 JPA 是两种完全不同的思路。JPA 是帮你把 Java 对象映射到数据库表基础增删改查都不用写 SQL。但电影订票这种系统查询场景极其丰富查某个时间段有哪些电影排片、查某个场次的座位剩余情况、查某个用户的订单列表附带电影信息。这时候 MyBatis 的灵活度就体现出来了——SQL 写在自己手里上线查慢查询的时候直接把日志里的 SQL 拿到数据库里跑一遍就能分析而不是看着一堆自动生成的查询方法发呆。数据库选 MySQL这个更不用纠结。MySQL 是绝大多数中小型项目的默认选择社区活跃、资料多你踩过的坑基本都有人踩过并且在网上写了解决方案。对于电影订票这种业务MySQL 提供的事务机制和行级锁完全足够应对核心的并发订票场景。这套技术栈的组合其实还照顾到了求职面试的场景。现在招 Java 岗的公司简历上写熟悉 SpringBoot、MyBatis、MySQL了解 Vue3 以及前后端分离开发模式是很加分的。把这套系统做完面试官问的很多问题比如 MyBatis 怎么防 SQL 注入、SpringBoot 自动配置原理、Vue Router 的导航守卫、MySQL 的索引优化你都能从自己的项目里拿出来讲比背八股文有说服力得多。2. 数据库设计六张表如何撑起订票与评论两大核心业务2.1 表结构全景概览电影订票系统的表设计核心就围绕两个业务链条。订票链路是用户选电影-选场次-选座位-生成订单评论链路是用户看完电影-打分-写评论。这两条链路由用户表贯穿由电影表串接。我实际建表的落地方案是这样的表名核心字段作用说明userid, username, password, phone, avatar, role用户表角色区分普通用户/管理员movieid, title, cover, director, actors, duration, release_date, description, status电影基本信息表sessionid, movie_id, hall_id, start_time, end_time, price, remaining_seats场次表关联电影和放映厅hallid, name, seat_rows, seat_cols放映厅表记录厅名和座位布局seatid, session_id, row_num, col_num, status, order_id座位表按场次生成状态标记是否已被锁定ordersid, order_no, user_id, session_id, seat_id, movie_name, session_time, price, status, create_time订单表记录用户购票信息commentid, user_id, movie_id, content, rating, likes, create_time评论表关联用户和电影这七张表之间通过三组外键关联起来。第一组是session表通过movie_id关联movie表表示哪个电影在哪个时间有场次第二组是seat表通过session_id关联session表表示某个场次的座位布局再通过order_id关联订单表标记归属第三组是comment表通过user_id和movie_id关联用户和电影表示谁给哪个电影写了什么评论。这个设计的核心思路是把座位跟场次绑定而不是跟电影绑定。因为同一个影片在不同时间、不同厅的场次座位状态完全是独立的。如果只存一个电影总共多少座位那场次之间的座位就打架了。2.2 座位表为什么独立一张表而不是用 JSON 数组这是设计阶段最重要的一个决策。很多初学者会想到在session表里加一个字段比如seat_map存一个 JSON 字符串类似{1_1:0,1_2:1}来表示座位是否被占。这种做法在演示项目里跑得通但一旦遇到并发就会出现严重问题——两个请求同时读到同一个 JSON同时修改最后写入的覆盖先写入的座位就重复卖了。把座位独立成seat表每一行就代表一个座位的具体状态配合 MySQL 的行锁在事务里执行UPDATE seat SET status1 WHERE session_id? AND id? AND status0数据库会锁住这一行其他事务只能等待。这就从根本上解决了座位超卖的问题。我按照每场次每个座位一行的方式来生成座位数据。一张 10 排 12 列的放映厅一场次就是 120 条座位记录。看起来是数据冗余但这种以空间换一致的方式在订票这类高并发写入场景里反而最稳妥。后面用 Redis 或者内存做缓存提升性能的时候也是以这个表为基础去做的不会影响原有逻辑。2.3 订单号生成策略时间戳随机数不等于靠谱订单表里的order_no字段很多项目直接用一个System.currentTimeMillis()加随机数生成。但这样做在并发下很容易重复尤其同一个用户一秒内下了两单时间戳一样、随机数可能撞上订单号就重复了。我的做法是用时间戳精确到毫秒 用户ID后四位 随机数组合再加一个数据库唯一索引兜底。生成逻辑大致是public String generateOrderNo(Long userId) { String timestamp String.valueOf(System.currentTimeMillis()); String userPart String.format(%04d, userId % 10000); String randomPart String.format(%04d, new Random().nextInt(10000)); return timestamp userPart randomPart; }这个方案不是绝对唯一的但因为加上了用户 ID 特征同一个用户下重复订单号的可能性已经很低再加上唯一索引万一真撞了数据库会直接报错你可以捕获异常重新生成而不是等到对账的时候才发现问题。比单纯时间戳加随机数要稳得多。当然如果系统规模要继续做大订单号可以引入雪花算法或者号段模式生成但那是分布式场景的课题。在单机 MySQL 的规模下这个方案足够用了。3. 后端服务搭建从Controller到Mapper的层次划分与关键接口实现3.1 项目工程结构与目录规划后端工程我用 Maven 管理依赖分包遵循按职责分的原则。这种分法看着老生常谈但确实最清晰com.example.movie ├── controller // 接收前端请求返回统一结果 ├── service // 业务逻辑层 │ └── impl ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体对象 ├── dto // 前端参数对象比entity轻量 ├── vo // 返回给前端的数据对象 ├── common // 统一返回结果、异常处理、常量 └── config // 配置类如跨域、拦截器分层的时候有一个容易被忽略的地方Controller 里不要直接返回 Entity。比如查询订单列表你从数据库查出来的是Orders对象里面可能带着userId、createTime这种前端不一定需要或者需要格式化后展示的字段。更好的做法是定义 VOView Object比如OrderVO里面放orderNo、movieTitle、sessionTime、seatDesc这种已经关联好的展示字段。这样前后端接口文档都写得更清爽也不会把数据库结构直接暴露给前端。3.2 统一返回结果与全局异常处理前后端分离项目里前端拿到的不只是数据本身还需要知道接口调用是成功还是失败。我常用的统一返回结构是{ code: 200, message: success, data: { ... } }对应的类是一个ResultT泛型类提供Result.success()和Result.error()两个静态工厂方法。Controller 里所有方法的返回类型都是ResultT前端 Axios 拦截器里统一判断 code如果是 200 就返回 data否则弹出错误提示。这样一个全局配置下来每个接口里就不用再写一堆 if-else 做异常处理。配合全局异常处理类用RestControllerAdvice注解标注再定义几个ExceptionHandler方法。比如参数校验异常MethodArgumentNotValidException返回参数错误业务异常BusinessException返回具体的错误信息未捕获的异常返回系统繁忙请稍后重试。这里有一个重要的实操经验不要把具体的 SQL 异常信息直接返回给前端那样不但暴露数据库结构还会让用户看到一堆莫名其妙的技术术语。日志里记录完整堆栈返回给前端的永远只是友好提示。3.3 电影模块接口列表分页与条件查询的MyBatis写法电影模块是前端最先展示的模块用户打开网站就能看到正在热映的电影列表。后端接口设计上要有分页和筛选两个能力。Controller 层的接口签名大概是这样GetMapping(/api/movies) public ResultPageResultMovieVO getMovieList( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 12) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) Integer category) { return movieService.getMovieList(pageNum, pageSize, keyword, category); }MyBatis 的 Mapper 接口和 XML 里我写了动态 SQL 来处理筛选条件。这里有一个值得多说两句的细节关键字查询搜电影名或导演名以及分类筛选在 XML 里用if标签拼接条件。比如select idselectMovieList resultTypecom.example.movie.vo.MovieVO SELECT id, title, cover, director, actors, duration, release_date, description FROM movie where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR director LIKE CONCAT(%, #{keyword}, %)) /if if testcategory ! null AND category_id #{category} /if AND status 1 /where ORDER BY release_date DESC LIMIT #{offset}, #{pageSize} /select注意我用了#{keyword}而不是${keyword}这个区别极其关键。#{}是预编译语句参数会通过?占位符传入可以防 SQL 注入${}是直接把字符串拼接进 SQL万一用户传了一个恶意关键字整个 SQL 就被注入了。你去看 MyBatis 面试题SQL 注入防护几乎是必问的项目里这样写面试就能顺理成章地拿出来讲。分页计算这一块我封装了一个PageResult类包含list、total、pageNum、pageSize、pages五个字段。pages的算法是(total pageSize - 1) / pageSize这个公式要记牢很多新手直接total / pageSize会导致最后一页数据丢失。3.4 场次与选座接口关联查询与状态判定用户点进某部电影详情页后前端要展示本周有哪些场次后端就需要一个接口根据movieId查出所有有效场次并且把对应的放映厅名称、票价、剩余座位数关联出来。这个接口最核心的 SQL 是关联查询select idselectSessionsByMovieId resultTypecom.example.movie.vo.SessionVO SELECT s.id, s.movie_id, s.start_time, s.end_time, s.price, h.name AS hall_name, (SELECT COUNT(*) FROM seat st WHERE st.session_id s.id AND st.status 0) AS remaining_seats FROM session s LEFT JOIN hall h ON s.hall_id h.id WHERE s.movie_id #{movieId} AND s.start_time NOW() ORDER BY s.start_time ASC /select这里用子查询计算剩余座位数在数据量小的系统里没问题但如果一个场次的座位量很大比如上千座这段 SQL 在列表页高频调用会有压力。优化方案是在session表里维护一个remaining_seats整数列每卖出一张票就减一每取消一单就加一。列表页直接查这个列不用实时去座位表 COUNT。后面如果用了消息队列或者 Redis还可以更高性能地维护这个字段。选座接口的入参设计也比较讲究。前端返回的是座位二维坐标rowNum加colNum后端要做的第一步是确认这个座位是否存在且状态为可售。有一种隐藏的并发风险如果前端展示的座位状态已经过期比如另一个用户已经买走但这个用户页面上还显示可售后端插入订单时就会出现用户满怀期待下单结果发现座位已售。所以下单接口必须做状态校验这也是下一章的重点。4. Vue3前端与后端的协作页面路由、状态管理、API封装实践4.1 前端工程结构与路由规划设计Vue3 工程我习惯用 Vite 初始化相比 Vue CLI 的 WebpackVite 冷启动快得多开发体验好不少。创建完工程后在src下建 views、components、router、store、api、utils 这几个目录。路由规划这一块我用了嵌套路由来实现前台和后台分离。前台用户访问的是电影院订票页面后台管理员管理电影和场次信息const routes [ { path: /, component: () import(/layout/FrontLayout.vue), children: [ { path: , component: () import(/views/home/Home.vue) }, { path: movie/:id, component: () import(/views/movie/MovieDetail.vue) }, { path: booking/:sessionId, component: () import(/views/booking/Booking.vue) }, { path: orders, component: () import(/views/order/OrderList.vue) }, ] }, { path: /admin, component: () import(/layout/AdminLayout.vue), meta: { requiresAdmin: true }, children: [ { path: dashboard, component: () import(/views/admin/Dashboard.vue) }, { path: movies, component: () import(/views/admin/MovieManage.vue) }, { path: sessions, component: () import(/views/admin/SessionManage.vue) }, ] }, { path: /login, component: () import(/views/Login.vue) } ]前后台分离的路由结构有一个好处管理后台的路由可以通过路由守卫做权限控制普通用户访问/admin开头的路径会被拦截并重定向到登录页。这是 Vue Router 里很实用的一个场景。4.2 Pinia状态管理用户信息与购物车数据放在哪里Vue3 项目里做状态管理Pinia 是社区目前最主流的方案。相比 Vuex 4Pinia 的 API 更简单直观去掉了 mutations 概念状态更新直接写 actions 就行了。在订票系统里真正需要放到全局状态里的东西有三个用户登录信息、当前选座暂存数据、购物车/待确认订单数据。用户信息放全局很合理因为很多组件顶栏显示用户名、订单页判断登录状态都要用。选座暂存数据也需要放全局因为用户选择座位的页面和确认订单的页面之间会跳转用路由参数传一个二维坐标数组非常麻烦。// store/booking.js export const useBookingStore defineStore(booking, () { const selectedSeats ref([]) const sessionInfo ref(null) function setSelectedSeats(seats) { selectedSeats.value seats } function clearBooking() { selectedSeats.value [] sessionInfo.value null } return { selectedSeats, sessionInfo, setSelectedSeats, clearBooking } })用 Pinia 的 composition 写法逻辑非常直观。在选座页面调用setSelectedSeats存数据在确认订单页面用selectedSeats读数据提交成功后调clearBooking清空不用再通过路由传递复杂对象。还有一个值得注意的点Pinia 的数据刷新页面就没了。如果用户已经选好座刷新一下页面selectedSeats 就空了。针对这个问题可以把选座数据同步存一份到 sessionStorage在页面加载时重新读取。这样体验会好很多。4.3 Axios封装与请求拦截器统一处理token和错误码前后端分离项目令牌token验证是一个绕不开的环节。用户登录成功后后端返回一个 token前端后续所有请求都要在请求头带上它后端才能识别当前用户是谁。我在src/utils/request.js里基于 Axios 做了一层封装核心逻辑是请求拦截器和响应拦截器import axios from axios import router from /router import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理业务码 request.interceptors.response.use( response { const res response.data if (res.code 401) { // token过期跳转登录页 localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登录)) } if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request这样封装完之后业务代码里请求接口就是一行非常清爽的代码const movieList await request.get(/movies, { params: { pageNum: 1, pageSize: 12 } })注意我在请求拦截器里用Bearer前缀拼接 token后端用 Spring Security 或者拦截器解析 token 时也按照这个格式解析两边约定好不要一边带一个token前缀另一边不带导致验证一直失败。4.4 选座页面的交互实现二维数组渲染与状态联动选座页面是整个前端最复杂的部分。业务逻辑是加载场次信息后根据座位数据渲染一个可点击的座位图用户点击可售座位加入待选列表已被锁定或被购买的座位显示为灰色不可点击已选座位高亮底部实时显示总价。这部分我用了一个二维数组来组织座位const seatMap ref([]) // 后端返回的座位列表转成二维数组 function buildSeatMap(seatList) { const rows [] // 遍历按行号分组 seatList.forEach(seat { if (!rows[seat.rowNum]) rows[seat.rowNum] [] rows[seat.rowNum][seat.colNum] seat }) seatMap.value rows }渲染时用v-for嵌套两层遍历座位行和列div v-for(row, rowIndex) in seatMap :keyrowIndex classseat-row div v-forseat in row :keyseat.id classseat :class{ seat-sold: seat.status 1, seat-selected: selectedMap.has(seat.id) } clicktoggleSeat(seat) {{ seat.rowNum }}-{{ seat.colNum }} /div /div选座逻辑里有一个细节必须处理要限制用户最多选几张票。很多影院支持一次最多选 5 张所以toggleSeat函数里要判断当前已选数量超过上限则提示用户。还有一个影响体验的点座位图的实际尺寸。10 排 12 列的座位图如果每格渲染太大小屏下会溢出太小又点不准。我通常给每个座位格设固定宽高比如 36px * 36px外层容器加overflow-x: auto这样中屏和小屏都能正常操作。5. 订票并发与座位锁定的实战方案5.1 超卖问题的复现与本质分析做过订票系统的人一定绕不开超卖这两个字。所谓超卖就是同一个座位被两个以上用户同时下单成功。我最早调试这个问题时在两个浏览器窗口同时登录不同账号选同一个座位疯狂点击下单按钮十次里面能复现出两三次两个订单都生成成功座位也被卖出去两次。原因拆开看并不复杂。两个请求同时到达后端后端先去查座位状态此时都是可售然后各自生成订单、更新座位状态。因为查操作和更新操作之间没有做原子性保护后面的更新把前面的覆盖了数据库最终只记录了一个订单但业务逻辑里已经产生了两个订单数据对账时发现订单数比座位数多。这种问题不是靠加一个 if 判断就能解决。你无论加多少层查了再改的逻辑前提都是两个请求能同时通过这个判断。真正的解法是让检查并更新变成一个原子操作。5.2 数据库行锁最可靠的兜底方案MyBatis 的 Mapper 里更新座位状态时用受条件限制的 UPDATE 语句这一步是整个并发方案的核心update idlockSeat UPDATE seat SET status 1 WHERE id #{seatId} AND status 0 /update这条 SQL 的执行效果是数据库在更新这一行时会加行级排他锁如果有两个事务同时执行这条 UPDATE后到的那个事务必须等待先到的事务提交或回滚。而且AND status 0这个条件保证了第一个事务把 status 改成 1 之后第二个事务再执行这条 SQL 时因为条件不满足影响行数为 0。后端 Service 层的逻辑就变得很清晰Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 尝试锁定座位影响行数为0说明座位已经被抢 int rows seatMapper.lockSeat(dto.getSeatId()); if (rows 0) { throw new BusinessException(500, 该座位已被选走请重新选择); } // 2. 座位锁定成功创建订单 Orders order new Orders(); order.setOrderNo(generateOrderNo(dto.getUserId())); // ... 填充其他字段 orderMapper.insert(order); // 3. 更新场次余票数 sessionMapper.decreaseRemainingSeats(dto.getSessionId()); return orderVO; }这里有几个 Spring 事务的细节要强调一下。第一Transactional注解必须加在createOrder方法上并且方法最好是public的Spring 默认通过 JDK 动态代理做事务增强只有通过代理对象调用方法才会走事务逻辑。如果同一个类里 A 方法调用 B 方法B 加了事务注解A 没有B 方法的事务是不会生效的因为调用发生在类内部没有经过 Spring 的代理。第二事务的隔离级别默认是REQUIRED也就是如果当前没有事务就新建如果有就加入当前事务。createOrder方法内的锁座位 创建订单 更新余票必须在同一个事务里这样一旦创建订单失败座位状态更新也会一起回滚不会出现座位已售但订单没生成的数据不一致。第三我在 Service 方法里抛出了BusinessException业务异常rollbackFor Exception.class指定任何异常都触发回滚。这一点特别重要因为 Spring 事务默认只在遇到运行时异常RuntimeException时才回滚如果抛的是检查异常比如IOException事务是不会回滚的。如果业务里自定义了继承Exception的异常类型千万别忘了指定rollbackFor。5.3 用户重复点击与幂等性处理座位锁解决了多用户竞争的问题但还有一个场景同一个用户手抖点了两下下单按钮产生了两个几乎一样的请求。虽然第二个请求因为座位已被锁会失败但用户的体验是我明明只买一张结果弹了个错而且因为网络延迟第二个请求可能是在第一个请求已经提交成功后到达的用户看着第一个订单成功了但还是心里犯嘀咕。从产品体验上讲更合理的做法是为用户生成一个会话级令牌。在进入确认订单页面时前端向后端请求一个唯一标识可以放 Redis也可以放数据库用户点击下单时带上这个标识后端处理完第一个请求后把标识标记为已使用第二个请求再带着同一个标识过来就直接丢弃。这个机制在接口设计上叫幂等性设计实现起来也不复杂。在一个没有引入 Redis 的练习项目里我用的折中方案是在orders表里加了一个source_token字段设置唯一索引。前端在进入下单页面时生成一个 UUID 存在 sessionStorage 里下单请求带上这个 UUID。后端插入订单时如果违反唯一索引约束就认为请求重复了直接返回请勿重复提交。5.4 座位超时释放防占座不付款用户选了座位但迟迟不付款这个座位如果一直锁着其他人就买不了了。实际业务场景里一般会给座位一个保留时间窗口比如 10 分钟超时未支付就自动释放。在练习项目中我实现了一个简化版方案。用户在选座页面点击提交订单后我先不立即创建订单而是把座位状态更新为锁定中比如 status 设为 2同时给前端返回一个订单创建的时间戳。用户必须在 10 分钟内完成支付否则前端在倒计时结束时调用取消接口释放座位。后端有一个定时任务兜底用 Spring 的Scheduled注解写一个定时方法每 30 秒扫一次订单表把创建超过 10 分钟且状态还是待支付的订单取消同时把座位恢复为可售。定时任务的 SQL 是update idreleaseExpiredSeats UPDATE orders o JOIN seat s ON o.id s.order_id SET o.status 4, s.status 0, s.order_id NULL WHERE o.status 0 AND o.create_time DATE_SUB(NOW(), INTERVAL 10 MINUTE) /update这个方案用 MySQL 的DATE_SUB函数和 JOIN 更新一个 SQL 就把订单和座位一起处理了比在 Java 里查出订单再逐个更新要高效得多。不过要注意Scheduled默认是单线程串行执行的如果同时有多个定时任务最好在配置里指定线程池大小避免一个任务卡住影响其他任务。6. 评论模块的防刷与敏感词过滤不只是一个简单的 INSERT6.1 评论表结构与评分聚合电影评论模块表面看就是用户发一条文本加一个评分后端存一条记录前端展示评论列表。但真正往产品形态上做有几个必须考虑的细节。首先评论表里加rating字段后一部电影的评分不应该实时通过COUNT和AVG去评论表里算那样随着评论增多查询越来越慢。我的做法是在movie表里维护rating_score总评分和rating_count评论数两个字段用户发新评论时事务里同时更新这两个字段movieMapper.updateRating(movieId, dto.getRating());updateRating的 SQL 是update idupdateRating UPDATE movie SET rating_score rating_score #{rating}, rating_count rating_count 1 WHERE id #{movieId} /update需要展示平均分时用rating_score / rating_count计算保留一位小数就可以了。这种存总分和总数用的时候再算平均的思路在积分、统计类场景里特别常用比实时聚合的性能好得多。6.2 防刷策略一单一评、时间窗口、评论内容长度限制评论模块如果不加防刷就会被人用脚本灌满垃圾内容。我总结了三道比较常见的防护手段。第一道是一单一评。用户在某个场次买票并看完电影后系统才允许对这部电影写评论。实现方式是后端在创建评论的接口里做一次校验// 校验用户是否买过该电影任意场次且订单已完成 int count orderMapper.countFinishedOrder(userId, movieId); if (count 0) { throw new BusinessException(400, 观影后方可评论); }这个方案能有效挡住大部分没有真实购票行为的刷评请求。第二道是时间窗口限制。哪怕用户真的买过票也不能一分钟发十条评论。我在评论表里给user_id建了索引创建评论时先查一下该用户最近 10 分钟有没有发过评论LocalDateTime tenMinutesAgo LocalDateTime.now().minusMinutes(10); int recentCount commentMapper.countByUserSinceTime(userId, tenMinutesAgo); if (recentCount 1) { throw new BusinessException(400, 评论过于频繁请稍后再试); }第三道是内容长度和空值校验。这个用 Spring 的参数校验注解就能轻松实现在 DTO 字段上加上NotBlank和Length(min 2, max 500)Controller 里用Valid触发校验Spring 会自动返回参数错误信息不用你在业务层手写一串 if 判断。6.3 敏感词过滤的落地方式评论内容审核这块儿很多初级项目直接啥也不做用户发什么就展示什么这是有风险的。一个合规的评论系统至少需要做敏感词过滤。最简单直接的方案是在后端维护一个敏感词列表创建评论时遍历列表做字符串匹配。但性能很差而且敏感词库多了之后维护困难。更优雅的方式是用 DFADeterministic Finite Automaton确定性有限自动机算法把敏感词构建成树结构一次遍历文本就能找出所有匹配的敏感词。这类工具库在 Java 生态里可以直接引自己写 DFA 实现也并没有想象中那么难。不过对于订票系统这个项目来说用不上这么复杂的方案。一个小技巧是在评论写入之前把内容送到一个过滤函数里对命中的敏感词做*替换比如public String filterWord(String content) { for (String word : sensitiveWords) { if (content.contains(word)) { content content.replace(word, ***); } } return content; }敏感词列表放内存里每次启动时加载一次。这种方式简单直接对评论量不大的系统完全够用。如果要做得更规范可以把敏感词库放到数据库表里管理后台可以动态增删再引入 DFA 算法做高效匹配那就是进阶优化的方向了。6.4 评论列表的排序与分页体验评论的排序方式通常有两种按时间倒序展示最新评论按点赞数排热门评论。我在评论表里加了一个likes字段用户点赞时执行UPDATE comment SET likes likes 1 WHERE id ?。前端展示上评论列表和电影列表一样需要分页。我做了下拉加载更多而不是页码切换。实现路径是Vue3 页面里维护pageNum和finished两个响应式变量滚动到底部时pageNum并请求下一页数据追加上一次的数据数组。这里也有一个前后端约定前端传给后端pageNum和pageSize后端返回PageResult结构。评论数据接口要返回的不只是评论本身还要带上评论者的昵称和头像。由于评论表里只存了user_id需要关联查询。这部分我直接用一条 SQL join 完成避免在 Java 里循环查数据库select idselectCommentsByMovieId resultTypecom.example.movie.vo.CommentVO SELECT c.id, c.content, c.rating, c.create_time, c.likes, u.username, u.avatar FROM comment c LEFT JOIN user u ON c.user_id u.id WHERE c.movie_id #{movieId} ORDER BY choose when testsortType hotc.likes DESC/when otherwisec.create_time DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /select7. 部署上线与常见问题排查从本地能跑到线上稳跑7.1 前后端分离部署Nginx作为入口服务前后端分离项目的部署方案最典型的就是 Nginx 承载静态资源 反向代理后端接口。Vue3 工程构建之后生成dist目录里面全是静态文件直接扔到 Nginx 的 html 目录里配置一个 server 块server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # 防止刷新404 } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这一行非常关键。Vue Router 使用 history 模式时路由路径是前端 JavaScript 控制的用户访问example.com/movie/3Nginx 找不到这个文件路径就会返回 404。加上这行配置后所有请求路径都会回退到 index.html由前端路由接管刷新页面就不会白屏了。后端接口的proxy_pass配置要注意斜杠。proxy_pass http://127.0.0.1:8080/;末尾带斜杠表示把/api/前缀去掉再转发。也就是说前端请求/api/moviesNginx 会转发给后端的/movies。如果你的后端接口本身就没有/api前缀这样配置就对了如果后端接口都带/api前缀那 proxy_pass 就不能带末尾斜杠。这块不搞清楚前端请求发过去就是 404排查半天都不知道问题出在哪。7.2 数据库连接池压力测试与参数调优上线之后第一个瓶颈往往出现在数据库连接池。SpringBoot 默认使用 HikariCP 连接池它的默认配置已经不错但在高并发场景下还是要主动调一下。spring: datasource: url: jdbc:mysql://localhost:3306/movie_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000maximum-pool-size不是越大越好。MySQL 默认最大连接数是 151如果你把连接池调到 200数据库自身就吃不消了。经验值是应用所在服务器的 CPU 核心数的两倍加一比如 8 核机器连接池调到 16~20 比较合理。同时注意serverTimezoneAsia/Shanghai这个参数不能省否则 Java 8 和 MySQL 驱动之间的时区问题会导致数据库时间跟我们预期差 8 个小时。7.3 前端跨域问题为什么development环境能跑部署后挂了前后端分离开发时前端跑在localhost:5173后端跑在localhost:8080端口不同就存在跨域问题。Vue3 工程里我在 Vite 配置里配了一个代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样一来前端代码里所有请求都写/api/...这种相对路径开发时 Vite 会帮你把请求转发到后端。但到了生产环境Nginx 接管了跨域转发Vite 的代理就不再生效。如果你把前端请求写成了http://localhost:8080/api/movies这种绝对地址部署后用户在浏览器里访问的域名是example.com请求却发到localhost:8080除非在浏览器地址栏访问的也是同一台机器否则必然失败。**正确做法是前端代码里统一用相对路径/api让同源的 Nginx 做代理转发。**这是我在自己项目里踩过最深的一个坑开发环境一切正常docker 部署后接口全部 404最后发现是前端请求路径写死了开发地址。后来规范成相对路径后无论本地还是线上都走 Nginx 代理问题彻底解决。7.4 一个典型的定位案例订票接口偶发超时项目上线后最让人头疼的就是偶发性问题。有段时间我收到反馈说订票接口时不时会卡十几秒甚至直接报错。单看日志每个步骤的执行时间都正常没有异常 SQL没有 OOM但就是整体耗时长。后来我用SHOW ENGINE INNODB STATUS查看数据库事务状态发现大量锁等待。原因是我的定时任务releaseExpiredSeats在扫描过期订单时需要更新 order 表和 seat 表而制造测试数据时用户正常下单也在更新这两张表。两个事务同时对同一行数据加锁后到的就要等待如果定时任务里的事务没及时提交后面的订票请求就被堵住了。排查后的优化方案是定时任务的执行时间放到凌晨人少的时候同时把批处理 SQL 改成小批量分多次执行每次只处理 100 条减少持锁时间。另外在代码里给事务设置了合理的超时时间防止极端情况下事务卡死Transactional(timeout 5) // 单位秒这个案例告诉我们SQL 层面的锁并不可怕可怕的是持锁时间太长。数据库的锁机制像交通信号灯它保证通行秩序但如果绿灯持续时间太长其他车道就会严重拥堵。8. 写在最后这套项目还能往哪些方向迭代做完这套电影订票系统我的体会是它不是终点而是很多经典问题的最佳试验场。我建议你把这份源码作为一个基础底座在上面继续加功能来提高项目的含金量。比如接一个 Redis用缓存来抗住热门场次的查询压力把场次详情、电影列表这些读多写少的数据放在缓存里能明显降低数据库负担。或者加一个简单的消息队列订单支付成功之后异步通知出票缓解下单高峰期的接口时延。再狠一点可以引入登录注册的 token 刷新机制让用户长时间不用重新登录体验更贴近真实产品。我自己后续的计划是给这个项目做一次全面的性能压测用 JMeter 模拟 100 个用户同时抢票看看座位锁在并发 200 下表现怎么样再把监控系统接进来把接口时延、数据库连接池占用率这些指标可视化。等这套做完了这个项目的含金量就远远不是一个毕业设计的水平了。技术上有个小经验分享一下项目写完之后一定要顺手写一份 README。不用多长把技术栈、数据库初始化脚本路径、后端启动步骤、前端启动步骤写清楚。很多人在面试的时候介绍项目说得头头是道但被要求把代码发我看看之后杳无音信多半就是人家打开 README 一看连怎么启动都没写清楚直接就劝退了。搞开发这件事很多时候不是会不会的问题而是熟不熟的问题。把这套系统的代码写三遍第一遍照着抄第二遍自己默写关键接口第三遍尝试优化一个模块的性能之后你面对 SpringBoot 和 Vue3 的面试题基本就没什么怕的了。