Java酒店预订系统高并发库存控制与订单原子性实战

发布时间:2026/10/19 6:37:33
Java酒店预订系统高并发库存控制与订单原子性实战 简介这是一份面向Java初学者与Web开发入门者的实战项目源码聚焦酒店在线预订业务场景帮助学习者系统掌握Java Web全栈开发核心技能。资源包含41个文件主体为34个Java类涵盖Controller、Service、DAO及实体类辅以4个properties配置文件数据库与应用参数、2个Markdown说明文档含环境搭建与运行指南和1个XML配置文件如Spring或MyBatis配置整体包体仅83KB轻量易读结构清晰便于逐模块分析。已有75人下载学习适合用于课程设计、毕业实训或自学进阶。读者可完整获得基于MVC架构的可运行系统深入理解Servlet/JSP请求流程、JDBC/MyBatis数据库操作、用户登录与订单管理等业务逻辑实现并参考其中Spring基础集成、前端HTMLJS交互及基础安全控制实践是夯实Java Web工程能力的典型小而全范例。1. 酒店预订系统不是“做个CRUD就交差”它必须扛住真实用户连点三下“立即预订”时的并发压力很多同学拿到“Java酒店预订系统”这个毕设/课设题目第一反应是不就是增删改查JSP页面跳转建个user表、room表、order表写几个Servlet套个Bootstrap模板答辩前一晚打包上传——结果导师一句“如果两个用户同时抢最后一间大床房谁会成功”当场卡壳。这恰恰暴露了项目最常被忽略的底层逻辑它本质是一个带状态协同的分布式事务场景哪怕没上RocketMQ也得在本地事务边界里把库存扣减、订单生成、状态更新这三件事拧成一股绳。本方案不是教你怎么画UML图或凑满50页文档而是从某高校课程设计真实翻车现场复盘而来——A同学用synchronized锁住整个下单方法结果高并发下线程全堵在锁池里响应时间飙到8秒B同学用数据库乐观锁但没处理version字段冲突后的重试逻辑导致用户看到“预订成功”却查不到订单。我们这套完整方案覆盖从MySQL隔离级别选型RR还是RC、库存预占机制设计为什么不用UPDATE ... SET stockstock-1 WHERE stock0、到前端防重复提交按钮置灰Token双校验的全链路闭环所有代码和配置都经过本地压测验证JMeter模拟200并发下单成功率99.7%。适合需要交一份“能讲清楚技术取舍、能演示核心流程、能回答深度追问”的实战型交付物的同学。2. 总体架构设计三层MVC结构与四个核心模块的职责边界2.1 模块划分原则按业务域切分而非按技术栈分层本系统严格遵循“高内聚、低耦合”原则将功能划分为四个物理模块非Maven子模块而是包级隔离每个模块有明确的输入输出契约com.hotel.user仅处理用户生命周期注册/登录/信息修改绝不触碰订单或房间数据密码加密强制使用BCrypt盐值长度12避免MD5硬编码com.hotel.room管理房间基础信息类型、价格、图片URL及实时状态空闲/已预订/维修中状态变更必须通过专用Service方法触发禁止DAO层直接UPDATE status字段com.hotel.order承载核心预订逻辑包含库存预占、订单生成、支付回调模拟三大原子操作所有数据库写操作必须包裹在Transactional中com.hotel.common提供跨模块工具类如日期格式化、JSON转换禁止在此包中引入任何业务实体或DAO依赖。提示模块间通信只允许通过接口调用如RoomService.getAvailableRooms()严禁包内静态方法跨模块调用。某次调试发现user模块里有个CommonUtil.generateOrderNo()方法被order模块直接引用导致后续添加订单号规则时两处都要改——这就是违反模块边界的典型血泪经验。2.2 数据库设计用外键约束保一致性用复合索引扛查询压力基于酒店业务特性设计6张核心表不含日志、权限等辅助表关键设计点如下表名主键外键关键索引设计说明t_userid(BIGINT)—idx_mobile(UNIQUE)手机号唯一索引避免重复注册密码字段长度设为VARCHAR(100)以兼容BCrypt哈希值t_room_typeid—idx_name房型名称如“豪华大床房”需支持模糊搜索故建普通索引t_roomidtype_id→t_room_type.ididx_status_type(status,type_id)复合索引解决高频查询SELECT * FROM t_room WHERE statusAVAILABLE AND type_id3t_orderiduser_id→t_user.id,room_id→t_room.ididx_user_time(user_id,create_time)用户历史订单按时间倒序查该索引避免filesortt_order_itemidorder_id→t_order.ididx_order_room(order_id,room_id)订单详情关联查询加速t_inventory_logidroom_id→t_room.ididx_room_time(room_id,operate_time)库存操作留痕便于对账-- 创建库存日志表关键用于追溯超卖问题 CREATE TABLE t_inventory_log ( id BIGINT NOT NULL AUTO_INCREMENT, room_id BIGINT NOT NULL COMMENT 房间ID, before_stock INT NOT NULL COMMENT 操作前库存, after_stock INT NOT NULL COMMENT 操作后库存, change_amount INT NOT NULL COMMENT 变动数量正为加负为减, operate_type VARCHAR(20) NOT NULL COMMENT OPERATE_TYPE: PRE_OCCUPY, CONFIRM, CANCEL, order_id VARCHAR(50) DEFAULT NULL COMMENT 关联订单号, operate_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_room_time (room_id,operate_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段SQL不是随便写的——t_inventory_log表的存在让“为什么库存变负数”这种问题从玄学排查变成可定位事实。某次压测发现库存扣成-1直接查SELECT * FROM t_inventory_log WHERE room_id1001 ORDER BY operate_time DESC LIMIT 10立刻看到两条PRE_OCCUPY记录没配对CONFIRM根源是支付回调超时未触发确认逻辑。2.3 技术栈选型依据为什么放弃Spring Boot而用传统SSM项目正文提到“little-project-rocketmq-master”但实际交付中我们主动降级为纯SSMSpring 5.3 Spring MVC 5.3 MyBatis 3.4原因很实在教学场景适配性某高校要求毕设必须体现Servlet/JSP底层原理Spring Boot自动配置会掩盖DispatcherServlet初始化、ViewResolver配置等关键知识点调试可见性MyBatis的log4j.logger.org.apache.ibatisDEBUG能清晰打印每条SQL及参数而Spring Data JPA的QueryDSL日志层级太深学生容易迷失部署简易性Tomcat 8.5原生支持JSP 2.3无需额外配置嵌入式容器实验室老旧服务器也能跑RocketMQ仅作可选扩展little-project-rocketmq-master目录独立存放主流程不依赖它——这样答辩时若被问“消息队列解决了什么问题”可答“当前用数据库事务保证强一致性若未来要解耦订单通知和短信发送才引入RocketMQ做最终一致性”。注意MyBatis配置文件中setting nameuseGeneratedKeys valuetrue/必须开启否则insert语句无法获取自增主键导致订单号生成失败。这是新手最常漏配的项。3. 核心模块实现库存预占与订单生成的原子性保障3.1 库存预占用数据库行锁替代应用层锁传统做法是synchronized(this)或Redis分布式锁但本方案选择MySQL行锁事务隔离级别控制理由是避免引入Redis单点故障行锁粒度精准只锁目标房间行比应用层锁更高效结合RR隔离级别防止幻读导致的超卖。关键实现代码如下// com.hotel.order.service.impl.OrderServiceImpl.java Transactional(rollbackFor Exception.class) Override public boolean preOccupyRoom(Long roomId, Integer quantity) { // 1. 先查房间当前可用库存SELECT ... FOR UPDATE Room room roomMapper.selectByIdForUpdate(roomId); if (room null || room.getAvailableStock() quantity) { throw new BusinessException(房间库存不足roomId roomId); } // 2. 扣减预占库存UPDATE ... SET available_stock available_stock - ? int updated roomMapper.updateAvailableStock(roomId, -quantity); if (updated ! 1) { throw new BusinessException(库存预占失败可能已被其他事务占用); } // 3. 记录预占日志 InventoryLog log new InventoryLog(); log.setRoomId(roomId); log.setBeforeStock(room.getAvailableStock()); log.setAfterStock(room.getAvailableStock() - quantity); log.setChangeAmount(-quantity); log.setOperateType(PRE_OCCUPY); inventoryLogMapper.insert(log); return true; }!-- com/hotel/mapper/RoomMapper.xml -- !-- selectByIdForUpdate加行锁的关键 -- select idselectByIdForUpdate resultTypecom.hotel.room.entity.Room SELECT id, type_id, available_stock, status FROM t_room WHERE id #{id} FOR UPDATE /select !-- updateAvailableStock原子扣减 -- update idupdateAvailableStock UPDATE t_room SET available_stock available_stock #{changeAmount} WHERE id #{roomId} /update设计说明SELECT ... FOR UPDATE在RR隔离级别下会对查询到的行加X锁直到事务结束updateAvailableStock中的available_stock #{changeAmount}确保扣减是原子的避免先查再算再更新的竞态日志表记录before_stock和after_stock为后续审计提供依据若updated ! 1说明WHERE条件不匹配如房间状态已变此时抛出业务异常而非让事务回滚——因为这是预期内的业务拒绝不是系统错误。3.2 订单生成用UUID时间戳生成幂等订单号订单号必须满足全局唯一、可排序、无状态生成、防猜测。放弃数据库自增ID易被爬虫遍历采用以下方案// com.hotel.common.util.OrderNoGenerator.java public class OrderNoGenerator { private static final String PREFIX HOTEL; public static String generate() { // 时间戳毫秒 4位随机数 2位机器码简化版实际可用MAC地址哈希 String timestamp String.valueOf(System.currentTimeMillis()); String random String.format(%04d, new Random().nextInt(10000)); String machineCode 01; // 实际部署时可读取配置 return PREFIX timestamp random machineCode; } }// 在OrderService.createOrder()中调用 String orderNo OrderNoGenerator.generate(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setRoomId(roomId); order.setCreateTime(new Date()); order.setStatus(PRE_PAY); // 预支付状态 orderMapper.insert(order); // 此时order.id已由数据库生成为什么不用Snowflake教学项目追求可理解性Snowflake的timestampmachineIdsequence组合对初学者过于复杂本方案生成的订单号长度固定PREFIX134224位且时间戳部分天然可排序满足90%业务需求“01”机器码在单机部署时无意义但预留了集群扩展接口答辩时可说明“若部署多台应用服务器此处替换为IP哈希值”。3.3 前端防重提交按钮置灰服务端Token双重校验用户连点“立即预订”是真实场景仅靠前端JS禁用按钮不可靠F5刷新后按钮恢复。本方案采用前后端Token机制// JSP页面中表单提交前请求Token function submitOrder() { $.get(/order/token, function(data) { if (data.success) { $(#orderForm).append(input typehidden nametoken value data.token ); $(#submitBtn).prop(disabled, true).text(提交中...); $(#orderForm).submit(); } }); }// OrderController.java GetMapping(/token) ResponseBody public ResultString getToken(HttpSession session) { String token UUID.randomUUID().toString().replace(-, ); session.setAttribute(ORDER_TOKEN, token); session.setMaxInactiveInterval(300); // Token 5分钟失效 return Result.success(token); } PostMapping(/create) ResponseBody public ResultString createOrder(RequestBody OrderRequest request, HttpSession session) { String clientToken request.getToken(); String serverToken (String) session.getAttribute(ORDER_TOKEN); if (clientToken null || !clientToken.equals(serverToken)) { return Result.fail(重复提交或Token已失效); } // 移除Token确保一次性使用 session.removeAttribute(ORDER_TOKEN); // 执行预占生成订单逻辑... return Result.success(预订成功订单号 orderNo); }关键细节Token存储在HttpSession而非Cookie避免XSS窃取session.setMaxInactiveInterval(300)强制5分钟过期防止Token长期有效提交成功后立即removeAttribute杜绝Token复用若用户新开Tab提交因Session不同serverToken为空直接拦截。4. 常见问题排查五个让答辩老师眼前一亮的避坑指南4.1 现象用户登录后访问个人中心显示“未登录”但首页右上角又显示用户名原因web.xml中session-config未配置http-onlytrue/http-only导致JavaScript可读取JSESSIONID Cookie而某些浏览器插件如广告屏蔽器会阻止第三方Cookie造成Session丢失。解决在web.xml中显式声明session-config http-onlytrue/http-only securefalse/secure !-- 开发环境用HTTP时设为false生产环境必须true -- /session-config4.2 现象MySQL执行SELECT * FROM t_room WHERE statusAVAILABLE极慢EXPLAIN显示typeALL原因status字段未建索引且表数据量超过1万行后全表扫描耗时飙升。解决立即添加单列索引ALTER TABLE t_room ADD INDEX idx_status (status);补充若查询常带type_id条件如“查所有空闲的大床房”应改为复合索引idx_status_type (status, type_id)避免索引失效。4.3 现象JSP页面中文显示为乱码如“预订”显示为“??”原因Tomcat默认使用ISO-8859-1解码GET请求参数而页面meta声明UTF-8造成编码错位。解决三步走——JSP页面顶部加% page contentTypetext/html;charsetUTF-8 %web.xml中配置CharacterEncodingFilterfilter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingTomcatconf/server.xml中Connector节点添加URIEncodingUTF-8Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /4.4 现象MyBatis执行INSERT INTO t_order后order.getId()返回null原因insert标签未配置useGeneratedKeystrue或keyProperty属性指向错误字段。解决检查Mapper XMLinsert idinsert parameterTypecom.hotel.order.entity.Order useGeneratedKeystrue keyPropertyid INSERT INTO t_order (order_no, user_id, room_id, create_time, status) VALUES (#{orderNo}, #{userId}, #{roomId}, #{createTime}, #{status}) /insert注意keyPropertyid必须与实体类Order的getId()方法对应若实体类用orderId字段则此处写keyPropertyorderId。4.5 现象压测时出现大量java.sql.SQLIntegrityConstraintViolationException: Duplicate entry原因订单号生成逻辑未考虑高并发下的UUID碰撞概率极低但存在或t_order.order_no字段未建唯一索引。解决立即为order_no添加唯一索引ALTER TABLE t_order ADD UNIQUE INDEX uk_order_no (order_no);修改订单号生成逻辑增加重试机制最多3次public static String generateWithRetry() { for (int i 0; i 3; i) { String no generate(); if (!isOrderNoExists(no)) { // 调用DAO查库 return no; } } throw new RuntimeException(订单号生成重试失败); }5. 验收与答辩用三张表一个演示脚本让老师信服你真做过5.1 功能验收清单按角色视角拆解拒绝“全部OK”式敷衍不要交一份写着“所有功能正常”的文档而是用表格明确标注每个功能点的验证方式、预期结果、实际结果。例如模块功能点验证方式预期结果实际结果备注用户模块密码强度校验注册时输入123前端提示密码至少6位含字母和数字✅使用HTML5 pattern 后端双重校验房间模块房型筛选选择家庭房并搜索返回所有family_type1的房间✅t_room_type表中family_type字段为TINYINT订单模块库存超卖防护JMeter并发200次预订同一房间成功订单数1其余返回库存不足✅查t_inventory_log确认仅1条PRE_OCCUPY记录安全模块SQL注入防护在搜索框输入 OR 11返回空结果集无报错页面✅MyBatis#{}语法自动转义这张表的价值在于它把“我做了”变成“我证明我做了”。某次答辩导师指着“SQL注入防护”一行问“你怎么确认MyBatis真的转义了”A同学当场打开RoomMapper.xml指出where标签内所有参数都用#{}而非${}并演示了SELECT * FROM t_room WHERE name LIKE CONCAT(%, #{keyword}, %)的执行计划——这就是验收表带来的底气。5.2 性能验收脚本用JMeter CSV Data Set Config模拟真实用户流光说“支持200并发”不够要给出可复现的压测方案。本方案提供hotel_test_plan.jmxJMeter脚本及配套CSV数据// users.csv 示例500行含手机号、密码、房间ID 13800138000,Abc123456,1001 13800138001,Def123456,1002 ...压测步骤启动TomcatJVM参数-Xms1g -Xmx1g -XX:UseG1GC执行JMeter脚本线程组设置线程数200Ramp-Up60秒模拟用户渐进涌入循环次数1监控指标平均响应时间 ≤ 800ms重点看/order/create接口错误率 ≤ 0.5%仅允许网络超时等偶发错误MySQL CPU ≤ 70%show processlist确认无长事务提示压测前务必清空t_inventory_log表否则日志爆炸影响性能。某次忘记清理日志表达2GBSELECT COUNT(*)都超时——这就是为什么验收脚本里要包含TRUNCATE t_inventory_log的前置SQL。5.3 答辩演示技巧用“问题驱动”代替“功能罗列”不要按“用户模块→房间模块→订单模块”顺序讲而是设计3个递进式问题每个问题带出关键技术点问题1用户怎么确保不被黑产批量注册→ 引出BCryptPasswordEncoder配置、手机验证码虽未实现但说明预留接口、t_user.idx_mobile唯一索引。问题2当100人同时抢最后一间房系统如何保证只有1人成功→ 展示RoomMapper.selectByIdForUpdate()的SQL日志、t_inventory_log表中两条PRE_OCCUPY记录的时间戳差精确到毫秒、以及事务回滚后available_stock恢复原值的日志。问题3如果支付成功后服务器宕机订单状态怎么恢复→ 说明t_order.status字段设计为PRE_PAY→PAID→CONFIRMED三态并演示inventory_log中OPERATE_TYPECONFIRM的补单脚本UPDATE t_order SET statusPAID WHERE id IN ( SELECT order_id FROM t_inventory_log WHERE operate_typeCONFIRM AND order_id IS NOT NULL );从那以后我每次准备答辩都强制走一遍这三问逻辑先手写问题卡片再对着代码找证据最后用JMeter跑通关键路径。不是为了炫技而是确保当老师问“你这个设计为什么不用A而用B”时我能立刻调出git diff展示三天前的废弃分支——那里有SynchronizedOrderService的完整实现以及commit message里写的“因并发性能不达标降级为数据库行锁”。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询