
手机买电影票系统实战:面试必问的并发陷阱
刚接手劳务班组管理项目,第一周就让我崩溃了。
为了演示“手机买电影票”功能,我花了一整天配环境。
结果跑起来全是报错,面试被问得哑口无言。
这不仅是配置问题,更是底层逻辑没搞清。
今天把这套坑全填平,全是实战经验。
概念速懂:为什么手机买票会卡死
很多人觉得买票就是简单的 SELECT 和 UPDATE。
错了。这是典型的高并发竞态条件问题。
想象一下,热门电影《流浪地球3》开售。
10000人同时点击“确认支付”。
你的数据库如果没做好锁,会出现两种灾难:超卖:座位只剩1个,卖出了5张票。
少卖:库存还有100个,但系统提示已售罄。在劳务班组场景中,这和**“抢工单”**一模一样。
班组负责人要在APP上抢下个月的施工任务。
如果系统响应慢或数据不一致,直接导致收入损失。
面试官问“手机买电影票”,其实是在考你的分布式一致性思维。
这不是前端按钮的事,是后端架构的事。
核心矛盾:高并发下的数据一致性 vs 系统吞吐量。
解决思路只有三条路:数据库悲观锁(简单,性能差)
Redis 原子操作(快,需处理过期)
消息队列削峰(复杂,最终一致性)初学者建议从Redis预扣减入手。
这也是目前90%电商系统的标准做法。
环境准备:别再卡在依赖冲突
别再问我 npm install 报错了。
那是前端的事,我们今天讲后端核心。
但环境不对,代码写得再漂亮也是白搭。
1. 基础栈选择
为了演示清晰,我们使用最通用的技术栈:语言:Java 17 (LTS版本,稳定)
框架:Spring Boot 3.0+
缓存:Redis 7.0 (必须)
数据库:MySQL 8.02. 关键依赖配置
pom.xml 里这几个包缺一不可。
特别注意版本兼容性,这是新手翻车重灾区。
dependencies!-- Spring Boot Web --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependency!-- Spring Data Redis --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependency!-- Lettuce 连接池 (默认) --dependencygroupIdorg.apache.commons/groupIdartifactIdcommons-pool2/artifactId/dependency!-- MySQL Driver --dependencygroupIdcom.mysql/groupIdartifactIdmysql-connector-j/artifactIdscoperuntime/scope/dependency
/dependencies3. 配置文件避坑
application.yml 中,Redis 连接超时时间必须调短。
默认配置在弱网环境下会卡死线程。
spring:redis:host: 127.0.0.1port: 6379timeout: 200ms # 关键:快速失败,别傻等lettuce:pool:max-active: 200max-idle: 50min-idle: 10max-wait: 100msdatasource:url: jdbc:mysql://localhost:3306/movie_ticket?useSSL=falseserverTimezone=UTCusername: rootpassword: 123456hikari:maximum-pool-size: 20connection-timeout: 3000重点:max-wait 设为 100ms。
如果获取连接超过 100ms,直接抛异常。
这是保护系统不雪崩的第一道防线。
在掘金技术社区看过的架构分享都强调:快速失败是稳定性的基石。
核心语法:Lua脚本才是王道
很多新手喜欢用 DECR 命令扣减库存。
这是错的。
DECR 是原子操作,但判断库存是否充足不是原子的。
如果你先 GET 判断 0,再 DECR,中间就有时间差。
高并发下,这个时间差就是超卖的根源。
正确做法:Lua 脚本。
Redis 执行 Lua 脚本是原子性的。
整个脚本执行期间,其他命令无法插入。
1. 扣减库存脚本
将以下脚本保存为 decr_stock.lua。
-- key: 电影场次ID, 如 stock:movie_001
local key = KEYS[1]
local stock = tonumber(redis.call('get', key))-- 如果库存不存在,返回 -1
if stock == nil thenreturn -1
end-- 如果库存充足,扣减并返回新库存
if stock 0 thenredis.call('decr', key)return stock - 1
elsereturn 0
end2. Java 端调用
Spring Boot 3.0 中,使用 RedisScript 接口。
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
import org.springframework.core.io.ClassPathResource;import java.util.Collections;@Service
public class TicketService {private final StringRedisTemplate redisTemplate;private final RedisScriptLong decrStockScript;public TicketService(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;// 加载 Lua 脚本this.decrStockScript = new DefaultRedisScript(new ClassPathResource(lua/decr_stock.lua).toString(), Long.class);}/*** 尝试扣减库存* @param movieId 电影ID* @return 是否扣减成功*/public boolean tryDecrStock(String movieId) {String key = stock: + movieId;try {Long result = redisTemplate.execute(decrStockScript,Collections.singletonList(key));// 返回 -1 表示 key 不存在// 返回 0 表示库存不足// 返回 0 表示扣减成功return result != null result 0;} catch (Exception e) {// 记录日志,但不抛出异常,返回 falselog.error(Redis 扣减库存异常: + movieId, e);return false;}}
}关键行解释:ClassPathResource:从类路径加载 Lua 文件。
Collections.singletonList:Lua 脚本需要 Key 列表参数。
try-catch:Redis 可能宕机,必须捕获异常,防止线程池被占满。完整代码示例:从点击到出票
光扣减库存不够,还要落库。
如果扣减成功,但写数据库失败,用户钱扣了,票没了。
这叫数据不一致。
解决方案:本地消息表 或 事务消息。
为了演示简单,我们用**“Redis扣减 + 异步落库”**模式。
1. 控制器入口
@RestController
@RequestMapping(/api/ticket)
public class TicketController {@Autowiredprivate TicketService ticketService;@Autowiredprivate OrderService orderService;@PostMapping(/buy)public ResultString buyTicket(@RequestBody BuyTicketRequest req) {// 1. 参数校验if (req.getMovieId() == null || req.getUserId() == null) {return Result.fail(参数错误);}// 2. 尝试扣减 Redis 库存boolean success = ticketService.tryDecrStock(req.getMovieId());if (!success) {return Result.fail(手慢了,票卖光了);}try {// 3. 异步创建订单 (关键点)orderService.createOrderAsync(req);// 4. 返回成功 (实际是“受理成功”,非“出票成功”)return Result.success(订单创建中,请刷新查看);} catch (Exception e) {// 5. 业务异常,回滚 Redis 库存ticketService.rollbackStock(req.getMovieId());return Result.fail(系统繁忙,请重试);}}
}2. 异步落库服务
使用 Spring 的 @Async 注解,避免阻塞主线程。
@Service
public class OrderService {@Autowiredprivate OrderMapper orderMapper;@Autowiredprivate RedisTemplateString, String redisTemplate;@Async(taskExecutor) // 需要配置线程池@Transactional // 开启事务public void createOrderAsync(BuyTicketRequest req) {try {// 1. 创建订单记录 (状态: 待支付)Order order = new Order();order.setUserId(req.getUserId());order.setMovieId(req.getMovieId());order.setStatus(OrderStatus.PENDING);orderMapper.insert(order);// 2. 记录操作日志 (用于对账)log.info(订单创建成功: {}, order.getId());} catch (Exception e) {// 数据库写入失败log.error(订单落库失败, e);// 注意:这里不能直接回滚 Redis// 需要人工介入或延迟队列补偿// 简单场景下,可以抛出异常让上层回滚throw new RuntimeException(DB Error, e);}}
}避坑指南:@Async 必须配合线程池使用,否则默认用 SimpleAsyncTaskExecutor,不可控。
@Transactional 在异步方法中可能失效,需确保代理生效。常见报错:这些坑我全踩过
1. Redis 连接池耗尽
现象:Connection reset 或 GetConnectionTimeoutException。
原因:高并发下,所有连接都被占用,新请求等待超时。
解决:调大 max-active 连接数。
检查是否有长事务占用连接。
核心:确保 Redis 操作在毫秒级完成,不要做复杂计算。2. Lua 脚本执行超时
现象:SCRIPT ERROR 或响应慢。
原因:脚本逻辑复杂,或 Key 热点导致单线程阻塞。
解决:简化 Lua 脚本逻辑。
如果 Key 是热点(如爆款电影),考虑分桶。将 stock:movie_001 拆分为 stock:movie_001_1 到 stock:movie_001_10。
请求随机访问其中一个桶。
这样可以将单点压力分散到 10 个 Key 上。3. 订单重复创建
现象:用户点击一次,生成多个订单。
原因:前端重试机制 + 后端幂等性缺失。
解决:前端:点击后禁用按钮,显示 Loading。
后端:幂等性 Token。用户进入页面时,生成唯一 token 存入 Session/Redis。
提交订单时,必须携带 token。
后端先删除 token,删除成功才处理请求。// 幂等性校验示例
public boolean checkIdempotent(String token) {String key = idempotent: + token;// 原子操作:删除 keyBoolean deleted = redisTemplate.delete(key);return Boolean.TRUE.equals(deleted);
}小结:从买票到职业发展
讲完代码,聊聊职业。
手机买电影票 这个场景,在面试中属于**“中等难度”**。
它能考察你对并发、缓存、数据库的综合理解。
但对于劳务班组负责人或初级后端来说,这还不够。
真正的挑战在于:如何监控库存准确性?(对账系统)
如何处理 Redis 宕机?(降级到数据库,限流)
如何防止恶意刷单?(风控规则)在劳务行业,这些对应着:工单分配公平性:算法是否透明?
系统可用性:抢工单时APP崩了,工人会骂娘。
数据安全:工人信息泄露,法律责任巨大。晋升路径:初级:能写出扣减库存的 Lua 脚本。
中级:能设计完整的防超卖方案,包含幂等、对账、降级。
高级:能主导分布式事务架构,处理跨服务数据一致性。你现在的代码,可能只能应付初级面试。
想拿大厂 Offer,必须把**“为什么”**讲清楚。
为什么用 Redis?为什么用 Lua?为什么异步落库?
每一个技术选型,都要有成本收益分析。
你公司项目里是怎么处理的?欢迎评论。
是用了消息队列?还是直接数据库行锁?
或者有没有遇到过更诡异的并发 Bug?
留言区见,我会挑几个典型问题单独拆解。