Spring Boot汽车租赁系统:状态机与并发控制实战解析
2026/9/11 22:58:25 网站建设 项目流程

简介:这是一套基于Spring Boot和Vue的汽车租赁管理系统源码,主要面向正在学习JavaWeb开发的学生、准备毕业设计的人员以及需要快速搭建业务后台的开发者。系统实现了用户注册登录、车辆信息发布与检索、在线租车下单、订单管理、后台数据统计等核心功能,前端页面使用Vue和ElementUI组件库构建,后端采用Spring Boot整合MyBatisPlus与MySQL数据库,整体结构清晰,适合作为前后端分离项目的入门范例。压缩包内共有六百五十五个文件,涵盖一百五十五个Java源文件、一百一十个Vue页面组件、四十一个JavaScript脚本、二十个XML配置,以及大量SVG图标、JPG图片、PNG素材和CSS样式文件;此外还包含bat启动与构建脚本、项目配置文件与使用说明,资源包整体大小为二十三点三一兆字节。目前该资源已有133人学习下载,内容具有一定参考价值。通过这份源码,读者可以快速掌握汽车租赁系统的业务设计逻辑、接口调用方式以及后台管理界面的实现思路,同时也可以根据自己的需求进行二次开发或改造,是一份实用性较强的项目参考。

1. 车钥匙背后的状态机:Spring Boot 汽车租赁系统到底在管什么

汽车租赁系统,本质上就是要把"哪辆车、被谁、从什么时候、用到什么时候"这四件事,变成可以被数据库和代码共同约束的规则。一辆车停在停车场里,状态是"可租";客户手机上下单,状态要立刻变成"已锁";钥匙交出去的那一刻,状态变成"已取车";还车回来做完检查,状态才回到"可租"。任何一个环节出问题,要么车被重复租出去,要么订单金额对不上。基于 Spring Boot 的汽车租赁管理系统,核心工作就是把这一套流程用 Java 代码落地成可维护的业务系统。下文从领域模型开始讲,逐步给出可以直接用的建表 SQL、JPA 实体、订单状态机和并发控制代码,最后落到 Java 面试点和线上自查清单上,适合想自己搭一套租车管理后台、或者正在准备 Spring Boot 岗位面试的开发者。

2. Spring Boot 汽车租赁系统的领域模型与数据库设计

2.1 租车业务的四个核心领域对象

做任何一个业务系统,第一步都是把"业务里经常提到的词"翻译成"代码里能落地的对象"。汽车租赁这个场景里,最常见的词是:客户、车辆、订单、结算。

  • 客户(Customer):租车的人,至少要记录姓名、手机号、驾照编号。手机号在真实系统里通常做唯一索引,因为它是找回账号和发送提醒的主要凭证。
  • 车辆(Car):车的品牌、型号、车牌号、每日租金、当前状态。状态字段是整个系统最重要的数据,后面所有并发控制都围着它转。
  • 租赁订单(RentalOrder):一次租赁行为的完整记录,关联客户和车辆,包含预计取车时间、预计还车时间、实际取车时间、实际还车时间、订单状态。
  • 结算记录(Payment):订单完成后生成的费用明细,包含租期天数、租金、押金、违约金。

一个常见的错误是把"车辆状态"直接写死在订单里,比如给 car 表加一个 status 字段,然后在业务代码里到处 if-else 修改。这样做的后果是:两个人同时下单同一辆车时,两个事务都读到"可租",都执行 update,结果一辆车被租给两个人。正确做法是先建订单,再通过订单的状态去推动车辆状态的变更,并且用数据库行锁来保证同一时刻只有一个事务能修改车辆状态。

在设计表结构时,还要区分"业务主键"和"代理主键"。rental_order 表里建议保留两个键:id 是数据库自增主键,order_no 是业务编号,给客户看、给客服查的都用 order_no。order_no 要保证唯一,它的生成规则放在应用层,不能用 UUID 直接当主键,因为 UUID 的无序性会导致 InnoDB 主键索引频繁页分裂,写入性能在数据量上来之后下降明显。

2.2 建表 SQL:把订单和车辆分开建模

下面是三张核心表的建表语句,MySQL 8.0 语法。为了控制篇幅只保留最关键字段,实际项目中需要加上 created_at、updated_at 这样的审计字段。

CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, license_no VARCHAR(30) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB; CREATE TABLE car ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(20) NOT NULL UNIQUE, brand VARCHAR(30), model VARCHAR(30), daily_rate DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '0-dirty 1-available 2-rented 3-maintenance', version INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_status (status) ) ENGINE=InnoDB; CREATE TABLE rental_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, customer_id BIGINT NOT NULL, car_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待取车 1-已租出 2-已还车 3-已取消', expected_pickup_time DATETIME NOT NULL, expected_return_time DATETIME NOT NULL, actual_pickup_time DATETIME, actual_return_time DATETIME, total_amount DECIMAL(10,2), deposit_amount DECIMAL(10,2) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_customer (customer_id), KEY idx_car (car_id), KEY idx_status (status), CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customer(id), CONSTRAINT fk_order_car FOREIGN KEY (car_id) REFERENCES car(id) ) ENGINE=InnoDB;

这几张表的关联逻辑是:订单表通过 customer_id 和 car_id 两个外键,把客户与车辆关联起来。外键在互联网高并发场景下经常被吐槽影响写入性能,但在租车门店这种每天几千单、几十个门店的体量下,外键带来的数据一致性保障远大于性能损失。如果你的系统要做到全国联网、高峰每秒上千个下单请求,再考虑去掉外键,把关联逻辑上移到应用层。

car 表里特意加了一个 version 字段,配合 JPA 的 @Version 注解做乐观锁。它的作用是:每次 update 时,SQL 会自动变成update car set status = ?, version = version + 1 where id = ? and version = 旧值,影响行数为 0 就说明有并发冲突。租车场景里乐观锁和悲观锁各有适用位置,后面第三章、第四章会分别展开。

2.3 时间字段的精度选择:不要用 timestamp 存预计还车时间

租车系统的核心数据是时间。什么时候取车、什么时候还车,直接决定租金计算。这里有一个实际项目里踩过的坑:如果预计还车时间精准到分钟,而实际还车时间精确到秒,结算时计算天数会因为精度不一致出现 0.1 天的偏差。建议所有业务时间字段统一用 DATETIME(0),程序里用 LocalDateTime 与之对应,计算天数时用 ChronoUnit.DAYS 取整。

另一个容易踩的坑是时区。MySQL 的 DATETIME 不带时区信息,如果应用服务器和数据库服务器时区不一致,订单的取车时间可能会差 8 个小时。最常见的做法是:JDBC 连接串里显式指定 serverTimezone=Asia/Shanghai,应用层全部使用 Asia/Shanghai 时区,数据库不主动转换。时间类型选择可以参考下面这个对比:

数据类型精度时区适用场景
DATETIME(0)订单取还车时间、创建时间
TIMESTAMP自动转换尽量别用,容易踩时区坑
DATETIME(3)毫秒日志、审计流水

索引设计上,rental_order 表给 customer_id、car_id、status 分别建了单列索引。很多人会问为什么不建联合索引,比如 (car_id, status),这个问题要结合查询模式回答:查"某辆车未来一个月有哪些订单"用 (car_id, expected_pickup_time) 更合适;查"所有待取车的订单"用 status 单列索引就够。联合索引要等业务查询模式固定之后再优化,起步阶段单列索引更好维护,遇到慢 SQL 时再用 EXPLAIN 针对性加索引。

3. 用 Spring Data JPA + MySQL 把核心租赁流程跑通

3.1 实体类与枚举定义

表建好之后,在 Spring Boot 工程里建对应的实体类。推荐用 Lombok 减少 getter/setter 样板代码,实体上用 @Entity 标记,枚举用 @Enumerated(EnumType.STRING) 存字符串,这样数据库里直接能看到 AVAILABLE、RENTED 这样的文本,排错时不用翻字典表。

@Entity @Table(name = "car") @Getter @Setter public class Car { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "plate_no", unique = true) private String plateNo; private String brand; private String model; @Column(name = "daily_rate") private BigDecimal dailyRate; @Enumerated(EnumType.STRING) private CarStatus status; @Version private Integer version; }

@Version 是 JPA 里做乐观锁的关键注解,它对应 car 表里的 version 字段。每次更新时,JPA 会自动生成带where id=? and version=?的 update 语句,如果更新影响行数为 0,直接抛 OptimisticLockException。这比手动在 SQL 里写版本判断要省很多事,而且不容易写漏。

订单实体同样用枚举表示状态,注意订单里的客户和车辆引用要用 @ManyToOne(fetch = FetchType.LAZY),避免查询订单时把车辆和客户的信息全部加载出来。很多人会忽略这个细节,结果一次简单查询把三张表全 join 了,线上接口越跑越慢。

3.2 Repository 层的查询方法

Spring Data JPA 的优势是方法名解析查询,不需要写实现类。下面这个 repository 同时展示了两个典型场景:加锁查询和条件列表查询。

public interface CarRepository extends JpaRepository<Car, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select c from Car c where c.id = :id") Optional<Car> findByIdForUpdate(@Param("id") Long id); List<Car> findByStatusAndDailyRateLessThanEqual(CarStatus status, BigDecimal maxRate); }

findByIdForUpdate 方法上的 @Lock(LockModeType.PESSIMISTIC_WRITE) 会让 JPA 在执行查询时追加for update,这条 SQL 在 InnoDB 下会对 car 表的这一行加互斥锁。这个锁的意义是解决并发下同一辆车被重复租出的问题,下一节结合订单创建代码详细说明。

findByStatusAndDailyRateLessThanEqual 则是典型的用户端查询场景,给用户展示"当前可租、且日租金不超过预算"的车辆列表。方法名即查询条件,Spring Data JPA 会自动拼接 SQL,不需要手写 JPQL。这里要注意,方法名越长生成的 SQL 越复杂,如果超过三个条件,建议直接写 @Query,可读性更好。

3.3 创建订单的服务层代码

订单创建是整个系统里最核心的写操作,需要同时完成三件事:查车辆是否可租、创建订单、把车辆状态改为已租。这三步必须在一个事务里,任何一步失败都要回滚。

@Service @RequiredArgsConstructor public class RentalOrderService { private final CarRepository carRepository; private final RentalOrderRepository orderRepository; private final CustomerRepository customerRepository; @Transactional public RentalOrder createOrder(Long customerId, Long carId, LocalDateTime pickupTime, LocalDateTime returnTime) { Customer customer = customerRepository.findById(customerId) .orElseThrow(() -> new BusinessException("客户不存在")); // 行锁:同一时间只有这个事务能读到并修改该车辆 Car car = carRepository.findByIdForUpdate(carId) .orElseThrow(() -> new BusinessException("车辆不存在")); if (car.getStatus() != CarStatus.AVAILABLE) { throw new BusinessException("车辆当前不可租,状态:" + car.getStatus()); } if (!returnTime.isAfter(pickupTime)) { throw new BusinessException("还车时间必须晚于取车时间"); } long days = ChronoUnit.DAYS.between(pickupTime.toLocalDate(), returnTime.toLocalDate()); if (days <= 0) { throw new BusinessException("租期至少为1天"); } RentalOrder order = new RentalOrder(); order.setOrderNo(generateOrderNo()); order.setCustomer(customer); order.setCar(car); order.setStatus(OrderStatus.PENDING_PICKUP); order.setExpectedPickupTime(pickupTime); order.setExpectedReturnTime(returnTime); order.setDepositAmount(car.getDailyRate().multiply(BigDecimal.valueOf(2))); order = orderRepository.save(order); car.setStatus(CarStatus.RENTED); carRepository.save(car); return order; } private String generateOrderNo() { return "RO" + System.currentTimeMillis() + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); } }

代码里值得注意的点有三个。第一个是更新车辆状态时,没有写任何二次 if 判断,因为 findByIdForUpdate 拿到的行锁已经保证了在事务提交前其他事务读不到这一行,所以校验只在查询结果上做一次即可。第二个是租期计算用的是取车日和还车日之间的天数差,比如 9 月 1 日取、9 月 3 日还,按 2 天算租金;如果订单时间精确到小时,某些租车公司按 24 小时为一天,那就要把 ChronoUnit.HOURS 除以 24 并向上取整。第三个是押金,这里简单写了日租金的 2 倍,实际项目中要根据车型价值动态调整。

提示:如果不用悲观锁,也可以用"条件更新"的思路——先执行update car set status = 'RENTED' where id = ? and status = 'AVAILABLE',然后检查影响行数。如果影响行数为 0 说明车辆已被抢走,直接抛异常。这种写法本质上是通过条件更新实现乐观锁,比上面的悲观锁方案少一次 select,但是失败后缺少车辆状态信息,排错时不太方便。

4. 租赁订单状态机:从预订到还车的状态流转与并发控制

4.1 订单状态定义与转换规则

租车订单不是建完就不变了,它会经历一系列状态变化。我见过把状态写成 0、1、2 然后散落在 service 方法里的写法,后期新增一个"订单取消"需求,改来改去很容易漏掉某个 if 分支。更可控的做法是定义一个状态机枚举,把每个节点允许跳转的目标状态限制住。

public enum OrderStatus { PENDING_PICKUP { @Override public List<OrderStatus> allowedTransitions() { return List.of(RENTED, CANCELLED); } }, RENTED { @Override public List<OrderStatus> allowedTransitions() { return List.of(RETURNED, CANCELLED); } }, CANCELLED { @Override public List<OrderStatus> allowedTransitions() { return List.of(); } }, RETURNED { @Override public List<OrderStatus> allowedTransitions() { return List.of(); } }; public abstract List<OrderStatus> allowedTransitions(); public boolean canTransitionTo(OrderStatus target) { return allowedTransitions().contains(target); } }

用枚举实现状态机的好处是,非法转换在编译期就能暴露一部分,运行期只要写一个通用方法统一校验:

OrderStatus current = order.getStatus(); if (!current.canTransitionTo(target)) { throw new BusinessException(String.format("订单不能从 %s 流转到 %s", current, target)); }

把这个能力封装成订单服务里的一个 private 方法,每次状态修改前调用。客户端的按钮是否可用、接口是否返回错误,都由这一处校验决定,不会出现"订单已取消还能还车"这种逻辑漏洞。

状态机的边界条件也要想清楚。比如 PENDING_PICKUP 可以变更为 RETURNED 吗?实际业务中可能客户忘记点击取车,直接来还车,这时候运营人员需要能手动修正状态。所以状态机的规则不是死的,设计时要给运营留一个"人工干预"的入口,通常加一个 OPERATOR_ADJUSTED 状态,或者直接记录操作日志后允许指定状态间强制跳转。

4.2 并发压测下订单接口怎么防重

上面 3.3 节用了悲观锁来保证同一辆车不会被同时租给两个人。这里补充一个压测时的观察点:如果不对车辆行加锁,直接用 findById 加状态判断加更新,在 50 个并发线程同时创建订单时,因为事务隔离级别默认是 REPEATABLE_READ,部分事务会先读到 AVAILABLE 快照,之后各自执行 update,最后一个提交成功,其他事务会因为版本冲突或行锁等待而失败。失败方式不同,对应到接口层面的表现也不一样:

并发手段失败表现适用场景
悲观锁 for update事务排队,后面的请求超时或返回可重试错误高频下单,必须即时反馈
乐观锁 version抛 OptimisticLockException,业务侧提示用户重试低频修改,冲突概率低
条件更新 where status='AVAILABLE'影响行数为 0,直接提示已租出状态字段单一,无需回读信息

从代码可维护性来看,租车系统建议直接用悲观锁,因为一次下单涉及车辆状态变更和订单创建两个写操作,悲观锁把资源竞争的等待时间控制在数据库层面,业务代码不需要处理重试逻辑。但要注意,悲观锁在事务里持续持有行锁,事务时间越长,其他请求等待越久,所以不要在持有锁的范围内做远程调用或短信发送这类 IO 操作。

4.3 还车结算与超时订单自动取消

还车操作是另一个核心写操作,要做的事包括:计算实际租金、把车辆状态改回 AVAILABLE、标记订单为 RETURNED。计算租金时,实际还车时间可能晚于预计还车时间,这时要额外收取超时费。

@Transactional public RentalOrder finishOrder(Long orderId) { RentalOrder order = orderRepository.findById(orderId) .orElseThrow(() -> new BusinessException("订单不存在")); if (!order.getStatus().canTransitionTo(OrderStatus.RETURNED)) { throw new BusinessException("当前状态不能还车"); } Car car = order.getCar(); LocalDateTime now = LocalDateTime.now(); order.setActualReturnTime(now); long rentDays = ChronoUnit.DAYS.between( order.getExpectedPickupTime().toLocalDate(), now.toLocalDate()); BigDecimal total = car.getDailyRate().multiply(BigDecimal.valueOf(rentDays)); if (now.isAfter(order.getExpectedReturnTime())) { long overdueDays = ChronoUnit.DAYS.between( order.getExpectedReturnTime().toLocalDate(), now.toLocalDate()); BigDecimal overdueFee = car.getDailyRate() .multiply(BigDecimal.valueOf(overdueDays)) .multiply(BigDecimal.valueOf(0.5)); total = total.add(overdueFee); } order.setTotalAmount(total); order.setStatus(OrderStatus.RETURNED); car.setStatus(CarStatus.AVAILABLE); carRepository.save(car); return orderRepository.save(order); }

超时费按日租金的 50% 每天收取,这个费率只是示例,具体数值由门店运营规则决定。now.toLocalDate() 和 expectedPickupTime.toLocalDate() 之间的天数差,注意如果只是晚了不到一天,可能不构成超时费或者应该按小时计费。实际项目中建议把"超时不足一天是否收费"做成配置项,存到数据库配置表里,避免每次改规则都要发版。

订单创建后客户可能一直不来取车。系统需要定时扫描超时订单,自动取消并释放车辆。这类任务用 Spring 自带的 @Scheduled 就能实现,扫描条件之外必须加 limit,避免一次捞太多数据造成内存压力。

5. 汽车租赁系统 Java 代码里的高频面试点与性能自查

5.1 这套代码里的 6 个 Java 面试考点

标题里带了"java代码"这个关键词,很多读者也是在准备 springboot 和 java 面试。租车系统是一个非常适合考察 Java 功底的项目,下面几条是面试和实际排查都绕不过去的:

Java 知识点出现在本系统的位置面试怎么讲
乐观锁与悲观锁Car 实体的 @Version、findByIdForUpdate对比两种锁在订单并发下的差异和取舍
事务传播机制@Transactional 在 createOrder 上的行为嵌套方法事务如何合并、REQUIRES_NEW 什么时候用
数据校验与异常处理BusinessException、参数校验如何统一处理业务异常与系统异常
ConcurrentHashMap门店车辆状态缓存容器为什么不用 Hashtable,分段锁如何演变
LocalDateTime租期计算与时间比较对比 Date、Calendar 的线程安全与 API 设计
Spring 生命周期启动时加载车辆基础数据InitializingBean 和 @PostConstruct 的区别与顺序

事务传播机制是高频面试题,对照这个系统的实际场景:3.3 节的 createOrder 方法标记了 @Transactional,如果后续引入一个 sendSms 方法也标上了 @Transactional,那么 sendSms 里抛出的异常会导致整个订单回滚,但短信已经发出去了,数据不一致。正确做法是把 sendSms 设置成 REQUIRES_NEW,或者去掉事务注解让它使用自己的事务环境。这个细节在租车确认短信、还车回访提醒这类需求里非常容易踩坑。

5.2 上线前必做的性能自查清单

提交 Java 代码之前,建议按下面顺序自查一遍。第一个是慢 SQL:打开 MySQL 慢查询日志,把超过 500ms 的 SQL 捞出来,重点看 rental_order 表的订单查询有没有走 idx_customer 和 idx_car 索引,检查方式是对查询语句执行 EXPLAIN,看 type 字段是 ref 不是 ALL。第二个是压测:用 vert.x 或者 JMeter 模拟 200 个并发下单,观察数据库连接池的活跃线程数和事务平均耗时,如果事务大量超时,优先检查是否所有写操作都加了合适的锁,而不是先加缓存。第三个是缓存边界:车辆列表可以缓存,但车辆状态和订单状态不能轻易缓存,一旦缓存了还车路径上的状态变更,就会出现订单已还车、页面还显示已租出的问题。第四个是定时任务:扫描超时未取车订单时加上 limit 条件和合理的 fixedRate 间隔,避免高峰期和下单接口抢数据库连接。

@Scheduled(fixedRate = 300000) @Transactional public void autoCancelUnpickedOrders() { List<RentalOrder> expiredOrders = orderRepository.findByStatusAndExpectedPickupTimeBefore( OrderStatus.PENDING_PICKUP, LocalDateTime.now().minusMinutes(30)); for (RentalOrder order : expiredOrders) { order.setStatus(OrderStatus.CANCELLED); Car car = order.getCar(); car.setStatus(CarStatus.AVAILABLE); carRepository.save(car); } }

这个定时任务里的 OrderRepository 需要提供对应的方法声明。在 Spring Data JPA 中,事务会为该方法中所有的 orderRepository.save() 提供一致的上下文,但要注意每次循环里都调用 save 并不是最优解。数据量大时改成批量 saveAll 更合理。另一个容易忽略的问题是定时任务默认单线程串行执行,如果 autoCancelUnpickedOrders 和另一个报表任务同时配置了 @Scheduled,它们会排队。需要并发执行时,在配置类上实现 SchedulingConfigurer 或者为任务单独指定线程池。

还有一个值得留意的点:多数据源场景下,如果租车系统把 MySQL 和 Redis 分开,@Transactional 只对数据源生效,Redis 操作不会跟着回滚。所以"先扣库存再发优惠券"这种跨存储写操作,要么用本地消息表做最终一致,要么在业务上接受 Redis 里有少量无人认领的优惠券。优惠券发放动作放到订单创建成功之后再异步处理,比放进同一个事务里更稳妥。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询