最近把一套基于微信小程序的电影院订票选座系统完整跑通了前后端联调、真机预览、并发模拟都折腾了一遍。整个项目用SSMSpring SpringMVC MyBatis做后端接口微信小程序端负责影片展示、场次选择、座位点选和订单确认配合MySQL做数据持久化。这套东西做出来后不仅把选座、锁座、下单支付这一条完整链路走通了还把库存一致性的问题研究得比较透彻。这篇文章就把整个项目的拆解过程、核心功能的设计思路、实操环节的关键代码以及我踩过的那些坑一次性整理出来。不管你是准备做毕业设计的学生还是刚入行想接触完整全栈项目的开发者或者有影院类业务需求的从业者都可以拿这份笔记做参考。1. 项目全貌与技术选型复盘1.1 为什么选定“微信小程序 SSM”这套组合先说选型。市面上做订票选座类的系统方案其实不少有纯H5的有Vue Node的有原生安卓的还有直接套电商模板的。但在这个项目里最终敲定了微信小程序做前端、SSM做后端接口、MySQL存数据理由很直接。微信小程序的天然优势在于“免安装、易分享”。用户去电影院买票最大的痛点就是打开App成本太高。而小程序随用随走扫个码或者搜索就能进入购票页特别契合“低频次、强场景”的购票需求。再加上微信生态内的支付能力直接复用微信支付省去了单独接入支付渠道的不少工作量。后端选择SSM不是因为新潮而是因为它足够稳妥。Spring负责对象管理SpringMVC做路由分发MyBatis处理SQL映射三层各司其职结构清晰。对于订票选座这类业务逻辑比较明确、并发量没有到互联网级的中小型系统SSM完全够用而且部署简单学习曲线也不陡。关键是SSM这套组合的资料非常多踩坑之后很容易找到解决方案。做项目最怕的不是技术难而是卡在一个小问题上没人对着聊。选择成熟的组合意味着遇到问题时有大量的前人经验可以抄作业。1.2 系统整体架构与角色划分这个系统从大方向上分成两端C端用户端小程序和M端后台管理端。我这边把管理端的内核也统一到SSM里通过接口区分角色权限。用户端的核心流程是进小程序看影片列表点进影片看场次与影厅信息选座页勾选想坐的座位提交订单并支付支付成功后在小程序里查订单、扫码入场。整个过程涉及的核心角色是用户、影片、影院影厅、场次、座位、订单。管理端的职责主要是排片管理、影厅管理、座位维护和订单核销。排片是后台手动配置的包括哪个影厅、什么时间段放哪部电影、什么票价。座位的状态也是管理端维护的基础数据用户端只是在场次基础上做选择。从技术架构上看小程序端静态资源和页面渲染都在微信里完成业务数据全部通过HTTP接口走后端。SSM后端只提供JSON格式的数据接口小程序端做数据渲染和交互反馈。数据库侧通过事务和SQL条件更新来保证座位的唯一性和订单的一致性。1.3 业务流程梳理从选片到入场的完整闭环我用一条时间线把业务主流程理一遍用户授权并登录拿到openId系统自动创建/绑定用户账号。首页展示正在热映的影片列表支持按影片名称搜索。用户点击影片进入详情页查看简介、海报、场次安排。选择一个场次后进入选座页小程序加载该场次的座位布局。用户点击座位进行选中/取消提交订单时携带选定座位编号。后端校验座位状态扣减库存并生成待支付订单。用户完成支付开发阶段可模拟支付支付回调更新订单状态。用户在我的订单页查看电子票到影院后由管理员进行核销。这条链路看起来不复杂但每一步都有值得深挖的细节。尤其是选座和锁座的并发控制是整个系统的核心命门。2. 数据库设计与核心流程拆解2.1 表结构设计与字段规划数据库设计是订票选座系统的基础表设计得好不好直接决定后续代码写得顺不顺畅。我这个项目里一共设计了8张核心表分别是用户表、影片表、影厅表、场次表、座位表、订单表、订单明细表和管理员表。其中最关键的是座位表和订单表。座位表设计如下CREATE TABLE seat ( id int NOT NULL AUTO_INCREMENT COMMENT 座位ID, schedule_id int NOT NULL COMMENT 场次ID, row_no varchar(8) NOT NULL COMMENT 排号, col_no int NOT NULL COMMENT 列号, status tinyint NOT NULL DEFAULT 0 COMMENT 座位状态0空闲1锁定2已售出, lock_time datetime DEFAULT NULL COMMENT 锁定时间, order_id varchar(32) DEFAULT NULL COMMENT 锁定/购买的订单ID, PRIMARY KEY (id), KEY idx_schedule_status (schedule_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里必须注意两个设计点。第一座位表不是存“哪些座位被卖了”而是把影厅里所有座位都提前插入用status字段标注状态。这种设计在查询座位图时非常高效一次性查出场次对应的所有座位前端渲染就很快。如果只存已售座位前端就要反过来推断哪些座位是空的逻辑复杂多了。第二座位锁定时要记录lock_time和order_id。lock_time用于后续的定时过期处理比如用户锁座5分钟不支付系统自动释放座位order_id则用来追溯这个座位是被哪个订单占用的。订单表同样不简单涉及状态流转CREATE TABLE orders ( id int NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id int NOT NULL COMMENT 用户ID, schedule_id int NOT NULL COMMENT 场次ID, total_price decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 订单状态0待支付1已支付2已取消3已使用4已退款, create_time datetime DEFAULT NULL, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单状态是一个典型的有限状态机。待支付可以转为已支付、已取消已支付可以转为已使用、已退款已取消和已退款是终态。状态流转必须通过后端统一控制前端只能基于当前状态做界面呈现。2.2 座位布局的存储方式与优劣分析座位布局的存储有两种常见路子一种是上面提到的“每个座位一行”的方案另一种是把整个影厅布局存成一个JSON字符串或者用位图编码。我身边曾有同事用JSON字符串存储座位结构类似{“A1”:0,“A2”:1}看起来省事但实际用起来很痛苦。每次修改某个座位状态都得把整串JSON读出来反序列化改完再存回去并发情况下极其容易互相覆盖数据库层面也做不了任何状态约束。位图方案则在统计余座和状态更新时有计算优势但可读性差排错麻烦。对比下来关系型存储是最稳妥的方案。每行座位一条记录字段清晰SQL表达能力充分事务和行锁都能直接发挥作用。尤其是做“锁座”操作时直接更新有状态条件的SQL更新的行数就是判断成功与否的依据非常自然。2.3 核心业务时序分析选座、锁座、下单、支付这个系统的核心时序可以归纳成一句话选座只是前端交互锁座才是后端真正落库的动作。用户在前端点击座位时小程序本地记录选中状态这个阶段没有任何后端操作。用户点击“确认选座”提交订单时后端才开始介入。这时候需要做一个关键动作把选中的多个座位从“空闲”状态批量更新为“锁定”状态并生成一条待支付订单。锁座的关键SQL如下UPDATE seat SET status 1, lock_time NOW(), order_id ? WHERE id IN (?,?,?) AND status 0这条SQL的巧妙之处在于通过status 0这个条件数据库层面保证了不会重复锁座。假设两个用户同时看到同一个座位都是空闲的同时提交锁座请求数据库中这条UPDATE语句最终只会有一个事务成功。根据受影响的行数就能判断锁座是否成功。如果返回0说明座位已经被抢了系统要给用户提示重新选座。锁座成功后用户进入支付流程。支付回调成功后更新订单状态为已支付同时把对应的座位状态从“锁定”改为“已售出”。这里有个先后顺序问题建议先更新订单状态再更新座位状态两个操作放在同一个事务里保证一致性。万一支付回调处理时服务宕机重启后也要有补偿机制去核对座位的最终状态。3. 后端接口与订单状态机的实现细节3.1 SSM后端接口清单与路径设计后端接口我用REST风格设计统一以 /api 开头按资源划分路径。核心接口如下功能路径方法说明获取影片列表/api/film/listGET按热度排序获取影片详情/api/film/{id}GET影片信息场次获取场次座位图/api/schedule/{id}/seatsGET返回全部座位及状态锁座并生成订单/api/order/createPOST携带场次ID座位ID数组模拟支付/api/order/payPOST开发环境用传订单号支付回调/api/order/callbackPOST生产环境微信支付推送查询用户订单/api/order/listGET按用户ID查核销订单/api/order/verifyPOST管理端扫码核销接口划分的原则是单一职责每个接口只做一件事。比如锁座和下单合并到一个接口里因为这两个动作必须在同一事务中完成拆开反而容易出现状态不一致。支付回调则单独拆出来因为它来自第三方支付服务处理逻辑和参数来源都不同。3.2 锁座与下单的事务控制详解锁座并下单这个接口是整个系统最核心的代码。我写的时候专门开了一个事务方法过程如下校验用户登录态。校验场次是否还在售票时间内。批量锁座执行UPDATE语句成功后判断影响行数。如果有任何座位锁定时失败抛出异常回滚提示用户重选。全部锁座成功后计算总价生成订单记录。返回订单号和选座结果给前端。事务必须用Spring的声明式事务注解并且要特别注意事务的边界。默认情况下Spring只对RuntimeException进行回滚checked exception不会触发回滚。如果这里锁座成功了生成订单时抛出IOException之类的问题就可能导致座位被锁定但没有订单造成数据孤儿。所以我在项目中用了rollbackFor Exception.class确保任何异常都回滚。还有一个容易被忽视的点数据库隔离级别。默认的RR可重复读级别下锁座UPDATE会加行锁只要事务没有提交其他事务的相同UPDATE就会被阻塞。这种阻塞是合理的因为需要串行化处理同一个座位的竞争。但要注意事务里不要夹杂网络请求、JSON解析这类耗时操作尽量缩短持锁时间否则高并发下容易出现锁等待超时。3.3 支付回调与订单状态一致性保障支付环节在开发阶段我用的是模拟支付接口只要用户在前端点击“确认支付”后端直接把订单改成已支付。但在生产环境微信支付回调的处理逻辑要谨慎得多。微信支付回调流程是用户支付成功后微信服务器向配置的回调URL发送POST请求带上支付结果数据后端验签后返回应答。整个处理链路的注意点如下验签是第一步。拿到回调数据后用API密钥对签名做校验防止伪造回调。这一步不做后续一切都白搭。幂等处理是第二步。同一个订单的回调可能因为网络原因被微信服务器发送多次后端必须保证多次回调不会造成订单状态重复变更。实现方式很简单先查询订单当前状态如果是已支付就直接返回成功应答不再重复处理。事务内更新是第三步。确认订单状态是待支付后在事务里把订单改为已支付同时把座位状态从锁定改为已售出。如果订单已支付但座位状态更新失败要记录异常日志通过定时任务或人工处理兜底。3.4 超时未支付订单的取消策略用户锁定座位后不一定马上支付有些可能中途退出小程序。这样的座位如果一直被锁定会严重影响影院的售票率。所以必须有超时释放机制。我的做法是使用定时任务每两分钟扫描一次座位表把status 1且lock_time超过5分钟的座位重置为空闲状态。对应的订单也同步标记为已取消。这个流程同样要放在事务里执行并且要先更新座位再更新订单避免出现订单已取消但座位还在锁定的情况。定时任务写在一个独立的Service里通过Spring的Scheduled注解触发。如果项目后续并发量大了可以换成延迟队列或者消息中间件但当前这个规模用定时扫描是性价比最高的方案。4. 小程序端的选座页面与交互实现4.1 小程序页面结构与路由规划小程序端的页面结构我按业务功能划分总共7个页面分别是首页pages/home/index影片详情页pages/film/detail选座页pages/schedule/select确认订单页pages/order/confirm支付结果页pages/order/result订单列表页pages/order/list个人中心页pages/user/index页面与页面之间的跳转主要通过navigateTo完成。选座页是整个小程序最复杂的页面因为它需要处理座位图的渲染和大量状态变更。4.2 座位图的渲染方案Canvas还是组件遍历座位图是小程序端最大的技术难点之一。我第一次实现时直接用Canvas绘制座位结果发现有一个很严重的问题Canvas绘制出来的图形是静态的无法直接绑定单击事件。要实现每个座位的点击效果就得计算每个座位在Canvas上的坐标区域再在tap事件里做坐标判断。这种写法不仅代码量大而且适配不同尺寸的屏幕极其痛苦。后来我改用视图组件遍历渲染的方案。后端返回的座位数据是一个数组每个座位包含row、col、status三个字段。小程序端用两层循环渲染座位矩阵每一行是一个flex容器每个座位是一个view组件点击时绑定动态的座位ID。组件遍历的写法虽然会生成很多节点但影厅座位通常不会超过200个性能完全没问题。关键是每个view都能直接绑定点击事件事件参数里带上座位ID交互逻辑写起来非常顺手。用性能换开发效率和可维护性这波值。4.3 选座交互的状态管理与边界条件选座时的状态管理是另一个容易踩坑的地方。我维护了一个已选座位的Map存储structureId - seat对象。用户点击座位时按以下规则处理如果座位状态不是空闲直接return不做任何操作。如果座位已经在已选集合中则取消选中从Map移除。如果座位不在已选集合中加入Map同时更新该座位的UI样式。如果已选集合达到上限比如一个订单最多选5张票提示用户并禁止继续添加。这里有个交互细节值得提一下用户点击座位后前端本地立即高亮等待用户提交订单时后端再真正校验。这种“乐观更新”的做法用户体验最好因为选座时没有网络等待的卡顿感。代价是后端必须做好兜底校验防止用户勾选了已被别人锁定的座位。4.4 选座后的订单确认与支付引导用户选好座位后点击底部“确认选座”按钮进入确认订单页。这个页面会展示影片、场次、影厅、座位编号、单价和总价。用户确认无误后点击“提交订单”后端返回订单号前端跳转到支付页面。开发阶段我直接在支付页面放了“模拟支付成功”和“模拟支付失败”两个按钮用来测试不同分支。生产环境则换成wx.requestPayment调用微信支付能力。支付结果页根据回调结果展示成功或失败状态并提示用户跳转到订单列表。有一个地方容易出错支付结果页刷新时如果用户手动刷新或者页面重新加载需要根据订单号重新查询订单状态而不是依赖首次跳转时携带的参数。因为支付回调可能存在延迟首次跳转时的状态并不一定是最终态。5. 部署上线、并发压测与常见问题排查5.1 本地联调环境的搭建顺序本地开发时小程序端和后端的联调是必经之路。第一步是保证后端能通过HTTP接口正常访问。我本地用Tomcat跑后端的war包然后用Postman逐个测试接口确认数据返回正确。第二步是在微信开发者工具里配置合法域名。开发阶段可以在小程序后台勾选“不校验合法域名”直接把后端地址指向本地的局域网IP方便真机调试。等准备上线时再把域名换成正式环境并配置好HTTPS证书。第三步是并联调。在小程序端写好request统封装所有请求统一带上openId作为登录凭证。调通第一个接口后再逐个页面联调确保每个环节的数据流是通的。5.2 生产环境的部署要点与配置优化上线部署时我选了一台云服务器配置大概是2核4G数据库和后端都部署在同一台机器上。对小型的订票系统来说这个配置完全够用。部署过程中的几个关键点数据库连接池用Druid并配置好初始连接数和最大连接数。连接池参数不合适高并发下会出现获取连接超时这个问题在压测阶段很容易暴露。Tomcat的线程池也要调大默认的200显然不够我设成了400。但线程数不是越大越好要和数据库连接数、服务器资源配合着来。微信小程序端的AppSecret属于敏感信息绝对不能写在小程序代码里。后端通过接口代替小程序请求微信登录接口获取openId这个逻辑是必须的否则AppSecret泄露会造成很严重的问题。5.3 并发场景下最容易翻车的几个问题我压测的时候专门针对选座接口做了并发测试。模拟100个用户同时抢同一场次的座位暴露出来的问题很典型。第一个问题是超时。100个并发同时打锁座接口数据库的事务锁会让一部分请求排队等待。如果排队的请求超过MySQL的锁等待时间会出现死锁报错。解决方法是设置innodb_lock_wait_timeout为相对合理的值并且后端接口要做好异常捕获把锁等待超时的请求转换成用户可理解的提示“座位被其他人抢占请重新选择。”第二个问题是重复下单。如果用户在极短时间内连续点击提交按钮两次请求可能同时到达后端。后端要做一个防重机制最简单的方法是在创建订单接口里为每个用户和场次加一个唯一约束保证同一用户同一场次不能重复创建待支付订单。或者用户端做一次提交按钮的loading态禁用也能挡住大部分误触。第三个问题是座位图的缓存。小程序端如果在选座页面停留时间过长其他用户可能已经买了部分座位。等用户提交订单时后端校验出座位已经被买需返回明确提示并让前端重新拉取座位图刷新状态。不要直接报服务器错误要给用户一条可理解的文案。5.4 排查过的典型问题与问题定位方法现象可能原因定位方式解决措施锁座接口超时报错行锁等待超时MySQL慢查询日志异常堆栈调大锁等待时间缩短事务耗时订单取消但座位仍锁定定时任务异常检查定时任务日志事务内先释放座位再改订单状态支付回调成功后订单是待支付回调处理幂等失效检查订单状态判断逻辑增加状态分支判断重复回调直接返回成功座位图渲染错位行/列坐标数据错乱打印接口原始数据数据库检查row_no和col_no取值小程序请求后端超时服务器带宽/线程池打满查看Tomcat访问日志增加线程池压缩接口返回数据排查问题的思路一定要清晰。出现线上故障时先看日志再看数据库状态最后才改代码。很多时候问题不在代码本身而在数据库的数据状态和配置参数上。5.5 压测数据与实际性能表现用JMeter对核心接口做了一轮压测。压测场景是100个用户并发访问选座接口和下单接口持续10分钟。最终数据是选座接口的平均响应时间在180ms左右下单接口约230ms错误率控制在1%以内。最耗时的环节是数据库的事务操作这也验证了选座的性能瓶颈主要在后端库存一致性控制而不是小程序端的渲染。如果后续要扩展更高的并发可以考虑引入Redis来做座位锁的预扣减MySQL只做最终落库系统吞吐量会有一个很大的提升。但这个方案复杂度也高需要处理好Redis和MySQL的一致性。对现有的业务规模来说当前方案已经够用了。6. 源码组织与二次开发扩展建议6.1 后端代码包的划分与职责边界后端的源码组织我按三层架构划分成controller、service、mapper三个包每个包再按业务模块分子包。业务模块有film、schedule、seat、order、user、admin六个模块。Controller层只负责参数接收和结果封装不写业务逻辑。Service层是核心业务层所有的事务控制、状态判断都在这里。Mapper层就是数据访问接口配合MyBatis的XML文件做SQL映射。这样的划分方式后续无论是加功能还是排查问题都非常方便。还有一个没有说的点统一返回结构。所有接口的返回格式都是一致的用Result类封装包含code、message、data三个字段。小程序端只用判断code是200就走正常逻辑其他情况统一弹toast提示。这样前后端协作非常顺畅不容易出现各自为政的情况。6.2 可以继续扩展的功能方向这个项目做完后我还列了不少扩展方向留着后续迭代用座位连坐推荐功能。可以根据当前已选座位自动推荐相邻空位这个功能对家庭和朋友结伴观影很有价值。会员积分体系。用户购票后累计积分积分可以兑换优惠券提高用户黏性。观影提醒服务。在影片开场前几小时通过微信订阅消息提醒用户降低爽约率。后台数据看板。从管理端展示每日票房、热门影片、上座率走势用图表呈现运营数据。这些扩展方向在现有架构上做增量开发都不难因为基础的数据表已经把这些业务领域都有覆盖了。6.3 从项目完结回头看的总结思考整个项目从零到一前后大概花了三周时间。核心的时间都花在选座并发控制这一块其他部分反而比较顺利。做完这个项目我对数据库事务、行锁和状态机的理解深入了很多。以前写CRUD接口只觉得简单这次真正面对“多个人同时抢一个座位”的真实业务场景时才发现一个简单的UPDATE语句背后有那么多门道。现在回头整理这套源码我更想对刚接触这个项目的朋友说不要一开始就急着写页面先把数据库表和核心业务时序画清楚。选座系统的本质是库存争夺页面只是用户交互的外壳。把锁座的核心逻辑弄明白了这个项目就等于完成了百分之六十。最后分享一个实际开发中的小习惯每改动一次核心逻辑我都会用JMeter跑一遍并发场景确认没有出现新的死锁或超时再继续往下写。这个习惯帮我挡掉了不少后续联调和上线时的麻烦。希望这份拆解对正在做类似项目的人有帮助。
阅读完成 · 觉得有帮助?