3天搞定广州市公路客运网上售票系统:告别报错,性能优化实战
盯着屏幕上那一片刺眼的红色 Stack Trace,你大概率已经想砸键盘了。NullPointerException 或者 ConnectionTimeout 满屏飞,根本不知道从哪一行代码开始查起。这种“报错一堆看不懂”的绝望感,是每个后端开发在接手或重构老系统时的必经之路。
今天我们要聊的,是一个极具代表性的实战案例:广州市公路客运网上售票系统。别被这个名字吓到,它本质上就是一个高并发的分布式票务系统。我见过太多开发者在这个系统里栽跟头,不是代码逻辑错了,而是性能优化没做对,导致高峰期直接崩盘。
这篇文章不整虚的,我们直接从工程化角度,把这个系统从零搭起来。你会看到真实的目录结构、核心代码实现,以及那些能让系统扛住并发洪流的性能优化技巧。哪怕你是刚入行的新手,跟着敲完这套代码,对高并发处理的理解也能上一个台阶。
项目目标与架构选型
在动手写代码之前,先明确我们要解决什么问题。一个标准的公路客运售票系统,核心业务流程很简单:用户查询班次 - 选择座位 - 下单支付 - 出票。但魔鬼藏在细节里:高并发查询:春运期间,成千上万用户同时查询同一线路,数据库扛不住。
座位超卖:两个用户同时抢最后一个座位,必须保证数据一致性。
库存扣减:票务库存是核心资源,必须原子操作。为了在 3 天内跑通核心流程并具备可扩展性,我们选择 Spring Boot + MyBatis-Plus + Redis + MySQL 技术栈。这是国内企业最主流的组合,招聘需求量大,且资料丰富。
为什么选 Redis?
因为 MySQL 在处理高频读请求时,性能瓶颈非常明显。我们将班次信息、剩余票数等热点数据缓存到 Redis 中,数据库只负责最终持久化和复杂查询。
核心目标:实现座位的并发安全锁定。
查询接口响应时间控制在 50ms 以内。
支持水平扩展,无状态服务设计。目录结构与工程化规范
很多初学者喜欢把代码全堆在 Service 层,导致后期维护简直是灾难。我们采用标准的分层架构,确保代码职责单一。
ticket-system/
├── src/main/java/com/gz/ticket/
│ ├── config/ # 配置类(Redis, Web, Exception)
│ ├── controller/ # 接口层(接收请求,参数校验)
│ ├── service/ # 业务层(核心逻辑,事务控制)
│ ├── mapper/ # 数据层(MyBatis 接口)
│ ├── entity/ # 数据库实体类
│ ├── dto/ # 数据传输对象(前端交互)
│ ├── common/ # 通用工具(Result, 常量, 异常)
│ └── TicketApplication.java
├── src/main/resources/
│ ├── application.yml # 配置文件
│ ├── mapper/ # MyBatis XML 映射文件
│ └── static/ # 静态资源(可选)
└── pom.xml关键文件说明:Result.java:统一响应格式。前端讨厌不一致的返回结构,我们定义 {code, msg, data} 三元组,所有接口必须返回这个对象。
GlobalExceptionHandler.java:全局异常捕获。这是解决“报错看不懂”的第一道防线。所有的 Exception 都会在这里被拦截,转换成友好的 JSON 返回给前端,而不是直接抛出 500 页面。在 pom.xml 中,除了引入 Spring Boot Starter Web 和 MyBatis Plus,别忘了引入 lombok 和 hutool。Lombok 能减少大量的 Getter/Setter 样板代码,Hutool 提供了一些便捷的字符串和集合工具,能提升开发效率。
核心代码实现与逐行解析
接下来进入硬核部分。我们实现最核心的功能:查询班次列表 和 锁定座位。
1. 统一异常处理:让报错不再神秘
很多开发者遇到报错,第一反应是看控制台,但生产环境控制台往往只有一行 Internal Server Error。我们需要一个全局异常处理器。
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.gz.ticket.common.Result;
import lombok.extern.slf4j.Slf4j;@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {/*** 捕获所有未处理的异常*/@ExceptionHandler(Exception.class)public Result? handleException(Exception e) {// 关键:记录完整堆栈到日志文件,而不是直接返回给前端log.error(系统发生未知异常, e);// 返回给前端的错误信息要模糊,避免暴露系统细节return Result.error(500, 系统繁忙,请稍后再试);}/*** 捕获业务自定义异常*/@ExceptionHandler(BusinessException.class)public Result? handleBusinessException(BusinessException e) {// 业务异常,比如“座位已售罄”,直接返回具体错误码return Result.error(e.getCode(), e.getMessage());}
}逐行讲解:@RestControllerAdvice:这是 Spring 提供的注解,类似于 @ControllerAdvice,但返回的是 JSON 而非视图。它会将所有 Controller 层抛出的异常集中处理。
log.error(..., e):注意这里传入了异常对象 e。Slf4j 会自动打印完整的 StackTrace 到日志文件。这样即使前端只看到“系统繁忙”,运维人员也能在日志里看到确切的报错行号。
避坑指南:千万不要在异常处理器里 e.printStackTrace(),这只会输出到标准输出流,生产环境根本看不到。必须使用日志框架。2. 班次查询:Redis 缓存优化
查询接口是流量最大的入口。如果每次都查 MySQL,数据库连接池很快会被打满。
@Service
public class TicketService {@Autowiredprivate TicketMapper ticketMapper;@Autowiredprivate RedisTemplateString, Object redisTemplate;private static final String CACHE_KEY_PREFIX = ticket:line:;/*** 查询某条线路的班次列表*/public ListTicketDto queryLine(String lineId) {String cacheKey = CACHE_KEY_PREFIX + lineId;// 1. 尝试从 Redis 获取ListTicketDto cacheData = (ListTicketDto) redisTemplate.opsForValue().get(cacheKey);if (cacheData != null) {return cacheData;}// 2. 缓存未命中,查询数据库ListTicket tickets = ticketMapper.selectByLineId(lineId);// 3. 转换为 DTO 并放入 RedisListTicketDto dtoList = tickets.stream().map(this::convertToDto).collect(Collectors.toList());// 设置过期时间 5 分钟,防止数据不一致redisTemplate.opsForValue().set(cacheKey, dtoList, 5, TimeUnit.MINUTES);return dtoList;}
}性能优化要点:缓存穿透防护:如果查询的数据在 DB 中也不存在,cacheData 为 null,我们会去查 DB。如果 DB 也没数据,我们应该缓存一个空对象或特殊标记,防止恶意请求频繁击穿缓存。
TTL 设置:5 分钟是一个经验值。太短了缓存命中率低,太长了用户看到的座位状态可能滞后。根据业务场景调整。3. 座位锁定:解决并发超卖
这是最容易出 Bug 的地方。如果两个用户同时点击“下单”,普通的 UPDATE 语句会导致超卖。
错误示范(绝对不要用):
// 1. 查库存
int count = ticketMapper.selectCount(stockId);
// 2. 判断库存
if (count 0) {// 3. 扣减库存ticketMapper.updateStock(stockId, -1);
}在并发下,步骤 1 和 3 之间有时间差,两个线程可能同时读到 count 0,导致多卖一张票。
正确方案:数据库乐观锁 + Redis 预扣减
为了简化演示,我们采用数据库层面的原子操作。
/*** 锁定座位* @param ticketId 班次ID* @param seatNo 座位号*/
public void lockSeat(Long ticketId, String seatNo) {// 1. 构造更新条件// 利用数据库的 UPDATE ... WHERE status = 0 (空闲)// 如果 status 已经是 1 (已锁定),则更新行数为 0int rows = ticketMapper.lockSeat(ticketId, seatNo);// 2. 判断更新结果if (rows == 0) {throw new BusinessException(400, 座位已被占用或不存在);}// 3. 发送延迟队列消息,如果 15 分钟未支付,释放座位// 这里省略 RabbitMQ/RocketMQ 的具体实现,仅示意逻辑sendReleaseMessage(ticketId, seatNo, 15);
}Mapper XML 中的 SQL:
update id=lockSeatUPDATE t_ticket_seat SET status = 1, lock_time = NOW(),lock_user_id = #{userId}WHERE ticket_id = #{ticketId} AND seat_no = #{seatNo} AND status = 0
/update原理解析:原子性:UPDATE 语句在数据库层面是原子操作。WHERE status = 0 是关键。如果第一个线程已经把 status 改成了 1,第二个线程执行同样的 SQL,影响行数(rows)就是 0。
无锁并发:这种方案利用了 MySQL 的行锁机制(InnoDB 引擎默认支持),不需要我们在代码里加 synchronized 或 ReentrantLock。对于高并发场景,数据库的行锁比应用层的 JVM 锁更可靠,因为它不依赖单机。运行与测试:如何验证性能
代码写完只是第一步,必须通过测试来验证性能优化是否生效。
1. 本地运行
确保 MySQL 和 Redis 服务已启动。修改 application.yml 中的连接配置:
spring:datasource:url: jdbc:mysql://localhost:3306/gz_ticket?useUnicode=truecharacterEncoding=utf8username: rootpassword: 123456redis:host: localhostport: 6379启动项目,访问 /actuator/health 确认服务正常。
2. 压力测试
使用 JMeter 或 Locust 模拟并发请求。
测试场景:并发用户数:1000 个用户。
操作:同时查询同一热门线路(如广州-深圳),并尝试购买相同的 10 个座位。
预期结果:查询接口 QPS(每秒查询率)应高于 5000。
座位购买成功率:10 个座位,最终只有 10 个订单成功,其余 990 个请求应返回“座位已被占用”。
绝对不能出现:11 个订单成功(超卖)。常见测试报错:
如果在测试中出现 Connection Pool Exhausted(连接池耗尽),说明数据库连接数不够。调整 HikariCP 配置:
spring:datasource:hikari:maximum-pool-size: 50 # 根据 CPU 核心数调整,通常为 2 * CPU核数 + 磁盘数优化扩展与避坑指南
系统跑通后,还有几个关键点决定它能走多远。
1. 缓存一致性
Redis 和 MySQL 数据不一致是常态。策略:采用“Cache Aside Pattern”(旁路缓存)。更新数据库后,删除缓存,而不是更新缓存。
原因:更新缓存可能失败,且并发更新时容易乱序。删除缓存后,下次请求会重新加载最新数据。2. 数据库索引优化
在 t_ticket_seat 表上,必须建立联合索引:
CREATE INDEX idx_ticket_seat_status ON t_ticket_seat(ticket_id, seat_no, status);这个索引覆盖了查询和更新操作,避免了全表扫描。在高并发下,索引缺失是性能杀手。
3. 日志规范化
不要到处打 System.out.println。统一使用 @Slf4j。INFO 级别:记录关键业务流程(如下单成功、支付成功)。
DEBUG 级别:记录调试信息(如 SQL 执行时间),生产环境关闭。
ERROR 级别:记录异常,必须包含堆栈信息。4. 参考开源项目
如果你想看更复杂的分布式锁实现(如 Redisson),可以参考 GitHub 上的开源仓库 Redisson。它在 distributed_lock 模块中实现了 Redlock 算法,比简单的 SETNX 更安全可靠。阅读源码能帮你理解底层原理。
小结
从报错一堆看不懂,到能够从容应对高并发场景,关键在于工程化思维和对细节的掌控。全局异常处理让你不再迷失在 StackTrace 里。
Redis 缓存让查询接口轻快如风。
数据库原子操作保证了数据的一致性,杜绝超卖。
压力测试是检验优化效果的唯一标准。搭建一个广州市公路客运网上售票系统,不仅是写代码,更是学习如何设计一个健壮、可扩展的后端架构。这些技术点(缓存、锁、索引、日志)在任何电商、票务、金融系统中都是通用的。
你在项目里踩过这个坑吗?比如缓存穿透导致数据库宕机,或者并发下数据不一致?评论区聊聊,我们一起拆解解决方案。