资讯详情

Java Spring Boot电影在线订票系统:选座并发与订单事务实战

📅 2026/11/2 2:01:09 | 华诺云谱 👁 阅读
Java Spring Boot电影在线订票系统:选座并发与订单事务实战
简介面向具备Java基础、希望进入在线票务领域的开发者及创业者这份docx围绕基于Spring Boot的电影在线订票选座系统展开重点解决用户、会员与管理员三角色的功能规划及技术落地问题。文档共1个文件、约34KB篇幅紧凑但目录完整涵盖功能介绍、系统截图、IntelliJ IDEA开发环境、Spring Boot项目结构解析、技术选型、开发编译运行步骤、核心代码示例与项目总结等模块。已有315人学习说明其对票务系统入门与方案参考具有一定认可度。读者可从中获取三角色权限与功能清单、基于Spring Data JPA与Thymeleaf的典型实现思路、用户注册登录及电影信息查询等核心代码片段以及数据库配置、异常处理、安全与数据完整性等注意事项结合数据模型和业务流程解释可快速理解系统骨架并用于学习参考、企业票务平台原型设计或二次开发借鉴。1. 一个选座页卡死引出的项目这套电影在线订票系统到底能干什么周五晚八点新片刚开画两个用户同时点了同一个座位页面双双卡住——线上票务系统最容易出问题的地方就在这一步。这套基于 Java 与 Spring Boot 的电影在线订票系统把选座、下单、支付、会员积分、后台管理这一整条链路都拆开写了一遍配 Spring Data JPA 做持久化、Thymeleaf 做服务端渲染MySQL 存电影、场次、座位和订单数据。它不是那种只跑得通登录的演示壳子普通用户、会员、管理员三种角色的功能边界都实现了用户能浏览影片、发帖留言、下单买票会员额外有折扣、积分兑换、优先预约和更大的收藏夹管理员则管用户、影片类型、场次资源、留言和论坛秩序后台还带数据统计图表。适合手里有 Java 基础、想照着做一个完整业务系统的开发者也适合接了票务类外包想找参考结构的团队。2. 工程骨架拆解Maven 目录、依赖清单和那份不能乱写的配置文件拿到源码包第一件事不是急着点运行而是把目录摊开看一遍。这套项目是标准的 Maven 单模块结构没有搞多模块拆分好处是新手一眼能看到全貌坏处是所有东西堆在一个 module 里后期业务涨起来要自己动手拆。2.1 目录结构包名怎么分层代码往哪放解压之后根目录下是pom.xml、src可能还有一份sql目录放初始化脚本。src/main/java下按经典三层走controller接 HTTP 请求service写业务repository对接 JPAentity放实体类config放拦截器和安全配置common里塞统一返回体和异常处理。src/main/resources下是application.properties、templatesThymeleaf 模板、staticcss/js/图片、mapper或data.sql初始化数据。movie-ticket/ ├── pom.xml ├── sql/ │ └── init.sql # 建库建表 初始影片/场次数据 └── src/main/ ├── java/com/example/ticket/ │ ├── TicketApplication.java # 启动类 │ ├── controller/ # 前台 后台接口 │ ├── service/ # 业务逻辑 │ ├── repository/ # JPA 接口 │ ├── entity/ # 实体 │ ├── config/ # 登录拦截、跨域、异常处理 │ └── common/ # Result、常量、工具类 └── resources/ ├── application.properties ├── templates/ # index.html / movie/detail.html / admin/* └── static/这个分层看着朴素但改起来省事要加一个「优惠券」功能路径很明确——entity 加类、repository 加接口、service 加逻辑、controller 加接口、templates 加页面不用满项目找该往哪塞。我一般接手别人的项目先看controller包的类名基本上业务全貌就出来了比看文档快。2.2 依赖清单pom.xml 里真正关键的几行pom.xml里最要紧的是 parent 的版本锁定Spring Boot 的 starter 会把一堆依赖版本统一管好自己别再手动指定 Jackson、Tomcat 的版本不然版本冲突能让你调一下午。!-- 父工程锁定整体版本子依赖不写 version -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies !-- Web 内嵌 Tomcat提供 REST 接口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JPA 做 ORMHibernate 是默认实现 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- Thymeleaf 服务端模板后台管理页面靠它渲染 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MySQL 驱动8.x 版本注意 driver-class-name 要写全 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- 写实体时少写 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里spring-boot-starter-data-jpa是关键JpaRepository接口一继承findAll、findById、save、deleteById全都免费拿到简单 CRUD 一行 SQL 都不用写。复杂查询就用方法名推导比如findByTitleContainingAndStatusJPA 会自动拼like和等值条件。真遇到多表统计比如后台那种按天算票房的图表再上Query写原生 SQL别硬凑方法名。2.3 application.properties四个参数写错就起不来配置文件是新手期最常出问题的地方四个参数盯死数据源地址、时区、DDL 策略、模板缓存。# 数据库连接serverTimezone 不写会报时区错误 spring.datasource.urljdbc:mysql://localhost:3306/movie_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver # update 表示启动时自动比对实体和表结构并补字段开发期方便 spring.jpa.hibernate.ddl-autoupdate spring.jpa.show-sqltrue spring.jpa.open-in-viewfalse # Thymeleaf开发期关缓存改页面不用重启 spring.thymeleaf.cachefalse spring.thymeleaf.prefixclasspath:/templates/ spring.thymeleaf.suffix.html server.port8080ddl-autoupdate在开发期非常省事加个字段重启就自动alter table。但生产环境一定要换成none或validate靠update上线哪天手滑改了字段类型数据就废了。open-in-viewfalse是刻意关掉的逼着在 Service 层把关联数据取完不然 Thymeleaf 渲染模板时懒加载抛LazyInitializationException报错位置离真实原因十万八千里这种问题体验极差。3. 核心模块拆解从选座到出票一个订单要走完的四步这套系统的业务重心不在登录注册而在「场次—座位—订单」这条线。登录注册是模板化代码照抄就行选座扣库存才是有含金量的部分也是面试和答辩时最容易被追问的地方。3.1 实体建模五个核心表怎么连User、Movie、Schedule场次、Seat座位、Order订单是主干。Schedule关联Movie和影厅Seat关联Schedule并带一个status字段表示可用/锁定/已售Order关联User和Schedule一条订单对应多个座位时用中间表或者把座位 id 用逗号拼在字段里——前者规范后者省事看项目原始实现用哪种就跟着哪种别中途混用。Entity Table(name t_order) // order 是 MySQL 关键字表名要避开 Data public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private Long userId; private Long scheduleId; // 座位 id 集合简单实现用逗号拼接规范做法是拆中间表 private String seatIds; // 应付金额会员折扣在此体现 private BigDecimal amount; // 0-待支付 1-已支付 2-已取消 3-已退票 private Integer status; private LocalDateTime createTime; private LocalDateTime payTime; }Table(name t_order)这行很容易漏。MySQL 里order是保留字实体类名默认映射成order表建表直接报语法错误。要么改表名要么在配置里加spring.jpa.properties.hibernate.globally_quoted_identifierstrue给所有标识符加反引号我更倾向改表名加引号这种全局配置后期写原生 SQL 时到处是反引号看着难受。状态字段用整型而非枚举是考虑到数据库兼容和后续扩展真要用枚举记得加Enumerated(EnumType.STRING)默认是ORDINAL往枚举中间插一个值历史数据的含义就全错位了这种数据错乱排查起来极其麻烦。3.2 下单链路锁座、算价、落单必须在同一个事务里并发选座是这套系统最大的技术点。两个人同时点同一个座位如果没有锁两条update seat set status1都成功票就卖重了。项目里用的是悲观锁简单直接适合座位数量有限、冲突概率高的场景。Repository public interface SeatRepository extends JpaRepositorySeat, Long { // PESSIMISTIC_WRITE 生成 for update锁住这些座位行直到事务结束 Lock(LockModeType.PESSIMISTIC_WRITE) Query(select s from Seat s where s.id in :ids) ListSeat lockByIds(Param(ids) ListLong ids); }Service 层把整个流程包在一个Transactional里任何一步抛异常整体回滚Service public class OrderService { Autowired private SeatRepository seatRepository; Autowired private OrderRepository orderRepository; Autowired private UserRepository userRepository; Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long scheduleId, ListLong seatIds) { // 1. 加行锁把要买的座位全锁住其他事务在这里阻塞 ListSeat seats seatRepository.lockByIds(seatIds); if (seats.size() ! seatIds.size()) { throw new BizException(座位不存在); } // 2. 逐个校验状态只要有一个被占就整体失败 for (Seat seat : seats) { if (seat.getStatus() ! 0) { throw new BizException(座位 seat.getSeatNo() 已被占用); } seat.setStatus(1); // 锁定 } seatRepository.saveAll(seats); // 3. 计算金额会员走折扣 User user userRepository.findById(userId).orElseThrow(() - new BizException(用户不存在)); BigDecimal amount calcAmount(seats.size(), user); // 4. 落单 Order order new Order(); order.setUserId(userId); order.setScheduleId(scheduleId); order.setSeatIds(seatIds.stream().map(String::valueOf).collect(Collectors.joining(,))); order.setAmount(amount); order.setStatus(0); order.setCreateTime(LocalDateTime.now()); return orderRepository.save(order); } }几个点值得说清楚。第一rollbackFor Exception.class别省Spring 默认只对RuntimeException回滚自定义的受检异常抛出去事务照常提交这种错很难发现。第二锁的粒度是座位行不是整张表不同场次的座位互不影响并发度能保住。第三先在数据库里把座位状态改成「锁定」再落单中间任何一步失败都会回滚不会出现「订单没生成但座位被占了」的情况。代价也有悲观锁在高并发下会让事务排队响应时间上涨。如果场次特别热门一秒几百人抢更好的做法是先用 Redis 预扣库存再异步落库。这个项目没上 Redis属于够用就行的取舍二次开发时可以按这个方向优化但别一上来就上分布式锁先把单体跑通再说。订单还有超时未支付要释放座位的需求常见做法是定时任务扫表每分钟查一次status0 and create_time now() - 15分钟的订单把状态置为取消、座位状态改回可用。项目里如果有Scheduled注解的类就是干这个的别忘了在启动类上开EnableScheduling不然定时任务静悄悄地不执行你还会以为代码没写对。3.3 会员体系折扣和积分怎么挂到订单上会员功能的实现路径比想象中简单User表里一个role字段区分 USER / MEMBER / ADMIN一个points字段存积分一个memberLevel存等级。折扣算价写在calcAmount里。private BigDecimal calcAmount(int seatCount, User user) { BigDecimal unitPrice new BigDecimal(45.00); // 实际从场次表取 BigDecimal total unitPrice.multiply(new BigDecimal(seatCount)); if (Role.MEMBER.equals(user.getRole())) { // 会员 9 折保留两位小数 total total.multiply(new BigDecimal(0.90)).setScale(2, RoundingMode.HALF_UP); } return total; }积分在支付成功后累加一般是消费 1 元得 1 分写在支付回调里。这里有个常见的坑支付成功后要更新订单状态、加积分、改座位为已售、可能还要发通知四个动作必须在一个事务里否则出现「钱扣了积分没加」或者「订单已支付座位还锁着」的对不上账。支付接口的幂等也得考虑同一个订单号回调两次不能加两次积分用订单号加唯一索引或者先select for update查状态再更新。会员的「优先预约」「收藏夹扩容」这类权限本质是布尔判断加数量上限在 Service 里判断role决定maxFavorite是 50 还是 200别为此设计一套复杂的权限框架杀鸡用牛刀后期维护成本反而高。4. 从零跑起来建库、打包、启动到跑通第一单环境三件套JDK 8 或 11Spring Boot 2.7 支持到 17但别混用、Maven 3.6、MySQL 5.7 或 8.0。用 IntelliJ IDEA 打开项目等 Maven 依赖下载完右下角别一直转圈转圈说明仓库地址不通换个镜像源。4.1 建库与初始化数据先建库再导数据。源码包里一般带sql/init.sql没有的话按实体手写建表也行但初始影片、场次数据得自己造不然首页空空如也看不出效果。-- 建库字符集必须 utf8mb4否则片名里的生僻字和表情存不进去 CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE movie_ticket; -- 影片表 CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, description TEXT, release_date DATE, poster VARCHAR(255), score DECIMAL(3,1) DEFAULT 0.0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场次表关联影片和影厅 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_name VARCHAR(50), start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, KEY idx_movie (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集这块踩过坑库建成utf8前端提交的影评里带个 emoji插入直接报Incorrect string value报错信息还只给一串乱码。统一用utf8mb4连接串里的characterEncodingutf8其实对应的是 utf8mb4别被名字误导。4.2 编译打包与首次启动IDEA 里直接跑启动类的main方法最快命令行则是先打包再运行# 跳过测试打包第一次跑先确保能编过 mvn clean package -DskipTests # 产物在 target 目录名字看 pom 里的 artifactId java -jar target/movie-ticket-0.0.1-SNAPSHOT.jar启动日志里盯三行Tomcat started on port(s): 8080、Started TicketApplication in x.xxx seconds、Hibernate 的建表语句有没有报错。如果卡在HikariPool反复重连八成是数据库地址或密码不对如果报Table xxx doesnt exist而ddl-autoupdate明明配了检查实体类有没有加Entity扫不到实体就不会建表。浏览器打开http://localhost:8080应该能看到首页影片列表、公告、导航都在。后台入口一般是/admin或者/admin/login具体路径翻controller包里带RequestMapping(/admin)的类别猜。4.3 用接口跑一遍主流程页面点一遍能发现问题但接口测一遍更快定位。用 curl 走一遍注册、登录、查影片、下单# 注册 curl -X POST http://localhost:8080/api/user/register \ -H Content-Type: application/json \ -d {username:demo01,password:123456,role:USER} # 登录成功返回 200失败返回 401 curl -i -X POST http://localhost:8080/api/user/login \ -H Content-Type: application/json \ -d {username:demo01,password:123456} # 查影片列表 curl http://localhost:8080/api/movies/ # 查单个影片详情 curl http://localhost:8080/api/movies/1登录返回 401 先别怀疑密码打印一下LoginRequest收到的字段常见原因是前端传的是userName而实体里叫usernameJackson 大小写对不上字段全是 null。真要对字段名做兼容在 DTO 上加JsonProperty(userName)显式指定别改实体字段名一改牵动一片。下单接口建议用 Postman 或者 REST Client 发body 里带userId、scheduleId、seatIds数组。成功返回订单号然后去数据库select * from t_order确认记录在、金额对、status0再select * from seat where id in (...)确认状态变成 1。两个查询都对了这条链路才算通。4.4 后台页面校对Thymeleaf 模板最容易白屏后台管理页是服务端渲染的templates/admin/下的 html 靠 Thymeleaf 语法取数据。白屏或者 500八成是模板里的变量名和 Controller 里model.addAttribute的 key 对不上。比如 Controller 写model.addAttribute(movieList, list)模板里却写th:eachm : ${movies}页面不会报错只是列表是空的这种问题最难查因为日志干干净净。调这类问题有个笨办法但很有效在模板里临时加一行span th:text${movieList}/span把对象直接打印出来看到null就说明 key 错了或者 Controller 压根没进这个方法。排查完把这行删掉别留在代码里。5. 避坑与排查五个把我卡了半天的坑下面这几条都是这套技术栈上真实高频的问题现象、原因、解决一条条列清楚遇到类似的直接对号入座。5.1 启动就报数据库连接失败现象日志刷Communications link failure或Access denied for user rootlocalhost应用反复重启。原因一是 MySQL 服务没起二是密码写错三是连接串里的时区参数缺失导致握手失败四是driver-class-name还写着旧版com.mysql.jdbc.Driver。解决先命令行mysql -uroot -p手连一次确认服务和密码连接串加serverTimezoneAsia/ShanghaiuseSSLfalseMySQL 8 的驱动类名必须是com.mysql.cj.jdbc.Driver旧名会打一行警告然后可能连不上。端口不是 3306 的别忘了在 url 里补上。5.2 两个人抢同一个座位票卖重了现象压测或者手动开两个浏览器同时点同一个座位两条订单都创建成功座位表里同一个 id 出现两次售出记录。原因下单逻辑先findById查状态再save查和改之间没锁两个事务都读到status0都以为自己能买。解决改用悲观锁Lock(PESSIMISTIC_WRITE)锁行或者把状态更新写成带条件的原子 SQLupdate seat set status1 where id? and status0返回影响行数为 0 就说明被抢了直接抛异常。后者不依赖事务隔离级别性能也更好我后来基本都用这种写法。5.3 页面 500Thymeleaf 模板找不到现象访问后台页面报Error resolving template [admin/movieList]或者静态资源 404css 全丢。原因模板路径不对templates下的文件层级和 Controller 返回的视图名对不上或者spring.thymeleaf.prefix被改过静态资源被登录拦截器拦掉了。解决视图名和文件路径严格一一对应返回admin/movieList就必须有templates/admin/movieList.html。拦截器配置里记得把/static/**、/css/**、/js/**排除掉否则未登录用户看到的页面是没有样式的纯文本很容易误判成前端代码写错了。5.4 接口返回 JSON 直接栈溢出现象调用查影片接口控制台刷StackOverflowError或者JsonMappingException: Infinite recursion。原因实体之间双向关联Movie里有ListScheduleSchedule里又有MovieJackson 序列化时互相递归。解决在关联字段上加JsonIgnore断开一边或者用JsonBackReference/JsonManagedReference配对标注。更好的做法是别直接把实体返回给前端转成 DTO只暴露需要的字段顺带把密码这类敏感字段也屏蔽掉——User实体直接返回password就跟着出去了这是个实打实的安全问题。5.5 ddl-autoupdate 改了字段没生效现象实体里把title长度从 50 改成 200重启后数据库字段还是 50插入长片名报Data too long。原因Hibernate 的update策略只做增量加字段、加表会执行但改字段类型、改长度、删字段它一律不管。解决开发期改结构就手动执行alter table或者干脆删库重建开发环境数据不重要时最省事。生产环境用 Flyway 或 Liquibase 做版本化迁移脚本别指望 ORM 自动改表。这条是真正的血泪经验我曾经因为字段长度没改测试环境导数据导到一半全挂。6. 二次开发把优惠券和推荐位接进去改动路径与验证方法基于这套骨架做二次开发最值得先动手的是优惠券因为它会串起实体、算价、订单、后台管理四条线做完一遍整个项目的结构就摸透了。先加实体Coupon字段包括id、name、type满减/折扣、threshold门槛金额、value、startTime、endTime、stock。再建一张user_coupon关联表记录谁领了哪张、用没用。然后在OrderService.calcAmount里插入一段逻辑查用户可用券校验门槛和时间按类型减价最后把券标记为已使用。注意券的核销必须和下单在同一个事务里且用带条件的更新防止同一张券被并发用两次// 核销优惠券影响行数为 0 说明已被占用 Modifying Query(update UserCoupon uc set uc.used 1 where uc.id :id and uc.used 0) int consume(Param(id) Long id);调用后判断返回值等于 0 就直接抛异常回滚整个下单。这个模式和 5.2 里说的原子更新座位是同一个思路凡是「状态只能变一次」的地方都该这么写。推荐位改造更轻属于纯查询优化。首页的「猜你喜欢」不用上推荐算法先用最简单的规则取用户最近三部已支付订单的影片类型查出同类型里评分最高的六部排除已看过的。一条 JPQL 就能搞定Query(select m from Movie m where m.type :type and m.id not in :seenIds order by m.score desc) ListMovie recommend(Param(type) String type, Param(seenIds) ListLong seenIds, Pageable pageable);改完之后怎么验证这一步比写代码更重要。我会按这个顺序过一遍验证项操作预期结果并发核销同一张券两个线程同时下单只有一单成功另一单报券已使用门槛校验用 100 减 20 的券下 80 元订单提示不满足使用条件过期校验把券的 endTime 改到昨天再下单提示券已过期折扣叠加会员 9 折券再叠加 9 折会员价按业务约定顺序计算金额与手工算的一致回滚完整性下单中途抛出异常座位解锁、券未核销、订单不存在这套表格我一般会直接贴在提测单里测试同学照着点就行比口头说「你测一下优惠券」高效太多。另外提醒一句改造时别动原有表结构的主键类型和关联方式。我见过有人在二次开发时把Long id改成 UUID结果 JPA 的GenerationType.IDENTITY全部失效所有save都报主键为空改回去花了两小时。扩展功能就用新增表、新增字段的方式能不动老代码就不动改一处要回归一片得不偿失。从那以后我每次接一个陌生源码包第一件事都是先只跑通登录和首页再动业务代码一条链路一条链路地验证绝不一次性改五个地方然后一起调试。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑