基于Spring Boot+Redis+MySQL的停车预约系统实战:从需求到并发锁位

发布时间:2026/9/7 19:25:08
基于Spring Boot+Redis+MySQL的停车预约系统实战:从需求到并发锁位 前阵子帮朋友小区做了一个停车预约系统需求很简单业主回家前先在手机上预约车位到车库后车位锁自动降下不用再兜圈找位子。技术选型方面用了Java系的主流方案项目从零搭到上线大概三周。现在把这个项目的完整思路和核心实现拆开来讲包括需求链路、数据库设计、并发锁位、车位锁联动这些关键环节给正在做类似Java实战项目的同学一个可参考的完整案例。1. 需求拆解停车预约的核心链路与隐藏难点先说结论这件事的难点根本不在“写一个预约接口”而在“预约之后的一整套状态流转”。如果只是做一个简单的订单表加个状态字段那基本没法在实际场景里用。一个完整的一键预约流程至少包含下面这条链路用户打开小程序或H5输入车牌号、选择停车时间段点击“一键预约”系统检查目标停车场在时间段内是否有空余车位有空位则锁定一个车位生成预约订单并下发指令给对应车位的地锁控制器地锁控制器执行降锁车位进入“已预约”状态用户到场后点击“入场”或系统通过摄像头识别车牌自动入场停车期间订单状态保持“使用中”出场时结算费用并释放车位这条链路上真正容易出问题的点是“并发抢同一个车位”和“超时未到达后的车位释放”。这两个问题如果在设计阶段不考虑后面线上就会陆续爆雷。1.1 核心角色模型与状态机在做任何代码之前先把角色和状态梳理清楚。我的设计用了三个核心实体用户端微信小程序/H5负责发起预约、查看订单、在线支付运营端后台管理页面负责查看车位占用情况、手动释放异常订单、设置收费标准设备端车位锁控制器接收降锁/升锁指令并回传状态订单的状态流转是这套系统的命脉。我用一个状态机把整个生命周期固定下来避免后面写业务逻辑时到处散落状态判断CREATED(已创建) → RESERVED(已锁定/等待入场) → ACTIVE(使用中) → FINISHED(已完成) ↘ CANCELLED(已取消) ↘ TIMEOUT(超时未入场自动释放)每个状态之间的流转条件都必须明确。比如“RESERVED”状态下如果用户超过预约时间15分钟仍未入场系统自动取消订单并降级为空闲车位。这个“超时释放”的逻辑看着简单实际上牵扯到定时任务、消息通知、设备联动三个模块的配合后面会专门讲。1.2 从需求到表结构用户视角 vs 系统视角我们习惯用“剩余车位”这个词来思考比如还剩12个空位。但从系统实现的角度“剩余车位”不是一个数字而是一组车位的集合。每个车位有自己的唯一编号和设备ID有自己的状态空闲/已锁定/使用中/维护中还有它在停车场里的物理位置。所以数据库设计时不要设计成“停车场表 余位数字段”这是很多新手项目常犯的错误。余位数字一旦有并发更新就很容易出错。正确的做法是停车场表记录基础信息和总车位数车位表记录每个车位的独立状态预约订单表记录每一次预约行为和状态流转结算表记录费用明细这三个核心表的关系是停车场 1:N 车位车位 1:N 预约订单。所有并发判断都基于车位这一行数据的行锁或乐观锁来做而不是基于一个聚合字段去做减法。2. 技术选型为什么是Spring Boot Redis MySQL这套组合项目技术栈确定之前我对比过几套方案包括用Node.js写后端、用Python Django快速实现、甚至考虑过用物联网平台托管设备接入。最后定的是现在这套原因是它最贴合“小区/商业停车场管理系统”这类中小型业务场景的开发效率和运维成本。后端框架Java 8 Spring Boot 2.7.xORM框架MyBatis-Plus 3.5.x分布式锁/缓存Redis 6.x单机即可不需要集群但部署时至少开AOF持久化数据库MySQL 8.x设备通信HTTP回调 Netty长连接地锁设备侧管理后台Vue 3 Element Plus用户端H5调用后端API可快速包壳成小程序2.1 Spring Boot为什么仍然是这个场景的稳妥选择虽然现在很多新项目在讨论微服务、云原生但这个系统规模就是中等偏小单体应用反而最合适。Spring Boot的价值在于生态成熟需要什么组件基本上都有开箱即用的整合包事务管理、定时任务、参数校验这些基础能力非常稳招聘市场上Java开发者的供给充足后续接手维护成本低用Java初看会感觉比脚本语言“繁琐”——定义实体类、写Mapper、写Service、写Controller但这套繁琐在业务规则变复杂后反而变成了保护。比如车位状态和订单状态的一致性维护用Spring的Transactional管理起来很清晰出错能回滚代码逻辑一眼能看懂。2.2 为什么在高频访问层放Redis停车预约系统虽然不算超大并发但有一个特点用户在某个时间点集中操作比如下班前10分钟而且热点集中在查询剩余车位和锁定车位这两个动作上。查余位如果不加缓存每次请求都去数据库COUNT车位多了之后查询性能是浪费的锁车位借助Redis的原子操作实现分布式锁避免MySQL行锁竞争太激烈我的设计是每个车位在Redis里以一个Hash结构存储key是parking:slot:{slotId}field包括status、currentOrderId、expireTime。查询余位时直接SCAN或维护一个parking:available:slots的Set集合锁位时用SETNX实现租约锁。数据库的实时状态通过异步双写保持一致不会把压力压在数据库侧。这个结构也方便后续做缓存预热、定时巡检、设备状态同步。Redis的TTL机制还能天然模拟“超时自动释放”的语义——预约时设置过期时间Redis自动把锁释放掉这是数据库方案不容易做到的。2.3 设备通信为什么不用纯MQTT本来按惯性思维设备接入应该用MQTT或EMQX这种物联网消息中间件。但实际考察之后发现小区里用的这类地锁设备很多是走自定义TCP协议或HTTP轮询的真正支持标准MQTT的少。最终采用的做法是对设备侧提供HTTP接口的回调地址同时保留一个Netty长连接通道用于下行指令。这种“双通道”设计的好处是HTTP回调逻辑简单、好调试Netty通道则保证降锁指令能实时推送到设备。地表层的硬件型号各异但这一点抽象之后对上层完全透明。当然如果你接的设备是标准MQTT协议也可以把Netty替换成MQTT客户端核心业务逻辑不变。3. 数据库设计车位、订单与一次数据丢失的排查数据库设计这块我踩过一个比较大的坑花了一个晚上定位。先说结论**订单号到底该用自增主键还是该用业务唯一号**这个问题的答案直接影响整套系统的并发安全和排查效率。3.1 核心表结构与字段规划先给出最终的表结构参考。这是我在实际项目中经过好几轮迭代后稳定下来的方案。CREATE TABLE parking_lot ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, lot_name VARCHAR(64) NOT NULL COMMENT 停车场名称, address VARCHAR(128) DEFAULT NULL, total_slots INT NOT NULL DEFAULT 0 COMMENT 总车位数, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态0-停用1-启用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT停车场表;CREATE TABLE parking_slot ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, lot_id BIGINT NOT NULL COMMENT 所属停车场ID, slot_code VARCHAR(32) NOT NULL COMMENT 车位编号例如A-001, device_sn VARCHAR(64) DEFAULT NULL COMMENT 地锁设备序列号, status TINYINT NOT NULL DEFAULT 0 COMMENT 车位状态0-空闲1-已预约2-使用中3-维护, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_lot_slot (lot_id, slot_code), KEY idx_lot_status (lot_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT车位表;CREATE TABLE parking_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL COMMENT 用户ID, lot_id BIGINT NOT NULL, slot_id BIGINT NOT NULL COMMENT 车位ID, plate_number VARCHAR(16) NOT NULL COMMENT 车牌号, status VARCHAR(20) NOT NULL COMMENT 订单状态RESERVED/ACTIVE/FINISHED/CANCELLED/TIMEOUT, start_time DATETIME NOT NULL COMMENT 预约开始时间, end_time DATETIME DEFAULT NULL COMMENT 预约结束时间, amount DECIMAL(10,2) DEFAULT 0.00 COMMENT 金额, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, cancel_reason VARCHAR(128) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_slot_status (slot_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约订单表;这套表结构最大的特点是把车位状态和订单状态各自独立但通过slot_id、order_no建立关联。这样无论从车位维度查询还是从用户维度查询SQL都简单高效。3.2 订单号生成策略一场“自增主键”引发的数据丢失第一版我图省事直接让parking_order表用自增ID当订单主键。结果上线当天就遇到一个诡异问题用户明明预约成功了但查看订单详情时提示“订单不存在”。查日志发现问题的根源在于我用了Long类型的自增ID做主键而前端H5页面在展示订单号时用了JavaScript的Number类型去承载。JavaScript的Number安全整数范围是2^53 - 1超过这个范围后精度会丢失。虽然我们的订单量远没到这个级别但自增ID生成的趋势是有迹可循的某些ID会因为精度丢失导致查询匹配不上。这个坑提醒了我业务系统中一定要有独立的业务订单号不要用数据库自增ID作为暴露给前端的单据号。最终方案是public String generateOrderNo(String lotCode) { // 前缀P 表示停车 日期 随机数 后缀 return P DateTimeFormatter.ofPattern(yyyyMMddHHmmss).format(LocalDateTime.now()) lotCode String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); }这个生成逻辑不依赖数据库也不会重复并发量不大时时间戳随机数足够。如果再严谨一点可以加Redis自增序列做全局递增但我这个场景没有必要。3.3 数据一致性为什么我放弃了外键和复杂的存储过程很多学计算机刚接触数据库设计的同学喜欢在表之间加外键约束觉得这样“严谨”。但在实际项目中我从不加外键原因有两个外键会让每次插入、删除都做额外的一致性检查在业务侧已经通过代码保证引用关系时外键就是纯性能浪费一旦后续做分库分表或数据归档外键就是最大的迁移障碍这个项目的所有数据一致性都用应用层事务控制。比如“预约下单”这个动作我会在一个事务里完成三步操作1. UPDATE parking_slot SET status 1, version version 1 WHERE id ? AND status 0 2. INSERT INTO parking_order ... 3. 通知设备降锁走异步不入当前事务第1步的WHERE status 0是关键。如果更新影响行数为0说明车位已经被别人抢走直接返回“车位已被预约”错误。这比先查询再判断再更新要安全得多避免了并发下的竞态条件。4. 核心接口实现预约、锁位与状态流转的完整代码逻辑接下来这部分是全文最核心的部分。我会从用户发起“一键预约”这个动作开始把从Controller到SQL的全链路代码逻辑拆开讲清楚。核心接口一共四个预约车位、订单详情、取消预约、超时释放。4.1 预约车位接口一次请求背后的七步走预约接口是整套系统技术含量最高的部分因为它同时涉及缓存、数据库事务、分布式锁和外部设备通信。先看最终控制器层代码RestController RequestMapping(/api/reservation) public class ReservationController { Autowired private ReservationService reservationService; PostMapping public ApiResultReservationVO reserve(Valid RequestBody ReservationCreateRequest request) { // 参数校验车牌号格式、时间范围、用户基础信息 if (!request.getPlateNumber().matches(^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{5,6}$)) { return ApiResult.error(400, 车牌号格式不正确); } return ApiResult.success(reservationService.createReservation(request)); } }Service层是核心。我特意把Redis分布式锁和数据库事务尽量分开避免“持锁做数据库操作”导致锁超时误释放。整个流程可以抽象成七步Slf4j Service public class ReservationServiceImpl implements ReservationService { private static final String SLOT_LOCK_KEY parking:slot:lock:%s; private static final String SLOT_INFO_KEY parking:slot:%s; Override public ReservationVO createReservation(ReservationCreateRequest request) { // 1. 查可用车位 ListSlotInfo availableSlots slotCacheManager.getAvailableSlots(request.getLotId()); // 2. 选择一个车位并尝试加Redis分布式锁 for (SlotInfo slot : availableSlots) { if (tryLockSlot(slot.getId(), request.getUserId(), 15 * 60)) { // 3. 已获取锁进入数据库事务 return doCreateOrder(slot, request); } } throw new BusinessException(当前时段没有可用车位请稍后重试或选择其他时间); } private ReservationVO doCreateOrder(SlotInfo slot, ReservationCreateRequest request) { try { return transactionTemplate.execute(status - { // 4. 数据库乐观锁更新车位状态 int updated parkingSlotMapper.lockSlot(slot.getId(), System.currentTimeMillis()); if (updated 0) { throw new BusinessException(很抱歉车位刚刚被其他用户预约了); } // 5. 创建预约订单 ParkingOrder order new ParkingOrder(); order.setOrderNo(generateOrderNo(slot.getLotCode())); order.setUserId(request.getUserId()); order.setSlotId(slot.getId()); order.setPlateNumber(request.getPlateNumber()); order.setStatus(RESERVED); order.setStartTime(request.getStartTime()); order.setEndTime(request.getEndTime()); parkingOrderMapper.insert(order); // 6. 更新Redis缓存中的车位状态 slotCacheManager.updateSlotStatus(slot.getId(), RESERVED, order.getOrderNo()); // 7. 异步通知地锁设备降锁 deviceNotifyService.notifyUnlockAsync(slot.getDeviceSn(), order.getOrderNo(), request.getPlateNumber()); return ReservationVO.from(slot, order); }); } catch (BusinessException e) { // 回滚缓存 slotCacheManager.releaseLock(slot.getId()); throw e; } } }看到这里你可能会问为什么Redis锁和MySQL乐观锁两个都用这是多了一层保护双保险Redis锁解决的是“同一个车位在一秒内被十个请求同时尝试预约”的缓存穿透问题MySQL乐观锁解决的是跨服务、跨进程的最终一致性兜底两个机制都失效的概率极低。同时由于Redis锁设置了15分钟过期即使设备回调迟迟不来也不会死锁卡死整个业务。4.2 WHERE status 0 的乐观锁到底解决了什么问题上面的parkingSlotMapper.lockSlot对应的SQL是UPDATE parking_slot SET status 1, version version 1, locked_at NOW() WHERE id #{slotId} AND status 0这里的关键点是status 0这个条件。正常的并发场景下两个用户同时发起预约MySQL的行锁保证两条UPDATE语句串行执行。第二个UPDATE执行时第一个已经将status改成了1因此第二个UPDATE影响行数为0就自然失败了。这种写法避开了“先SELECT后UPDATE”的check-then-act模式。如果先SELECT查出来是空闲再执行UPDATE中间一旦有别的请求抢先提交就会出问题。直接用条件UPDATE判断与执行变成原子操作是最简单也最可靠的做法。4.3 取消预约状态流转的“安全出口”用户实际使用中取消预约是一个高频操作。这里的业务规则是预约成功后30分钟内可以免费取消超过30分钟但离预约开始时间还有1小时以上的收取少量违约金其余情况不允许取消只能等系统超时释放。实现时我在订单表增加了一个cancel_reason字段。用户取消时的代码逻辑如下Transactional(rollbackFor Exception.class) public Boolean cancelOrder(String orderNo, Long userId) { // 1. 校验订单归属权 ParkingOrder order parkingOrderMapper.selectByOrderNo(orderNo); if (order null || !order.getUserId().equals(userId)) { throw new BusinessException(无权操作该订单); } // 2. 可取消时间判断 if (!canCancel(order)) { throw new BusinessException(当前时段不可取消预约); } long slotId order.getSlotId(); String lotId order.getLotId(); // 3. 更新订单状态 parkingOrderMapper.updateStatusById(order.getId(), CANCELLED); // 4. 释放车位车位表状态 parkingSlotMapper.releaseSlot(slotId); // 5. 更新Redis缓存 slotCacheManager.updateSlotStatus(slotId, FREE, null); // 6. 通知设备升锁异步 deviceNotifyService.notifyLockAsync(slotId); return Boolean.TRUE; }这里我特别注意了步骤3和4的顺序不能颠倒。必须先改订单状态再释放车位防止出现“车位被释放但订单还是RESERVED”的中间状态。4.4 超时释放定时任务 Redis过期通知的双保险超时未入场系统需要自动取消订单并将车位释放给下一位用户。这个功能一开始只有一个定时任务在跑Scheduled(cron 0 */1 * * * ?) // 每分钟执行一次 public void releaseTimeoutOrders() { LocalDateTime threshold LocalDateTime.now().minusMinutes(15); ListParkingOrder timeoutOrders parkingOrderMapper.selectTimeoutOrders(threshold); for (ParkingOrder order : timeoutOrders) { doCancelOrder(order, 超时未入场自动取消); } }这个方案有个明显缺陷如果任务运行时间间隔太长或者服务器宕机超时订单的释放就会延迟。后来我加了一层Redis过期事件通知车位被预约时在Redis里写入一个Key过期时间设置为“预约开始时间15分钟”Key过期后通过Redis的Keyspace Notification回调触发释放操作。两层机制互为冗余定时任务兜底Redis通知提高时效。这里有个小提示Redis的Keyspace Notification默认是关闭的要在配置文件里开启notify-keyspace-events Ex否则过期事件不会自动发送。提示notify-keyspace-events Ex代表只监听key过期事件。设置后记得重启Redis并在客户端订阅__keyevent0__:expired频道。这个机制在分布式场景下非常实用但不要依赖它做唯一的业务逻辑触发定时任务仍是必要的兜底方案。5. 智能联动与前端反馈车位锁降锁和一键预约的体验闭环预约下单之后用户最关心的就是“我到车位前锁到底降下去没有”。这块体验做得好不好直接决定项目口碑。这里我拆成两个部分讲设备联动层的设计思路以及用户端H5的信息反馈逻辑。5.1 设备降锁指令异步消息与回调确认预约成功后系统需要通过设备通道下发降锁指令。虽然用户在点击“预约”后希望马上看到“预约成功”的提示但地锁物理降下需要时间通常2-5秒。所以这里不能做同步等待必须走异步消息。我用的是一个简单的内存消息队列方案不引入额外的MQ中间件Component public class DeviceCommandQueue { private final ExecutorService executor Executors.newFixedThreadPool(8); private final MapString, CompletableFutureBoolean pendingCallbacks new ConcurrentHashMap(); public void sendUnlockCommand(String deviceSn, String orderNo, String plateNumber) { executor.submit(() - { // 组装协议并发送到设备或Netty通道 boolean sent deviceGateway.sendCommand(deviceSn, UNLOCK, orderNo, plateNumber); if (!sent) { // 发送失败补偿机制3秒后重试最多3次 retrySend(deviceSn, UNLOCK, orderNo, plateNumber, 3); } }); } public void onDeviceReport(String deviceSn, String orderNo, String status) { if (UNLOCKED.equals(status)) { // 设备报告已降锁更新Redis缓存状态 slotCacheManager.updateDeviceStatus(orderNo, UNLOCKED); } // 释放阻塞中的CompletableFuture CompletableFuture? future pendingCallbacks.remove(orderNo); if (future ! null) { future.complete(UNLOCKED.equals(status)); } } }设备回传的确认回调是整条链路“闭环”的关键。如果只有下发指令没有回传确认用户到了车位前才发现锁没降那就变成事故了。回调接口也做了超时重试和释放降锁指令发出后5分钟内没收到设备回执系统自动将订单标记为“异常联动”并在管理后台告警运营人员可以手动下发指令或直接电话联系用户。5.2 用户端H5的交互细节与错误提示优化用户端的体验上我总结了几个容易被忽略的细节车牌号输入框必须有智能联想和格式自动补全比如输入“沪A12345”时把“沪”和字母之间的分隔符自动处理掉提交预约按钮要做防重复点击处理避免用户手快点了两下生成两个订单预约成功后展示完整的入场指引包括车位编号、停车场入口照片、预约码/二维码如果车位被抢极端并发下页面不是冷冰冰的报错而是提示“当前车位刚刚被抢走了已为你自动筛选同楼层其他空闲车位是否一键改约”这些看似小的交互点实际对用户留存率影响非常大。我见过太多后端项目把精力全放在接口性能上忽略了用户到前端时的第一感知。预约类产品本质是“信任工具”用户体验越顺畅用户越愿意持续使用。5.3 车位锁联动异常的补偿方案设备联动出错是无法完全避免的。市面上的地锁设备质量参差不齐有的设备在弱网环境经常失联有的设备反馈延迟高达十几秒。所以一定要在业务侧做一套补偿方案。常规做法是在管理后台加一个“设备诊断”页面显示每个车位设备的最近心跳时间显示未处理的下发指令数和失败次数一键补发降锁/升锁指令支持将某个车位临时标记为“人工值守状态”改为由保安人工放行这套补偿机制虽然不复杂但能救回很多极端情况下的糟糕体验。我在停车场项目的二期迭代里专门花了三天做了这个管理功能上线后故障工单量下降了一半以上。6. 并发场景踩坑复盘分布式锁、幂等与状态一致性的实战总结项目上线一个月后迎来了第一次真正的并发高峰某个工作日下午6点半小区业主集中预约车位瞬时QPS冲到了之前的5倍。这次高峰暴露了三个问题逐一复盘如下。6.1 问题一Redis锁误删别人锁初版代码里释放锁时直接del lockKey。在某个请求持锁时间过长、锁自动过期后另一个请求拿到新锁这时第一个请求执行完业务去释放锁就把别人刚加的锁误删了。这个经典问题导致的是“两个用户同时拿到一个车位的锁”的严重事故。修复方式很标准释放锁前比对锁里的唯一标识UUID或orderNo是否和自己的一致。使用Lua脚本保证比对和删除的原子性private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end;加锁时写入UUID.randomUUID().toString()作为持锁标识释放时把lockKey和标识传进Lua脚本执行。只有标识匹配才删除这样就杜绝了误删。6.2 问题二预约接口的幂等性丢失用户在网络抖动时提交预约前端自动重试结果同一秒内产生了两个订单。排查后确认是缺少幂等处理。解决方案是增加一个幂等键前端生成一个UUID作为请求幂等号后端Redis以该UUID为Key记录处理状态重复请求直接返回第一次的处理结果。String idempotentKey parking:idempotent: request.getIdempotentId(); Boolean firstCall redisTemplate.opsForValue().setIfAbsent(idempotentKey, 1, 10, TimeUnit.MINUTES); if (Boolean.FALSE.equals(firstCall)) { // 幂等校验未通过说明请求已提交过 throw new BusinessException(您已提交过预约请勿重复操作); }这个机制在用户端表现为“点击按钮后立即置灰并显示‘提交中’”就算用户强行刷新重试后端也能保证只创建一单。同时也保障了后续的支付回调、取消接口的幂等性。6.3 问题三数据库连接池被慢查询拖垮高峰期发现数据库CPU飙高到90%大量请求超时。查看慢查询日志定位到一条“查询可用车位”的SQLSELECT s.* FROM parking_slot s WHERE s.lot_id #{lotId} AND s.status 0 ORDER BY s.slot_code LIMIT 10看似没问题的SQL因为lot_id和status组合索引没建好走了全表扫描。加上这个场景的查询特点——车道很多、每个lot的车位几百个MySQL的优化器选择了错误的执行计划。修复方式不是改SQL而是建对索引ALTER TABLE parking_slot ADD INDEX idx_lot_status (lot_id, status);应用这个索引之后慢查询直接降到毫秒级。顺带把Redis缓存加上了同一停车场的一分钟内查询余位直接从缓存读取极大地减轻了数据库压力。6.4 系统稳定性的几行关键配置在解决完上面三个问题后我还调整了几个关键配置这些配置虽然在正常时期看不出差别但关键时刻能救命数据库连接池HikariCP的maximumPoolSize从默认的10调到了20connectionTimeout设成3000msRedis客户端的timeout设为2000ms避免连接Redis超时导致线程卡死定时任务线程池独立配置避免和设备下发线程池互相干扰所有外部设备接口调用都设置显式超时默认5秒熔断重试这些配置都很基础但很多人写完业务代码后根本不关注等到线上出问题才后悔。建议无论项目大小上线前都把这类配置过一遍。7. 复盘从“一键预约”功能到可运行的商业闭环整个项目做完后再回头看一个看似简简单单的“一键预约”功能背后串联的是前端交互、后端业务、数据库设计、缓存策略、设备联动、异常补偿、管理后台多个模块的配合。停车场景的特色在于“真实世界的状态反馈”和“信息系统的状态流转”必须保持高度一致这正是这个项目最有挑战性也是最值得做的地方。如果现在要从零开始复刻这个项目我的建议先定状态机把所有可能的状态和流转路径画出来再动手写代码能为后面省掉大量返工数据库表先把索引设计到位尽量不要上线后再补充大规模索引Redis和MySQL双写方案建议先明确哪边是主、哪边是辅助不要两边都当主源设备层的对接尽早找真实设备测试模拟器和真实硬件之间的差异比想象中大得多H5和运营端后台并行开发提前留好联调时间7.1 个人实践中的几个“反直觉”体会最后分享几个我在实际操作中体会比较深的点可能和很多教程里讲的“最佳实践”不完全一样但都是真实项目里验证过的关于事务处理预约和锁位这种操作时我尽量让事务保持小且快不在事务里做Redis更新、设备通知这类I/O操作。事务只负责数据库状态变更其余全部丢到事务外异步处理。这样既保证核心数据安全又不会因为外部服务慢而拖跨数据库。关于Redis不要把所有的状态都放RedisRedis只是加速和锁的载体MySQL才是唯一的数据源。曾有段时间我在Redis里存了订单的完整JSON结果缓存和数据库双写时有一侧写成功另一侧失败导致数据不一致持续了很久。后来改成Redis只存轻量的状态标记完整数据一律查库问题就消失了。关于设备联动第一次接入地锁设备时觉得只要拿到设备手册按协议对接就行了。实际调试才发现设备回传的报文格式并不是百分之百符合手册有些字段在不同固件版本下含义不同。一定要在系统里留一个“原始报文日志”的开关排查问题时会救你一命。关于前端体验“一键预约”四个字说起来简单做起来难在“等待反馈”。用户点击按钮的瞬间如果页面停留在‘提交中’超过2秒就会产生焦虑。所以我专门做了一个“乐观UI更新”点击按钮后立刻把车位置灰显示“预约中请稍候”后台接口返回后再切换成“预约成功”。这样用户感知到的是即时响应即使后端耗时比较长也不会觉得卡。做这类项目给我的最大感受是业务系统中90%的时间不是花在写新功能上而是花在解决“两个状态没对上”“两个请求互相干扰”这类边界问题上。把边界想清楚把兜底机制做充分系统才能真的稳定运行。停车预约只是其中一个场景同样的架构也适用于图书馆座位预约、会议室预约、充电桩预约等一切“资源分时占用”类业务思路是完全可以复用的。