尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Boot预约系统实战:数据模型、并发防超卖与权限设计
上个月帮当地一家旅行社把导游预约管理从Excel表格搬到线上系统从需求沟通到部署上线大概花了两周。整个项目基于Spring Boot框架做的就是在线导游预约系统数据层用MyBatis加MySQL热点数据放了Redis权限用的是Spring Security和JWT。系统上线后散客可以在线按日期预约导游导游端能接单、改状态管理员后台做排期和统计基本覆盖了日常业务。这篇分享不是教科书式的框架讲解而是把这个项目从零到一过程中我认为最值得讲的几个点拆出来数据模型怎么设计、预约并发怎么防超卖、角色权限怎么控制、上线后踩了哪些坑。如果你正准备做一个类似的预约系统或者想用Spring Boot快速交付一个中小型业务项目这篇应该对你有参考价值。1. 为什么选Spring Boot这个项目的真实业务场景与架构取舍1.1 在线导游预约系统到底要解决什么问题先讲业务背景。旅行社以前主要接团队会有固定的几个导游带团排期靠一张Excel表。散客化转型后游客要自己选导游、选日期下单Excel根本撑不住几个导游的档期重叠了不知道、导游临时换人要在群里吼半天、游客问某天有没有导游只能人工翻表格。所以这个系统第一版的目标很朴素游客能看导游的开放日期选中后提交预约导游能看到自己每天的预约列表并确认后台能看到所有订单和导游的接单量。这里有个容易忽略的关键点导游行业的预约和酒店预约不一样。酒店房间当天可订多次但一个导游一天通常只能服务一单除非半日游拆成上午下午两单。所以业务上其实是对导游某一天的服务能力做库存扣减。很多新手做这类系统直接设计成只存订单表没有排期表结果并发预约时根本没法判断导游某天还能不能接单。这是我们设计时最关键的决定必须有guide_schedule排期表用它作为库存载体。业务上所有库存判断、锁竞争、超卖防护都围绕这张表展开。1.2 为什么是Spring Boot而不是SSH或Node初期也纠结过要不要用老一套SSM框架或者直接用Node快速写接口。最后选了Spring Boot核心原因有三个。第一Spring Boot的自动配置和Starter生态能极大压缩配置工作量。以前SSM要写一堆XML配置数据源、事务、MyBatis映射Spring Boot一个spring-boot-starter-web加mybatis-spring-boot-starter就解决了内嵌Tomcat也让部署从装Tomcat、丢war包变成了java -jar一个包。对这种需要快速交付的业务系统省下的都是真金白银。第二生态成熟。预约系统必然会用到事务、缓存、定时任务、权限控制这些Spring Boot都有非常成熟的落地方式。就算以后要接微信支付、对接OTA平台也能找到现成的SDK排查问题有大量案例可查。第三后续维护成本低。Java这行招人容易Spring Boot现在是主流后面维护的人上手快不至于像老项目一样只有写的人看得懂。技术选型上我最后敲定的是Spring Boot 2.7 MyBatis MySQL 8.0 Redis Spring Security JWT前端先用一套Vue管理后台加手机H5顶着。核心逻辑全在服务端以后换小程序也只是加客户端的事。2. 数据模型设计导游排期、订单与状态机怎么落表2.1 六张核心表的字段设计与冗余思路数据库设计是整个系统最不能省的一环。我实际用了六张核心表用户表、导游表、排期表、订单表、评价表、以及一张记录导游每日统计的汇总表。权限相关的角色直接放在用户表里用role字段区分没有单独建角色权限表因为业务角色就三种没必要过度设计。用户表sys_user核心字段id、username、password、phone、role、status、create_time。密码必须存BCrypt加密后的值不要用MD5。导游信息表guide核心字段id、user_id、name、avatar、phone、city、intro、status、create_time其中user_id关联到用户表登录和资料分离以后导游认证要传证件照也方便扩展。排期表guide_schedule是最核心的表字段包括id、guide_id、work_date、total_count、booked_count、status、version、create_time。total_count是当天可接的最大单数默认1booked_count是已预约人数status表示当天是否开放预约0开放1关闭2休息。很多教程里只放status不放开预约数量做热门导游半日游拆分时就会很尴尬。version字段是给乐观锁用的后面说并发时再展开。订单表appointment_order不能只存关联还要做冗余。实际字段是id、order_no、user_id、guide_id、guide_name、travel_date、start_time、end_time、tourist_count、total_amount、status、remark、create_time、update_time。guide_name冗余是为了列表页不联表order_no用日期加随机数生成方便人工对账。为什么冗余guide_name因为订单页要高频展示导游名如果每次都去join导游表数据量大了以后索引和IO都会浪费。这种单表冗余在中小系统里非常实用。评价表review就简单了id、order_id、user_id、guide_id、rating、content、create_time订单完成后才能评价。order_id必须加唯一约束保证一个订单只能评价一次。每日统计表guide_daily_stat用来记录导游每天的接单量和评分变化后台报表直接查这张表不用跑聚合SQL。2.2 预约订单状态机的流转规则状态机是这类业务系统里最容易乱的地方。我用整数常量加枚举类管理状态绝对不允许代码里到处写魔法值。订单状态定义如下状态值名称说明0待支付游客提交预约但还未支付1待确认已支付等待导游确认2已确认导游确认接单3已取消整个订单取消终态4已完成服务结束导游或系统确认完成有人会问国内很多旅游平台是支付后直接确认为什么我们中间还插一个待确认因为散客预约和酒店不同导游是自然人他可能有临时的私人安排你让系统自动确认接单导游会有抵触反过来如果让导游先确认再支付游客又会担心被放鸽子。所以折中游客先付定金导游要在2小时内确认超过时间系统自动取消并退款。这个规则用来平衡双方风险。订单状态流转的核心是用条件更新而不是先查后改。比如导游确认订单SQL应该是UPDATE appointment_order SET status 2, update_time NOW() WHERE id #{orderId} AND status 1如果更新行数是0说明状态已经被别人改了直接提示订单已处理请刷新后再试。这种写法在并发场景下比先SELECT再UPDATE安全得多。我见过太多项目因为先查后改上线之后偶发订单状态错乱查半天都查不出原因。3. 预约核心链路开发并发防超卖与状态流转的实现3.1 库存校验数据库乐观锁 vs Redis分布式锁预约接口是整个系统并发压力最大的一环。热门导游在节假日就是秒杀场景一个日期就那么一两个名额不处理并发就会超卖。最简单的做法是给guide_schedule表加version字段扣减库存用乐观锁Update(UPDATE guide_schedule SET booked_count booked_count 1, version version 1 WHERE id #{scheduleId} AND version #{version} AND booked_count total_count) int deductStock(Param(scheduleId) Long scheduleId, Param(version) Integer version);这里booked_count total_count是库存约束version #{version}是乐观锁。两条同时满足才会更新成功返回影响行数0就说明库存被抢完了或版本过期。乐观锁的问题是重试逻辑要自己写而且一个请求里要处理多张排期表时会比较麻烦。所以我们最终在真正创建订单的入口用了Redis分布式锁锁的key设计成guide:schedule:{scheduleId}用setIfAbsent加锁设置2秒过期时间防止线程死掉导致死锁。为什么不推荐直接用synchronized因为单体多实例部署后synchronized只对单个进程有效后面系统部署两台服务器时直接失效。Redis锁至少跨节点可用而且实现成本很低。3.2 核心代码创建预约订单的Service实现创建预约订单的完整逻辑在AppointmentServiceImpl#createOrder里。我把主要步骤贴出来并加上注释解释每个步骤的意义Override Transactional(rollbackFor Exception.class) public AppointmentOrder createOrder(CreateOrderRequest request) { // 1. 参数校验travelDate不能是过去日期导游/排期必须存在 GuideSchedule schedule scheduleMapper.selectById(request.getScheduleId()); if (schedule null || schedule.getStatus() 1) { throw new BizException(该日期不可预约); } // 2. 分布式锁防止并发重复创建 String lockKey guide:schedule: schedule.getId(); boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(2)); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { // 3. 再次查询排期最新状态确认还有库存 GuideSchedule latest scheduleMapper.selectById(schedule.getId()); if (latest.getBookedCount() latest.getTotalCount()) { throw new BizException(该导游当天已约满); } // 4. 扣减库存同时更新version int rows scheduleMapper.deductStock(schedule.getId(), latest.getVersion()); if (rows 0) { throw new BizException(该导游当天已约满); } // 5. 创建订单记录 AppointmentOrder order new AppointmentOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setGuideId(schedule.getGuideId()); order.setTravelDate(latest.getWorkDate()); order.setStatus(OrderStatusEnum.WAIT_PAY.getCode()); // ... 其他字段 orderMapper.insert(order); return order; } finally { redisTemplate.delete(lockKey); } }这里有个很多人忽略的关键点Transactional是方法结束后才提交事务但Redis锁在方法执行完finally里就释放了。如果事务还没提交另一个线程拿到锁后去读取数据库可能读到的还是旧库存导致超卖。为了稳妥我在第3步重新查了一次排期并且扣减库存的SQL本身就带了booked_count total_count条件数据库这层兜底才是真正的安全闸门。为什么不在事务提交后再释放锁理论上最优做法是在TransactionSynchronizationManager里注册事务提交后的回调来释放锁但代码复杂度会高一些。对当前这个业务量用数据库条件更新兜底已经足够。如果你的系统要做到严格不超卖建议把释放锁放在事务提交后的回调里。3.3 导游确认、拒绝与取消退单处理导游端两个核心操作是确认和取消。确认的SQL前面已经说了用条件更新成功变状态2。拒绝接单的话把订单置为取消同时需要把排期的booked_count减回去否则库存就凭空消失了。这里必须放在同一个事务里不然会出现订单取消了但库存没恢复的脏数据。游客取消订单同样要恢复库存。但要注意时间窗口如果导游已经确认接单游客再取消会打乱导游安排所以要区分取消规则。我定的规则是导游确认前游客可无条件取消全额退定金导游确认后距离服务日期超过48小时游客可取消并退50%定金服务前48小时内游客不能取消只能联系后台人工处理。这些规则抽了一个CancelRuleEngine输入订单和当前时间输出可取消、不可取消、扣款比例。因为业务方后面一定会调整规则抽出来好改。4. 游客端、导游端与后台的权限划分及接口鉴权4.1 Spring Security JWT 的接入方式预约系统的用户分三类游客、导游、管理员。接口不能裸奔至少要做登录鉴权。我用的是Spring Security JWT的无状态方案服务端不存Session后面加小程序也方便。接入步骤其实就三步。第一步在SecurityConfig里放行登录注册接口和导游列表展示接口其余接口全部认证。第二步写一个JwtAuthenticationFilter从请求头Authorization: Bearer xxx里解析token把用户ID和角色塞进SecurityContextHolder。第三步在Controller方法上标注PreAuthorize(hasRole(GUIDE))做校验Spring Security自带这个能力没必要自己造轮子。JWT工具类里要注意两点一是token过期时间不要设置太长我设置的是7天因为游客可能持续几周挑选行程二是token里只放用户ID和角色这些非敏感信息不要放手机号密码。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); Claims claims Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); Long userId Long.valueOf(claims.get(userId).toString()); String role claims.get(role).toString(); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(userId, null, Collections.singletonList(new SimpleGrantedAuthority(ROLE_ role))); SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(request, response); } }4.2 三类角色的接口权限设计接口按角色划分我整理成一张表开发时可以对着做接口游客导游管理员说明POST /api/auth/login公开公开公开登录GET /api/guides公开公开公开查看导游列表GET /api/guides/{id}/schedules公开公开公开查看导游的开放日期POST /api/orders需要禁止禁止提交预约PUT /api/orders/{id}/confirm禁止需要禁止导游确认接单PUT /api/orders/{id}/reject禁止需要禁止导游拒绝接单PUT /api/orders/{id}/cancel需要需要需要取消订单GET /api/admin/orders禁止禁止需要后台订单列表PUT /api/admin/guides/{id}/status禁止禁止需要上下架导游一个容易漏掉的是导游只能操作自己的订单。你可以在Security里校验角色但具体某条订单是不是属于当前登录导游必须在Service里再校验一次。比如confirmOrder方法里要先查订单的guideId是否等于当前登录用户绑定的guideId否则游客给一个导游下的单另一个导游只要知道订单ID就能确认这种越权事故事后很难解释。4.3 密码安全与用户注册用户注册时密码必须用BCrypt加密String encoded new BCryptPasswordEncoder().encode(request.getPassword());BCrypt的盐是自动加入密文里的不需要额外存盐这是它比MD5加固定盐强的地方。除了密码登录接口还要做失败次数限制比如5次失败锁账号15分钟防止暴力破解。用一个Redis计数器实现key是login:fail:{username}每次登录失败加1并设置过期时间。5. 我把系统部署到服务器后真实遇到的六个细节问题5.1 日期时区差点让预约错位一天这是上线第一天就遇到的灵异问题。游客在前端选了9月10日预约导游结果数据库中的travel_date存的是9月9日。排查发现是MySQL连接串没有指定时区默认用了会话时区而JVM默认时区是系统时区两边差了8小时。解决方案是在JDBC连接串里显式加上serverTimezoneAsia/Shanghaispring.datasource.urljdbc:mysql://localhost:3306/travel_guide?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai同时Spring Boot的Jackson也要设置时区不然JSON返回给前端的时间又会偏spring.jackson.time-zoneGMT8 spring.jackson.date-formatyyyy-MM-dd HH:mm:ss日期字段统一用LocalDate和LocalDateTime不要在实体里用java.util.Date因为LocalDate天然不携带时区处理日期字符串更安全。这个坑排了一个小时最后发现是时区真的很冤。5.2 事务里调外部接口导致回滚失效刚开始写确认订单时我在事务里调用了短信服务给游客发通知。当时想得很简单发送失败就抛异常回滚嘛结果发现通知没成功但订单状态却确认了。原因是短信服务是通过RestTemplate同步调用的网络超时被外层捕获后我打日志继续走事务判定没有异常所以正常提交了。后来把对外调用全部移出了事务或者只在事务提交成功后发通知。更规范的做法是注册TransactionSynchronizationManager.registerSynchronization在事务提交后执行短信发送这样业务成功才通知也避免事务回滚后短信已经发出去了的尴尬。创建订单的事务里只做数据库操作不要去调支付接口支付成功后通过回调接口再更新订单状态。回调里注意做幂等处理不然同一笔支付回调两次会导致订单状态被覆盖。5.3 Redis缓存和数据库的一致性问题系统里有一个高频查询接口游客查看某导游未来30天哪些日期可约。这个接口被首页和筛选页频繁调用一开始直接查数据库高峰期数据库CPU飙升。后来加了Redis缓存key是guide:schedules:{guideId}:{month}缓存30分钟。问题出在导游或管理员修改了排期后游客端看到的还是旧数据。我们的处理很简单凡是更新排期状态、删除排期、修改导游信息都会主动删除对应的Redis key下次查询再回源数据库。删除缓存比更新缓存简单可靠因为更新缓存要保证数据格式完全一致删掉反而省心。但这里也有个经典坑先删缓存后更新数据库会导致并发查询把旧数据又写回缓存。更稳的顺序是先更新数据库再删除缓存。即便如此极端情况下还是会有短暂不一致但30分钟过期时间给了兜底游客实际影响很小。如果以后要求更高可以引入Canal监听binlog更新缓存那是另一个深水区中小项目没必要。5.4 配置环境分离与文件上传路径开发环境、测试环境、生产环境配置不一样如果手改配置文件打包迟早会出事。我用了多Profile方式spring: profiles: active: profile-activepom.xml里配置了dev、test、prod三个profile。打包时不改代码只改参数mvn clean package -DskipTests -Pprod因为Spring Boot最终打包成可执行jar不同环境的application-xxx.yml会被打进去启动时通过--spring.profiles.activeprod指定使用哪个。本地跑dev环境服务器上跑prod环境配置互不干扰。还有一个很小的坑导游头像上传到本地磁盘时部署在Linux服务器上目录权限不够导致上传失败。后来统一把文件上传路径配到/data/upload/并在启动脚本里先mkdir -p再给运行用户授权。如果要正规化应该用对象存储但小项目用本地目录配合nginx静态代理完全够用。5.5 线程池资源耗尽的问题系统里有一个定时任务每天凌晨会把第二天所有导游的排期状态初始化。一开始用Executors.newFixedThreadPool(10)创建线程池上线后某天通知服务突然全部卡住排查发现定时任务和短信发送共用了同一个线程池任务堆积把线程池占满了。Spring Boot官方并不推荐Executors创建线程池而是建议用ThreadPoolTaskExecutor并明确核心线程数、队列容量、拒绝策略。后来把短信发送线程池单独隔离出来核心线程4个队列500拒绝策略是CallerRunsPolicy也就是执行不了就退回调用方线程执行保证通知不丢。线程池资源是公共资源业务之间一定要隔离。另外定时任务目前没有加分布式锁因为只部署一台服务器。如果以后要部署两台定时任务会重复执行初始化排期需要引入ShedLock或者用Redis锁包一层。我提前在代码里预留了LockService接口后面扩展时不用改业务逻辑。5.6 订单号生成的幂等与冲突问题订单号如果只用时间戳 随机数在并发下很容易重复。我踩过一次订单号重复导致的数据库主键冲突当时是同一毫秒内两个请求生成了相同的随机数。后来改成基于数据库序列或者Redis自增生成String orderNo G LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)) String.format(%06d, redisTemplate.opsForValue().increment(order:seq: LocalDate.now()));Redis的INCR是原子操作同一天内从1开始递增补足6位基本不会重复。同时数据库的order_no字段加了唯一索引作为最后兜底。如果想让订单号更不可预测可以在后面加两个随机字符但对账其实不喜欢随机字符所以保持纯数字递增更实用。到这里系统所有核心环节基本都过了一遍。最后说一点个人体会这种预约类系统的难点从来不是某个接口写不出来而是数据状态流转是不是严谨、并发下库存会不会超、上线后时间和缓存会不会出幺蛾子。你在做类似项目时先把状态机和表结构画清楚再去写代码真的能省掉后面大量的返工。我踩过的时区、事务、线程池和订单号这几个坑前三个几乎在同类项目里必遇到希望这篇分享能让你绕开它们。
RELATED

相关推荐

SpringBoot与微信小程序:个性化服装搭配推荐系统部署实战

SpringBoot与微信小程序:个性化服装搭配推荐系统部署实战

如果你正在做一个Spring Boot 微信小程序的毕设项目,或者想快速上手“小程序 后端接口”这套组合拳,那么这个标题里提到的“个性化服装搭配推荐”项目,基本能把一条完整的技术链路给你串起来。它不是单纯教你写几个CRUD接口,而是…

📅 2026/10/9 3:22:19
SSM+Vue教学日志管理系统毕设:从环境配置到答辩全攻略

SSM+Vue教学日志管理系统毕设:从环境配置到答辩全攻略

开题季刚过,又是一年中最热闹的毕设周期。今年问得最多的题目里,“SSMVue教学日志管理系统”至少出现了十几次。说句实话,这个题目能在“Spring Boot Vue”大行其道的今天依然坚挺,靠的不是噱头,而是实实在在的稳妥—…

📅 2026/10/9 3:22:19
AI反拖延实战指南:把模糊任务拆成无脑第一步

AI反拖延实战指南:把模糊任务拆成无脑第一步

前阵子读到一篇英文文章,标题大意是《AI是如何解决我的拖延症的》。作者是个独立开发者,把自己和拖延搏斗的过程写得特别具体,不是那种“AI真厉害”的爽文,而是把拖延拆成了十几种触发点,逐一讲AI在哪一步真正帮上了忙…

📅 2026/10/9 3:22:19
MORE NEWS

更多资讯

📰

Agent-Reach 实战:CLI 驱动的 AI Agent 执行框架与工具调用

1. 从零认识 Agent-Reach:它到底解决什么问题第一次看到 Agent-Reach 这个名字,很多人会以为又是一个套壳的聊天机器人。实际用下来你会发现,它更像是一套给 AI Agent 装上“手脚”的中间层工具。简单说,Agent-Reach 是一个基于 C…

📰

Agent-Reach:AI Agent生产可用的关键触达能力,你了解吗?

这两年只要聊到 AI Agent,大家习惯性先比模型参数和推理能力,仿佛 prompt 调得越花,Agent 就越接近“智能”。但真正把 Agent 推上线、跑业务的人心里都清楚:模型只是大脑,Agent 能不能干活,还得看它能不能…

📰

万字长论文批量降AI:从全篇扫描到分章精修的完整流程

长文档的降AI处理,听起来像是应该放在论文写完以后再做的事,但我的实操经验正好相反:如果你写的是几万字、十几章的长论文,等到全文拼起来才发现“AI味”过重,那工作量几乎是灾难级的。我之前处理一篇五万多字的硕士论…

📰

单词拆分LeetCode 139:从动态规划到面试追问的完整拆解

LeetCode热题100刷到第82题,单词拆分(Word Break),这道题我太有印象了——去年面一家独角兽的时候被原题面过,当时只要求判断能否拆分,答完后面试官轻描淡写补了一句"那如果要求输出所有拆分方案呢&qu…

📰

栈算法核心:单调栈、表达式求值与回溯递归的实战指南

1. 先把栈的本质聊透:不只是“先进后出”栈这个数据结构,几乎所有写代码的人第一天就见过,但真正到算法题里能把它用明白的,其实不多。很多朋友问我“栈怎么刷题”,我的回答永远是:先把三个场景啃透&#x…

📰

JCache接口键不存在时get与put行为详解及避坑指南

后台总有读者在准备Java面试,问得比较多的一道"基础篇"题目就是今天要聊的:JCache(JSR-107)中 Cache 接口的 put 和 get 方法,在键不存在时到底是什么行为。题目确实只有一句话,但这句话背后牵出…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬