☰
Spring Boot预约系统实战:数据模型、并发防超卖与权限设计
2026/10/9 3:27:18 网站建设 项目流程

上个月帮当地一家旅行社把导游预约管理从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是当天可接的最大单数,默认1;booked_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连接串里显式加上serverTimezone=Asia/Shanghai:

spring.datasource.url=jdbc:mysql://localhost:3306/travel_guide?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

同时Spring Boot的Jackson也要设置时区,不然JSON返回给前端的时间又会偏:

spring.jackson.time-zone=GMT+8 spring.jackson.date-format=yyyy-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-active@

pom.xml里配置了dev、test、prod三个profile。打包时不改代码,只改参数:

mvn clean package -DskipTests -Pprod

因为Spring Boot最终打包成可执行jar,不同环境的application-xxx.yml会被打进去,启动时通过--spring.profiles.active=prod指定使用哪个。本地跑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字段加了唯一索引,作为最后兜底。如果想让订单号更不可预测,可以在后面加两个随机字符,但对账其实不喜欢随机字符,所以保持纯数字递增更实用。

到这里,系统所有核心环节基本都过了一遍。最后说一点个人体会:这种预约类系统的难点从来不是某个接口写不出来,而是数据状态流转是不是严谨、并发下库存会不会超、上线后时间和缓存会不会出幺蛾子。你在做类似项目时,先把状态机和表结构画清楚,再去写代码,真的能省掉后面大量的返工。我踩过的时区、事务、线程池和订单号这几个坑,前三个几乎在同类项目里必遇到,希望这篇分享能让你绕开它们。

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

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

立即咨询