☰
电影院订票选座小程序:Spring Boot+Redis并发锁座完整设计
2026/10/5 15:32:25 网站建设 项目流程

做毕业设计接了一个电影院订票选座的小程序项目,花了三周从零到交付,调试阶段踩了不少坑。网上关于这个题目的资料大多是零散的代码片段或者只讲某一块的博客,真正能把“选座并发”“支付回调”“小程序端状态同步”串成一条完整链路的很少。这篇文章就把我这套系统的完整设计思路、核心代码逻辑和调试经验整理出来,源码、数据库脚本、接口文档都齐了,只要你照着搭一遍就能跑起来,更适合拿来当毕设或者练手的参考。

1. 在动手写代码之前:这套系统到底要管理哪些业务状态

先别急着打开IDE写接口。电影院订票选座系统和普通的商品下单有一个本质区别——它同时涉及“库存”和“座位状态”两个维度的实时变化。一张电影票卖出去,不仅是一个SKU库存减一,还意味着某个影厅里某个座位的占用状态要立刻变更,而且要保证同一时间只有一个人能锁定同一个座位。

我从需求分析里拆出来的核心状态有这么几个:

  • 场次(Session):一部电影在某个影厅、某个时间段的放映计划。一张票必然归属于某个场次。
  • 座位(Seat):影院影厅的物理座位,有排号、列号、区域(普通区/情侣区/VIP区)属性。一个座位在某一场次下只有三种状态:可售、已锁定、已售出。
  • 订单(Order):用户下单的记录,关联场次、座位集合、支付状态。订单状态机的设计是整个系统的地基。
  • 排片(Schedule):管理端录入的电影放映计划。这个模块决定了用户能看到哪些场次。

我画的业务流程图是:用户选择电影 → 看到排片列表 → 进入选座页 → 点选座位 → 生成待支付订单(座位锁定) → 支付成功(座位永久占用) → 生成电子票 → 到店扫码入场。

整个链路里最容易被忽略、也最容易在后期出问题的是订单状态和座位状态的同步。如果用户下单后一直不支付,座位要不要一直锁着?锁多久释放?如果用户支付成功了但服务端没收到回调,座位是变成已售还是回滚成可售?这些问题在写代码之前必须想清楚,否则后面每一个都是Bug来源。

2. 技术选型:为什么是小程序原生 + Spring Boot + MySQL + Redis

这个题目看名字就知道客户端锁定微信小程序。服务端的选择我对比过几个方案,最后敲定的是Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis,理由很简单:

  • Spring Boot:Java生态里开发效率最高的脚手架,社区资料最多,遇到问题随手一搜就有答案,最适合毕设和中小型项目。不用SSM那套老古董配置,注解搞定一切。
  • MyBatis-Plus:单表CRUD基本不用写SQL,自带分页插件,代码量能少三分之一。复杂一点的多表关联查询自己写XML映射,灵活度也够。
  • MySQL 8.0:存储业务数据。之所以不用5.7,是因为8.0支持窗口函数,后面做“场次热度排行”这类统计会方便很多,而且8.0默认字符集就是utf8mb4,存电影名称里的emoji不会乱码。
  • Redis:干两件事——一是座位锁定的临时状态存储,二是接口幂等性凭证的存储。选座并发控制靠Redis Lua脚本实现原子操作,这是这套系统的技术核心,后面的章节我会专门展开讲。

微信小程序端我用的是原生框架,没有上uniapp或Taro。原因很简单:这个项目只需要微信一个端,原生框架的组件和API支持最直接,调试工具链最稳。如果用跨端框架,等于平白多了一层编译转换,出了问题排查链路更长。当然,如果你后续想同时发布支付宝小程序,再考虑迁移框架也不迟。

小程序端的页面结构按照功能拆成四层:首页电影列表、影院/场次选择页、选座页、订单确认页和订单列表页。这里有一个设计细节值得注意:一台手机就是一个用户端,但一个用户可以有多张订单,订单和座位是多对多的关系,所以数据表设计里必须有“中间表”来关联订单和座位,不能把座位直接写死在订单表的一个字段里。

3. 数据库设计:六张核心表和一个关键的时间字段

我把数据库表拆成了六张:电影表(movie)、影院表(cinema)、影厅表(hall)、场次表(session)、座位表(seat)、订单表(orders),订单与座位的关联用一张子表 order_seat 记录。

这里重点说说几个核心表的结构设计,这些字段直接影响后续的查询效率和扩展性。

场次表 session是核心中的核心,我给它加了三个索引字段维度:hall_id(哪个影厅)、movie_id(放什么电影)、start_time(开演时间)。这三个字段联合查就是“某部电影在某个影厅的所有排片”,是用户选场次页面最核心的查询。我顺手加了一个冗余字段status,用来标记场次是“售票中”还是“已开场/已结束”,避免每次查询都要拿当前时间跟start_time比大小。

座位表 seat除了常规的 prase_id 和 hall_id,我加了一个很实用的字段叫seat_type。因为电影院有普通座、情侣座、VIP沙发座的区别,不同区域的价格逻辑也不同。选座页前端渲染的时候就是根据这个字段决定座位的颜色和可交互状态。

还有一个所有表里都必须有的字段叫deleted(逻辑删除标志),而不是物理删除。原因很简单:电影院的数据一旦录入就有历史价值,比如统计某部电影在某个厅的上座率时,如果之前把下线的场次物理删了,历史统计就废了。MyBatis-Plus 自带@TableLogic注解,逻辑删除用起来非常无感。

订单表 orders我单独放了一节,因为它设计得好不好直接影响支付链路。除了常规的 user_id、session_id、total_amount 之外,关键字段是这几个:

字段用途说明
order_no订单编号用“yyyyMMddHHmmss + 随机6位”生成,保证并发情况下不重复
status订单状态0待支付 1已支付 2已取消 3已退款
expire_time锁定过期时间这是整套系统最核心的字段,通俗点说就是“座位的临时保质期”
payment_method支付方式微信支付/模拟支付,毕设里这个字段很实用

订单的表结构还有一个容易踩的坑:不要把座位号直接存成“3排5座”这种字符串。表面上看着挺直观,但一旦要统计“哪个座位最抢手”“情侣区的上座率”,你就得写一堆字符串解析逻辑。正确做法是通过 order_seat 关联表存 seat_id,查询的时候再join座位表取排行列号,数据规范,扩展性好。

4. 选座的核心难点:座位锁定、防超卖与会话保持的完整链路

选座功能是整个系统的门面,也是最考验并发处理的地方。用户看一个座位空着,点了付款,结果被另一个用户抢先占了,这种情况必须避免——好的选座系统应该做到“谁先发起锁定,谁就暂时拥有这个座位”。

我用的方案是Redis缓存 + Lua脚本原子操作,整体流程是这样:

  1. 用户点选座位时,小程序端把选中的count个座位id发送到后端。
  2. 后端先在Redis里检查这些座位的状态,如果全部是可售(没有锁,也没有卖),就执行Lua脚本批量把座位写入Redis,键格式是seat:locked:{sessionId}:{seatId},同时设置过期时间,默认是5分钟倒计时。
  3. 同时生成待支付订单写入MySQL,订单状态是0(待支付),订单的expire_time就是当前时间加5分钟。
  4. 前端进入支付页面,开始5分钟的倒计时。
  5. 支付成功回调后,把Redis里对应座位的lock状态升级为sold,同时更新MySQL订单状态为1,座位表也更新为已出售。

这里用Lua脚本的原因很直接:Redis单线程执行Lua能保证多座位检查+写入这两个操作是原子的。如果不用Lua而用“先查再写”,在高并发下就会出现两个用户同时查到空位,然后都写入成功——这就是超卖。

我实际写的Lua脚本核心逻辑简化后是这样的:

local session_id = KEYS[1] local seat_ids = ARGV local expired_time = tonumber(ARGV[#ARGV]) -- 最后一个参数是过期时间戳 -- 先检查所有座位是否可售 for i = 1, #seat_ids - 1 do local key = 'seat:locked:' .. session_id .. ':' .. seat_ids[i] local exists = redis.call('EXISTS', key) if exists == 1 then return -1 -- 有座位已经被占,直接失败 end end -- 全部可售才写入锁定 for i = 1, #seat_ids - 1 do local key = 'seat:locked:' .. session_id .. ':' .. seat_ids[i] redis.call('SET', key, 'locked', 'PX', expired_time) end return 1

这段脚本看起来简单,但实际调试的时候有几个细节要处理干净:

  • 过期时间不是随意拍的。用户在选座页停留的时间、支付流程的耗时、网络波动的时间都要算进去。我设了5分钟,后端在写Redis的时候还会额外加30秒的缓冲,这样即使前端倒计时归零、用户刚好点了支付,也不会因为缓存过期导致座位回滚。
  • 锁定期内的轮询问题。用户锁定座位以后,如果迟迟不支付,5分钟一到Redis键自动过期,座位会变回可售状态。这种“自动释放”的特性是选择Redis做锁的天然优势,如果用MySQL存锁状态,你还得写一个定时任务去扫表,麻烦很多。

说完锁定,再说超卖防护的另一个层面——MySQL层的兜底。Redis处理的是高并发瞬间的请求,但它毕竟是个缓存层,万一Redis里的数据没有正确同步到MySQL(比如缓存被清理、数据丢失),订单还是会出问题。所以我在MySQL的order表插入前,做了一个“座位状态二次校验”:

-- 在生成订单前执行,确保座位真的没有被占用 SELECT COUNT(*) FROM order_seat o INNER JOIN orders oo ON o.order_id = oo.id WHERE o.seat_id IN (#{seatIds}) AND oo.status IN (0, 1) -- 待支付和已支付都算占用

这步操作能挡住“Redis缓存已经过期但订单还没过期”的极端情况。两个服务的校验结合起来,才算是一道真正的双保险。

5. 支付环节的处理:微信支付对接的完整链路与毕业设计里的替代方案

支付是整个项目里最容易被老师追问、也最需要讲清楚的部分。微信支付的完整流程是:小程序端拿到用户的操作凭证,调用wx.requestPayment拉起微信支付弹窗,微信支付完成后,微信服务器携带支付结果向我们的后端服务器发送异步通知回调,后端在回调里更新订单状态。这里的顺序和同步/异步关系是面试和答辩的高频考点。

现实项目中,支付回调必须做幂等处理。什么意思?就是同一个支付成功的通知可能会被微信发送多次,如果后端收到两次成功回调就把订单状态从0改成1执行两遍,虽然最终结果可能没错,但如果中途有新建票券、调用其他系统等副作用操作,就会产生脏数据。所以我给回调处理加了一个检查:如果订单状态已经是1,直接返回成功,不作任何处理。

但毕设里完整对接微信支付有实实在在的阻碍——微信支付商户号申请需要企业资质,个人开发者拿不到。我的做法是,在系统中同时保留两条支付链路:

  • 完整模式:如果你有商户号,走标准的wx.requestPayment+ 回调通知。
  • 演示模式:给毕设演示和本地调试用,前端直接调用一个“模拟支付成功”的接口,后端把这个接口当作支付成功的回调来处理同一套逻辑。

这个设计的好处是:代码层面的订单状态流转完全一致,切换起来只差一个开关配置。答辩的时候,你可以自如地展示完整的选座、锁座、支付、出票流程,不用因为资质问题而卡在演示环节。

模拟支付接口的实现也很简单,前端在支付确认页直接把金额展示出来,用户点一个“确认支付(演示环境)”的按钮,后端就会执行和真实回调一样的方法:

@PostMapping("/api/pay/simulate") public Result simulatePay(@RequestBody SimulatePayRequest request) { Order order = orderService.getByOrderNo(request.getOrderNo()); // 校验订单确实是待支付状态 if (order.getStatus() != 0) { return Result.error("订单状态异常,请刷新"); } // 调用与真实回调相同的处理逻辑 return payService.handlePaymentSuccess(order.getOrderNo()); }

这里有一个细节我在调试时踩过坑:生成订单后,前端的倒计时可能已经到了最后一秒,用户刚好在这一刻点击支付。如果Redis的座位锁已经过期,但订单还处于待支付状态,这时候支付成功处理逻辑应该怎么走?我的答案是在支付成功处理的方法里,不要只依赖Redis状态,而是以MySQL订单为准——只要订单状态是0,就允许支付成功,然后在支付成功后再同步Redis状态。这样既保证了用户体验,也避免了一些边界产生的死锁问题。

6. 小程序前端:组件化渲染座位矩阵的三种状态与页面交互逻辑

小程序端的选座页是整个前端最核心的页面。一个影厅的座位渲染本质上就是二维数组的套循环,把每一排的座位横向排列,然后根据座位状态来决定组件怎么渲染。

我用的数据结构是这样:

{ "hallSeatMap": [ { "row": "1", "count": 20, "seats": [ { "id": 1001, "row": "1", "col": "1", "type": "normal" }, ... ] }, { "row": "2", "count": 20, "seats": [...] } ] }

WXML里就是两层wx:for,外层遍历排,内层遍历该排的座位。每个座位的状态用一个三元值控制:0表示可售,1表示已售出(不可点),2表示已被别人暂锁定(不可点),3表示当前用户已选中(高亮)。渲染的时候根据状态切换css类名,实现灰色、红色、绿色的视觉区分。

选座的交互逻辑有个前端层面的防抖需求:用户连续点了两个座位,前端要收集所有选中的座位id,选完以后点击“确认选座”,一次性提交给后端。这里不能做成“点一个就往后端请求一次”,因为网络请求有延迟,用户手速快就会造成状态不一致。正确做法是前端先把所选座位的id存到本地Data里,确认后才统一提交。

还有一个小程序的原生坑:setData的数据量限制。一个影厅如果有12排×20列,那就是240个座位,每个座位还要带id、行号、列号、类型、状态。一次性放进setData,在某些低端安卓机上会出现渲染卡顿。我的优化方案是只setData当前屏需要渲染的数据,或者把座位的状态数据拆开——座位静态属性(行号、列号)第一次渲染后不再改动,每次交互只更新状态数组,这样setData的payload能小一半以上。

页面跳转的参数传递也值得注意:选座页需要知道场次id,从排片列表页跳转过来的时候,我用了wx.navigateTo的URL参数透传,页面接收后在onLoad里处理并请求座位数据。如果你用事件通道(EventChannel)传递,也能实现,但代码会分裂成两个地方,不如URL参数直观。

订单确认页的数据组装也很讲究:选座页选完座位 → 跳转订单确认页时,要把哪些信息带过去?我的做法是前端只带seatIds数组和sessionId,订单金额和影片信息统一在订单确认页通过接口从后端取。这样能防止用户篡改前端上传的金额,是支付类系统的基本安全要求。

7. 调试与联调是最磨人的阶段:我遇到过的四个经典问题

这部分可能是对大家最有用的——我实际调试过程中遇到并解决的四个问题,每个都值得拿出来说说排查思路。

问题一:真机预览时接口请求全部失败,但开发工具里一切正常

这个坑几乎人人都会遇到。根本原因是小程序的网络请求必须走HTTPS,而且必须在微信公众平台配置request合法域名。我在开发工具阶段勾选了“不校验合法域名”,所以一切正常,但手机预览时微信强制校验,导致所有wx.request直接失败。

排查时我看了下Network面板,请求都是pending状态,没有任何返回。最终解决分两步:在公众平台的后台把服务器的HTTPS域名加入request合法域名白名单;同时在开发调试阶段,如果后端是本地服务,可以用“真机调试”功能,它允许开发者工具连上真机后绕过域名校验。但注意,这个模式只能用于开发调试,正式发布的小程序还是必须配置合法域名。

问题二:往MySQL写入订单时出现“Data too long for column”

这个报错直接卡了我半天。原因很隐蔽:订单号我定义成了VARCHAR(32),但生成的order_no格式是“yyyyMMddHHmmss”加6位随机数,实际长度是32个字符,看起来刚好卡在边界。但MySQL的VARCHAR(32)在utf8mb4字符集下最多能存32个字符,而不是32个字节,中文字符占3个字节,英文数字占1个字节。我订单号里全是数字字母,本来应该没问题,但订单表另一个字段存了用户昵称,昵称里有中文,而我把昵称字段长度设短了。

排查方法很简单:看异常堆栈里指的哪个字段,再去数据库看该字段的定义。解决就是把昵称之类的用户信息字段全部改成VARCHAR(128),订单号改成了BIGINT类型为主键,从根上杜绝长度问题。

问题三:两个用户同时点同一个座位,都显示“选座成功”

这个问题最能体现并发控制的必要性。我当时在本地起了一个Java后端,开两个浏览器窗口模拟两个用户同时操作。第一版代码只用了“先查询MySQL座位状态,再写入锁定”的逻辑,结果因为两个请求交替执行查询和写入,后一个请求覆盖了前一个的锁定。

这就是典型的“读改写”竞争条件。我的修复方案就是前面说的Redis Lua脚本,把检查+写入合并成原子操作。如果Redis连接不可用,Lua脚本执行失败,后端会捕获异常并向前端返回“系统繁忙,请重试”,不会让用户觉得自己选座成功了。

问题四:订单状态在“待支付”和“已取消”之间反复横跳

这个其实是定时任务的锅。我写了一个扫描器,每30秒扫一次所有待支付订单,如果超过过期时间就自动取消。逻辑本身没错,但问题是这个扫描器没有把“刚刚支付成功但回调还没处理”的订单排除在外——用户在倒计时最后两秒支付成功,回调还没结束,扫描器先跑了,把订单改成已取消,后面回调回来就发现订单不存在了。

解决方式是给订单取消任务加了一个“宽限期”:扫描器只取消expire_time + 60秒还没支付的订单,给支付回调留足处理时间。这也是我在答辩时一定会讲到的业务边界考虑,比单纯写完功能更体现工程思维。

8. 部署与交付:从本地跑通到服务器上线的完整操作流程

如果你的毕设或者项目需要部署到服务器,我这里给一套完整的操作命令,照着执行就能跑起来。

后端打jar包:

mvn clean package -DskipTests

打包完会在target目录生成jar文件。推荐直接用nohup方式启动:

nohup java -jar cinema-booking.jar --spring.profiles.active=prod > app.log 2>&1 &

注意,生产环境的application-prod.yml里要把数据库地址、Redis地址都换成服务器上的地址,还要在小程序后台把request合法域名改成服务器的域名。如果用的是云服务器,记得在安全组里放行8080端口(或你配置的端口)。

小程序端上传:微信开发者工具点“上传”按钮,填版本号和备注,然后去微信公众平台提交审核。注意提交审核需要完成小程序类目认证,个人主体的小程序不能开通支付功能,这也是我一直强调要在系统里保留模拟支付模式的原因。

部署完成以后,我建议做一轮完整的回归测试,覆盖这些业务链路:

  • 用户浏览电影列表、查看排片 → 正常展示
  • 用户选座、锁定座位 → 座位状态变绿,倒计时出现
  • 5分钟内不支付 → 订单自动取消,座位恢复可售
  • 用户支付成功 → 订单状态变已支付,座位变成已售
  • 并发测试:两用户点同一座位 → 只有一方成功
  • 后台管理:录入电影、排片、大厅管理 → 前端同步出现新数据

我当时用Apache JMeter做了一个简单的并发测试,50个线程同时请求同一个场次的座位锁定接口,结果只有1个请求成功,其余49个全部返回失败,Redis的防超卖机制确实稳。

9. 这套系统还可以怎么扩展

如果你不满足于交完毕设就结束,这套系统的可扩展方向其实相当可观:

  • 座位价格按区域差异化:目前座位表里已经有了seat_type字段,价格可以拆成单独的票价规则表,支持不同场次不同区域动态定价。
  • 优惠券与会员积分:订单表加两个关联字段,下单前校验优惠券状态,支付完成后按规则返积分。
  • 选座页面的座位偏好推荐:比如单人用户优先推荐靠过道的座位,情侣用户优先推荐情侣座,这个用简单的规则引擎就能实现。
  • 管理后端:我目前的排片和影院信息都是直接在数据库录入的,如果做成一个小管理后台,用Vue+ElementUI就能实现可视化排片,体验会好很多。

我个人的建议是,先把主线业务跑通、把并发调试的经验吃透,再考虑扩展功能。尤其要理解好“锁座位”这件事的底层逻辑——因为所有订票类系统(机票、火车票、演出的核心逻辑其实都一样,换个界面就能复用。把这一个项目做深、讲透,比做五个半途而废的功能强得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询