news 2026/9/22 22:15:14

3天搞定广州市公路客运网上售票系统:告别报错,性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
3天搞定广州市公路客运网上售票系统:告别报错,性能优化实战

3天搞定广州市公路客运网上售票系统:告别报错,性能优化实战

盯着屏幕上那一片刺眼的红色 Stack Trace,你大概率已经想砸键盘了。NullPointerException 或者 ConnectionTimeout 满屏飞,根本不知道从哪一行代码开始查起。这种“报错一堆看不懂”的绝望感,是每个后端开发在接手或重构老系统时的必经之路。

今天我们要聊的,是一个极具代表性的实战案例:广州市公路客运网上售票系统。别被这个名字吓到,它本质上就是一个高并发的分布式票务系统。我见过太多开发者在这个系统里栽跟头,不是代码逻辑错了,而是性能优化没做对,导致高峰期直接崩盘。

这篇文章不整虚的,我们直接从工程化角度,把这个系统从零搭起来。你会看到真实的目录结构、核心代码实现,以及那些能让系统扛住并发洪流的性能优化技巧。哪怕你是刚入行的新手,跟着敲完这套代码,对高并发处理的理解也能上一个台阶。

项目目标与架构选型

在动手写代码之前,先明确我们要解决什么问题。一个标准的公路客运售票系统,核心业务流程很简单:用户查询班次 -> 选择座位 -> 下单支付 -> 出票。但魔鬼藏在细节里:

  1. 高并发查询:春运期间,成千上万用户同时查询同一线路,数据库扛不住。
  2. 座位超卖:两个用户同时抢最后一个座位,必须保证数据一致性。
  3. 库存扣减:票务库存是核心资源,必须原子操作。

为了在 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,别忘了引入 lombokhutool。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 RedisTemplate<String, Object> redisTemplate;private static final String CACHE_KEY_PREFIX = "ticket:line:";/*** 查询某条线路的班次列表*/public List<TicketDto> queryLine(String lineId) {String cacheKey = CACHE_KEY_PREFIX + lineId;// 1. 尝试从 Redis 获取List<TicketDto> cacheData = (List<TicketDto>) redisTemplate.opsForValue().get(cacheKey);if (cacheData != null) {return cacheData;}// 2. 缓存未命中,查询数据库List<Ticket> tickets = ticketMapper.selectByLineId(lineId);// 3. 转换为 DTO 并放入 RedisList<TicketDto> 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="lockSeat">UPDATE 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 引擎默认支持),不需要我们在代码里加 synchronizedReentrantLock。对于高并发场景,数据库的行锁比应用层的 JVM 锁更可靠,因为它不依赖单机。

运行与测试:如何验证性能

代码写完只是第一步,必须通过测试来验证性能优化是否生效。

1. 本地运行

确保 MySQL 和 Redis 服务已启动。修改 application.yml 中的连接配置:

spring:datasource:url: jdbc:mysql://localhost:3306/gz_ticket?useUnicode=true&characterEncoding=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 缓存让查询接口轻快如风。
  • 数据库原子操作保证了数据的一致性,杜绝超卖。
  • 压力测试是检验优化效果的唯一标准。

搭建一个广州市公路客运网上售票系统,不仅是写代码,更是学习如何设计一个健壮、可扩展的后端架构。这些技术点(缓存、锁、索引、日志)在任何电商、票务、金融系统中都是通用的。

你在项目里踩过这个坑吗?比如缓存穿透导致数据库宕机,或者并发下数据不一致?评论区聊聊,我们一起拆解解决方案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/22 22:15:01

一文搞懂如何在电脑上安装打印机:从驱动握手到数据流

一文搞懂如何在电脑上安装打印机:从驱动握手到数据流 学会语法却不知怎么搭项目,这是无数技术人的通病。你背熟了TCP/IP模型,却在本地网络里卡住,连个打印机都配不通。其实, 如何在电脑上安装打印机 并非简单的“即插即用”,而是一场精密的系统级对话。本文旨在 一文搞懂…

作者头像 李华
网站建设 2026/9/22 22:14:41

广州大学城论坛高并发优化实战:3个最佳实践搞定卡顿

广州大学城论坛高并发优化实战:3个最佳实践搞定卡顿 别翻那几百页的官方架构文档了,直接看这里。 很多刚毕业的学弟学妹接手广州大学城论坛这类校园社区后端时,第一反应是查文档。结果发现文档写得像天书,什么“高可用架构”、“服务网格”、“分布式事务”,看得人头晕眼花,根本抓不住重点。…

作者头像 李华
网站建设 2026/9/22 22:14:40

ENFP型人格算法解析:一文搞懂核心逻辑与实战避坑

ENFP型人格算法解析:一文搞懂核心逻辑与实战避坑 版本升级后 API 全变了,是不是让你瞬间头皮发麻?别急,这种“面目全非”的焦虑在编程圈太常见了。今天咱们不聊虚的,直接拆解【enfp型人格】这个看似玄学实则极具工程价值的概念。很多人以为它只是星座里的“快乐小狗”,但在高并发推荐系统和个性化交互设…

作者头像 李华
网站建设 2026/9/22 22:14:20

3个致命坑让你手机封面实战项目上线就崩

3个致命坑让你手机封面实战项目上线就崩 看了一堆教程还是不会写项目?别慌,这不是你的问题,是教程没带你踩过坑。我见过太多转岗的兄弟,语法背得滚瓜烂熟,一做手机封面相关的实战项目,上线第二天就收到用户投诉:图片裂了、加载慢得像蜗牛、换行还错乱。手机封面看似简单,实则藏着大量移动端适配的深坑。今天不讲虚…

作者头像 李华
网站建设 2026/9/22 22:14:14

胸肌上部怎么练饱满源码解析:面试突击避坑指南

胸肌上部怎么练饱满源码解析:面试突击避坑指南 报错一堆看不懂 StackTrace?别慌,这就像你练胸肌只练了中缝,上部空得能塞进拳头,看着就不专业。今天咱们不聊虚的,直接拆解【胸肌上部怎么练饱满】背后的逻辑,用【源码解析】的思路,把那些让人头大的技术难点掰开了揉碎了讲清楚。…

作者头像 李华
网站建设 2026/9/22 22:14:07

韩语零基础入门实战:搞定高频面试题里的性能瓶颈

韩语零基础入门实战:搞定高频面试题里的性能瓶颈 你是不是也遇到过这种情况:从网上复制了一段韩语发音合成或文本处理的代码,本地跑起来报错一片,或者速度慢得像蜗牛,完全不知道该怎么调?别急,这不仅仅是韩语学习的问题,更是编程性能优化的经典场景。在准备后端或算法岗位的高频面试题时,处理多语言文本的效率往往…

作者头像 李华