1. SSM框架下的电影售票系统设计与实现
最近在整理技术笔记时,翻到一个去年用SSM框架开发的电影售票系统项目。这个系统从需求分析到上线部署前后花了两个月时间,期间踩了不少坑,也积累了一些实战经验。今天就把这个项目的完整实现过程梳理出来,重点分享那些在官方文档里找不到的实操细节。
电影售票系统看似简单,但实际开发中需要考虑的细节非常多:座位锁定机制、支付超时处理、高并发场次处理等等。采用SSM(Spring+SpringMVC+MyBatis)框架组合既能保证开发效率,又能满足性能要求。下面就从技术选型开始,逐步拆解这个系统的实现过程。
2. 技术架构设计
2.1 为什么选择SSM框架
SSM框架组合在Java Web开发中非常经典:
- Spring:提供IoC容器和AOP支持,管理Bean生命周期
- SpringMVC:基于MVC模式的Web框架,处理HTTP请求
- MyBatis:轻量级ORM框架,简化数据库操作
对于电影售票系统这种典型的CRUD应用,SSM框架的优势很明显:
- 开发效率高:MyBatis的Mapper接口+注解方式比传统JDBC开发快3倍以上
- 易于维护:Spring的依赖注入让各层解耦,后期修改不影响整体结构
- 性能可控:MyBatis的SQL优化空间大,应对高并发查询场景更灵活
2.2 系统分层架构
系统采用标准的三层架构:
表现层(Web) ↑↓ 业务逻辑层(Service) ↑↓ 数据访问层(Dao)每层的具体实现:
- 表现层:SpringMVC + Thymeleaf模板
- 业务层:Spring事务管理 + 自定义业务异常
- 持久层:MyBatis + PageHelper分页插件
提示:在实际开发中,建议为每层建立独立的package,例如:
- com.example.controller
- com.example.service
- com.example.dao
3. 数据库设计关键点
3.1 核心表结构
电影售票系统主要包含以下表:
- 电影表(movie):存储影片基本信息
- 场次表(screening):记录放映时间、影厅等
- 座位表(seat):影厅座位信息
- 订单表(order):用户购票记录
3.2 特别注意的字段设计
在订单表中,有几个关键字段需要特殊处理:
CREATE TABLE `order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL, `screening_id` bigint(20) NOT NULL COMMENT '场次ID', `seat_ids` varchar(255) NOT NULL COMMENT '座位ID集合,逗号分隔', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0-待支付 1-已支付 2-已取消', `create_time` datetime NOT NULL, `expire_time` datetime NOT NULL COMMENT '订单过期时间', PRIMARY KEY (`id`), UNIQUE KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意:seat_ids字段存储多个座位ID,这种设计虽然违反第一范式,但能显著减少联表查询次数。在实际应用中需要权衡规范性和性能。
4. 核心功能实现
4.1 座位锁定机制
电影售票最关键的并发控制就是座位锁定。我们采用乐观锁+Redis的方案:
// 伪代码示例 public boolean lockSeats(Long screeningId, List<Long> seatIds) { String lockKey = "lock:screening:" + screeningId; // 使用Redis的SETNX实现分布式锁 boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("当前有其他用户正在选座"); } try { // 检查座位是否可用 List<Seat> seats = seatDao.selectByIds(seatIds); if (seats.stream().anyMatch(s -> s.getStatus() != 0)) { return false; } // 更新座位状态为锁定(1) seatDao.updateStatus(seatIds, 1); return true; } finally { redisTemplate.delete(lockKey); } }4.2 订单超时处理
使用Spring的@Scheduled实现定时任务,扫描超时订单:
@Scheduled(cron = "0 */5 * * * ?") public void cancelExpiredOrders() { List<Order> orders = orderDao.selectExpiredOrders(); for (Order order : orders) { try { orderService.cancelOrder(order.getId()); // 释放座位 seatService.unlockSeats(order.getSeatIds()); } catch (Exception e) { log.error("取消订单失败, orderId={}", order.getId(), e); } } }5. 高并发优化实践
5.1 缓存策略
针对热门场次查询,采用多级缓存:
- 本地缓存(Caffeine):缓存影厅座位图
- Redis缓存:存储场次余票数
- 数据库:最终一致性存储
缓存更新策略:
@CacheEvict(value = "screening", key = "#screeningId") public void updateSeatStatus(Long screeningId, Long seatId, int status) { seatDao.updateStatus(seatId, status); // 异步更新Redis中的余票计数 asyncTask.updateTicketCount(screeningId); }5.2 数据库优化
为高频查询字段添加索引:
- screening表的 cinema_id 和 movie_id
- order表的 user_id 和 screening_id
大表分库分表:
- 订单表按月分表(order_202301, order_202302等)
- 使用Sharding-JDBC实现透明访问
6. 典型问题排查
6.1 座位重复售卖
现象:同一座位被不同用户同时购买 排查步骤:
- 检查锁的实现:确保Redis锁的key唯一性
- 验证事务隔离级别:需要REPEATABLE_READ以上
- 检查SQL:UPDATE语句是否带状态条件
最终解决方案:
UPDATE seat SET status = 2 WHERE id IN (1,2,3) AND status = 16.2 支付成功但座位未占用
现象:支付回调成功后,座位状态仍是"锁定"而非"已售" 原因:支付回调处理中未加事务管理 修复:
@Transactional public void handlePaySuccess(String orderNo) { Order order = orderDao.selectByNo(orderNo); orderDao.updateStatus(orderNo, 1); seatDao.updateStatus(order.getSeatIds(), 2); }7. 部署注意事项
7.1 环境配置
生产环境推荐配置:
- Tomcat连接池:maxActive=50 (根据服务器配置调整)
- MyBatis二级缓存:建议关闭,用Redis替代
- Spring事务超时:设置为30秒
7.2 监控指标
需要重点监控:
- 订单创建QPS:反映系统负载
- 平均响应时间:超过500ms需要预警
- 座位锁定失败率:高于1%需检查
在项目上线后,我们通过JMeter压测发现,单服务器(4核8G)能支撑800+ QPS的并发购票请求。关键是把座位锁定时间控制在200ms以内,这对Redis的性能要求较高。实际部署时,我们为Redis配置了哨兵模式,确保高可用性。
开发这类系统最大的体会是:并发控制不能只依赖数据库,必须结合缓存和分布式锁。另外,事务边界要仔细设计,避免长事务拖累整体性能。如果现在重新做这个项目,我会考虑引入Seata来处理分布式事务,让座位锁定和订单创建真正实现原子性。