Java实战:基于Spring Boot的电影院购票系统设计与并发控制
简介:在实际业务系统中,座位资源的实时抢占与订单数据的一致性,是后端开发经常面对的经典问题。电影院购票系统正是这类场景的典型缩影:多个用户同时选座,如何保证同一个座位不会被重复锁定?订单生成时,座位状态更新与订单记录写入又如何保持原子性?这些问题背后涉及数据库事务、并发控制、状态管理等核心技术。通过一个具备完整业务流程的Java Web项目,可以从工程实践角度深入理解乐观锁、悲观锁、唯一约束以及Spring声明式事务的适用场景与取舍。从基础的表结构设计到高并发场景下的兜底策略,整个过程既覆盖了CRUD之外的业务难点,也为面试中的项目经验提供了扎实的素材。无论是学习Java的在校生,还是准备求职的开发者,都可以通过这个项目串起分散的技术知识点,掌握在真实业务中平衡性能与一致性的工程能力。
1. 项目定位与需求拆解
1.1 为什么选择电影院购票系统作为练手项目
电影院购票系统算是Java Web方向里很经典的实战题目了,和图书管理系统、学生管理系统这类纯CRUD项目不同,它天然带着几个有含金量的业务难点:座位状态需要实时维护、同一场次多个用户可能同时选座、订单创建要保证数据一致性。这几个点恰好能把Java后端里最常考的事务、并发、状态管理这些概念落到具体场景里,做完之后你对这些东西的理解深度和只看面试题完全不是一个级别。
我当年做这个项目的时候,正值准备校招的阶段,想着与其刷一堆零散的八股文,不如做一个能把这些知识点串起来的完整项目。折腾了两周左右,从数据库设计到前后端联调全部走了一遍,最后不管是面试聊项目经验,还是自己写代码时的思维能力,提升都非常明显。如果你正在学Java,或者准备课程设计、毕业设计,这个题目值得认真做一遍。
这个系统能做什么,简单来说是这么几件事:用户注册登录后可以浏览正在上映的电影、查看某部电影的场次和票价、选择座位并生成订单;管理员可以在后台维护电影信息、安排场次、管理影厅座位,以及查看所有订单数据。听上去不复杂,但要把这些功能做得“能用、稳、不会超卖座位”,里面需要琢磨的细节非常多。
1.2 适合谁来参考这套代码
先自己对号入座一下,看看你是不是这篇博文的目标读者:
- 正在学Java的在校生:学完了Servlet、JDBC、SSM,但不知道这些零散的技术组合起来能做什么,需要一个完整项目把知识串起来。
- 准备面试的求职者:简历上缺少一个有业务深度的项目,想找一个能聊出并发控制和事务处理的项目经历。
- 需要交课程设计作业的学生:需要一个逻辑清晰、代码规范、功能完整的系统,能答辩能交差。
- 想转行的非科班朋友:想通过一个实战项目来检验自己的Java基础,看看持续做项目的状态是不是自己想要的。
如果你属于上面任何一类,这篇博文会从需求分析讲到代码落地,再讲到踩坑实录,基本可以照着一步步做出一个能跑起来的电影院购票系统。我会把核心代码、SQL脚本和排查思路都放出来,方便你直接参考和改写。
2. 整体架构设计与技术选型
2.1 技术栈怎么选:Spring Boot还是SSM
先说结论:如果你是交作业或者自学练手,直接用Spring Boot + MyBatis + MySQL这个组合;如果你的课程要求里明确写了必须用SSM(Spring + Spring MVC + MyBatis),那也可以用Spring Boot的思路去套SSM,本质是一样的。
为什么我更推荐Spring Boot?倒不是说我不会SSM,而是Spring Boot帮你省掉了大量XML配置,比如Spring MVC的组件扫描、视图解析器、数据源配置,在Spring Boot里要么自动配置好了,要么一个application.yml就搞定。这样你的注意力可以集中在业务代码上,而不是花半天时间在那改web.xml和springmvc.xml,最后项目还启动失败。
这套组合里的技术选型可以拆开来说说:
- MySQL:选它的原因很简单,开源免费、用的团队最多、面试问数据库大概率也是MySQL。电影院购票系统涉及的也就是几张普通业务表,MySQL完全够用,没必要上Oracle或PostgreSQL。
- MyBatis:相比JPA,MyBatis的SQL可控性更强,尤其是选座、改座位状态这些要精确控制SQL的场景,直接写SQL反而舒服。而且国内公司用MyBatis的比例很高,面试也经常问。
- 前端:我建议课程设计用JSP+Bootstrap就够了,学习成本低,老师验收也方便;如果你想做前后端分离,可以配Vue或者原生HTML+Ajax,这个不影响后端核心逻辑。
提示:技术栈不是越新越好,而是要符合你的时间成本和验收方要求。我见过有人为了显得“高级”硬塞了Redis和消息队列,结果项目过度设计,自己都讲不清楚,答辩时被问倒了反而扣分。
2.2 系统要分成哪几个模块
电影院购票系统的模块划分,可以从用户视角和管理员视角两条线来看:
用户端:
- 用户注册与登录(含密码加密存储)
- 电影列表与搜索(按上映状态、类型筛选)
- 电影详情页(演员、简介、场次列表)
- 场次选择与座位选择(这是核心模块,最重要)
- 订单确认与生成
- 个人订单查询
管理端:
- 管理员登录
- 电影信息管理(新增、上架/下架、修改)
- 影厅管理(影厅名称、座位行数/列数)
- 排片管理(为电影安排场次,定时间、影厅、票价)
- 订单管理(查看所有用户订单、按条件检索)
在做模块划分的时候,有一个经验可以分享:把用户端和管理端尽量在功能上分离,但是共用的实体类(比如电影、影厅)不要复制两份。一开始就做好包结构的规划,比如controller、service、mapper、entity、dto这些包里按业务再分子包,后面加功能会很顺手。
2.3 包结构怎么组织才清晰
我当时项目的包结构大致是这样,直接给你参考:
com.cinema ├── controller │ ├── UserController.java │ ├── MovieController.java │ ├── SessionController.java │ ├── OrderController.java │ └── AdminController.java ├── service │ ├── UserService.java │ ├── MovieService.java │ ├── SessionService.java │ ├── OrderService.java │ └── SeatService.java ├── mapper │ ├── UserMapper.java │ ├── MovieMapper.java │ ├── SessionMapper.java │ ├── OrderMapper.java │ └── SeatMapper.java ├── entity │ ├── User.java │ ├── Movie.java │ ├── CinemaSession.java │ ├── Seat.java │ ├── Order.java │ └── OrderItem.java ├── dto │ ├── SeatLockDTO.java │ └── OrderCreateDTO.java └── common ├── Result.java └── BusinessException.java实体和Mapper按表对应,Service层做业务编排,Controller层只做参数接收和结果封装。这样分层清晰,后面做事务控制时也方便,在Service层加@Transactional即可。
3. 数据库设计与核心表结构
3.1 表结构设计:5张表怎么设计最合理
数据库设计我建议用“先画关系,再建表”的方式。电影院购票系统里主要的实体有:用户、电影、影厅、场次、座位、订单。它们之间的关系是:
- 一个电影可以有多场场次,一个场次属于某部电影
- 一个场次在一个影厅里,一个影厅有多排多列的座位
- 一个订单属于某个用户,一个订单可以包含多个座位
因此核心表建议这样设计(按最常见的方案):
用户表(t_user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| username | varchar(50) | 登录名,唯一 |
| password | varchar(100) | 密文存储 |
| nickname | varchar(50) | 昵称 |
| phone | varchar(20) | 手机号 |
| create_time | datetime | 创建时间 |
电影表(t_movie)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar(100) | 电影名 |
| cover_url | varchar(255) | 海报地址 |
| director | varchar(50) | 导演 |
| actors | varchar(255) | 主演 |
| description | text | 简介 |
| duration | int | 时长(分钟) |
| status | tinyint | 1上映,0下架 |
| release_date | date | 上映日期 |
影厅表(t_hall)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar(50) | 影厅名,如1号厅 |
| row_count | int | 座位行数 |
| col_count | int | 座位列数 |
场次表(t_session)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| movie_id | bigint | 关联电影 |
| hall_id | bigint | 关联影厅 |
| start_time | datetime | 开场时间 |
| end_time | datetime | 散场时间(可选) |
| price | decimal(10,2) | 票价 |
| session_date | date | 放映日期,便于按日查询 |
座位表(t_seat):这里的座位表不是静态的座位坐标,而是每个场次下的座位状态表。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| session_id | bigint | 关联场次 |
| row_no | int | 排号 |
| col_no | int | 列号 |
| status | tinyint | 0空闲,1锁定,2已售 |
| version | int | 乐观锁版本号 |
订单表(t_order)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(50) | 订单编号,唯一 |
| user_id | bigint | 下单用户 |
| session_id | bigint | 场次 |
| total_amount | decimal(10,2) | 总金额 |
| status | tinyint | 0待支付,1已支付,2已取消 |
| create_time | datetime | 下单时间 |
| pay_time | datetime | 支付时间(可空) |
订单座位关联表(t_order_seat):一张订单对应多个座位,用多对多关联表。这也是为什么订单表里不放一个seat_id字段的原因,一张票买两张的时候一条订单记录需要关联两个座位,如果塞成seat_ids字符串,后续统计和查询非常难受,表结构也不规范。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_id | bigint | 关联订单 |
| seat_id | bigint | 关联座位 |
3.2 为什么座位表要设计成“每个场次一份”
这里有一个关键设计决策:座位表是绑定场次的,而不是绑定影厅的。也就是说,同一个影厅在1号场次和2号场次各有一套独立的座位记录。
为什么要这么设计?因为同一影厅在不同场次,同一个座位的状态是不同的。比如1号厅第3排第5座,上午10点场次被用户A选了,下午2点场次还是空闲的。如果座位表只维护“影厅+座位”的静态信息,就没办法表达“这个座位在这个场次是否可用”的状态,还得额外用一张关联表去维护场次和座位的一对多关系,反而更绕。
所以在排片阶段,管理员创建一场新的场次时,系统要做的逻辑是:根据影厅的row_count和col_count,为该场次批量生成所有座位记录,初始状态都是0(空闲)。这也是管理员排片功能里一个比较重要的Service逻辑,批量插入可以用MyBatis的foreach。
生成座位的核心SQL大致是这样(Java中循环拼接后批量插入):
public void initSeatsForSession(Long sessionId, int rowCount, int colCount) { List<Seat> seats = new ArrayList<>(); for (int r = 1; r <= rowCount; r++) { for (int c = 1; c <= colCount; c++) { Seat seat = new Seat(); seat.setSessionId(sessionId); seat.setRowNo(r); seat.setColNo(c); seat.setStatus(0); seat.setVersion(1); seats.add(seat); } } seatMapper.batchInsertSeats(seats); }对应MyBatis的批量插入:
<insert id="batchInsertSeats" parameterType="list"> insert into t_seat (session_id, row_no, col_no, status, version) values <foreach collection="list" item="seat" separator=","> (#{seat.sessionId}, #{seat.rowNo}, #{seat.colNo}, #{seat.status}, #{seat.version}) </foreach> </insert>注意:批量插入的SQL在MySQL里默认是有长度限制的,如果影厅特别大(比如100行*100列=10000条),建议分批插入,每批500条,不然可能报
max_allowed_packet相关的错误。我实际做的时候因为这个踩过一次坑,后来改成每500条提交一次,问题就没了。
4. 核心功能实现:选座、并发控制与订单事务
4.1 选座功能的需求和难点在哪里
选座是电影院购票系统里最有技术含量的功能,也是面试时最能聊的部分。用户看到的选座界面大概是这样:进入一个场次后,屏幕中央显示一个座位图,绿色的是空闲,灰色的是已售,黄色的是当前用户正在选的座位。用户点击一个绿色座位,它变成黄色并加入待选列表;点击“确认选座”后,系统把这些座位锁定,同时生成一笔待支付订单。用户支付成功后,座位状态从“锁定”变成“已售”。
这个流程里最大的难点是:两个用户同时点了同一个座位怎么办?这就是我们常说的“并发超卖”问题——同一个座位被两个人同时锁定了,但最终只能卖出去一次。
如果不用任何并发控制,可能会出现这种情况:用户A和用户B同时打开了第3排5座这个座位,都看到它是空闲的,然后同时提交订单。两个请求同时通过了“查询座位状态”这一步,都认为座位可买,结果两个人都下单成功。这在业务上就是严重的事故,用户付款后到了影院发现座位没了,投诉能把你淹没。
4.2 方案一:乐观锁,用version字段控制
我在项目里优先采用的是乐观锁方案,实现简单而且性能足够。具体做法是在座位表加一个version字段,每次更新座位状态时,在SQL的WHERE条件里带上版本号,只有当版本号匹配时才更新成功。
落座操作的SQL:
update t_seat set status = 1, version = version + 1 where id = #{seatId} and status = 0 and version = #{expectedVersion}这条SQL的含义是:“我只把那些状态还是空闲、版本号还是我一开始查到的版本号的座位进行锁定”。如果执行后影响行数为1,说明抢座成功;如果影响行数为0,说明座位已经被别人抢了,当前用户需要重新选座。
用Java代码串起整个流程大概是这样的:
@Transactional(rollbackFor = Exception.class) public boolean lockSeat(SeatLockDTO lockDTO) { // 1. 查询当前座位信息,拿到version Seat seat = seatMapper.selectById(lockDTO.getSeatId()); if (seat == null || seat.getStatus() != 0) { throw new BusinessException("座位不可选"); } // 2. 尝试用乐观锁更新 int rows = seatMapper.updateStatusWithVersion( lockDTO.getSeatId(), 0, 1, seat.getVersion()); if (rows == 0) { throw new BusinessException("手速慢了,座位已被其他人锁定"); } // 3. 更新成功,将座位加入订单关联表或临时锁定表 orderSeatMapper.insert(lockDTO.getOrderId(), lockDTO.getSeatId()); return true; }这里有一个重要的点:事务别开太大。锁座位的操作要尽可能快,不要把整个选座+生成订单+扣库存全放在一个大事务里,不然事务持有数据库连接的时间太长,并发高的时候连接池很容易被打满。我当时是把“锁座位”和“生成订单”拆成了两个方法,锁座位单独一个事务,这样单个座位的锁定操作能在毫秒级完成,资源占用最小。
4.3 方案二:悲观锁,SELECT FOR UPDATE
乐观锁已经能解决90%的问题,但如果你想在极端情况下更保险一点,可以用悲观锁——在查询座位的时候直接加上FOR UPDATE,把这一行锁住,直到事务提交才释放。
select * from t_seat where id = #{seatId} for update;这个方案的好处是:只要拿到了行锁,后续操作就绝对安全,不用担心版本号不一致或者更新失败。坏处也很明显:MySQL的InnoDB行锁在事务结束才会释放,如果持锁的事务逻辑比较复杂,其他请求会被阻塞。假设用户选中座位之后一直没有生成订单,事务一直不提交,那这个座位会被一直锁着,后续所有人都买不了。
所以我更推荐的还是乐观锁方案,性能和体验都更好。悲观锁可以作为面试时的一个扩展知识点来聊,展示你理解两种方案的取舍。
4.4 方案三:唯一约束兜底
其实在生产环境里,最稳的做法不是单一靠一种锁,而是多种机制叠加。比如在订单座位关联表t_order_seat中,给seat_id加上唯一约束,也就是说同一个座位在关联表中最多只能有一条记录。这样即使业务代码出现了并发问题,数据库层面也会直接拒绝重复插入,相当于兜底。
alter table t_order_seat add unique key uk_seat (seat_id);这种做法是我在踩了几次坑之后才加上的。因为代码是人写的,哪怕你写了乐观锁,也保不齐某个接口漏了逻辑,或者有人绕过Service直接调了Mapper。有了唯一约束,数据库就是最后一道防线。虽然这个约束不能应对“锁定时发现座位已售”的所有情况,但在核心的安全性上,多一道保险总比少一道好。
4.5 订单生成为什么必须加事务
选座成功后,系统要做的事情不止是改座位状态,还要插入订单记录和订单座位关联记录。这三步操作必须放在一个事务里,任何一步失败都要回滚,否则会出现“座位状态改了但订单没生成”或者“订单生成了但没记录买了哪些座位”这种脏数据。
我在OrderService里用的方式是这样的:
@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 生成订单号 String orderNo = generateOrderNo(); // 2. 插入订单主表 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setSessionId(dto.getSessionId()); order.setStatus(0); order.setCreateTime(new Date()); orderMapper.insert(order); // 3. 逐个锁座位,并插入关联表 for (SeatLockDTO seatLock : dto.getSeatList()) { int rows = seatMapper.lockSeat(seatLock.getSeatId()); if (rows == 0) { throw new BusinessException("座位已被锁定,请重新选择"); } orderSeatMapper.insert(order.getId(), seatLock.getSeatId()); } // 4. 计算总金额(用场次的票价 * 座位数) BigDecimal price = sessionMapper.selectPriceById(dto.getSessionId()); BigDecimal total = price.multiply(new BigDecimal(dto.getSeatList().size())); order.setTotalAmount(total); orderMapper.updateAmount(order.getId(), total); return order; }注意几点:
@Transactional默认只在抛出RuntimeException时回滚,所以我用了rollbackFor = Exception.class,确保检查性异常也能触发回滚。- 生成订单号时最好带时间戳和随机数,比如
yyyyMMddHHmmss + 用户ID + 四位随机数,避免并发时重复。我用的是订单号 = 时间戳 + 用户ID + ThreadLocalRandom随机数,实测下来没有重复过。 - 事务里尽量别做远程调用、网络等待等耗时操作,不然会长时间占用数据库连接。比如支付回调如果走事务,建议把状态更新拆出来单独处理。
4.6 超时未支付:座位锁死了怎么办
用户锁定了座位但一直不支付,如果座位永远处于“锁定”状态,对其他用户太不公平。所以需要设计一个释放机制。
简单的做法是:在订单表中加一个expire_time字段,下单时设置为当前时间+15分钟,订单状态为“待支付”。然后写一个定时任务,每1分钟扫描一次,把超过expire_time还未支付的订单状态改为“已取消”,并把对应座位的状态改回“空闲”。
如果用的是Spring Boot,开启定时任务很简单,主类上加@EnableScheduling,然后在方法上写@Scheduled(fixedDelay = 60000)即可。
@Component public class OrderExpireTask { @Autowired private OrderMapper orderMapper; @Autowired private SeatMapper seatMapper; @Scheduled(fixedDelay = 60000) @Transactional(rollbackFor = Exception.class) public void releaseExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredOrders(new Date()); for (Order order : expiredOrders) { // 1. 把订单改为已取消 orderMapper.updateStatus(order.getId(), 2); // 2. 把该订单关联的座位改回空闲 List<Long> seatIds = orderSeatMapper.selectSeatIdsByOrderId(order.getId()); if (seatIds != null && !seatIds.isEmpty()) { seatMapper.releaseSeats(seatIds); } } } }这里要注意,定时任务只查待支付订单,别把已支付或已取消的订单也扫进来。另外,释放座位时状态需要判断一下当前是不是“锁定”状态,避免把“已售”的座位也改成空闲,那就要出大问题了。
5. 管理端功能与权限控制
5.1 管理员后台需要哪些基本功能
管理端的核心职责是维护业务数据的正常运转。在电影院购票系统里,管理员的功能大致分为三大块:基础数据管理、排片管理、订单监控。
- 电影管理:增删改查电影信息。这里有个小的经验点,电影下架操作不能把数据物理删除,而是要用
status字段标记,因为历史订单还要关联到这部电影信息,物理删除了之后订单详情里就没有电影名了。 - 影厅管理:创建影厅,并配置行数和列数。创建完影厅后,它在排片时才会作为场次的候选场所。
- 排片管理:选择一部电影、一个影厅、设置开始时间和票价,系统自动为该场次生成该影厅所有座位。
- 订单管理:查看所有订单列表,可以按订单号、用户名、场次时间筛选。订单金额和座位数最好都展示出来,方便对账。
管理端的代码其实没什么特别复杂的地方,就是标准的CRUD,但写的时候要注意两点:一是所有涉及金额的字段都用BigDecimal,不要用double;二是列表查询尽量做分页,用MyBatis的PageHelper插件,避免数据多了之后一次性查全部导致页面卡死。
5.2 登录状态怎么控制:拦截器和Session
用户端和管理端都需要做登录校验。我是用拦截器实现的,思路很直接:写一个LoginInterceptor,在preHandle方法里检查Session中是否有登录用户,没有就重定向到登录页或返回401。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } return true; } }然后注册拦截器时,通过路径匹配来区分哪些接口需要登录:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/order/**", "/user/**", "/seat/**") .excludePathPatterns("/login", "/register", "/movie/list", "/session/list"); }面试时聊到这个点,可以主动补充为什么用拦截器而不是Filter,因为拦截器能拿到HandlerMethod的信息,适合做细粒度的权限判断;Filter更偏底层,适合做编码处理、跨域配置等通用逻辑。这种思考会让面试官觉得你不是只会用框架,而是真的理解它们之间的区别。
密码存储这块,我用了BCrypt加密,没有用MD5。MD5加盐虽然也能用,但BCrypt是自适应哈希,计算耗时可以调节,抗暴力破解能力更强。Spring Security里有现成的BCryptPasswordEncoder,但如果不想引入Spring Security,也可以用jBCrypt这个独立库,用法很简单。
6. 常见问题与排坑经验
6.1 项目启动失败和数据库连接问题
做这个项目的时候,我遇到的第一类问题集中在项目启动阶段。最典型的是端口被占用,Spring Boot默认8080端口,如果本地有其他服务占用了,启动会直接失败。解决办法是在application.yml里改端口:
server: port: 8081第二个高频问题是数据库连接不上,报Access denied for user 'root'@'localhost'或者Unknown database。这两种基本都是配置文件写错了,检查一下spring.datasource.url、username、password是否和本地MySQL一致。还有个隐蔽的坑是连上了但表找不到,那是因为url里没指定数据库名,或者指定的数据库名和实际建的库名不一致。
6.2 中文乱码问题
中文乱码在我做这个项目时也出现过,而且出现在两个地方:一个是接口返回的JSON中文乱码,另一个是前端页面上显示乱码。
JSON乱码的原因一般是Spring Boot默认的字符串消息转换器没有设置UTF-8编码。解决办法有两个,一是在application.yml里加:
server: servlet: encoding: charset: UTF-8 enabled: true force: true二是在Controller的方法上显式指定produces = "application/json;charset=UTF-8"。实测下来第一种方式能解决大部分场景。
页面显示乱码则要检查前端页面的<meta charset="UTF-8">,同时确保JSP文件本身是以UTF-8编码保存的。如果用的是Vue或HTML+Ajax,那重点检查一下后端接口返回的Content-Type是否带了charset。
6.3 MyBatis XML里的SQL报错
用MyBatis写动态SQL时,最容易踩的坑是XML里的小于号(<)和大于号(>)会被当作XML标签解析,导致SQL报错。比如查询某个时间之前的场次:
<select id="selectBeforeTime" resultType="CinemaSession"> select * from t_session where start_time < #{time} </select>这里必须把<写成<,>写成>。如果不小心写错了,启动时不会报错,但执行这个SQL时XML解析就会抛异常,排查起来比较耗时间。另外,动态SQL里的if判断和where标签要注意正确的拼接方式,用<where>标签可以自动去掉多余的AND或OR,比手动拼接方便得多。
我个人的习惯是:能用注解写SQL的简单查询就用@Select,复杂动态查询用XML。其实对于这个项目,真正的动态SQL也没多少,主要就是电影列表的条件筛选和订单列表的模糊搜索。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 启动报端口被占用 | 8080被其他进程占用 | 改端口或用`netstat -ano |
| 访问页面报Whitelabel Error Page | 接口路径写错或Controller未生效 | 检查@RequestMapping路径、类上是否有@RestController |
| 登录后Session失效 | 拦截器排除了登录接口却排除了所有路径 | 检查拦截器配置的addPathPatterns和excludePathPatterns |
| 选座时提示“座位已被锁定” | 乐观锁冲突正常提示 | 检查数据库中该座位status和version是否被其他流程修改 |
| 订单生成了但座位没锁定 | 事务没有生效 | 检查ServiceImpl类上是否加了@Transactional、方法是否为public |
| 金额为0或精度丢失 | 用double计算金额 | 全部改用BigDecimal,金额字段用DECIMAL存储 |
| 批量插入座位报SQL异常 | 插入语句过长超过MySQL限制 | 分批插入,每批500条 |
这个表格是我在实际开发中总结出来的高频问题,如果做的时候遇到对得上号的,直接按排查思路去看,能节省不少时间。
6.5 一个容易忽略的坑:事务失效
这个坑我必须单独拎出来说,因为太典型了。@Transactional注解不是加上就一定生效,它的底层是通过AOP代理实现的,如果方法被private修饰,或者是在同一个类中通过this调用,事务就不会生效。
举个例子:
@Service public class OrderService { @Transactional public void createOrder(OrderCreateDTO dto) { // 业务逻辑 } public void handleOrder(OrderCreateDTO dto) { // 这个方法内部直接调用 createOrder createOrder(dto); // 这样事务是失效的! } }因为this.createOrder()没有经过Spring的代理,所以@Transactional不会起作用。解决办法是:要么把createOrder拆到另一个Service里,要么在handleOrder方法上也加上@Transactional,或者通过AopContext.currentProxy()来调。面试如果问事务失效的场景,这是一个很好的回答素材。
6.6 建议后续扩展的方向
这个系统做完基础功能之后,还有几个方向值得自己再练一练:
- 用Redis实现分布式锁替代乐观锁:模拟两台服务器部署场景,用
SETNX或Redisson实现座位锁定,体会一下分布式环境下有什么区别。 - 用RabbitMQ做订单超时处理:下单时发送延迟消息,延迟时间到后自动取消订单,比定时任务更实时。
- 接入支付宝沙箱支付:支付功能虽然可以模拟,但真正接一次第三方支付流程,能学到接口签名、回调验签这些企业级实践。
这些扩展不需要全做,挑一个感兴趣的去实现就行。我个人做完系统后,顺手把分布式锁那部分研究了一遍,收获比单纯模仿代码大得多。
最后说一点个人体会:电影院购票系统这个题目,表面上是CRUD,实际上把并发控制、事务管理、状态流转这些后端核心问题都带出来了。做一遍这个项目,比刷一百道面试题更能理解“为什么Spring事务要这样设计”“为什么数据库要加唯一约束”这些问题的答案。希望这篇博文能给你提供一个完整可参考的路线,少走我当年走过的弯路。
本文还有配套的精品资源,点击获取
