3天搞定广州市公路客运网上售票系统:告别报错,性能优化实战
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 缓存让查询接口轻快如风。 数据库原子操作保证了数据的一致性,杜绝超卖。 压力测试是检验优化效果的唯一标准。搭建一个广州市公路客运网上售票系统,不仅是写代码,更是学习如何设计一个健壮、可扩展的后端架构。这些技术点(缓存、锁、索引、日志)在任何电商、票务、金融系统中都是通用的。 你在项目里踩过这个坑吗?比如缓存穿透导致数据库宕机,或者并发下数据不一致?评论区聊聊,我们一起拆解解决方案。