Java实现民宿预订平台:库存扣减与并发控制实战 📅 发布时间:2026/9/12 22:23:02 👁 浏览次数: 简介这是基于SpringBoot与Vue的民宿在线预定平台Java源码包适合有JavaWeb基础、需要开发或毕业设计参考的开发者。资源实现完整的民宿预订闭环涵盖民宿信息展示、在线选房、订单提交、后台管理等模块采用SpringBoot、MyBatisPlus、MySQL、Vue、ElementUI及Ajax技术栈具备清晰的前后端分离结构。压缩包共780个文件包括122个Java后端源文件、46个Vue前端组件、153个JS脚本、44个CSS样式及大量SVG图标素材另含数据库相关配置与说明文档包体总大小18.63MB目录按功能划分便于查找。已有98人学习下载既能帮助理解SpringBoot与Vue的整合方式也能直接参考用户登录、订单状态流转等典型业务场景适合快速改造或扩展为其他预定类系统。1. 民宿在线预定平台的核心不是页面而是房间状态机一个 Java 民宿在线预定平台很容易被做成“小型电商 日历”房东传照片、用户收藏、评价晒单界面花哨但订单逻辑薄弱。真正投入运营后最先暴露问题的一定是预订环节同一间房在同一天被两个用户同时下单取消订单后房量没有回补价格日历把退房当天也算进房费。整件事的核心是在 Java 代码里表达清楚“某房间某晚的占用状态”。这个状态由空闲、锁定、占用、释放四种迁移构成落到实现上就是价格日历表、库存扣减 SQL、事务边界和并发控制。下面按这个顺序把民宿平台的表结构、价格日历、库存扣减和下单接口逐个落地给正在用 Java 项目练手的工程师以及想拿民宿项目应对 java 面试题追问的同学一条完整可复现的路径。2. 用 Java 落地民宿预订平台的数据层表结构、实体与批量查询2.1 技术选型Spring Boot MyBatis Plus不选 SSM 的理由民宿平台的访问量集中在节假日和周末平峰期并不高技术选型不必为“高并发”提前引入复杂组件。我常用的基础组合是 Spring Boot 3 JDK 17 MySQL 8 Redis工程结构按 controller/service/mapper 三层展开。Spring Boot 通过自动装配解决数据源、事务管理器、JSON 序列化等大量胶水配置这恰好符合民宿项目“业务简单、快速交付”的特点。持久层选 MyBatis Plus理由有两个列表页筛选条件经常变化LambdaQueryWrapper 能在 Java 代码里动态构造查询后台管理需要的多表关联查询可以显式写 SQLSQL 对最终执行计划完全可控。相比之下SSM 的手动配置在陌生同事接手时会增加沟通成本而 Spring Data JPA 在复杂关联查询时 SQL 生成不够透明排查慢查询要先翻日志。JDK 版本建议锁定 17 这类 LTS。环境变量配置这里有一个高频排查点JAVA_HOME 要指向 JDK 安装目录PATH 里追加 %JAVA_HOME%\bin。很多 IDE 自带 JDK运行时和编译时版本不一致会出现 java.lang.UnsupportedClassVersionError优先检查三个地方Maven 的 compiler 插件版本、IDE 的 Project SDK、环境变量 JAVA_HOME。这个知识点常见于 java 面试基础题属于不背但必须能说清来龙去脉的范畴。2.2 四张核心表房源、房间、价格日历、订单民宿业务和酒店的最大区别是房型粒度。酒店标准间可以批量卖民宿更强调单间的独特属性。表结构设计上我把它拆成四层house 保存房源归属地room 描述单间容量和默认价格price_calendar 记录每晚价格与实际剩余可售量booking 保存订单。下面这组 SQL 可以直接作为初始化脚本也是整套民宿在线预定平台代码里最先需要定下来的部分CREATE TABLE house ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_id BIGINT NOT NULL COMMENT 房东ID, city_code VARCHAR(16) NOT NULL COMMENT 城市编码, address_detail VARCHAR(255), status TINYINT DEFAULT 1 COMMENT 1-上架 0-下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, house_id BIGINT NOT NULL, room_name VARCHAR(64) NOT NULL, max_guests TINYINT NOT NULL DEFAULT 2, base_price DECIMAL(10,2) NOT NULL COMMENT 默认价, inventory INT NOT NULL DEFAULT 1 COMMENT 同配置可售数量 ); CREATE TABLE price_calendar ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, date DATE NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 当日售价, stock_remaining INT NOT NULL DEFAULT 1 COMMENT 剩余可订量, UNIQUE KEY uk_room_date (room_id, date) ); CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, room_id BIGINT NOT NULL, user_id BIGINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT 0待支付 1已确认 2已取消 3已完成, created_at DATETIME NOT NULL );price_calendar 是整个模型的核心。它把“某房间某晚是否可订”收敛到一行记录查询时只看 stock_remaining 是否大于 0扣减时用条件更新完成原子操作。如果只在 booking 表里判断时间区间是否重叠每次下单都扫描订单表代价会随订单量快速上升并发场景还需要额外加锁。用日历表替代区间判断是民宿预订并发方案最常见的做法核心思路是把区间冲突转成单点扣减。Java 实体与表字段的映射要注意类型选择。金额用 BigDecimal 而不用 Double日期用 LocalDate 而不用 java.util.Date。下面是常用映射对照表写 mapper 时可以直接照这个标准Java 字段类型MySQL 列类型使用场景BigDecimalDECIMAL(10,2)价格、金额运算保留 4 位后归一化LocalDateDATEcheck_in、check_out、价格日期LocalDateTimeDATETIME创建时间、更新时间IntegerTINYINT订单状态等小范围状态码LongBIGINT主键与关联外键避免 int 溢出2.3 列表查询的 Java 集合组装一次 IN 查询替代 N 次数据库调用列表页的核心查询是城市、入住日期、离店日期、人数返回可订房源及其每晚价格。最差的实现是外层循环房间对每个房间单独查价格日历房源多时数据库压力很大。我用一次 IN 查询把所有候选房间的价格日历取回来在 Java 内存里用集合做聚合public ListHouseSearchVO searchAvailable(String cityCode, LocalDate checkIn, LocalDate checkOut, int guests) { // 1. 先按城市和人数筛房间 ListRoom rooms roomMapper.selectList(new LambdaQueryWrapperRoom() .eq(Room::getCityCode, cityCode) .ge(Room::getMaxGuests, guests)); if (rooms.isEmpty()) return Collections.emptyList(); // 2. 批量拉价格日历入住当天至退房前一天 ListLong roomIds rooms.stream().map(Room::getId).collect(Collectors.toList()); ListPriceCalendar calendars priceCalendarMapper.selectList( new LambdaQueryWrapperPriceCalendar() .in(PriceCalendar::getRoomId, roomIds) .ge(PriceCalendar::getDate, checkIn) .lt(PriceCalendar::getDate, checkOut) .gt(PriceCalendar::getStockRemaining, 0)); // 3. 按房间分组在内存里判断是否覆盖完整入住区间 MapLong, ListPriceCalendar roomCalendarMap calendars.stream() .collect(Collectors.groupingBy(PriceCalendar::getRoomId)); return rooms.stream() .filter(room - { ListPriceCalendar list roomCalendarMap.get(room.getId()); long nights ChronoUnit.DAYS.between(checkIn, checkOut); return list ! null list.size() nights; }) .map(room - buildVO(room, roomCalendarMap.get(room.getId()), checkIn, checkOut)) .collect(Collectors.toList()); }这里的两个关键参数.lt(PriceCalendar::getDate, checkOut)是“退房当天不计费”.gt(PriceCalendar::getStockRemaining, 0)是“只保留有房日期”。分组后的 Map 用于判断“该房间每日都有余房”且“天数与入住晚数一致”。库里已经过滤掉无房日期返回的列表长度等于晚数时说明整段区间都可订。条件过滤在 SQL 层完成内存中只做完整性校验不要在分页之后才判断剩余房量否则总条数与可用房源数会对不上。用 MapLong, List 做分组聚合是 java 集合在真实业务里很典型的使用方式。数据量在 1 万到 10 万这个级别一次 IN 查询替代 N1 次循环查询响应时间通常从秒级降到几十毫秒。parallelStream 在这里不一定快因为数据库连接池连接有限并行查询会因等待连接而退化为排队。3. 库存扣减、价格叠加与 Redis 计数预订核心业务的三个关键点3.1 库存扣减必须用条件 UPDATE不能“先查再改”超卖问题的根源多半是“先 SELECT 剩余量Java 判断大于 0再 UPDATE”。两个请求同时读到剩余 1都判断通过最后两个订单都成功。这个问题在 java 基础面试题里叫“丢失更新”正确做法是把判断和修改合并进同一条带条件的 SQLUPDATE price_calendar SET stock_remaining stock_remaining - 1 WHERE room_id ? AND date ? AND stock_remaining 0;MyBatis 的 update 返回值是影响行数返回 1 说明扣减成功0 说明库存已被抢完这个返回值是业务逻辑的输入不能忽略。在 InnoDB 的 REPEATABLE READ 隔离级别下多个事务更新同一行时靠行锁排队WHERE 条件会在锁释放后重新评估所以连续两个请求只会有一个拿到 1。多晚预订必须把多个晚上的扣减放进同一个事务任何一晚失败则整体回滚。执行顺序有门道先扣库存后插订单。如果先插订单再扣库存扣减失败时订单行还在要靠额外动作才能清理状态机会多出一个无意义的中间状态。处理顺序在 java 面试题的“如何保证库存不超卖”中常被追问要把它当成状态机的一部分来回答而不是单纯背一条 SQL。3.2 平日价、周末价、节假日价三级规则如何叠加民宿价格很少只有单一数值。房东希望在周末上浮、节假日倍增、淡季打折运营又可能临时修改某一天的价格。把这些规则放在一个组件里按优先级顺序判断是最容易维护的方式public BigDecimal dailyPrice(Long roomId, LocalDate date) { // 第一优先级价格日历中的运营覆盖价 PriceCalendar cal priceCalendarMapper.selectOne( new LambdaQueryWrapperPriceCalendar() .eq(PriceCalendar::getRoomId, roomId) .eq(PriceCalendar::getDate, date)); if (cal ! null cal.getPrice() ! null) { return cal.getPrice(); } // 第二优先级节假日倍率 Holiday holiday holidayMapper.selectByDate(date); Room room roomMapper.selectById(roomId); if (holiday ! null) { return multiPrice(room.getBasePrice(), holiday.getMultiplier()); } // 第三优先级周末上浮 10% DayOfWeek dow date.getDayOfWeek(); if (dow DayOfWeek.FRIDAY || dow DayOfWeek.SATURDAY || dow DayOfWeek.SUNDAY) { return multiPrice(room.getBasePrice(), new BigDecimal(1.1)); } return room.getBasePrice(); } private BigDecimal multiPrice(BigDecimal base, BigDecimal ratio) { return base.multiply(ratio).setScale(2, RoundingMode.HALF_UP); }优先级顺序直接决定业务正确性。把节假日判断放在价格日历之前运营在后台改了某天的特价最终结算却按节假日倍率算这就是线上矛盾。BigDecimal 的入参写new BigDecimal(1.1)而不是1.1后者会先把 double 值转为 BigDecimal带来二进制浮点误差。多晚总价建议先累加每晚价格再乘以连住折扣率最后统一保留两位小数如果每晚单独打折再求和总额会出现多余的小数位入库时被 DECIMAL(10,2) 截断和前端显示对不上。价格日历通常会有一个定时任务为未来 90 天批量生成默认价格生成时用 base_price 配合周末规则通过INSERT ... ON DUPLICATE KEY UPDATE保证重复执行不会产生脏数据。规则层级之间的覆盖能力可以按下面的表来理解规则层级触发条件覆盖能力价格日历显式价格日期在日历中有配置覆盖其余全部规则节假日倍率命中节假日表覆盖默认价与周末上浮周末上浮周五、周六、周日无节假日时生效默认价其余所有日期兜底3.3 Redis 扣库存“not integer or out of range”报错的真正原因热点日期把剩余量预热到 Redis 后用 increment 扣减是最直接的方案。常见报错 ERR value is not an integer or out of range问题通常不在 Redis 本身而在序列化器。写入键时使用 StringRedisTemplate扣减时换成另一个配置了 JSON 序列化器的 RedisTemplateRedis 拿到的 value 是带引号的字符串increment 自然返回 not integer or out of range。处理办法是让计数字段的写入和扣减共用同一个序列化器。我通常在项目中单独分配一个 beanBean(countRedisTemplate) public RedisTemplateString, String countRedisTemplate(RedisConnectionFactory factory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); return template; }所有用 Redis 做计数、限流的场景统一使用 countRedisTemplateJSON 序列化留给对象缓存比在每次调用前手动指定 ValueSerializer 更不容易出错。还要给 Redis 扣库存加降级Redis 不可用时退回 MySQL 条件更新。降级后要注意补偿Redis 扣成功但 MySQL 事务回滚时要把 Redis 计数补回 1否则两边数据不一致。注意操作类 Redis 的 bean 不要混用。计数用字符串序列化器业务对象缓存用 JSON 序列化器两个模板按各自场景固定使用排查问题会容易很多。4. REST 接口划分与下单流程的并发控制4.1 民宿在线预定平台的 8 个核心 REST 接口接口按查询、操作、管理三类划分方便确定缓存策略和权限粒度查询类 GET /api/houses?cityCode330100checkIn2025-05-01checkOut2025-05-04guests2 GET /api/houses/{houseId} GET /api/rooms/{roomId}/calendar?month2025-05 操作类 POST /api/bookings Body: roomId, checkIn, checkOut, guestName, guestPhone POST /api/bookings/{orderNo}/pay POST /api/bookings/{orderNo}/cancel 管理类 GET /api/bookings/{orderNo} GET /api/houses/{houseId}/bookings分页参数 page 从 1 开始size 默认 10、最大 100超过上限直接返回参数错误。民宿的剩余房量属于实时数据不能用 HTTP 缓存响应头要加 Cache-Control: no-store。orderNo 可以用“yyyyMMddHHmmss 4 位随机数”拼 32 位字符串不要用数据库自增 ID 对外暴露否则订单量容易被遍历统计。请求体校验也放在这一层checkIn 和 checkOut 用 LocalDate 接收配合 JsonFormat(pattern yyyy-MM-dd)格式错误时返回 400 而不是 500接口语义更明确。4.2 乐观锁、Redis 分布式锁与数据库行锁的分工既然 price_calendar 有带条件的 UPDATE为什么还需要额外的锁条件更新只保护单行数据一个订单跨多个晚上、多个房间时扣减的顺序和组合校验需要更全局的控制。Redis 分布式锁可以把同一个房间的重复下单请求在业务入口拦截掉减少数据库层的等待。加锁与释放之间业务执行时间必须小于锁过期时间。String lockKey booking:lock: req.getRoomId() : req.getCheckIn(); String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(该房间正被预订请稍后重试); } try { // 校验与扣减 } finally { String current (String) redisTemplate.opsForValue().get(lockKey); if (requestId.equals(current)) { redisTemplate.delete(lockKey); } }requestId 是锁的所有者标识防止业务超时后请求 A 的 finally 把请求 B 刚创建的锁删除。更严谨的做法是把“查询并删除”用 Lua 脚本原子化但大多数单体项目里前面的 if 判断已经足够。几种并发方案各自有适用场景方案适用场景注意点SQL 条件更新单行库存扣减不影响多行组合判断SELECT ... FOR UPDATE单体应用、事务内锁持有时长必须短Redis 分布式锁多实例部署关注过期时间和误删Version 乐观锁实体更新重试成本低冲突率高时形成大量重试单体应用阶段SELECT ... FOR UPDATE 往往比 Redis 锁更省事。唯一注意的是持锁时间事务里只能做本地计算和数据库操作任何等待外部接口的时间都不该落在持锁区间否则数据库连接数很快被打满。4.3 下单 Service 与事务边界先扣库存再建订单下单 Service 的代码量不大但容易在细节上出错。一个常见的骨架像这样Transactional(rollbackFor Exception.class) public Booking createBooking(BookingRequest req) { String lockKey booking:lock: req.getRoomId() : req.getCheckIn(); boolean locked tryLock(lockKey, 30); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { for (LocalDate d req.getCheckIn(); d.isBefore(req.getCheckOut()); d d.plusDays(1)) { int updated priceCalendarMapper.deductStock(req.getRoomId(), d); if (updated 0) { throw new BizException(所选日期房间已满); } } Booking booking buildBooking(req); bookingMapper.insert(booking); return booking; } finally { // 锁的释放放到事务提交后的回调里执行 registerAfterCommit(() - unlock(lockKey)); } }先扣库存再插订单的顺序最关键。扣库存失败时事务整体回滚不会产生半截数据插订单失败时同样回滚库存恢复不需要额外补偿。如果反过来订单已插入而库存扣减失败就多了一个需要人工兜底的状态。Transactional 的 rollbackFor 必须指定默认事务只对 RuntimeException 回滚如果代码里用 try-catch 吞掉 BizException 并返回错误响应事务不会回滚库存被永久扣掉这是排查订单短账时最隐蔽的原因之一。业务异常统一继承 RuntimeException事务回滚行为就变得可预期。5. 用并发压测来验证超卖修复并确认边界用例5.1 10 个线程抢 1 间房CountDownLatch 并发验证脚本代码写完后需要证明在 10 个线程同时抢一间房时只有一单成功。下面这个脚本可以直接放进测试目录ExecutorService pool Executors.newFixedThreadPool(10); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(10); AtomicInteger success new AtomicInteger(); for (int i 0; i 10; i) { pool.submit(() - { start.await(); try { bookingService.createBooking(buildRequest()); success.incrementAndGet(); } catch (BizException e) { // 预期的业务拒绝 } finally { done.countDown(); } }); } start.countDown(); done.await(10, TimeUnit.SECONDS); System.out.println(成功订单数量: success.get()); pool.shutdown();测试前把 price_calendar 中目标日期的 stock_remaining 重置为 1。如果 success 为 1 且无异常说明库存扣减逻辑正确如果大于 1重点排查 UPDATE 的 WHERE 条件是否带回stock_remaining 0以及事务隔离级别是否被调到了较低级别。测试后把测试用到的 Redis lock key 清理掉自己实现的 setIfAbsent 方案尤其要注意否则下一个用例会被遗留锁卡住。每次压测从干净的 Redis 和 MySQL 状态开始连续跑三轮结果都是 1这套并发方案才算真正收敛。5.2 四个必须覆盖的边界用例与回补逻辑日期和金额是民宿平台最容易出故障的地方建议固定四组用例跨月订单、节假日价格覆盖、退房当天不计费、取消后库存回补。跨月订单用来验证循环扣库存时 byDays 推进的日期正确性节假日价格覆盖用来验证价格优先级退房当天不计费用来验证退房日是否被排除在价格日历之外。最后一个回补逻辑取消订单补库存的 SQL 写成UPDATE price_calendar SET stock_remaining stock_remaining 1 WHERE room_id ? AND date ?这里不能用SET stock_remaining 1否则会把其他用户已扣减的数量覆盖掉导致本来没房的日期显示有房。这行 SQL 与扣减逻辑是一个可逆闭环也是民宿平台订单状态机里最基础也最关键的一步。把并发脚本和这几组边界用例连同初始化 SQL 一起写进项目说明后续任何一次改动订单逻辑都能在十分钟内回归验证。本文还有配套的精品资源点击获取