手机买电影票系统实战:面试必问的并发陷阱
刚接手劳务班组管理项目,第一周就让我崩溃了。
为了演示“手机买电影票”功能,我花了一整天配环境。
结果跑起来全是报错,面试被问得哑口无言。
这不仅是配置问题,更是底层逻辑没搞清。
今天把这套坑全填平,全是实战经验。
概念速懂:为什么手机买票会卡死
很多人觉得买票就是简单的 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.0
2. 关键依赖配置
pom.xml 里这几个包缺一不可。
特别注意版本兼容性,这是新手翻车重灾区。
<dependencies><!-- Spring Boot Web --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Spring Data Redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- Lettuce 连接池 (默认) --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency><!-- MySQL Driver --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
</dependencies>
3. 配置文件避坑
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=false&serverTimezone=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
end
2. 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 RedisScript<Long> 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 Result<String> 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 RedisTemplate<String, 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?
留言区见,我会挑几个典型问题单独拆解。