说实话,电影院订票选座这个业务看着不起眼,真正动手做下来才发现里面全是活:选座状态要实时同步,多个用户抢同一张座位要防超卖,用户选完座不付款座位还得自动释放,再加上微信小程序的登录态、前端渲染性能、后端并发控制……任何一个环节没处理好,演示的时候都可能当场翻车。这篇文章我就把一套完整的“微信小程序 + SSM后端”影院订票选座系统从里到外拆开讲,重点放在数据库设计、选座交互、锁座下单的并发处理,以及我实测过程中踩过的坑。不管你是要做毕业设计、课设项目,还是想找一个能跑通完整业务闭环的SSM练手项目,这套系统的设计思路和核心代码都能直接拿来参考。
1. 为什么是“小程序前端 + SSM后端”这套组合
很多人在做影院订票系统时,纠结的点往往是“现在都流行Spring Boot了,你还用SSM是不是过时了”。我的看法是:SSM作为Spring生态的经典组合,用在教学、毕设、中小型项目上完全没有问题,而且由于它的配置更显式、分层更清晰,反而更容易让你理解Spring容器、SpringMVC请求流转和MyBatis持久层的协作关系。这套系统的价值不在于框架新旧,而在于业务逻辑是否完整、并发设计是否合理。
1.1 影院订票场景的核心需求拆解
动笔写代码之前,先把业务场景拆清楚。电影院订票系统的核心用户是普通观影者,核心动线是:浏览影片、选择场次、进入选座页、点击座位、提交订单、支付、出票。但站在系统设计角度,这个动线背后还隐藏着几个关键需求:
- 座位状态必须实时准确。同一个场次,一个座位只能被一个订单占用,不能被两个人同时选中。
- 选座过程中要“锁座”。用户选好座位后通常会有2到5分钟的支付犹豫期,这期间座位不能被别人抢走,但如果用户一直不付钱,座位又要能自动释放。
- 订单和座位强关联。订单取消或支付超时后,关联座位要回滚为“可售”状态;支付成功后,座位永久变为“已售”。
- 后端需要抗住小范围的并发冲击。比如热门影片开座后的前几分钟,可能有几十上百人同时刷座位、抢座位。
这些需求单靠一张业务表硬怼是实现不好的,必须从表结构设计上就把“座位状态”和“订单状态”联动起来,再配合后端接口的原子操作,才能保证数据不错不乱。
1.2 技术选型背后的考量
前端选择微信小程序,原因很直接:用户不需要下载独立App,打开微信扫个小程序码就能用,影院的购票场景是低频刚需,用完即走,特别契合小程序的产品形态。而且微信开发者工具里可以直接模拟器调试、真机预览,前后端联调的门槛也不高。
后端选择SSM,也就是Spring + SpringMVC + MyBatis这套组合,主要考虑有三点:
- Spring负责对象管理和事务控制。锁座、下单、释放座位这些操作都涉及数据库的更新,必须放在同一事务里,用Spring声明式事务可以很干净地解决。
- SpringMVC负责前后端接口对接。小程序端拿到的数据要求是JSON格式,SpringMVC通过
@ResponseBody可以快速把对象序列化成JSON返回,非常省事。 - MyBatis负责SQL控制。座位状态的更新条件、多表联查场次信息、订单座位关联查询,这些都是典型的数据库操作,用MyBatis写动态SQL能精确控制每一步。
不谈高并发的话,这套组合完全够用。如果以后真要上大规模并发,可以再引入Redis做分布式锁和座位状态缓存,但数据库本身的原子更新机制依然是兜底方案,这在后面的章节会详细讲。
2. 数据库表设计:座位状态与订单状态的联动关系
数据库是这套系统的地基。很多初学者上来就建一张“订单表”,字段里塞一堆座位编号,结果后面统计场次售出情况、释放锁定座位时写得极其痛苦。正确做法是把这个业务拆成“基础数据表”和“过程数据表”两类,基础表保存静态信息,过程表保存动态状态。
2.1 用户、电影、场次、影厅的基础表
先看最基本的几张表。用户表主要存微信用户信息,因为小程序登录拿到的openid是用户唯一标识,所以我们通常会以openid作为业务唯一键,同时冗余存储昵称和头像。
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(50), avatar_url VARCHAR(255), create_time DATETIME );电影表和影厅表属于基础配置。电影表存标题、时长、导演、海报、上映日期和简介;影厅表存影厅名称、座位行数、列数,以及厅的类型(2D厅、3D厅、IMAX厅等)。
CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, duration INT NOT NULL COMMENT '片长(分钟)', director VARCHAR(50), release_date DATE, poster_url VARCHAR(255), description TEXT ); CREATE TABLE hall ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL, rows_count INT NOT NULL, cols_count INT NOT NULL );场次表,也就是排片表(schedule),是连接电影和影厅的桥梁,记录哪部电影、在哪个影厅、什么时间开场。这里需要特别注意:同一个影厅在同一个时间段只能排一个场次,否则后面选座就乱套了。所以我在设计时给(hall_id, start_time)加了唯一约束。
CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT NOT NULL, hall_id INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, UNIQUE KEY uk_hall_start (hall_id, start_time) );2.2 座位实例表:为什么每个场次都“复制”一份座位
这是整套表结构里最关键的一步。物理上,影厅座位是固定的,比如一个影厅有8排,每排10座,那它就是80个物理座位。但业务上,每个场次的座位状态是独立的——同一个座位,今天10点的场次可能卖出去了,今天14点的场次可能还空着。所以不能直接在“物理座位表”上改状态,必须把物理座位和场次绑定,生成“座位实例”。
我的做法是加两张表:一张是“物理座位表”(seat),记录某影厅里第几排第几列是什么类型的座位,属于静态数据;另一张是“场次座位表”(schedule_seat),把每个场次下的每个座位实例化出一条记录,并维护它的实时状态。
CREATE TABLE seat ( id INT PRIMARY KEY AUTO_INCREMENT, hall_id INT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, seat_type TINYINT DEFAULT 0 COMMENT '0普通 1情侣 2无障碍', UNIQUE KEY uk_hall_seat (hall_id, row_no, col_no) ); CREATE TABLE schedule_seat ( id INT PRIMARY KEY AUTO_INCREMENT, schedule_id INT NOT NULL, seat_id INT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0可售 1锁定 2已售', UNIQUE KEY uk_schedule_seat (schedule_id, seat_id) );可能有人会问:每个场次都生成80条记录,数据会不会膨胀?其实完全不用担心。一个影院就算一天排20个场次,每个厅100个座位,一天也才2000条schedule_seat记录,一个月6万条,对数据库来说根本不是压力。但换来的好处非常明显:查询某个场次所有座位状态只需一次SELECT,更新状态时只需精准UPDATE一行,而且天然支持事务锁。
生成座位实例的时机也有一点讲究。我是在创建场次的时候,同时往schedule_seat表批量插入该影厅的全部座位数据。这样选座接口查询时直接查结果,不需要临时计算哪些座位存在。
2.3 订单表与订单座位关联表
订单表负责记录一次购票行为。订单状态用status字段表示,我用的是:0待支付、1已支付、2已取消、3已退票。订单号唯一,用户ID关联用户表,场次ID关联排片表,总金额存起来,后面退款或对账用。
CREATE TABLE order_info ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, schedule_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME, pay_time DATETIME );因为一次订单可能买多张票,所以订单和座位之间是多对多关系,需要一张关联表。这张关联表同时把order_info的快照信息(比如单价、座位编号)和schedule_seat关联起来。这里我特意冗余了schedule_seat_id,目的就是后面更新座位状态时能快速定位。
CREATE TABLE order_seat ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, schedule_seat_id INT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, price DECIMAL(10,2) NOT NULL );订单状态和座位状态必须保持联动,这是一切逻辑的核心。下表是我在编码前就整理好的状态对照关系,后续锁座、释放、支付成功都围绕这张表展开。
| 场景 | 订单状态 | 场次座位状态 |
|---|---|---|
| 用户选座锁定成功 | 0 待支付 | 1 锁定 |
| 用户支付成功 | 1 已支付 | 2 已售 |
| 用户支付超时/主动取消 | 2 已取消 | 0 可售 |
| 用户申请退票并通过 | 3 已退票 | 0 可售 |
这套关系理清楚之后,后端所有接口的逻辑就都围绕“订单状态迁移”和“座位状态迁移”的原子更新展开,不容易乱。
3. 小程序端选座交互:渲染、点选、提交的完整链路
小程序端最核心的页面就是选座页。这个页面既要展示影厅的座位布局,又要提供良好的交互反馈,还要防止用户短时间内狂点导致的重复提交。我实际开发中把选座页拆成了三个逻辑块:座位图渲染、本地选中状态管理、订单提交。
3.1 座位图渲染:用scroll-view横向滚动而不是整页缩放
一开始我尝试过用canvas画整个影厅的座位图,效果确实酷炫,但开发量比较大,因为要处理点击命中的坐标换算、缩放逻辑、不同屏幕适配。后来换成了更务实的方案:外层用一个可横滑的scroll-view,内部按座位行生成view节点,每个座位是一个独立的可点击区域。
这种做法的好处有三个:
- 复用性好,一个座位就是一个数据对象,循环渲染即可。
- 点击事件天然绑定行号和列号,不需要自己算坐标。
- 屏幕自适应简单:横向滚动解决宽厅放不下的问题,纵向不用滚动,配合顶部影厅信息固定区域。
具体实现思路是这样的:从后端拿到该场次座位列表,按row_no分组,每组渲染一行。每个座位的样式根据status字段动态绑定:
<view class="seat" wx:for="{{row.seats}}" wx:key="id" >selectedMap: {} // key为 row_col,value为座位信息选中的时候往selectedMap里加一条,取消选中就删除一条。页面上方实时显示“已选X座,共Y元”,金额根据场次单价乘以座位数即可。这里不用前端算折扣,因为学生票、会员折扣属于后端营销策略,尽量避免端上逻辑影响订单金额。
3.3 提交订单时的防重复点击与本地倒计时
用户选好座位后,点击“选好了,去支付”按钮,前端先做一次本地校验:选的座位数是否大于0,是否还在支付倒计时内。然后调用后端锁座接口,后端返回成功后会生成一个orderNo和锁定有效期。前端拿到后把按钮置灰,同时启动一个2分钟的倒计时,显示“请在02:00内完成支付”。
这里最怕的是用户手抖连点两次提交,导致后端生成两笔重复订单、锁定同一批座位。所以我在前端提交按钮加了disabled标记,请求发出后立刻不可点,等后端返回结果再恢复。但这只是第一道防线,后端还必须做原子性校验,防止绕过前端直接调接口。后端的做法在下一节详细讲。
本地倒计时结束之后,如果用户还没完成支付,前端要主动调用“取消订单并释放座位”的接口,把座位解锁,同时清空本地selectedMap,回到初始状态。有些同学会忽略这一步,结果2分钟后座位状态还显示绿选,给用户造成错觉。记住一个原则:本地选中只是临时UI状态,真正的状态永远以服务端为准。
3.4 微信登录与用户身份绑定
小程序端的登录流程也是这套系统绕不开的一环。标准做法是:
- 小程序端调用
wx.login获取临时code。 - 把
code发送到后端接口。 - 后端拿着
code和配置好的appid、secret去微信服务端换取openid和session_key。 - 后端生成自定义的登录凭证(比如token)返回给小程序。
- 小程序后续请求在请求头里带上这个token,后端根据token识别用户。
实际开发中,很多人会忽略一个细节:wx.login返回的code只能用一次,而且有效期很短,所以不能在每次页面加载时都重新登录。应该把拿到的token存在wx.setStorageSync里,如果token过期再静默调一次wx.login重新换取。这样既能保证用户身份稳定,又能减少无谓的后端请求。
wx.login({ success: res => { wx.request({ url: 'https://yourdomain/api/login', data: { code: res.code }, success: resp => { const token = resp.data.token; wx.setStorageSync('token', token); } }); } });4. SSM后端接口设计:锁座、下单、超时释放的并发处理
后端是整套系统的“裁判员”,所有状态变更必须经过它,而且必须是原子性的。这一节我按接口维度把核心逻辑讲透,顺便给出关键代码,重点讲并发场景下如何保证同一座位的唯一占用。
4.1 场次座位查询接口
这个接口很简单,但要注意别把不必要的数据发给前端。接口路径我设计为:
GET /schedule/{scheduleId}/seats
返回该场次所有座位的列表,包含座位ID、行号、列号、状态、类型。前端拿到数据后直接渲染座位图。
查询SQL用两张表联查:
SELECT ss.id AS scheduleSeatId, s.row_no, s.col_no, ss.status FROM schedule_seat ss JOIN seat s ON ss.seat_id = s.id WHERE ss.schedule_id = #{scheduleId} ORDER BY s.row_no, s.col_no;为了提高响应速度,这个接口可以用Redis做5到10秒的缓存,因为座位状态在无用户操作时变化并不频繁。但如果要保证实时准确,最简单的做法还是直接查数据库,数据量小,性能不是问题。我实际没有加缓存,因为单机MySQL在这个数据量下查询时间基本是毫秒级,加了缓存反而要考虑缓存一致性问题。
4.2 锁座接口:用“条件更新”防超卖
锁座是整套系统的核心,也是最容易出并发问题的环节。需求是:用户选了一批座位,后端必须把这些座位标记为“锁定”,同时生成一个待支付订单。
最直观但是错误的做法是:先查座位状态,如果都是可售,再依次更新状态。这种“先查后改”在高并发下一定会出问题。设想两个用户同时查到了座位A是可售,还没等第一个用户改状态,第二个用户也提交了,两个人都会认为自己锁座成功。这就是典型的超卖。
正确做法是直接用带条件的UPDATE语句,让数据库帮我们判断当前状态是否是预期状态:
UPDATE schedule_seat SET status = 1 WHERE id = #{scheduleSeatId} AND status = 0;这条SQL的含义是:只有当该座位当前是“可售”状态时,才把它更新成“锁定”。如果影响行数为1,说明你锁成功了;如果影响行数为0,说明这个座位已经被别人抢先锁定或买走了,需要返回失败。
对应到Service层,整个锁座过程应该放在一个事务里,伪代码如下:
@Transactional(rollbackFor = Exception.class) public String lockSeats(Long userId, Long scheduleId, List<Long> scheduleSeatIds) { // 生成订单号 String orderNo = generateOrderNo(scheduleId, userId); // 创建订单,初始状态为待支付 OrderInfo order = new OrderInfo(); order.setOrderNo(orderNo); // ... 设置其他字段 orderMapper.insert(order); // 循环更新座位状态,一旦失败立即抛出异常 for (Long scheduleSeatId : scheduleSeatIds) { int rows = scheduleSeatMapper.lockSeat(scheduleSeatId); if (rows == 0) { throw new BizException("座位已被占用"); } // 同时插入order_seat关联记录 // ... } return orderNo; }这里有一个容易被忽略的问题:如果循环中第3个座位锁失败了,前面第1、第2个座位已经被更新成锁定状态。由于整个方法上有@Transactional,抛异常时事务会回滚,前两个座位的更新也会被撤销,这样就保证了“同一订单要么全部座位锁定成功,要么全部失败”。这是事务带来的最大保障,千万别为了省事去掉事务注解。
4.3 支付成功回调与订单状态流转
在小程序端,如果对接的是真实微信支付,流程是:后端调用微信支付统一下单接口,拿到支付参数传给小程序端,小程序端调起wx.requestPayment,用户输入密码支付成功后,微信服务器会回调后端配置的支付结果通知地址。
但很多毕设项目并不真正接入微信支付,而是用“模拟支付”代替:用户点击支付后,后端直接标记支付成功。这种做法的好处是方便演示,省去商户号、证书等繁琐环节。我建议在没有真实支付资质的情况下,先用一个“模拟支付接口”把业务跑通,等需要上线再接真实支付。
支付成功的核心逻辑是:更新订单状态为“已支付”,同时把关联座位状态从“锁定”改成“已售”。这两个操作同样必须在同一个事务里完成,否则会出现“订单已支付但座位还是锁定”的不一致情况。
@Transactional(rollbackFor = Exception.class) public void paySuccess(String orderNo) { // 查询订单,校验状态必须是待支付 OrderInfo order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getStatus() != 0) { throw new BizException("订单状态不正确"); } // 更新订单为已支付 orderMapper.updateStatus(orderNo, 1); // 更新关联座位为已售 orderSeatMapper.updateSeatStatusByOrderId(orderNo, 2); }4.4 超时释放座位的定时任务实现
用户锁座后如果迟迟不支付,座位就永远被占着了,这对其他用户很不公平。所以必须有一个机制,把“锁定超过一定时间仍未支付”的座位自动释放。
我采用的方案是:用Spring自带的定时任务@Scheduled,每隔30秒扫描一次。扫描条件是:订单状态为待支付,且创建时间距今超过2分钟。
@Scheduled(fixedDelay = 30000) public void releaseExpiredOrders() { List<String> expiredOrderNos = orderMapper.selectExpiredOrderNos(2); for (String orderNo : expiredOrderNos) { releaseOrderSeats(orderNo); } }有一点要特别注意:定时任务和用户主动取消订单,可能同时操作同一个订单。比如定时任务刚扫描到订单超时,准备释放座位,用户这时恰好点击了“取消订单”。为了避免重复操作,必须在代码里再做一次状态校验。我推荐用SQL条件更新来实现“只有待支付订单才能被取消/释放”:
UPDATE order_info SET status = 2, cancel_time = NOW() WHERE order_no = #{orderNo} AND status = 0;如果影响行数为1,说明是当前操作真正把订单改成了取消状态,然后才去释放座位;如果影响行数为0,说明订单已经不是待支付状态,可能是已经支付了或者已经被别人释放了,直接返回,不做任何后续操作。
5. 源码部署实战:目录结构、真机调试与常见坑
项目做完了,部署和联调阶段才是折磨人的地方。这一节我把自己遇到过的几个典型问题整理出来,每个都值得反复看。
5.1 项目目录结构:分清楚后端和前端
后端SSM项目的标准结构大致是这样:
src/main ├── java/com/example/cinema │ ├── controller # 接口层 │ ├── service # 业务逻辑层 │ ├── mapper # MyBatis数据访问层 │ ├── entity # 实体类 │ ├── common # 公共类,如统一返回结果、异常处理 │ └── util # 工具类,如订单号生成、时间处理 ├── resources │ ├── mapper # MyBatis的XML文件 │ ├── spring # Spring、SpringMVC配置文件 │ └── jdbc.properties # 数据库配置 └── webapp/WEB-INF └── web.xml小程序端单独放在另一个目录,和Spring源码分开维护。小程序端目录结构一般包含pages、components、utils、request等。我习惯把请求封装成一个统一模块,统一处理token注入、错误提示、登录过期跳转等逻辑,这样页面里就不用每次写重复代码。
如果你拿到的是现成源码,第一步不要急着跑,先找到jdbc.properties把数据库连接信息改成自己的,然后检查Tomcat版本和JDK版本是否匹配。SSM项目通常要求JDK 1.8及以上,Tomcat 8或9。
5.2 小程序真机调试:绕过域名校验的正确姿势
微信开发者工具里有一个很人性化的选项:“不校验合法域名”。开发阶段勾上这个选项,请求本地局域网IP的接口也能通。但是这个选项只对模拟器有效,一旦用手机真机预览,这个选项就不生效了。所以真机调试时,最常见的问题是:网络请求一直报“url not in domain list”。
解决方式有三种:
- 把后端接口部署到有公网IP的服务器上,并配置HTTPS域名,然后在微信公众平台的小程序后台把该域名添加到request合法域名。这是上线必须走的路。
- 如果是纯本地开发调试,可以打开开发者工具中的“真机调试2.0”模式,它会通过开发机中转请求,可以临时绕过域名限制,但每次调试都要开着工具。
- 在后端配置CORS,允许跨域请求。小程序不像浏览器一样受CORS限制,但加一条也无妨,只是不解决域名白名单问题。
我个人的经验是:毕设演示时,最稳妥的方式是把后端项目打成War包部署到一台内网服务器,手机和服务器处于同一局域网,工具里开启“不校验合法域名”,然后用开发者工具的“真机调试”功能去访问。这样速度和稳定性都很好,也不需要提前申请HTTPS证书。
5.3 时间字段的格式化陷阱
小程序端展示场次时间时,经常出现“2025-01-15T14:30:00.000+0800”这种奇怪格式,而不是“2025-01-15 14:30”。原因很简单:SpringMVC默认使用Jackson序列化Date类型时,会输出带时区的ISO格式,小程序端不认识。
解决办法是在后端全局配置一下JSON序列化格式:
@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.timeZone(TimeZone.getTimeZone("GMT+8")); }; }或者更直接一点,把返回给前端的日期字段全部改成String类型,在Service层用SimpleDateFormat格式化好再返回。虽然听起来不够“优雅”,但绝对省心,尤其是当你对Jackson配置不熟的时候。
5.4 压测时才会暴露的“幽灵锁座”问题
最后一个坑是我在做并发测试时踩到的。我用JMeter模拟50个用户同时抢同一个场次的同一个座位,结果发现数据库表中确实只有一条记录更新成功,但接口返回的成功率却有2%。排查半天,发现问题出在MyBatis的一级缓存上:同一个SqlSession内重复执行同一条SQL时,二级缓存和一级缓存之间数据不一致,导致部分请求读到的是旧状态。
这个问题的本质不是业务逻辑错误,而是MyBatis缓存配置不当。解决办法是在schedule_seat表的Mapper XML中,将update语句加上flushCache="true",或者更简单一点,直接在mybatis-config.xml中把二级缓存关闭。平时增删改数据量不大,一级缓存带来的性能提升几乎可以忽略,为了数据一致性,关闭更省心。
<settings> <setting name="cacheEnabled" value="false"/> </settings>还有一个小细节:MyBatis的update操作虽然会触发一级缓存清空,但如果你在同一个事务内先select再update再select,第二次select在某些场景下可能仍然查询到脏数据。所以对于座位状态这种强一致性的数据,不要让缓存参与进来,老老实实直查数据库。
最后再分享一个我个人的实操习惯:每个核心接口写完,先不要连小程序,先用Postman或者JMeter把接口测通,尤其是并发锁座这个接口,至少要用50个并发线程跑一遍,确认不会出现超卖和重复订单再继续做前端页面。后端稳了,前端联调才能顺利。否则前端调着调着发现座位状态时对时错,你根本不知道是接口问题还是前端缓存问题,排查起来非常费劲。
这套系统整体做完,你会对SSM开发有一个完整的认识:从表结构设计、事务控制,到SpringMVC接口编写、MyBatis SQL优化,再到小程序端的交互体验,整个链路是闭合的。以后遇到类似的“预约 + 状态流转”业务,比如会议室预订、演出选座、挂号选时间段,都可以直接复用这套设计思路,无非是把“座位”换成“资源”,把“订单”换成“预约单”,核心逻辑完全一致。