1. 项目背景与核心需求
影院订票系统是典型的B/S架构应用,需要同时满足影院管理端和用户端的业务需求。这个项目采用前后端分离架构,后端基于SpringBoot+MyBatis+MySQL技术栈,前端使用Vue.js框架,实现了从影片管理、排期设置到在线选座购票的全流程功能。
在实际开发中,这类系统面临几个关键挑战:
- 高并发座位锁定:热门影片开售时需处理瞬时高并发请求
- 事务一致性:支付与座位状态变更必须保持原子性
- 实时数据展示:剩余座位数需要准确实时反馈给所有用户
- 多端适配:需要同时支持PC端和移动端访问
2. 技术栈选型分析
2.1 后端技术组合
SpringBoot 2.7 + MyBatis 3.5 + MySQL 8.0的组合是经过验证的成熟方案:
SpringBoot优势:
- 自动配置简化了传统SSM框架的XML配置
- 内嵌Tomcat容器便于部署
- Starter依赖机制统一管理第三方库版本
- Actuator端点提供系统监控能力
MyBatis考量:
- 相比Hibernate更贴近SQL,便于复杂查询优化
- 动态SQL能力适合多条件筛选场景
- 与PageHelper插件配合实现物理分页
MySQL选型原因:
- 事务支持完善(ACID特性)
- 行级锁适合订票系统的座位锁定场景
- 通过EXPLAIN可以方便优化查询性能
2.2 前端技术方案
Vue 3.x + Element Plus的组合提供:
组件化开发:
- 座位选择器、场次选择器等可复用组件
- Composition API提升代码组织性
状态管理:
- Pinia管理全局状态(如用户登录信息)
- 本地存储记住用户偏好设置
特殊功能实现:
- 使用WebSocket实现座位状态实时同步
- 腾讯地图API集成显示影院位置
3. 核心模块设计与实现
3.1 数据库设计要点
CREATE TABLE `schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `movie_id` bigint NOT NULL, `hall_id` int NOT NULL, `show_time` datetime NOT NULL, `price` decimal(10,2) NOT NULL, `status` tinyint DEFAULT '1', PRIMARY KEY (`id`), KEY `idx_movie` (`movie_id`), KEY `idx_time` (`show_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `seat_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `schedule_id` bigint NOT NULL, `seat_row` int NOT NULL, `seat_col` int NOT NULL, `user_id` bigint DEFAULT NULL, `order_id` varchar(32) DEFAULT NULL, `status` tinyint NOT NULL COMMENT '0-可选 1-已锁定 2-已售出', `lock_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_seat` (`schedule_id`,`seat_row`,`seat_col`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关键设计考虑:
- 座位表使用联合唯一索引防止重复预订
- 场次表建立时间索引加速查询
- 状态字段使用tinyint而非字符串提高查询效率
- 价格字段使用decimal避免浮点精度问题
3.2 高并发座位处理
采用乐观锁+定时任务的方案:
@Transactional public boolean lockSeats(Long scheduleId, List<SeatPosition> seats, Long userId) { // 1. 检查座位状态 List<SeatOrder> seatOrders = seatOrderMapper.selectByScheduleAndPositions( scheduleId, seats); if(seatOrders.stream().anyMatch(s -> s.getStatus() != 0)){ throw new BusinessException("座位已被占用"); } // 2. 批量锁定 String orderId = generateOrderId(); seatOrderMapper.batchUpdateStatus( scheduleId, seats, 1, // 锁定状态 orderId, userId, LocalDateTime.now().plusMinutes(15)); // 15分钟支付时限 // 3. 延迟消息检查支付状态 rabbitTemplate.convertAndSend( "order.delay.check", orderId, message -> { message.getMessageProperties() .setDelay(15 * 60 * 1000); // 15分钟延迟 return message; }); return true; }配套措施:
- Redis缓存热门场次的座位状态
- 定时任务释放超时未支付座位
- 前端轮询剩余座位数(节流处理)
3.3 支付流程设计
支付状态机实现:
stateDiagram-v2 [*] --> 待支付 待支付 --> 已取消: 超时未支付 待支付 --> 支付中: 发起支付 支付中 --> 已取消: 用户取消 支付中 --> 已支付: 支付成功 已支付 --> 已完成: 核销入场关键处理逻辑:
- 支付成功后异步通知更新订单状态
- 使用本地事务表保证支付回调幂等性
- 对账任务修复异常状态订单
4. 典型问题解决方案
4.1 座位状态同步延迟
问题现象:用户A看到座位可选但实际已被B锁定
解决方案:
- 前端使用WebSocket接收实时状态更新
- 后端采用发布-订阅模式广播变更
- 加入版本号机制处理消息乱序
实现代码:
@GetMapping("/seat-status/{scheduleId}") public SseEmitter streamSeatStatus(@PathVariable Long scheduleId) { SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); String subscriptionId = "schedule-" + scheduleId; seatStatusPublisher.addEmitter(subscriptionId, emitter); emitter.onCompletion(() -> seatStatusPublisher.removeEmitter(subscriptionId)); emitter.onTimeout(() -> seatStatusPublisher.removeEmitter(subscriptionId)); return emitter; }4.2 定时任务幂等性
问题场景:释放座位任务重复执行导致有效订单被取消
解决方案:
- 使用分布式锁保证单节点执行
- 任务记录执行日志
- 状态变更增加条件判断
@Scheduled(cron = "0 */1 * * * ?") @Transactional public void releaseExpiredSeats() { // 获取分布式锁 String lockKey = "job:releaseSeats"; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 5, TimeUnit.MINUTES); if(!locked) return; try { List<SeatOrder> expiredSeats = seatOrderMapper .selectExpiredLocks(LocalDateTime.now()); expiredSeats.forEach(seat -> { int updated = seatOrderMapper.updateStatusIfMatch( seat.getId(), 1, // 期望原状态=已锁定 0, // 新状态=可用 seat.getVersion()); if(updated > 0) { seatStatusPublisher.publish( seat.getScheduleId(), seat.getSeatRow(), seat.getSeatCol(), 0); } }); } finally { redisTemplate.delete(lockKey); } }5. 部署与性能优化
5.1 生产环境配置
推荐部署架构:
前端Nginx -> 后端集群(2-4节点) -> MySQL主从 -> Redis哨兵关键配置项:
- SpringBoot连接池配置:
spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000- MyBatis二级缓存:
<settings> <setting name="cacheEnabled" value="true"/> <setting name="localCacheScope" value="STATEMENT"/> </settings>5.2 性能压测数据
使用JMeter模拟测试(4核8G服务器):
| 场景 | 线程数 | 平均响应时间 | 错误率 | TPS |
|---|---|---|---|---|
| 查询场次 | 100 | 128ms | 0% | 420 |
| 锁定座位 | 50 | 235ms | 1.2% | 180 |
| 支付流程 | 30 | 310ms | 0.5% | 95 |
优化手段:
- 添加@Cacheable缓存热门影片数据
- 使用@Async异步处理非核心逻辑
- 数据库查询字段精确指定避免SELECT *
6. 扩展功能实现
6.1 微信小程序端适配
改造方案:
- 复用现有API接口
- 增加JWT认证支持
- 封装统一响应格式
安全措施:
- 接口签名验证
- 敏感数据脱敏
- 频率限制(如1秒内不能重复提交订单)
6.2 数据分析模块
使用Elasticsearch实现:
- 用户行为日志收集
- 热门影片分析
- 上座率统计
示例聚合查询:
{ "size": 0, "aggs": { "popular_movies": { "terms": { "field": "movieId", "size": 5 }, "aggs": { "total_sales": { "sum": { "field": "ticketCount" } } } } } }7. 开发经验总结
- 事务边界划定:
- 座位锁定与订单创建应放在不同事务中
- 支付回调处理需要单独的事务配置
- 缓存策略:
- 场次信息采用Cache-Aside模式
- 座位状态采用Write-Through模式
- 异常处理原则:
- 用户操作异常应给出明确提示
- 系统异常需记录完整上下文
- 重试机制需考虑幂等性
- 调试技巧:
- 使用Arthas监控MyBatisSQL
- 利用SpringBoot Actuator检查Bean加载
- 集成测试使用Testcontainers
实际开发中发现的最有价值经验是:对于座位锁定这类高并发操作,单纯依赖数据库行锁会导致性能瓶颈,最终采用的"Redis预检+数据库确认+异步通知"三级方案,在保证一致性的同时将并发处理能力提升了8倍。