1. 项目缘起与挑战:为什么我们要“造轮子”?
最近几年,但凡聊到Java后端开发,尤其是面试或者技术分享,高并发系统设计几乎成了一个绕不开的话题。大家似乎都在谈微服务、谈分布式、谈缓存、谈消息队列,但真正把一个完整的高并发业务场景从头到尾、从零到一实现一遍的人,其实并不多。很多人可能看过很多文章,背过很多八股文,比如“Redis缓存雪崩怎么解决”、“如何设计一个秒杀系统”,但真让你动手写一个,可能还是会觉得无从下手,或者写出来的东西一压测就崩。
这正是我决定启动这个系列实战项目的初衷。与其空谈理论,不如找一个足够复杂、足够有挑战性的业务场景,用最新的技术栈,把它实实在在地做出来。而“12306售票系统”,无疑是一个绝佳的“靶子”。它几乎涵盖了高并发系统设计的所有核心痛点:瞬时超高流量、复杂的业务状态(车次、座位、席别)、严格的库存一致性、复杂的查询与计算(余票查询)、以及极致的响应速度要求。网上关于它的讨论很多,但大多是零散的架构图或者某个技术点的分析,很少有完整的、可运行的代码实现。
所以,这个系列的目标非常明确:我们将基于Spring Boot 3.0,从零开始,一步步构建一个简化但核心逻辑完整的仿12306售票系统。这不是一个简单的CRUD项目,而是一个深度探索Java高并发编程、分布式系统设计原理的实战沙盘。我会把我这些年在一线处理高并发问题的经验、踩过的坑、以及那些在官方文档里不会写的“野路子”技巧,都揉进代码和讲解里。
在正式敲下第一行代码之前,我们必须把地基打牢。很多人一上来就急着搭框架、写接口,结果写到一半发现核心模型设计错了,或者对并发问题的复杂性预估不足,导致推倒重来。这一篇,我们就来聊聊那些至关重要的“前置知识”。这些知识,是你能否理解后续每一行代码设计意图的关键。
2. 核心业务模型抽象:从现实车票到数据对象
设计系统,尤其是业务复杂的系统,第一步永远是理解并抽象领域模型。12306的业务看似直观——卖票,但其背后的数据关系错综复杂。我们不能直接照搬铁路系统的所有细节,但必须抓住最核心的实体和它们之间的关系。
2.1 核心实体定义
我们需要抽象出几个最关键的实体,并思考它们的属性和生命周期。
车次(Train):这是调度和运行计划的核心。一个车次(如G101)代表一趟具体的列车运行安排。
- 关键属性:车次号、始发站、终点站、出发时间、到达时间、运行日期、列车类型(高铁、动车、普快等)。
- 设计思考:车次是一个“模板”或“计划”。它本身不直接参与售票,但它定义了后续一切的基础。一个车次在特定日期会生成一个具体的“车次实例”。
车次实例(DailyTrain):这是真正可售卖的主体。因为G101车次每天都会运行,但2023年10月1日的G101和10月2日的G101是两趟不同的、独立的库存实体。
- 关键属性:关联车次ID、具体日期、一个最重要的字段——初始座位库存。这个库存需要根据列车类型(如16节车厢的复兴号)和席别(商务、一等、二等)在系统初始化时生成。
- 设计思考:这是库存管理的核心单元。所有的余票查询、扣减库存、座位选择,都是基于某个日期的某个车次实例进行的。必须将它与“车次”模板分离,否则库存无法按天管理。
座位(Seat)与车厢(Carriage):为了支持“选座”功能,我们需要更细粒度的库存管理,而不是简单地用一个数字表示“二等座还剩100张”。
- 关键属性(Seat):所属车次实例ID、车厢编号、排号、列号(如“05车10F”)、席别、座位状态(可用、已售、锁定等)。
- 关键属性(Carriage):车厢编号、车厢类型、定员数、座位排布规则(用于生成具体的座位记录)。
- 设计思考:用“座位”作为库存的最小单位,是实现“一人一票一座”和“选座”功能的基础。这比使用“余票数”计数器要复杂得多,因为它涉及到对单个座位的状态进行并发修改,是后续解决“超卖”问题的关键设计点。
余票(TicketStock):这是一个衍生数据或缓存数据。虽然我们有每一个座位,但每次查询余票时,实时统计某个车次实例下某个区段、某个席别的可用座位数,性能是无法接受的。
- 设计思考:我们需要一个独立的余票数据模型,它可能是:
- 数据库汇总表:定期或触发式更新某个车次实例-席别-出发站-到达站组合的可用座位计数。
- Redis缓存:将上述计算结果放入内存,提供毫秒级查询。这是高并发查询的标配方案。
- 核心难点:如何保证“座位”的真实状态与“余票”缓存数据的一致性?这是一个经典的缓存与数据库一致性问题。
- 设计思考:我们需要一个独立的余票数据模型,它可能是:
购票订单(TicketOrder):用户下单后的产物。
- 关键属性:订单号、用户ID、车次实例ID、出发站、到达站、乘车人信息、所选座位信息、订单状态(初始化、待支付、已支付、已完成、已取消等)、订单总价。
- 设计思考:订单状态机是整个购票流程的驱动核心。状态的变化(如“待支付” -> “已支付”)会触发库存的最终扣减、座位的最终占用。订单号必须全局唯一且具备业务含义(如包含日期、渠道等信息)。
购票记录(Ticket):支付成功后生成的最终票务凭证。
- 关键属性:票号、关联订单ID、乘客姓名、身份证号、车次、座位号、票价、状态(已出票、已改签、已退票等)。
- 设计思考:一张票是订单的最终履约产物。它与订单是多对一的关系(一个订单可能包含多张票)。票的生命周期独立于订单,例如改签、退票操作是针对票进行的。
注意:在实际的12306中,模型远比这复杂,涉及车站、线路、票价计算规则、学生票/儿童票等。在我们的简化版本中,我们会聚焦于最核心的“查票-选座-下单-支付”流程,因此上述模型已经足够支撑我们探索高并发核心技术。
2.2 模型关系与状态流转
理清实体后,我们需要用一张图(这里用文字描述)来串联它们的关系和关键业务流程的状态流转:
- 初始化流程:管理员创建
车次-> 系统在每天凌晨为未来N天的该车次生成车次实例-> 根据车次类型和车厢配置,为每个车次实例生成具体的座位记录 -> 初始化或更新余票缓存。 - 查询流程:用户输入条件 -> 系统查询
余票缓存(极快) -> 返回车次列表和余票数量。 - 购票流程(简化核心):
- 步骤A(占座):用户选择车次、席别、座位 -> 系统尝试锁定(Lock)选中的
座位记录(将其状态改为“锁定”),并扣减余票缓存。 - 步骤B(下单):锁定成功后,创建
购票订单,状态为“待支付”,并将锁定的座位与订单关联。这里会设置一个锁定时效(如15分钟)。 - 步骤C(支付):用户支付成功 -> 回调系统,将
购票订单状态改为“已支付” -> 将关联的座位状态改为“已售” -> 生成购票记录。 - 步骤D(超时释放):如果用户在锁定时效内未支付,定时任务会扫描状态为“待支付”且超时的订单 -> 将关联的
座位状态恢复为“可用” -> 增加余票缓存。
- 步骤A(占座):用户选择车次、席别、座位 -> 系统尝试锁定(Lock)选中的
这个流程中,从“步骤A”到“步骤C”,涉及对同一座位记录的多次状态修改(可用->锁定->已售),且可能被成千上万的请求同时操作,这就是高并发冲突最集中的地方。如何保证一个座位不被两个人同时买到(超卖)?如何保证缓存里的余票数和数据库里真实的可用座位数一致?这些问题是整个系统的“命门”。
3. 高并发核心难题拆解:库存一致性这座大山
理解了业务模型,我们就能清晰地看到技术挑战所在。所有挑战都围绕着一个核心:在每秒数万甚至数十万次请求下,如何保证车票库存数据(特别是座位状态)的绝对正确性?
3.1 超卖问题:并发写的终极挑战
“超卖”是指库存只有1件,但成功卖出了2件或更多。在我们的场景里,就是同一个座位卖给了两个人。这是电商、票务等系统的经典难题。
为什么在并发下会发生超卖?假设座位Seat#A状态为“可用”。两个用户几乎同时请求购买它。
- 请求1:查询
Seat#A状态,为“可用”。 - 请求2:查询
Seat#A状态,为“可用”。 - 请求1:执行
UPDATE seat SET status = '锁定' WHERE id = A AND status = '可用'。 - 请求2:执行同样的UPDATE语句。
在数据库层面,如果只是简单的UPDATE ... SET status = '锁定' WHERE id = A,那么两个请求都会执行成功,因为它们查询时状态都是“可用”,这就超卖了。
解决方案思路:
- 悲观锁:在查询时就直接用
SELECT ... FOR UPDATE锁定行。这能解决问题,但性能极差,会把并发操作彻底串行化,在超高并发下等于系统自杀。 - 乐观锁:给座位记录增加一个版本号字段
version。更新时带上版本条件:UPDATE seat SET status = '锁定', version = version + 1 WHERE id = A AND status = '可用' AND version = #{oldVersion}。这样,只有第一个更新能成功,第二个更新会因为version不匹配而返回0行受影响,从而失败。这是更优的选择。 - 分布式锁:在应用层,使用Redis或ZooKeeper等实现一个分布式锁,确保在同一时间,对同一个座位ID的写操作只有一个线程能执行。这引入了外部组件,增加了复杂度,但控制粒度更灵活。
- 数据库唯一约束:结合业务设计,例如将“车次实例日期+车厢+座位号”设置为唯一索引,并在创建订单或票记录时以此作为条件。如果两个请求试图插入相同的座位占用记录,后者会因唯一冲突而失败。
在我们的项目中,会综合使用乐观锁和唯一约束。在座位状态变更时使用乐观锁,在生成最终票务记录时利用唯一约束做最终防重。
3.2 缓存与数据库的一致性问题
为了应对海量查询,余票信息必须放在Redis里。但用户购票(写操作)最终修改的是数据库里的座位状态。这就产生了数据不一致的窗口期。
不一致场景示例:
- 数据库:座位
Seat#A状态为“可用”。Redis:余票数=100。 - 请求1:成功锁定
Seat#A,数据库将其状态改为“锁定”。 - 此时,如果Redis余票数没有及时更新,仍然是100。
- 请求2:查询余票,Redis告诉它还有100张,于是它继续走购票流程,尝试锁定另一个座位,但实际上可能已经没有真正可用的座位了(虽然数据库有锁定的,但用户看来就是有票)。这会导致大量请求穿透到数据库,造成不必要的压力,甚至引发误判。
解决方案思路(没有银弹,只有权衡):
- Cache Aside Pattern(旁路缓存):这是最常用的模式。
- 读:先读缓存,命中则返回;未命中则读数据库,写入缓存。
- 写:先更新数据库,再删除缓存。
- 为什么是删除而不是更新缓存?因为更新缓存可能引发并发写缓存的一致性问题,且删除操作是幂等的。在我们的场景,用户购票成功后,直接删除该车次该席别的余票缓存。下次查询时,缓存未命中,会从数据库重新计算并加载最新的余票数。这个方案简单,但在删除缓存后、新缓存建立前,会有短暂的数据不一致,并且可能引起“缓存击穿”(大量请求同时打到数据库)。
- 定期刷新:启动一个定时任务,每隔很短时间(如1秒)扫描数据库,重新计算所有车次的余票并刷新到Redis。这能保证最终一致性,且刷新间隔内的数据是准确的。但对数据库有持续压力,且存在最多1秒的延迟。
- 基于Binlog的异步刷新:使用Canal、Debezium等工具监听数据库的Binlog,当
seat表发生变更时,实时计算并更新Redis缓存。这能实现准实时的一致性,但对架构复杂度和运维要求较高。
在我们的项目中,初期会采用“Cache Aside + 异步刷新兜底”的策略。即写操作后主动删除缓存,同时有一个低频的定时任务(比如每10秒)做全量或增量刷新,作为防止缓存长期不一致的兜底机制。
3.3 高并发查询:余票查询的优化
即使解决了写一致性问题,读的压力依然巨大。春运期间,首页余票查询的QPS可能是天文数字。
核心挑战:
- 复杂计算:余票不是简单的
COUNT(*)。它需要计算指定区段的可用座位。例如,从北京到上海的车次,用户查“南京到苏州”的余票,需要计算那些全程中南京到苏州这一段未被售出的座位。这是一个复杂的空间计算。 - 实时性:数据必须尽可能新。
解决方案思路:
- 预计算:这是关键。我们不可能在每次查询时实时联表计算。必须在后台提前算好。
- 维度:为每个
车次实例,预计算所有可能出发站-到达站组合(站序在前的作为出发站)的每个席别的余票数。 - 存储:将计算结果以结构化的方式存入Redis。例如,Key设计为:
ticket_stock:{daily_train_id}:{start_station_index}:{end_station_index}:{seat_type},Value就是可用座位数或可用座位ID列表(如果要做选座)。 - 更新:当某个座位被售出或释放时,需要更新所有包含该座位区段的预计算结果。例如,座位被从南京卖到苏州,那么所有出发站序<=南京且到达站序>=苏州的区段,其对应席别的余票数都要-1。这是一个O(n)的操作,需要仔细设计数据结构和更新逻辑。
- 维度:为每个
- 多级缓存:
- L1 - 本地缓存(Caffeine/Guava Cache):在应用服务器内存中,缓存一些最热门的车次查询结果(如未来2小时内出发的热门车次)。设置很短的过期时间(如1秒),应对瞬时爆发流量。
- L2 - 分布式缓存(Redis):存储全量的预计算余票数据。
- L3 - 数据库:作为最终的数据源和兜底。
- 请求合并与降级:对于完全相同的查询请求,可以在应用层进行短时间(如10毫秒)的合并,将多个请求合并为一个去查询缓存或数据库,减少重复开销。当系统压力过大时,可以降级余票查询的实时性,比如返回稍早一些的缓存快照。
在我们的实现中,预计算+Redis存储将是余票查询的基石。我们会详细设计如何高效地存储和更新这些预计算数据。
4. 技术栈选型与Spring Boot 3.0的新特性
明确了业务挑战,我们来看看手中的“武器”。技术选型直接决定了实现的复杂度和系统的天花板。
4.1 核心框架:为什么是Spring Boot 3.0?
Spring Boot 3.0是一个重大升级版本,它基于Spring Framework 6.0,并要求最低Java 17。对于我们的高并发项目,它带来了几个关键好处:
- 原生支持GraalVM原生镜像:虽然我们初期不一定会用,但这是未来性能压榨的一个重要方向。Spring Boot 3.0对AOT(Ahead-Of-Time)编译提供了更好的支持,能生成启动更快、内存占用更小的原生应用,非常适合云原生和资源敏感场景。
- Java 17+特性:LTS版本的Java 17提供了很多现代语言特性,如
Records(用于定义数据模型,减少样板代码)、Sealed Classes(限制类的继承,增强领域模型的安全性)、Text Blocks(更好的多行字符串支持)等,能让我们的代码更简洁、更安全。 - 改进的观测性:Spring Boot 3.0深度集成了Micrometer,提供了开箱即用的可观测性(Observability)支持,包括指标(Metrics)、追踪(Tracing)和日志(Logging)。这对于我们后期监控系统性能、定位高并发下的瓶颈至关重要。
- ** Jakarta EE 9+**:命名空间从
javax.*迁移到了jakarta.*,这是面向未来的变化。虽然会带来一些依赖库的升级阵痛,但长远看是必要的。
注意:选择Spring Boot 3.0也意味着我们要面对其相对较新的生态。一些旧的、未更新的第三方库可能不兼容。但考虑到这是一个学习型项目,拥抱最新稳定版技术栈是值得的。
4.2 持久层:MyBatis-Plus vs. Spring Data JPA
这是一个经典的抉择。两者都是优秀的ORM/持久化框架。
- MyBatis-Plus:
- 优势:对SQL的控制力极强,可以精细优化每一条查询语句,这对于高性能、复杂查询的场景(如我们的余票预计算、复杂联表)非常有利。它的Wrapper查询条件构造方式也很灵活。国内生态活跃,文档丰富。
- 劣势:需要手动编写或生成XML映射文件,相较于JPA,开发速度略慢,类型安全性稍弱(虽然Wrapper改善了这一点)。
- Spring Data JPA:
- 优势:基于Hibernate,遵循JPA规范,通过方法名或
@Query注解即可完成大部分查询,开发效率高。强大的级联和对象关系管理。 - 劣势:对于极端复杂的自定义SQL,不如MyBatis直接。生成的SQL有时不够优化,在超高并发、需要精细控制数据库交互的场景下,可能成为性能瓶颈。N+1查询问题需要开发者小心规避。
- 优势:基于Hibernate,遵循JPA规范,通过方法名或
我们的选择:MyBatis-Plus。理由很直接:在这个项目中,性能和控制力是第一位的。我们需要精确地控制每一次数据库操作,尤其是那些涉及乐观锁更新、复杂条件查询的场景。MyBatis-Plus的UpdateWrapper可以非常方便地实现UPDATE ... WHERE ... AND version = ?这样的乐观锁更新。同时,它的代码生成器能快速生成实体、Mapper和基础XML,弥补了开发效率的短板。
4.3 缓存与分布式协调:Redis的核心角色
Redis在这个项目中不只是缓存,更是系统状态的缓冲区和分布式协调器。
- 缓存(Cache):
- 存储预计算的余票数据:使用Hash或String结构。考虑到需要按多种维度(车次、日期、席别、起止站)快速查询,可能会采用精心设计的Key和Hash组合。
- 存储热点数据:如车站信息、车次基本信息等。
- 分布式锁(Distributed Lock):
- 场景:虽然数据库乐观锁是核心,但在一些更复杂的业务流程(如“一个订单同时购买多张票,需要保证要么全成功,要么全失败”)或非数据库操作(如调用外部支付接口前做校验)中,可能需要一个应用层的全局锁。我们将使用Redis的
SET key value NX PX timeout命令来实现简单的分布式锁,并考虑使用Redisson客户端以获得更完善的锁功能。
- 场景:虽然数据库乐观锁是核心,但在一些更复杂的业务流程(如“一个订单同时购买多张票,需要保证要么全成功,要么全失败”)或非数据库操作(如调用外部支付接口前做校验)中,可能需要一个应用层的全局锁。我们将使用Redis的
- 消息队列(Message Queue):
- 场景:将一些非实时强一致的操作异步化。例如,用户支付成功后,更新订单状态、扣减库存、生成车票是同步操作,但发送出票通知短信、更新用户购票历史统计等可以异步处理。
- 选型:Redis的
Stream数据结构或者List+BRPOP可以作为一个轻量级的消息队列使用,适合我们这种项目内部、吞吐量不是极端高的场景。如果后续需要更强大的功能(如消息持久化、确认机制、死信队列),可以引入RabbitMQ或Kafka。我们初期会使用Redis Stream。
4.4 其他关键组件
- 数据库连接池:使用高性能的HikariCP,它是Spring Boot的默认选择,足够优秀。
- 数据库:MySQL 8.0。对于关系型数据存储,它是可靠的选择。我们需要合理设计索引(特别是对
daily_train_id,seat_status,carriage_index,seat_index等字段的组合索引),以及考虑未来可能的分库分表(虽然本系列可能不深入,但设计时要留有余地)。 - API文档:使用Spring Doc OpenAPI 3(Swagger UI)来生成和展示API文档,便于前后端协作和接口调试。
- 构建工具:Maven或Gradle。Spring Boot对两者支持都很好,选择自己熟悉的即可。
- 压测工具:JMeter。在后续篇章中,我们将用它来模拟高并发抢票场景,检验我们的系统是否真的扛得住。
5. 环境准备与项目初始化:从零搭建脚手架
理论说得再多,不如动手开始。让我们先把开发环境搭建起来,创建一个干净的Spring Boot 3.0项目骨架。
5.1 基础环境要求
确保你的开发机已安装:
- JDK 17或更高版本:这是Spring Boot 3.0的硬性要求。可以在命令行输入
java -version确认。 - Maven 3.6+ 或 Gradle 7.x+:用于依赖管理和项目构建。
- MySQL 8.0:安装并启动服务,创建一个空的数据库,例如
ticket_system。 - Redis 6.0+:安装并启动服务。
- IDE:IntelliJ IDEA(推荐)或 Eclipse with STS。
5.2 使用Spring Initializr创建项目
最快捷的方式是访问 start.spring.io ,选择以下配置:
- Project: Maven Project
- Language: Java
- Spring Boot: 3.0.x (选择最新的稳定版)
- Project Metadata:
- Group:
com.yourdomain(例如com.ticket) - Artifact:
ticket-12306 - Name:
ticket-12306 - Packaging: Jar
- Java: 17
- Group:
- Dependencies: 添加以下依赖:
- Spring Web(构建Web接口)
- Spring Data Redis(访问Redis)
- MyBatis Framework(Spring Boot官方对MyBatis的集成)
- MySQL Driver(连接MySQL)
- Lombok(减少Getter/Setter等样板代码,强烈推荐)
- Spring Doc OpenAPI(用于API文档)
点击“Generate”下载项目压缩包,解压后用IDE打开。
5.3 关键配置详解
项目创建后,我们需要配置application.yml(或application.properties)来连接数据库和Redis。
# application.yml server: port: 8080 spring: application: name: ticket-12306 # 数据源配置 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ticket_system?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password hikari: # 连接池配置,根据压测调整 maximum-pool-size: 20 minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 # Redis配置 redis: host: localhost port: 6379 # password: 如果你的Redis有密码 database: 0 # 使用0号数据库,生产环境建议为不同业务分配不同db lettuce: pool: max-active: 20 # 连接池最大连接数 max-idle: 10 min-idle: 5 max-wait: -1ms # 连接池最大阻塞等待时间(负值表示没有限制) # MyBatis配置 mybatis: # mapper.xml文件位置,如果放在resources下需要配置 mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 自动将下划线命名映射为驼峰命名 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL,调试用,生产环境关闭 # Spring Doc OpenAPI配置 springdoc: api-docs: path: /api-docs swagger-ui: path: /swagger-ui.html operations-sorter: method # 按HTTP方法排序配置要点解析:
- 数据库时区:
serverTimezone=Asia/Shanghai至关重要,避免日期时间数据出现时区错误。 - HikariCP配置:
maximum-pool-size不宜设置过大,通常建议是CPU核心数 * 2 + 有效磁盘数。数据库连接是宝贵资源,过大的连接池反而会降低性能。我们的初始配置比较保守,后续压测后再调整。 - Lettuce连接池:Spring Boot 2.x后默认使用Lettuce替代Jedis,它基于Netty,性能更好,支持异步。连接池配置同理,需要根据实际并发量调整。
- MyBatis下划线转驼峰:这是非常实用的配置,可以让数据库的
seat_status字段自动映射到Java实体类的seatStatus属性上。
5.4 引入MyBatis-Plus
Spring Initializr只提供了基础的MyBatis依赖。我们需要手动替换为功能更强大的MyBatis-Plus。在pom.xml中,找到mybatis-spring-boot-starter依赖,替换为:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> <!-- 使用与Spring Boot 3.0兼容的最新版本 --> </dependency>同时,移除可能存在的mybatis-spring-boot-starter依赖。MyBatis-Plus几乎完全兼容MyBatis的配置,所以之前的application.yml配置仍然有效。
5.5 创建核心包结构与第一个实体
良好的包结构是项目可维护性的基础。我们按功能模块进行划分:
src/main/java/com/ticket/ ├── Ticket12306Application.java ├── config/ # 配置类(Redis配置、MyBatis-Plus分页插件配置等) ├── controller/ # 控制器层 ├── service/ # 业务逻辑层 │ ├── impl/ # 业务逻辑实现类 ├── mapper/ # MyBatis Mapper接口层 ├── entity/ # 数据库实体类 ├── dto/ # 数据传输对象(用于API入参出参) ├── vo/ # 视图对象(用于返回给前端的数据封装) ├── enums/ # 枚举类(订单状态、座位状态等) ├── utils/ # 工具类 └── common/ # 通用类(常量、异常、响应封装等)现在,让我们创建第一个实体类Seat,并感受一下Java 17 Record和Lombok的便利。
首先,在enums包下创建座位状态枚举:
// com.ticket.enums.SeatStatusEnum package com.ticket.enums; import lombok.Getter; @Getter public enum SeatStatusEnum { AVAILABLE(0, "可用"), LOCKED(1, "已锁定"), SOLD(2, "已售出"), ; private final int code; private final String desc; SeatStatusEnum(int code, String desc) { this.code = code; this.desc = desc; } }然后,在entity包下创建Seat实体。这里我们展示两种风格:
风格一:使用传统的Lombok@Data注解(推荐,兼容性好)
// com.ticket.entity.Seat package com.ticket.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.ticket.enums.SeatStatusEnum; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("t_seat") // 指定对应的数据库表名 public class Seat { /** * 主键ID */ @TableId(type = IdType.AUTO) // 主键自增 private Long id; /** * 关联的每日车次ID */ private Long dailyTrainId; /** * 车厢编号(如:01, 02) */ private String carriageNumber; /** * 排号 */ private Integer rowNum; /** * 列号(如:'A', 'B', 'C', 'D', 'F') */ private String colNum; /** * 座位类型(如:商务座、一等座、二等座) */ private String seatType; /** * 座位状态:0-可用,1-锁定,2-已售 */ private Integer status; /** * 乐观锁版本号 */ private Integer version; /** * 创建时间 */ private LocalDateTime createTime; /** * 更新时间 */ private LocalDateTime updateTime; // 提供一个便捷的方法获取状态枚举 public SeatStatusEnum getStatusEnum() { return SeatStatusEnum.of(this.status); } // 需要在SeatStatusEnum中添加一个`of`方法,根据code返回枚举 }风格二:使用Java 17 Record(不可变,更简洁)
// 使用Record,但注意MyBatis-Plus等ORM框架对Record的支持可能不完善,需要测试。 // 这里仅作展示,当前项目我们仍使用传统的类+Lombok。 public record SeatRecord( Long id, Long dailyTrainId, String carriageNumber, Integer rowNum, String colNum, String seatType, Integer status, Integer version, LocalDateTime createTime, LocalDateTime updateTime ) {}实操心得:在现阶段,虽然Record很诱人,但考虑到MyBatis-Plus的代码生成器、动态SQL构造器(如
UpdateWrapper)对传统Java Bean的支持最成熟,我们选择使用@Data注解的方式。这能避免在集成过程中遇到不必要的麻烦。等生态更成熟后再迁移也不迟。
创建对应的Mapper接口:
// com.ticket.mapper.SeatMapper package com.ticket.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.ticket.entity.Seat; public interface SeatMapper extends BaseMapper<Seat> { // 继承BaseMapper后,基本的CRUD方法已自动拥有 // 可以在此定义自定义的复杂查询方法 }别忘了在启动类上添加@MapperScan注解,告诉MyBatis-Plus去哪里扫描Mapper接口:
// Ticket12306Application.java package com.ticket; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.ticket.mapper") // 添加这行 public class Ticket12306Application { public static void main(String[] args) { SpringApplication.run(Ticket12306Application.class, args); } }至此,一个最基础的项目骨架就搭建完成了。你可以运行启动类,如果控制台没有报错,并且能看到Spring Boot的启动Logo,说明环境基本没问题。下一篇文章,我们将开始设计数据库表,并利用MyBatis-Plus的代码生成器快速创建所有核心实体和Mapper,然后进入最激动人心的部分——实现第一个核心功能:车次与座位数据的初始化。