SpringBoot电影选座系统:Redis分布式锁与座位状态机实战
发布时间:2026/9/12 22:27:54 作者:尧图编辑部 阅读量:1,286

简介基于SpringBoot的大剧院订票选座管理系统完整项目源码主要面向Java毕业设计及相关课程实践覆盖前后台核心业务流程用户注册登录、节目浏览与在线选座预订、订单管理后台则包含节目分类管理、订单审核、用户管理及公告发布等模块。压缩包总计1621个文件大小约87.26MB其中包含130个Java源码、96个Vue组件、306个JavaScript脚本、86个HTML页面以及SQL数据库脚本和XML/Map配置文件等前后端结构清晰便于直接导入开发工具运行调试。目前已有251人学习浏览与下载是较为典型的SpringBootMySQL前端Vue的毕业设计参考项目。资源附带数据库脚本、配置文件和说明文档部署后可快速还原系统环境助力理解订票选座业务建模与后台管理实现思路也可在此基础上扩展功能或修改界面用于自身设计。1. 为什么电影订票选座系统看着只是CRUD却一定要把“锁”讲清楚黄金座位19排5号10:00开售同一秒被两个用户同时选中两边页面都说“选座成功”——但这座位只有一把。用SpringBoot写电影订票选座管理系统最容易踩的坑就是“先插订单、再改座位状态”单用户怎么测都正常两个用户抢同一个座位立刻翻车。下面顺着这个毕设最常见的题目把表结构、座位状态机、Redis分布式锁、订单超时释放、重复提交拦截完整过一遍最后用40并发抢同一个座位的压测验证系统是不是真的锁得住。适合拿它做毕业设计、准备SpringBoot面试或第一次接触并发设计的后端读者。这个项目从头到尾的分水岭只有一句话同一座位、同一时刻、只允许一个请求动它。2. SpringBoot项目实战搭出电影选座系统的表结构、工程骨架与最简下单接口2.1 组件选型与版本SpringBoot 2.7.x MyBatis-Plus Redis 的稳妥组合做这类管理系统我不会一上来就追新框架先想清楚哪三样东西省不掉一个关系库存业务数据一个缓存扛高峰期读压力一个定时任务处理“锁住但没付钱”的座位。落在SpringBoot生态里就是MySQL、Redis和Spring Task再加MyBatis-Plus把单表CRUD的样板代码收掉。自动装配帮我们省掉大量XML配置这也是这类项目能快速成型的原因。版本选择上要克制。JDK是8就老老实实用SpringBoot 2.7.x不要看到springboot 3.x的新特性就往上冲。springboot版本太高换来的不是性能而是一套javax到jakarta的包名迁移MyBatis-Plus、Knife4j等配套组件版本稍微跟不上启动直接ClassNotFoundException排查成本比收益高得多。JDK17以上再考虑3.x系。如果用IDEA在线初始化项目一直超时把初始化服务地址换成国内镜像几秒钟就能把工程拉下来。Redis、MyBatis-Plus、MySQL的配置集中在application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your-password # 生产环境用Jasypt密文写法是ENC(密文) data: redis: host: localhost port: 6379 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl参数说明MySQL连接串里的serverTimezone必须写否则本地时区与MySQL默认时区不一致会直接报错密码不要明文躺在yml里springboot的yml密文配置用Jasypt实现答辩时主动提这一点是加分项mybatis-plus的log-impl只用于开发期打印SQL上线后去掉。如果引入了actuator把heapdump端点关闭避免heapdump敏感信息泄露漏洞。2.2 四张表定江山movie、showtime、seat、orders 的表结构设计选座业务可以收敛成四个实体电影、场次、座位、订单。一个场次属于一部电影一个场次下有一批座位一张订单锁定一个场次下的一个座位。表结构设计直接决定后面锁怎么写建表时就要把唯一约束和状态字段留好。CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 电影名称, duration_minutes INT NOT NULL DEFAULT 0, release_date DATE, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-上映中 2-已下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE showtime ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_name VARCHAR(32) NOT NULL COMMENT 影厅, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-未开场 1-已开场 2-已结束, KEY idx_movie (movie_id), KEY idx_start_time (start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, showtime_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-可售 1-锁定 2-已售, UNIQUE KEY uk_show_seat (showtime_id, row_no, col_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, showtime_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, amount DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已取消 3-已退票, expire_time DATETIME NOT NULL COMMENT 订单过期时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_show_seat (showtime_id, seat_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;orders表里这几个约束值得单独说明字段/约束作用为什么重要uk_order_no订单号唯一前端重复提交时只有一次能插入成功uk_show_seat同一场次同一座位只能有一条订单并发防超卖的数据库最后一道防线expire_time待支付订单的支付截止时间定时任务靠它决定何时释放座位seat表没有放version字段因为后面会用Redis分布式锁做第一道防线、数据库唯一索引做兜底乐观锁在这里属于冗余方案。状态字段统一用TINYINT表示不用MySQL的ENUM后续加状态类型时不用改表结构。2.3 工程骨架与“能跑”的最简下单接口单模块工程足够完成这个项目目录结构按Controller、Service、Mapper、Entity、Config分层src/main/java/com/example/cinema ├── controller/OrderController.java ├── service/OrderService.java ├── service/impl/OrderServiceImpl.java ├── mapper/SeatMapper.java ├── mapper/OrderMapper.java ├── entity/Movie.java ├── entity/Showtime.java ├── entity/Seat.java ├── entity/Order.java └── config/RedisConfig.java先给出“能跑但没并发保护”的版本后面章节专门修它。Controller只做参数接收和结果包装RestController RequestMapping(/api/order) public class OrderController { Resource private OrderService orderService; PostMapping(/create) public ResultString create(RequestBody CreateOrderRequest req) { return orderService.createOrder(req); } }Service里是最直观的“先查后写”Service public class OrderServiceImpl implements OrderService { Resource private SeatMapper seatMapper; Resource private OrderMapper orderMapper; Override Transactional(rollbackFor Exception.class) public ResultString createOrder(CreateOrderRequest req) { Seat seat seatMapper.selectById(req.getSeatId()); // 先检查座位是否可售 if (seat null || seat.getStatus() ! 0) { return Result.fail(这个座位已经不能选了); } Order order new Order(); order.setOrderNo(req.getOrderNo()); order.setUserId(req.getUserId()); order.setShowtimeId(req.getShowtimeId()); order.setSeatId(req.getSeatId()); order.setAmount(seat.getPrice()); order.setStatus(0); order.setExpireTime(LocalDateTime.now().plusMinutes(10)); orderMapper.insert(order); // 再更新座位为锁定状态 seat.setStatus(1); seatMapper.updateById(seat); return Result.ok(order.getOrderNo()); } }这段代码解释一下Transactional保证insert订单和update座位在同一个事务里任何一步失败都整体回滚先查status再做写操作逻辑顺序本身没有错。问题不在这里而在两个请求在同一毫秒都执行了selectById都看到status0然后各自插入订单——这就是下一章要解决的并发窗口。2.4 本地起服务MySQL、Redis 与接口文档的一次性跑通把上面的表结构导入数据库再启动Redis即可本地运行# 建库 mysql -uroot -p -e CREATE DATABASE cinema DEFAULT CHARACTER SET utf8mb4; # 没有本机 Redis 就直接用容器 docker run -d --name redis -p 6379:6379 redis:7启动SpringBoot应用后把接口都放在/api下。引入Knife4j的话访问/doc.html就能看到接口列表不想引入额外依赖原生Swagger在/swagger-ui/index.html。先跑通这个最小闭环再往里面加锁和状态机后面每一处修改都有可验证的入口。3. 座位状态机与Redis分布式锁选座并发控制的核心设计与实现3.1 先查后写为什么一定超卖三种常见方案的取舍上一章的createOrder在单用户测试里永远是对的但在同一场次的同一座位上并发两条请求时时间线是这样交错的请求A执行selectById读到status0请求B也执行selectById读到status0A插入订单并提交B插入订单并提交。两张订单都指向同一个座位但seat.status只能被更新成一次锁定订单表里已经超卖。方案做法并发结果适用场景先查后写select insert一定超卖纯演示乐观锁update带version影响行数0则失败不超卖但其余请求直接失败低并发SELECT FOR UPDATE行锁串行化不超卖事务期间持有锁小团队项目Redis分布式锁锁key精确到某个座位不超卖锁粒度最细高并发场景乐观锁的问题是抢不到的请求都要立刻失败体验粗糙FOR UPDATE的问题是行锁从SELECT开始持有到事务结束连接池小的时候一个热点座位能把一组连接拖住。选座场景的锁粒度天然是“座位”Redis锁的key可以直接设计成和座位一一对应锁住的范围最精确。3.2 座位状态机0可售、1锁定、2已售 以及三条合法流转路径座位状态要独立建一个状态机不能把业务状态散落在if else里。三个状态的定义0表示可售用户可以发起选座1表示锁定订单待支付这个座位暂时不给别人选2表示已售支付完成后座位的最终归属。合法流转只有三条0到1是选座成功1到2是支付成功回调1到0是超时未支付或用户取消。不允许0直接到2也不允许2回到0。落库时要把“当前状态必须是1”作为update的条件而不是直接updateByIdUpdate(UPDATE seat SET status 1 WHERE id #{seatId} AND status 0) int lockSeat(Param(seatId) Long seatId); Update(UPDATE seat SET status 2 WHERE id #{seatId} AND status 1) int soldSeat(Param(seatId) Long seatId); Update(UPDATE seat SET status 0 WHERE id #{seatId} AND status 1) int releaseSeat(Param(seatId) Long seatId);这三个SQL都带了状态条件作用是防止脏写releaseSeat只允许把“锁定中”的座位释放回可售如果座位已经支付变成2这条SQL影响行数为0数据不会被错误回滚。状态机的价值就体现在这些update语句的WHERE条件里。3.3 锁的粒度、key设计、过期时间与释放时机Redis 分布式锁的完整写法Redis锁只做一件事让同一时刻只有一个请求能对同一个座位执行“查状态、插订单、改状态”这段操作。逻辑上把锁放在事务外层先抢锁抢到再进事务Service public class SeatLockService { Resource private StringRedisTemplate redisTemplate; public boolean tryLock(Long showtimeId, Long seatId, String requestId) { String key ticket:lock: showtimeId : seatId; // SET NX EXkey不存在才写入并设置10秒过期 Boolean result redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(10)); return Boolean.TRUE.equals(result); } public void unlock(Long showtimeId, Long seatId, String requestId) { String key ticket:lock: showtimeId : seatId; // 用Lua保证“判断是不是自己的锁 删除”是一个原子动作 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute( new DefaultRedisScript(script, Long.class), List.of(key), requestId); } }锁的key为什么要带showtimeId同一把实体座椅在不同场次是两个独立的售卖单位key里不带场次会把不相关的场次锁串。value存requestId是为了删锁时校验身份不加这个判断会出现线程A的锁10秒后自动过期但A业务还没跑完线程B拿到同一把锁A结束时把自己的删除操作执行了把B的锁删掉——两个线程同时进入临界区。过期时间设10秒数据库操作通常几十毫秒足够用不要设60秒万一业务掉进慢SQL锁要等很久才能被新请求拿到。提示不要用简单的del key释放锁必须带value校验否则高并发下会出现误删他人锁的问题。下单入口变成先抢锁、再执行事务、最后在finally里释放锁public ResultString buySeat(CreateOrderRequest req) { String requestId UUID.randomUUID().toString(); boolean locked seatLockService.tryLock(req.getShowtimeId(), req.getSeatId(), requestId); if (!locked) { return Result.fail(手慢了座位刚刚被别人选走); } try { return orderTxService.doCreate(req); } finally { // 事务提交完成之后才删锁顺序不能反 seatLockService.unlock(req.getShowtimeId(), req.getSeatId(), requestId); } }doCreate方法就是上一章createOrder的事务版本把2.3节createOrder里从selectById到updateById这段逻辑原样搬进OrderTxService保持返回订单号。区别在于它只在抢到锁之后执行。锁的释放写在finally里保证doCreate抛异常时锁也能被删掉下一轮请求可以继续抢这个座位。3.4 数据库唯一索引兜底Redis 锁失效时的最后一道门Redis锁把并发请求在业务层串行化但它不是绝对可靠的主从节点切换时锁可能短暂丢失key过期后新的请求立刻抢到锁而旧事务还没提交。极端情况下两个请求都可能认为自己抢到了锁。这时候数据库的uk_show_seat唯一索引必须接住这场事故ALTER TABLE orders ADD UNIQUE KEY uk_show_seat (showtime_id, seat_id);第二个请求插入orders时MySQL直接抛DuplicateKeyException。业务层把异常翻译成人话而不是让前端看到500try { orderMapper.insert(order); } catch (DuplicateKeyException e) { throw new BizException(这个座位刚刚被别人锁定请刷新座位图); }唯一索引的意义不是替代Redis锁而是保证“即使锁被极端情况穿透最坏结果也只是用户收到友好报错不会产生两条指向同一座位的订单”。三层防线的顺序是Redis锁挡住并发、状态机WHERE挡住脏写、唯一索引挡住漏网之鱼。4. 订单超时释放与重复提交拦截把座位锁定状态的生命周期管住4.1 用Spring Task定时扫描把超时未支付的锁定座位释放回可售订单只靠状态机还不够一个座位被锁定后用户一直不支付不能永远锁着。常见做法是让订单带expire_time定时任务扫超时订单把订单取消、座位回滚到可售。先开启调度EnableScheduling Configuration public class ScheduleConfig { }释放逻辑用fixedDelay而不是fixedRate避免上一轮没跑完下一轮又启动把同一个座位重复处理Component public class OrderExpireTask { Resource private OrderMapper orderMapper; Resource private SeatMapper seatMapper; Scheduled(fixedDelay 30000) public void releaseExpiredOrders() { ListOrder expired orderMapper.selectExpiredOrders(LocalDateTime.now(), 100); for (Order order : expired) { orderMapper.markCancel(order.getId()); seatMapper.releaseSeat(order.getSeatId()); } } }selectExpiredOrders对应SQLSELECT * FROM orders WHERE status 0 AND expire_time NOW() ORDER BY expire_time ASC LIMIT 100;LIMIT 100的意思是每轮最多处理100条避免高峰期积累几千条过期订单时一次扫描把所有操作拖进长事务。30秒一次的延时对选座场景完全可接受因为用户本来就有几分钟的支付倒计时如果订单量特别大可以把这个任务的间隔调小或在下一次选座请求进来时先懒清理该场次的过期座位双管齐下。4.2 订单号幂等同一用户重复点击提交只落一条订单前端用户双击提交、网络超时重试都会让同一个订单号被Post多次。防重复的关键是订单号幂等order_no在前端进入结算页时生成后端收到相同order_no直接返回已存在的结果不重复创建订单public ResultString createOrder(CreateOrderRequest req) { if (StringUtils.isBlank(req.getOrderNo())) { return Result.fail(缺少订单号); } Order exist orderMapper.selectByOrderNo(req.getOrderNo()); if (exist ! null) { // 重复提交直接返回第一次的结果 return Result.ok(exist.getOrderNo()); } try { orderMapper.insert(buildOrder(req)); } catch (DuplicateKeyException e) { // 并发下两个请求同时走到insert唯一索引拦下后者 return Result.ok(订单已存在请勿重复提交); } return Result.ok(req.getOrderNo()); }先查再插这个组合本身不原子所以最后还是靠uk_order_no唯一索引收口。边界就在这订单号幂等拦的是同一个请求反复提交Redis锁拦的是不同请求抢同一个座位两层问题必须分开处理。4.3 锁的释放顺序事务没提交前删锁会让新线程读到旧状态这是Redis锁最容易踩的坑把unlock写在事务方法内部。Spring的Transactional是方法返回后才提交事务如果事务方法里先删锁、再return另一个线程立刻抢到锁它的selectById读到的还是上一个事务提交前的status0于是它也进入下单流程。数据库唯一索引能挡住第二张订单但请求会变成DuplicateKeyException靠报错兜底而不是靠业务判断如果唯一索引没有建超卖就发生了。正确顺序是业务事务方法执行完并正常返回、事务提交外层finally再删锁。buySeat里tryLock之后的所有数据库操作都在doCreate这个独立事务方法里锁释放发生在事务返回之后的finally块这正是前面3.3写法的原因。检查项目里有没有同类问题就看删锁代码是否出现在带Transactional的方法内部——是的话趁早拆出去。5. 从“毕设能跑”到“可上线”40并发抢同一个座位的压测与排错5.1 用并发脚本打同一个座位只有一个人能赢本地起服务后直接用shell验证。把showtimeId和seatId固定orderNo每次不同模拟40个真实用户在抢同一个座位for i in $(seq 1 40); do BODY{\showtimeId\:1,\seatId\:12,\userId\:1001,\orderNo\:\ORD-${i}\} curl -s -X POST http://localhost:8080/api/order/create \ -H Content-Type: application/json -d $BODY done waitorderNo必须每轮变化否则40个请求会被uk_order_no全部拦成同一单测的是幂等而不是并发。跑完后查订单和座位SELECT COUNT(*) FROM orders WHERE showtime_id 1 AND seat_id 12; -- 期望1 SELECT status FROM seat WHERE id 12; -- 期望1 或 2取决于是否完成了支付回调orders表只出现1条记录说明Redis锁、状态机和唯一索引三层防线都正常。如果count大于1说明至少一条防线失效往下查原因。5.2 超卖排查三件事锁是否同一把、释放顺序、唯一索引第一所有写入口是否走同一把锁。创建订单、支付回调把座位切成已售、超时释放座位这些操作如果各自实现一套逻辑就会出现“从接口A创建的订单和从接口B修改的状态不是同一套规则”的脏数据。写入口统一收口到同一组Service方法别在Controller里直接操作Mapper。第二锁是否在事务提交之后释放。把unlock从带Transactional的方法里挪出来放到外层事务调用点的finally里。检查时直接搜unlockSeat出现在哪些方法的什么位置凡是transaction方法内部调用且没经过REQUIRES_NEW的都是隐患。第三唯一索引是否真的建上了。本地用H2数据库做演示的同学最容易在这翻车——H2能跑不代表MySQL也能跑。上线前执行SHOW INDEX FROM orders WHERE Key_name uk_show_seat;查不到索引就手动补ALTER TABLE语句。这三件事按顺序排查完超卖基本无处藏身。5.3 给前端一个座位图 JSONRedis 缓存与主动失效选座的前端页面只需要一件事拿到某个场次的座位状态数组按下标算排和列。后端用一个接口输出按行列铺平的JSON数组并缓存到Redis// key: ticket:seatmap:{showtimeId} // value: 形如 [0,0,0,1,2,0,...] String key ticket:seatmap: showtimeId; String cache redisTemplate.opsForValue().get(key); if (cache ! null) { return JSON.parseArray(cache); } ListSeat seats seatMapper.selectByShowtime(showtimeId); ListInteger statusList seats.stream().map(Seat::getStatus).toList(); String json JSON.toJSONString(statusList); redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(300)); return JSON.parseArray(json);关键在于写操作之后必须主动删除这个缓存。下单锁定座位、支付成功、超时释放任何改动seat.status成功的地方都补一条redisTemplate.delete(ticket:seatmap: showtimeId)。不要依赖300秒过期兜底高峰期用户看到的座位图如果还是支付前的绿色可售状态点进去就会撞上“座位被抢”的提示体验极差。这个接口稳定之后前端连排和列都不需要知道拿到数组按长度换算布局就能渲染前后端在选座这个功能上的耦合基本就只剩这一个JSON结构。本文还有配套的精品资源点击获取