ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot影院售票系统:并发控制与安全加固实战解析

Spring Boot影院售票系统:并发控制与安全加固实战解析 简介基于Spring Boot框架的电影院售票管理系统源码包面向Java Web初学者、毕业设计及课程设计人员完整覆盖后台管理与前端用户购票核心链路。压缩包共1890个文件大小约70.75MB内容包含Java源码、Class编译文件、Freemarker模板ftl、JS脚本、CSS样式、图片素材及SQL脚本等其中660张jpg图片与368个js文件支撑了页面展示与动态交互。已有53人学习浏览。系统采用MVC设计模式整合Spring Boot、Spring MVC、JPA、Freemarker与MySQL实现用户管理、地域管理、电影排片、选座购票、订单支付、评价反馈等模块附带数据库初始化脚本和项目配置文件便于直接导入运行并二次开发。通过研读源码可掌握前后端数据交互、JPA持久层操作及模板渲染技巧适合作为实战参考项目。1. 别急着解压先想清楚这套售票系统的“命门”在哪这份源码标题里最容易被忽略的词不是Spring Boot而是“电影院售票”。影院售票的业务边界比普通商品秒杀更刁钻座位有二维坐标场次有时间窗口订单有支付超时回滚一个座位不能在两笔未支付订单里同时出现。很多人拿到源码先找Controller看接口怎么写的其实真正值钱的部分在库存模型和事务边界上。我拆过不少类似项目结论是售票系统90%的Bug不是CRUD写错而是并发下座位状态不一致或者订单支付回调后座位释放逻辑有洞。所以这篇文章按“架构选型 - 数据模型 - 核心业务实现 - 并发控制 - 安全加固 - 可观测性”这条线展开。适合正在做课程设计、毕业设计的人照着改也适合刚入行想搞明白“一个带状态的业务系统到底怎么落地”的后端开发。新手能跟着跑通老手重点看第四章的锁策略和第六章的日志采集排错。2. Spring Boot四层架构与影院售票的数据模型设计2.1 四层架构到底怎么切分才能让“售票”这种状态型业务不写乱常见的Spring Boot项目是Controller、Service、DAO三层但影院售票系统有状态流转三层容易把逻辑堆在Service里。这里建议用四层Controller接口层、Service业务层、Repository数据访问层、Domain领域模型层。Domain不是简单的POJO而是把“座位状态”“订单状态”这类枚举和状态流转方法放进实体里而不是散落在Service的if-else中。比如订单状态不要用魔法数字到处比较而是在Order实体里定义枚举和转移方法public enum OrderStatus { CREATED, // 已创建待支付 PAID, // 已支付出票成功 CANCELLED, // 已取消座位释放 EXPIRED // 支付超时系统自动取消 } public class Order { private OrderStatus status; // 状态流转只有CREATED状态才能取消 public boolean cancel() { if (this.status ! OrderStatus.CREATED) { return false; } this.status OrderStatus.CANCELLED; return true; } }逻辑说明把状态流转收拢到实体的方法里Service层只负责编排比如先查座位、再创建订单、再调用seatService.lock()。这样后续加支付回调、加超时任务都是在已有状态机上扩展不会出现“这里改状态忘了释放座位”的问题。2.2 三张核心表的设计场次、座位、订单之间的关系电影院售票的实体关系不复杂但字段设计上有几个易错点。第一张表是film影片字段要对齐票务平台的数据同步第二张表是schedule场次关键字段是放映厅ID和开场时间索引要建在 (hall_id, start_time) 上第三张表是seat座位它的状态字段既要有物理坐标row_no, col_no也要有业务状态available、locked、sold。订单表则要冗余快照字段。下单时把影片名、厅名、场次时间、座位坐标都冗余进order_item表。为什么要冗余因为影片改名、场次调整后历史订单不能跟着变。这是做票务系统的新手最容易漏的设计。CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, total_seats INT NOT NULL, KEY idx_hall_start (hall_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电影场次表; CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可用 1锁定 2已售, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no), KEY idx_schedule_status (schedule_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT座位表;uk_schedule_seat唯一键是防止同一场次插入重复座位version字段是给乐观锁用的第四章会细说。注意这里用InnoDB行锁和唯一约束必须依赖它MyISAM在这个场景下没法用。2.3 Spring Data JPA的Repository层怎么分层热词里有“spring boot jparepository 这个是什么”正好在这个位置说明。影院系统的查询维度多按影片查、按影院查、按场次查、按订单号查。JpaRepository内置了findById、save、delete但复杂查询要自己写JPQL或原生SQL。建议把Repository拆成两层继承JpaRepository的接口负责标准CRUD自定义的查询方法用Query注解放在同一个接口里。比如查“某场次的可用座位数”public interface SeatRepository extends JpaRepositorySeat, Long { Query(SELECT COUNT(s) FROM Seat s WHERE s.scheduleId :scheduleId AND s.status 0) int countAvailableByScheduleId(Param(scheduleId) Long scheduleId); Lock(LockModeType.PESSIMISTIC_WRITE) Query(SELECT s FROM Seat s WHERE s.scheduleId :scheduleId AND s.rowNo :rowNo AND s.colNo :colNo) OptionalSeat findByCoordinateForUpdate(Param(scheduleId) Long scheduleId, Param(rowNo) int rowNo, Param(colNo) int colNo); }countAvailableByScheduleId用于购票前展示“余座”findByCoordinateForUpdate则是真正的选座落库操作。加了Lock(PESSIMISTIC_WRITE)之后Spring Data JPA会在查询语句后追加FOR UPDATE用数据库行锁把这一行锁住直到事务结束。后面会对比它和乐观锁的使用场景。3. 把售票主链路写出来从影片列表到订单落库的最小可跑代码3.1 先列接口清单再做Controller层一套售票系统最核心的接口就四个查影片列表、查场次座位图、选座下单、支付回调改状态。支付回调是模拟的真正的支付网关对接要等上线前接但状态机的设计要留好接口。Controller层应该保持薄只做参数校验和结果包装不下业务判断。RestController RequestMapping(/api/ticket) public class TicketController { private final FilmService filmService; private final ScheduleService scheduleService; private final OrderService orderService; public TicketController(FilmService filmService, ScheduleService scheduleService, OrderService orderService) { this.filmService filmService; this.scheduleService scheduleService; this.orderService orderService; } GetMapping(/films) public ResultListFilmVO listFilms() { return Result.success(filmService.listNowPlaying()); } GetMapping(/schedules/{filmId}) public ResultListScheduleVO listSchedules(PathVariable Long filmId) { return Result.success(scheduleService.listByFilmId(filmId)); } PostMapping(/order) public ResultOrderVO createOrder(RequestBody CreateOrderRequest request) { return Result.success(orderService.createOrder(request)); } }createOrder内部要做的事是校验场次和座位坐标 - 锁定座位 - 创建订单 - 开启支付超时计时。Controller层只暴露Http语义真正的锁和事务都在Service层。3.2 下单Service的事务边界锁座位和创建订单必须同一事务这是整个系统最关键的代码。座位锁定和订单创建必须在一个事务里否则会出现“座位锁了但订单没建上”或“订单建了但座位还是可用”的中间状态。事务边界用Transactional注解划在Service方法上千万别划在Controller层。Service public class OrderService { private final SeatRepository seatRepository; private final OrderRepository orderRepository; Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderRequest request) { // 1. 行锁锁定座位防止别人同时选 Seat seat seatRepository.findByCoordinateForUpdate( request.getScheduleId(), request.getRowNo(), request.getColNo()) .orElseThrow(() - new BusinessException(座位不存在)); // 2. 检查座位状态0可用才允许继续 if (seat.getStatus() ! SeatStatus.AVAILABLE.getCode()) { throw new BusinessException(座位已被锁定或售出); } // 3. 座位状态改为锁定写版本号 seat.setStatus(SeatStatus.LOCKED.getCode()); seat.setVersion(seat.getVersion() 1); seatRepository.save(seat); // 4. 创建订单状态为CREATED Order order new Order(); order.setSeatId(seat.getId()); order.setScheduleId(request.getScheduleId()); order.setStatus(OrderStatus.CREATED); order.setAmount(calculatePrice(seat, request.getScheduleId())); orderRepository.save(order); // 5. 返回订单号进入支付流程 return OrderMapper.toVO(order); } }rollbackFor Exception.class必须写因为Spring默认只在RuntimeException下回滚而BizException如果继承了Exception不回滚就会产生脏数据。代码执行顺序是先拿行锁再查状态再改状态再插订单。整个过程中第二个用户若同时选同一个座位会在第1步阻塞直到第一个事务提交或回滚。3.3 支付超时怎么把“占住的座位”还给别人订单创建后用户不支付座位不能一直锁着。常见做法是延迟队列或定时任务扫描。定时任务简单粗暴但会有扫表压力建议用Redis的过期键监听或者用Spring的Scheduled定时只扫“超过N分钟未支付”的订单。Component public class OrderTimeoutTask { private final OrderRepository orderRepository; private final SeatRepository seatRepository; Scheduled(fixedDelay 60000) Transactional(rollbackFor Exception.class) public void releaseExpiredOrders() { ListOrder expiredOrders orderRepository .findByStatusAndCreatedAtBefore(OrderStatus.CREATED, LocalDateTime.now().minusMinutes(15)); for (Order order : expiredOrders) { if (order.cancel()) { orderRepository.save(order); // 把座位状态改回可用 Seat seat seatRepository.findById(order.getSeatId()).orElse(null); if (seat ! null seat.getStatus() SeatStatus.LOCKED.getCode()) { seat.setStatus(SeatStatus.AVAILABLE.getCode()); seatRepository.save(seat); } } } } }fixedDelay 60000表示上一次执行结束后隔60秒再跑下一次适用于对时间不敏感的场景。如果要做秒级精确释放就得引入Redisson的延迟队列或RocketMQ定时消息。定时任务方案部署简单适合源码学习和中小型影院的真实负载。4. 选座并发与超卖用悲观锁、乐观锁、Redis把购票做强一致4.1 为什么这里必须抛弃“先查再改”的普通写法很多初学者用“查座位状态 - 判断可用 - UPDATE”的方式这在单线程下没问题但并发下会超卖。两个请求同时查到座位状态为可用同时执行UPDATE后执行的把先执行的覆盖了。结果是一张票卖给了两个人。解决方案分三层从强到弱数据库悲观锁FOR UPDATE、乐观锁version字段、Redis分布式锁。各有用武之地下面做个对比方便你按业务选型。方案实现方式优点缺点适用场景悲观锁SELECT ... FOR UPDATE强一致实现简单并发大时锁等待严重低频但强竞争如选座落库乐观锁UPDATE ... WHERE version ?无阻塞吞吐高并发冲突多时重试成本高热点不是特别集中的座位Redis预占SETNX占位TTL过期挡流量减少DB压力和DB状态可能不一致大促秒杀前的缓冲层影院选座有一个人性化诉求用户要看到“座位图”再点座位这个过程中座位状态不停变化。方案是展示用Redis缓存座位图点击选座时走DB悲观锁保证最终一致性。4.2 乐观锁的具体写法与失败重试策略如果不用FOR UPDATE改用乐观锁SQL长这样UPDATE seat SET status 1, version version 1 WHERE id ? AND status 0 AND version ?Java侧的JPA写法Modifying Query(UPDATE Seat s SET s.status :newStatus, s.version s.version 1 WHERE s.id :id AND s.status :expectStatus AND s.version :expectVersion) int updateStatusWithVersion(Param(id) Long id, Param(newStatus) Integer newStatus, Param(expectStatus) Integer expectStatus, Param(expectVersion) Integer expectVersion);返回值为1表示更新成功为0表示版本冲突或状态不对需要提示用户“座位已被选走请重新选择”。这里要说一个关键点乐观锁不该用在“锁座后等待支付”这十几种钟里。因为支付等待期长用户在选座页看到“已被锁定”是合理的但如果用乐观锁锁座期间该座位的version没变别人还能再次UPDATE成功——除非status已经变成1WHERE条件里status 0拦截住了。所以乐观锁在电影院场景下只适合做“瞬间核销”不适合做“长时间占座”。4.3 Redis缓存座位图与冲突兜底当用户打开选座页面时不能直接查数据库要把座位图缓存到Redis。用Hash结构key为hall:schedule:{scheduleId}field为row:colvalue为状态。HSET hall:schedule:1001 1:1 0 HSET hall:schedule:1001 1:2 0 HSET hall:schedule:1001 1:3 1Java里用StringRedisTemplate操作String key hall:schedule: scheduleId; MapObject, Object seatMap redisTemplate.opsForHash().entries(key); // 遍历seatMap组装座位图VO返回给前端用户点击“选座”时先试着在Redis里HINCRBY做预占但这只是挡流量最终以数据库的FOR UPDATE结果为准。Redis写入失败或状态为1就直接提示不可选不进Service层。这种两级削峰的做法单机Redis能抗住每秒几万的选座请求数据库压力集中在下单这一条路径上。5. 别把Actuator裸奔出去接口暴露范围和上传参数的3个必调项5.1 Spring Boot Actuator未授权访问是真实存在的坑热词里“spring boot actuator未授权访问”是面试常客。Actuator在生产环境默认只暴露health和info但如果开发者图方便设置了management.endpoints.web.exposure.include*并且没加访问控制外部就能通过/actuator/env看到环境变量/actuator/heapdump能直接下载堆转储文件从中提取密码和Token。这个坑在售票系统这类内部管理端尤其危险因为线上数据库密码和支付密钥都挂在Environment里。management: endpoints: web: exposure: include: health,info,metrics,logfile exclude: env,beans,heapdump,threaddump,shutdown endpoint: health: show-details: never server: port: 9099include只放健康检查、基础指标、日志这几项exclude里把敏感的坚决关掉。management.server.port把Actuator独立到一个非业务端口这样外网扫描业务端口8080时这些接口是不可见的。但这只是缓解真正要防的是在Spring Security中加规则。5.2 Spring Security的白名单规则业务接口放行Actuator收紧Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth - auth .requestMatchers(/api/ticket/films, /api/ticket/schedules).permitAll() .requestMatchers(/actuator/**).hasRole(ADMIN) .anyRequest().authenticated()) .httpBasic(withDefaults()); return http.build(); }/api/ticket/films这类只在购票前展示基础数据的接口可以放行但下单、查询订单必须登录。/actuator/**限制为ADMIN角色。这里的热搜词“spring boot actuator未授权访问”对应的整改步骤就是这三板斧缩小暴露端点、独立端口、加Security权限。5.3 文件上传售票系统不一定用但管理后台肯定用影院管理后台会上传电影海报Spring Boot的文件上传有两个默认值必须调。spring.servlet.multipart.max-file-size默认只有1MB一张高清海报根本传不上去。max-request-size默认10MB批量传海报也会被拦截。spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MBController接收时注意用MultipartFile参数不要直接写File因为Spring Boot的临时文件清理机制可能导致File对象在异步处理时失效。正确做法是PostMapping(/admin/film/poster) public ResultString uploadPoster(RequestParam(file) MultipartFile file) { String filename file.getOriginalFilename(); String ext filename.substring(filename.lastIndexOf(.)); if (!List.of(.jpg, .png, .webp).contains(ext.toLowerCase())) { throw new BusinessException(不支持的图片格式); } String objectName UUID.randomUUID() ext; // 上传到OSS或本地磁盘返回访问URL return Result.success(objectName); }参数说明RequestParam(file)对应前端表单里的name属性文件大小在进入方法前已经被Spring拦截超限会抛MaxUploadSizeExceededException需要全局异常处理器转成友好提示。6. 让日志和慢查询可运维Docker Filebeat采集与EXPLAIN定位热点系统上线之后最大的痛点不是功能Bug而是“查不到现场”。售票系统的典型事故现场是某场次开场前两小时用户集中取票order_item表或seat表的某条SQL突然慢了数据库连接池被占满。这时候如果没有集中式日志和慢查询记录排查成本极高。6.1 Spring Boot的日志先结构化再交给Filebeat先把日志格式改成JSON这样采集端不用自己拼字段。在logback-spring.xml里配一个JSON格式的Encoderappender nameJSON_FILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/ticket-system.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/ticket-system.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder classnet.logstash.logback.encoder.LogstashEncoder/ /appenderLogstashEncoder会自动把MDC里的参数、traceId、耗时拼到JSON里。做链路追踪时在拦截器里往MDC放一个UUIDComponent public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId UUID.randomUUID().toString().replace(-, ); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }Docker部署时的日志采集Filebeat配置里注意路径要和容器日志目录映射保持一致filebeat.inputs: - type: container paths: - /var/lib/docker/containers/*/*.log output.elasticsearch: hosts: [http://elasticsearch:9200]paths里的路径对应Docker宿主机的容器日志目录Filebeat容器要挂载/var/lib/docker/containers和/var/run/docker.sock才能读到。如果采集不到日志先确认挂载路径权限再确认日志是输出到stdout还是文件——Spring Boot打包成镜像后日志默认应该打到stdout配合json-file驱动最省事。6.2 慢查询定位一张EXPLAIN看懂座位查询的索引走向售票系统的慢查询高发点在seat表和order表的联合查询。比如统计某个厅的座位售卖情况SQL写成SELECT s.row_no, s.col_no, s.status, o.status AS order_status FROM seat s LEFT JOIN order o ON o.seat_id s.id AND o.schedule_id s.schedule_id WHERE s.schedule_id ?o.schedule_id s.schedule_id这个关联条件如果order表只有seat_id索引而没有schedule_id索引就会触发全表扫描。用EXPLAIN验证EXPLAIN SELECT ... \G看type列如果是ALL说明没走索引如果rows很大说明扫描行数多。解决办法是给order表加复合索引(schedule_id, seat_id)避免回表。这些都是拿到源码后第一轮就该检查的隐患而不是等线上出事再看。6.3 一个可以立刻做的压测验证用JMeter验证单座不超卖最后给一个验证刚才所有代码的行为准备一个100个座位的场次用JMeter开50个线程同时抢同一个座位断言成功数只能为1其他全部返回“座位已被锁定或售出”。# 用命令行模式跑压测结果输出到jtl文件 jmeter -n -t ticket-test.jmx -l result.jtl -j jmeter.log断言逻辑很简单看响应体里BusinessException.getMessage()的出现次数50次请求中code0的成功响应必须且仅出现1次。如果你用的是悲观锁方案且事务边界正确这个断言百分之百通过如果出现2次以上成功回去检查Transactional是不是被同类调用绕过了比如在类内部调用createOrder事务注解不生效或者有没有在Service内部私自提交事务。这一步验证通过这套售票系统的心脏就是健康的。本文还有配套的精品资源点击获取
返回列表