news 2026/9/30 4:34:23

基于SpringBoot的理发店会员预约系统实战:从数据库设计到并发控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SpringBoot的理发店会员预约系统实战:从数据库设计到并发控制

理发店会员预约网站这类管理系统,在Java技术栈里可以说是常青树级别的实战项目。它业务边界清晰——会员管理、预约排班、订单流水、统计报表,每一块都足够典型,又不会复杂到失控,正好用来把SpringBoot的核心能力完整串一遍。这篇文章我会从一个实际开发者的角度,把整个系统的设计思路、表结构、关键代码、踩坑记录全部拆开来讲,不仅讲怎么实现,更讲为什么这么实现。

1. 项目整体设计与技术选型思路

1.1 为什么选SpringBoot而不是传统SSH/SSM

很多人在做这类管理系统时,第一反应是纠结技术栈。我个人的建议非常明确:以SpringBoot为基底。原因有三层。

第一层是开发效率。SpringBoot的自动装配机制把大量繁琐的配置收进了starter里。传统SSM项目要写一堆XML配置数据源、事务管理器、MyBatis映射,而SpringBoot只需要引入spring-boot-starter-web和spring-boot-starter-data-jpa或者mybatis-spring-boot-starter,再配一个application.yml,一个能跑起来的Web工程就成型了。你可以把SpringBoot的自动装配理解成装修房子时的“拎包入住”——框架已经帮你把水电、墙面、地板都铺好了,你只管往里面摆家具。核心入口就是@SpringBootApplication这个组合注解,它里面藏着@EnableAutoConfiguration,框架会扫描META-INF/spring.factories文件里注册的所有自动配置类,按条件注解(@ConditionalOnClass、@ConditionalOnMissingBean)决定哪些配置生效。

第二层是生态兼容。理发店会员预约系统需要权限认证、接口文档、缓存加速、定时任务,这些在SpringBoot生态里都有非常成熟的starter。用spring-boot-starter-security做登录授权、springdoc-openapi生成Swagger文档、spring-boot-starter-data-redis做缓存、@Scheduled做定时任务,基本不需要自己造轮子。

第三层是部署便捷。项目打包成可执行JAR,内嵌Tomcat,部署时只需要java -jar一行命令。这个优势在后期演示和交付时特别明显,不需要在目标机器上单独装Tomcat、配环境变量。

1.2 业务模块划分与功能矩阵

理发店会员预约系统,表面看是一个信息管理网站,实际拆开看,核心业务链路是:顾客注册/登录 → 浏览理发师与服务项目 → 选择时段提交预约 → 到店消费 → 会员储值/结算 → 积分与等级成长 → 数据统计。

围绕这条链路,我规划了六个核心模块:

  • 会员管理:注册、登录、个人信息维护、会员卡储值、消费明细查询。
  • 服务项目管理:理发项目(剪发、染发、烫发、护理)的增删改查、价格维护、时长设置、上下架状态。
  • 理发师管理:技师档案、擅长项目、服务时段配置、排班状态。
  • 预约管理:在线选店、选技师、选时段、提交预约、取消预约、预约状态跟踪。
  • 订单与结算:到店后生成消费订单、扣减储值余额或现金结算、生成积分。
  • 统计报表:日/周/月营业额、热门项目排行、理发师业绩排名、会员增长趋势。

这里有个容易犯的错:一上来就堆功能,把系统做成大杂烩。我在设计时坚持一个原则——核心链路优先,辅助功能其次。预约模块是绝对的核心,先把它做扎实;营销类的满减优惠、分享裂变等功能,在核心链路跑通后再迭代加。项目进度可控性会好很多。

1.3 技术栈选型的取舍

技术栈我选了以下组合,每一层都有明确理由:

层次选型理由
核心框架SpringBoot 2.7.x稳定成熟,资料多,避免过高版本带来的兼容问题
持久层MyBatis-Plus单表CRUD零XML,复杂的统计查询用注解SQL或XML
数据库MySQL 8.0InnoDB引擎,事务支持完善,8.0窗口函数方便做排行
缓存Redis缓存首页数据、验证码、热点服务项目,减轻数据库压力
认证JWT无状态登录,前后端分离场景下扩展性好
前端Vue 3 + Element Plus组件化开发快,后台管理的表格、表单场景完美覆盖

这套组合的逻辑是:数据库用MySQL保证事务可靠,缓存用Redis扛住高并发读,接口层用SpringBoot统一收口,前端用Vue做SPA。整个链路没有花哨的成分,每一环都是实践中验证过的高性价比方案。

2. 核心功能拆解与数据库设计

2.1 数据库设计的核心矛盾

这类管理系统的数据库设计,最大的难点不在表多,而在业务状态的一致性。预约要保证时段不冲突,储值要保证金额不超扣,订单要保证流水可追溯。这三个“保证”决定了表结构不能拍脑袋随便建。

我的设计思路是:用主表记录核心实体,用流水表记录每一次变动,用状态字段标记当前所处阶段。比如会员卡余额,一定不要只靠一个balance字段死撑——万一有人并发充值、并发消费,余额就错乱了。正确做法是有一张member_transaction流水表,每一笔充值和扣费都记一条流水,balance作为冗余字段用于快速展示,实际对账以流水表为准,涉及金额变更的接口必须开启数据库事务。

2.2 核心表结构详解

这里挑几张关键的表展开说。

会员表member

核心字段有id、member_no(会员编号,建议用业务规则生成,比如M+年月日+四位流水号)、name、phone、password、balance(储值余额,Decimal(10,2))、level_id(关联等级表)、total_consumption(累计消费,用于评级)、created_at。

其中total_consumption是累计消费额,不是当月消费,它是会员等级晋升的判断依据。等级又分普通、黄金、铂金、钻石几档,每档对应不同的折扣率。这里要注意:折扣率不能直接写死在会员等级表里,因为后续做活动时可能出现“全场8折但储值会员再享95折”这种叠加场景。为了不过度设计,我在等级表放了discount字段,结算时取会员等级折扣与服务项目原价相乘,叠加活动优惠用单独的coupon表处理,互不干扰。

理发师表barber

字段包含id、name、avatar、title(如首席设计师、高级设计师)、years_of_exp、skill_tags(擅长项目标签,用逗号分隔字符串存储)、status(在职/休息)、service_times(每天的服务时段,JSON格式存储)。

重点是service_times。理发师不是全天候接单的,中午休息、每周轮休,这些都要可配置。我用了JSON字段存每天的时段,比如[{"start":"10:00","end":"12:00"},{"start":"13:30","end":"19:00"}]。这种设计在配置灵活性上碾压固定每天9点到18点的硬编码,虽然查询时不能直接走索引,但这种低频配置数据完全无所谓。

服务项目表service_item

核心字段是name(项目名称)、price(原价)、duration_minutes(服务时长)、category(剪发/染发/烫发/护理)、status(上架/下架)、image、description。

duration_minutes这个字段很多人会忽略,但它恰恰是预约冲突检测的基础。剪发30分钟、染发90分钟,不同项目的时长不同,预约排班时必须以服务时长为单位做时间窗计算。

预约表appointment

字段包括id、member_id、barber_id、service_item_id、appointment_date、start_time、end_time、status(待确认/已确认/已完成/已取消/爽约)、remark、created_at。

预约表是系统的核心枢纽,所有并发冲突问题都集中在这张表上。end_time的设计非常关键,它不是用户选的,而是后端根据start_time + service_item.duration_minutes自动计算出来的,目的是为了后续做时间冲突检测时有确定的区间边界。

2.3 预约时间冲突检测的算法设计

这是整个系统技术含量最高的一块。预约的本质是在特定理发师的时间轴上,分配一段不重叠的时间区间。我采用的是经典的区间重叠判断。

假设新预约的时间区间是[newStart, newEnd),已存在的预约区间是[existStart, existEnd),两者存在冲突的条件是:

newStart < existEnd AND newEnd > existStart

这个公式可以覆盖所有相交的情况:新预约完全在已有区间内、部分重叠、旧区间完全包含新区间。SQL写出来就是:

SELECT COUNT(*) FROM appointment WHERE barber_id = #{barberId} AND appointment_date = #{date} AND status IN ('待确认', '已确认') AND (start_time < #{newEnd} AND end_time > #{newStart})

这里有个细节:为什么状态要过滤掉“已取消”和“已完成”?因为取消的预约已经释放了时间片,完成的预约也过去了,不应该阻塞未来的排班。另外,end_time在设计时用开区间处理,即一个预约16:00结束,另一个预约16:00开始,不算冲突。这符合门店的实际体验——前一个客人刚结束,技师需要收拾一下,所以我在判断结束时间时加了一个缓冲字段buffer_minutes,默认15分钟清理准备时间,防冲突时再叠加。

2.4 会员储值与订单结算的事务控制

储值和消费涉及的金额变动,必须放在数据库事务里。一个典型的消费结算流程是:

  1. 校验会员状态正常、余额充足。
  2. 计算订单金额,使用会员等级折扣。
  3. 扣减会员余额。
  4. 插入订单记录。
  5. 插入储值流水(金额为负)。
  6. 更新会员累计消费额和积分。

这六步任何一步失败,都必须全部回滚。在SpringBoot中,我直接在服务层方法上加了@Transactional(rollbackFor = Exception.class)。这里有个新手容易踩的坑:@Transactional默认只在抛出RuntimeException时回滚,如果你在方法里try-catch吞掉了异常,事务是不会回滚的。所以要么不catch,要么catch后throw new RuntimeException重新抛出。

再细一层,扣减余额时不能先查再算再更新,要用乐观锁/条件更新防超扣:

UPDATE member SET balance = balance - #{amount} WHERE id = #{memberId} AND balance >= #{amount}

如果返回的影响行数为0,说明余额不足,直接抛出业务异常。这种写法比先SELECT balance再在Java代码里判断更安全,因为数据库层面的原子操作天然避免了并发问题。

3. SpringBoot关键原理在项目中的落地

3.1 自动装配原理与自定义starter理解

用SpringBoot写了这么多业务代码,很多人其实没搞懂框架是怎么把一切串起来的。我在做这个项目期间,认真梳理了自动装配的完整链路,对排查问题帮助特别大。

一切从@SpringBootApplication开始,它等同于@SpringBootConfiguration、@EnableAutoConfiguration、@ComponentScan三个注解的集合。@EnableAutoConfiguration会通过AutoConfigurationImportSelector加载classpath下META-INF/spring.factories(SpringBoot 2.7及以下)或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(SpringBoot 3.0+)中声明的自动配置类。

比如引入spring-boot-starter-data-redis后,RedisAutoConfiguration会被加载,而它里面的@ConditionalOnClass(RedisOperations.class)会检测classpath下是否存在Redis的客户端类,如果有,就自动创建RedisTemplate和StringRedisTemplate交给容器管理。这就是为什么你什么都不写,就能直接@Autowired一个RedisTemplate用——框架在启动阶段就帮你准备好了。

理解了这套机制后,我就琢磨着把项目中重复的“登录鉴权逻辑”抽成一个自定义starter,给团队内部复用。做法是在resources/META-INF/spring.factories里配置自定义自动配置类,在配置类中用@ConditionalOnProperty控制开关,实现“引入依赖即生效,配置开关可关闭”的效果。这个思路放到理发店会员系统里,最合适的落地场景就是通用的Token校验拦截器:写一个AuthInterceptor自动配置类,把所有需要登录才能访问的接口统一拦下来做JWT校验。

3.2 JWT无状态登录的设计要点

这个系统的登录认证,我用的是JWT而非传统的Session方案。Session模式在单体应用里其实也没问题,但JWT的无状态特性让后续扩展移动端小程序、前后端分离部署都更从容。

JWT的核心逻辑是:用户登录成功后,服务端签发一个三段的Token——Header(声明算法)、Payload(携带用户ID、手机号、过期时间)、Signature(签名)。后续请求在Authorization头带上Token,服务端验签通过即认为身份合法。

落地时有两个容易忽略的点。

第一个是Token过期策略。我只签发AccessToken,有效期设为2小时。有同学建议加RefreshToken做续签,但对这种轻量系统来说过度设计了,用户重新登录的成本很低。真要提升体验,可以在拦截器里做“剩余有效期不足30分钟自动续签”,返回新Token让前端替换。

第二个是秘钥管理。绝不硬编码在代码里,放在application.yml中用环境变量占位符引用:

jwt: secret: ${JWT_SECRET:defaultSecretKeyForDev}

上线时通过系统环境变量注入真实的秘钥。顺带说一句,JWT虽然叫无状态,但一旦发生用户被封禁,已签发的Token在过期前依然有效。要解决这个问题,最简单的方案是把“用户状态版本号”放进Payload里,改密码或封禁时把版本号+1,验签时对比版本号不一致就拒绝。我在这个系统的“用户修改密码后强制重新登录”需求里就用到了这个思路。

3.3 全局异常处理与参数校验

一个管理系统的接口,如果没有统一的异常处理,那前端收到的报错信息会长得五花八门。我在项目里建立了一套完整的异常处理体系。

先定义一个业务异常类BizException extends RuntimeException,携带code和message两个字段。然后在全局异常处理器@RestControllerAdvice中分三层处理:第一层捕获BizException,返回业务错误码和提示信息;第二层捕获MethodArgumentNotValidException,把参数校验失败的具体字段名和错误消息组装好返回;第三层兜底捕获Exception,记录完整堆栈日志,返回“系统繁忙,请稍后重试”。

同时,所有的DTO参数都使用JSR-303规范注解校验。比如预约请求对象:

public class AppointmentRequest { @NotNull(message = "理发师ID不能为空") private Long barberId; @NotNull(message = "预约日期不能为空") @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate appointmentDate; @NotNull(message = "预约开始时间不能为空") @JsonFormat(pattern = "HH:mm") private LocalTime startTime; }

这样Controller层根本不需要写一堆if判断,框架在参数绑定阶段就完成了校验,非法请求直接进全局异常处理器。

3.4 XSS安全过滤的全局实现

这类管理系统免不了有留言或备注字段,如果不做安全过滤,攻击者在填入<script>alert('xss')</script>,前端渲染时就会执行恶意脚本。我在项目中用了一个全局过滤器来处理上传数据里的XSS风险。

做法是继承OncePerRequestFilter,重写doFilterInternal方法,用HttpServletRequestWrapper包装原始请求,在getParameter、getHeader等取值方法里对返回值做HTML标签清洗。用一个工具类把<、>、"、'等危险字符转义成安全的实体字符。只过滤text类型的参数,JSON请求体里的内容由MappingJackson2HttpMessageConverter配合自定义的反序列化器处理。这里要注意:不能无脑过滤所有内容,比如富文本编辑器上传的内容本身就含HTML标签,过度转义会导致显示异常。我在过滤器里加了白名单,只有配置了过滤开关的URL路径才生效,这个开关在配置文件中开合。

4. 实操过程与核心环节实现

4.1 从零搭建SpringBoot工程

把这个项目的搭建过程完整梳理一遍,直接照着操作就能复现。

先解决环境问题:JDK用1.8或11都行,Maven用3.6+,IDE用IDEA。新建项目时,通过Spring Initializr勾选依赖:Spring Web、MyBatis-Plus Framework(或MySQL Driver + MyBatis)、Validation、Lombok。不需要勾选Spring Security,因为JWT已经自己实现了,减少框架组合的复杂度。

工程建好后,pom.xml里引入关键依赖:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

application.yml的核心配置:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/barber_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 3 mybatis-plus: mapper-locations: classpath*:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里有个热词提到“SpringBoot版本太高”的担忧,很现实。SpringBoot 3.x要求JDK 17,且javax.*包迁移到了jakarta.*,很多旧教程的代码直接跑不起来。如果你跟着我的版本走(2.7.x + JDK 8),一切都会顺利很多。另外,IDEA创建项目时默认的SpringBoot版本可能已经很高,记得在Initializr界面切换成2.7.18。

启动类上标注@MapperScan("com.example.barber.mapper"),让MyBatis-Plus扫描到所有的Mapper接口。到这里,一个空白的SpringBoot工程就已经能启动了。

4.2 前端页面的组织方式

这个系统的前端我选Vue 3 + Element Plus,但考虑到有的同学不熟悉前后端分离的完整工程配置,这里至少要把结构说清楚。

前端工程用Vite创建,目录结构按views、components、api、router、store分好。核心页面有:登录页、首页仪表盘、会员管理页、预约日历页、订单管理页、统计报表页。接口请求统一封装在utils/request.js里,基于axios,拦截器里做两件事——请求带Token、响应统一处理业务码。预约日历页是全系统交互最复杂的页面:左边是理发师列表,中间是时间轴,点击空白格弹出预约弹窗,选择服务项目后自动计算时长并渲染区间。

前后端联调时最大的坑是跨域。我在SpringBoot里配置了全局CORS:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

需要提醒的是,上线部署时如果前端和接口通过Nginx映射到同一个域名下,用location /api反代,跨域问题从根本上就消失了,不需要在代码里放开CORS。

4.3 预约流程的完整实现链路

预约功能从点击到最后落库,完整链路是:

前端提交AppointmentRequest→ Controller接收并做参数校验 → Service层开启事务 → 校验会员身份和状态 → 校验理发师当天排班时间内是否合规 → 执行时间冲突检测SQL → 计算end_time→ 插入预约记录 → 返回预约成功信息。

关键代码集中在时间冲突检测。这里用MyBatis-Plus的QueryWrapper可以这样写:

long conflictCount = appointmentMapper.selectCount( new QueryWrapper<Appointment>() .eq("barber_id", request.getBarberId()) .eq("appointment_date", request.getAppointmentDate()) .in("status", Arrays.asList(1, 2)) // 待确认、已确认 .lt("start_time", endTime) .gt("end_time", startTime) ); if (conflictCount > 0) { throw new BizException(400, "该理发师在所选时间段已有预约,请更换时间"); }

这里用的是start_time < endTime AND end_time > startTime,也就是我前面讲的区间重叠公式。endTime的计算是LocalTime.parse(request.getStartTime()).plusMinutes(serviceItem.getDurationMinutes() + bufferMinutes)。

有个细节容易被忽略:预约日期边界。如果预约时间跨天,比如晚上10点开始,服务时长90分钟,endTime到了23:30,但部分服务可能做到午夜。虽然理发店很少这么晚,但保险起见,我做了跨天处理:日期存LocalDate,时间存LocalTime,跨天预约场景先不做,直接限制最晚预约开始时间为21:00。该限制写在前端表单校验和后端Service校验两层里,双保险。

4.4 排班管理与定时任务处理

理发师的排班我做成了一张独立的barber_schedule表,字段是barber_id、work_date、start_time、end_time、status(正常/休息)。这样比在barber表里存JSON更规范,也方便后期做调休管理。

预约提交时,除了查预约冲突,还要先查该理发师当天是否有排班、预约时间段是否落在排班区间内。两个校验都通过,才允许落库。

另外我写了一个定时任务,每天早上6点把当天排班信息加载进Redis缓存,key是barber:schedule:{date},value是JSON数组。前端查询“某天某理发师的空闲时间”时直接走缓存,大幅降低数据库压力。定时任务用SpringBoot自带的@Scheduled(cron = "0 0 6 * * ?"),并在启动类上加@EnableScheduling开启定时支持。要注意:Redis缓存里的排班信息如果有变更(比如理发师临时请假),必须主动删除对应key,让下次查询时回源数据库,而不是等待缓存过期。我实现了一个CacheService专门管理这把缓存,排班变更接口里调用删除逻辑。

4.5 统计报表的SQL编写心得

统计报表是这类系统的门面功能,老板最关心的就是“这个月赚了多少”“哪个理发师最赚钱”。我做了三个核心报表:

营业额日报,按天聚合订单金额:

SELECT DATE(create_time) AS day, SUM(amount) AS total_amount, COUNT(*) AS order_count FROM orders WHERE create_time >= #{startDate} AND create_time < #{endDate} GROUP BY DATE(create_time) ORDER BY day;

理发师业绩排行,按月统计每个理发师的完单数和营业额:

SELECT b.name AS barber_name, COUNT(o.id) AS finish_orders, SUM(o.amount) AS total_amount FROM orders o JOIN barber b ON o.barber_id = b.id WHERE o.status = 3 AND o.create_time >= #{monthStart} GROUP BY b.id, b.name ORDER BY total_amount DESC;

热门项目排行,分析服务项目销售占比:

SELECT s.name AS service_name, COUNT(od.id) AS sale_count, SUM(od.amount) AS total_amount FROM order_detail od JOIN service_item s ON od.service_item_id = s.id GROUP BY s.id, s.name ORDER BY sale_count DESC LIMIT 10;

MySQL 8.0支持窗口函数,如果要做“环比增长率”“Top N”这种分析,直接用ROW_NUMBER() OVER(PARTITION BY ...)解决,比在Java代码里多次查询再内存计算高效得多。

4.6 后端API列表一览

为了方便前端对接,我把主要的API设计整理成一个对照表:

模块请求方式路径功能说明
认证POST/api/auth/login会员/管理员登录,发放JWT
认证POST/api/auth/register会员注册
会员GET/api/member/profile获取当前会员信息
会员PUT/api/member/balance/recharge储值充值
会员GET/api/member/transactions会员储值流水
理发师GET/api/barber/list理发师列表(筛选在职)
理发师GET/api/barber/{id}/schedule某理发师某天的排班
服务GET/api/service/list服务项目列表(仅上架)
预约POST/api/appointment新增预约
预约GET/api/appointment/mine我的预约列表
预约PUT/api/appointment/{id}/cancel取消预约
统计GET/api/report/daily每日营业额统计

5. 常见问题与排查技巧实录

5.1 预约时间冲突检测失效的排查

这是我在测试阶段踩过最大的坑。场景是:A用户预约了10:00到11:00的剪发,B用户紧接着预约10:30到11:30的染发,系统居然提示预约成功。查问题花了一个下午,最后发现根源在于LocalTime的JSON反序列化格式不匹配。

前台传的是字符串"10:30",后端DTO里的字段类型是LocalTime,如果没有加@JsonFormat(pattern = "HH:mm")注解,Jackson默认反序列化格式是HH:mm:ss,直接抛异常。我是在全局异常兜底里看到的错——前端收到了“系统繁忙”,但具体原因被吞了,还以为是自己代码逻辑写错了。

排查思路给各位参考:

  1. 打开MyBatis的SQL日志(log-impl: org.apache.ibatis.logging.stdout.StdOutImpl),看实际执行的SQL是不是预期的冲突条件。
  2. 检查DTO字段的时间格式,确认反序列化没报错。
  3. 手工在数据库里插入一条冲突记录验证SQL逻辑本身是否正确。
  4. 检查事务边界,确认冲突检测和插入预约是否在同一个事务里,避免检测完还没插入,另一个请求就插入了。

这里明确一点:冲突检测和落库插入必须同一个事务,否则两个请求同时通过检测,然后先后插入,依然会撞车。不过要彻底解决并发问题,光靠事务不够,还需要给预约表加唯一约束。我的做法是加了一个uk_barber_date_start唯一索引,字段是barber_id + appointment_date + start_time,数据库层面兜底,双保险。

5.2 会员余额并发扣减导致负数

上线后遇到一个bug:两个订单同时结算,都通过了“余额是否充足”的校验,结果余额被扣成了负数。根因是传统的SELECT balance再在Java代码里比较,再UPDATE的模式存在时间差,并发请求全都读到了同一个旧的余额值。

解决方案就是我前面提到的条件更新SQL:

UPDATE member SET balance = balance - #{amount} WHERE id = #{memberId} AND balance >= #{amount}

这条SQL在数据库层面原子地完成了“判断余额够不够”和“扣减”两步操作。Java代码里判断影响行数,如果是0,说明余额不足或会员不存在,直接抛异常。这个方案简单、可靠,完全够用,不需要引入分布式锁这种重型方案。

顺带补充:如果是高并发秒杀类的场景,条件更新也可能因为InnoDB的行锁等待产生性能瓶颈,但理发店的并发量级完全不用担心。

5.3 Redis缓存与数据库数据不一致

做首页展示时,我把“热门服务项目”和“推荐理发师”缓存到了Redis,大大加快了首页加载速度。但出现了另一个问题:管理员在后台修改了项目价格或下架了某个项目,前台首页却没有立刻生效,还是旧数据。

原因是修改接口只更新了数据库,没有清理Redis缓存。解决方案是在写操作成功的事务提交后,主动删除对应缓存key:

@Transactional public void updateService(ServiceItem item) { serviceItemMapper.updateById(item); cacheService.delete("home:hot_services"); }

需要留意的是删除时机。如果在事务提交前删缓存,另一个请求可能先查询到旧数据库值再写回缓存,依然不一致。稳妥的做法是用@TransactionalEventListener监听TransactionPhase.AFTER_COMMIT事件,在事务真正提交后再删除缓存。虽然步骤多了一层,但可以做到万无一失。

5.4 前端上传图片失败的排查

理发师头像和项目图片用的是本地磁盘存储,Nginx映射静态资源路径。开发环境一切正常,部署到服务器后,头像上传一直报404。排查了一圈发现是Nginx配置的问题:前端上传请求到了服务端,服务端保存成功,但前端回显图片路径时,浏览器直接发起了对图片URL的请求,Nginx没把那个路径映射到磁盘目录。

解决方法是调整Nginx的静态资源映射:

location /uploads/ { alias /data/barber_shop/uploads/; expires 7d; }

另外,图片上传接口要限制文件大小和类型,直接在SpringBoot配置spring.servlet.multipart.max-file-size为5MB、max-request-size为10MB,超出返回友好提示。

5.5 JWT过期与前端路由跳转的处理

前端遇到的另一个典型问题是:登录后闲置半小时,再点击页面操作,接口返回401,页面直接白屏。前端 axios 拦截器收到 401 后,需要做两件事:清除本地Token、跳转登录页并携带redirect参数让登录成功后回到原页面。

// request.js 响应拦截器 if (error.response && error.response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname); }

同时,后端需要在拦截器里放过“登录、注册、获取验证码”等公开接口,避免登录页自身也走鉴权导致循环跳转。可以用一个Whitelist集合做路径匹配,集中管理放行规则。

5.6 常见问题速查表

现象可能原因解决方案
预约时提示“该时段已有预约”但界面显示有误冲突检测SQL时间边界写反用newStart < existEnd AND newEnd > existStart严格走开区间
余额充足却结算失败条件更新影响行数为0,存在并发修改检查同账号是否有多个会话并发操作;打印SQL确认条件
JWT登录成功但请求接口401Token未放在请求头或密钥不一致确认Authorization: Bearer <token>格式,确认JWT_SECRET一致
前端看到日期少一天时区问题JDBC URL加serverTimezone=Asia/Shanghai,Jackson配置time-zone
定时任务没执行启动类没有@EnableScheduling补注解;确认cron表达式含义
预览图片404Nginx没映射静态目录或映射路径错检查/uploads/的alias配置,确保和存储路径一致
Maven依赖冲突导致启动失败SpringBoot版本与MyBatis-Plus版本不匹配统一用2.7.x搭配MyBatis-Plus 3.5.x,用mvn dependency:tree查冲突

5.7 如何把项目从源代码管理到部署上线

项目代码管理我用Git,早期就初始化仓库,每次功能模块完成就commit一次,写清楚的commit message——比如feat: 完成会员储值模块、fix: 修复预约时间冲突检测边界。如果团队协作,建议用分支开发再合并到主分支。

部署环节,我提供两个方案。

方案A是JAR包直接部署,适合演示和轻量上线。执行mvn clean package -DskipTests打包,把生成的JAR传到服务器,java -jar barber-shop-1.0.0.jar --spring.profiles.active=prod启动。配合Systemd服务配置实现开机自启和崩溃重启。

方案B是Docker化部署。写一个简单的Dockerfile基于openjdk:8-jre-alpine镜像,把JAR打进去,用docker-compose.yml同时编排MySQL、Redis和应用容器。这样整套环境一条命令拉起,环境一致性问题彻底解决。

数据库初始化脚本用Flyway管理版本,每次表结构变更新建一个V{版本号}__description.sql脚本,启动时自动执行,避免手动同步数据库结构的烦恼。这个实践在生产环境尤其重要。

5.8 后续功能扩展的几个方向

项目做到这个程度,核心链路已经完整。如果还有余力,我建议往这几个方向扩展,难度递增但收益明显:

一是营销模块。加优惠券系统,包含满减券、折扣券、新人券。给会员等级再加一档“充值返还”活动,这个业务规则不难,但能显著提升系统的“完整感”,在演示和答辩时也是一个加分点。

二是消息通知。预约成功、预约提醒、消费结算,都通过短信或微信模板消息推送给用户。先用SpringBoot自带的JavaMailSender发邮件做低配版,接第三方短信平台也不是难事。

三是数据分析面板。目前SQL已经能统计营业额,画成ECharts折线图、饼图、柱状图,能显著提升系统的“高级感”。做一个“本月会员增长趋势”和“项目收入占比”,老板看了直点头。

四是多门店支持。在核心表都加store_id字段,把预约、理发师、服务项目范围收敛到门店,系统就从单店版升级成了连锁版。涉及重构,但表结构预留了扩展空间,这个方向优先级可以放后面。

6. 写在最后的几点真话

整个项目做下来,我最大的体会是:技术栈只是工具,业务逻辑才是骨架。SpringBoot把很多底层细节封装的非常“省心”,这反而要求开发者在业务设计上想得更深——表结构合不合理、并发场景有没有兜底、状态流转有没有漏洞,这些才是系统能不能稳定运行的关键。

最后分享一个我踩过的小坑:开发机上一切正常,但部署到服务器后,系统时间比本地晚8个小时。预约记录显示的时间完全错乱。排查到头,是JVM默认时区和MySQL连接时区设置不一致。解决方式是在启动参数加-Duser.timezone=Asia/Shanghai,JDBC连接串带serverTimezone=Asia/Shanghai,前端请求后端返回的时间格式统一用时间戳或标准字符串。这一下子解决了我大半的时间和日期相关的诡异问题。

如果你照着这个思路做一个类似的会员预约系统,祝少踩坑。遇到问题别慌,先把日志翻出来看,再对照我上面整理的排查清单找方向。动手过一次,你才能真正理解SpringBoot的自动装配、事务控制、缓存应用这些东西在实际业务里是怎么运转的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:33:45

企业RAG知识库实战:Office文档直接喂给AI,自动变成问答机器人

你们公司的共享盘里&#xff0c;是不是也躺着堆成山的 Word 方案、Excel 报表、PPT 汇报和 PDF 制度&#xff1f;想找一份三年前的合同模板&#xff0c;能翻半小时还找不到&#xff1b;新员工问个报销流程&#xff0c;老同事说“在某某部门的共享文件夹里”&#xff0c;结果那个…

作者头像 李华
网站建设 2026/9/30 4:33:43

移动端首页缓存架构优化实战:从接口耗时2.3秒到秒开的完整方案

去年年中有一次版本上线之后&#xff0c;我所在的移动端团队连续接了三天线上报警。首页接口的P95耗时从700ms一路涨到2.3秒&#xff0c;数据库连接池反复打满&#xff0c;值班手机凌晨两点还在响。当时第一反应是“是不是新功能写坏了 SQL”&#xff0c;可查了一圈之后发现&am…

作者头像 李华
网站建设 2026/9/30 4:33:38

强化学习稀疏奖励难题:HER后见之明经验回放原理与实战

如果把强化学习比作爬山&#xff0c;稀疏奖励环境就像一座被云层锁死的山峰&#xff1a;只有在山顶踩到终点的那一瞬间&#xff0c;你才知道自己对不对&#xff0c;而在此之前&#xff0c;所有的中间状态都一片漆黑。传统算法在这种环境里几乎寸步难行&#xff0c;因为学习信号…

作者头像 李华
网站建设 2026/9/30 4:33:19

ARM学习笔记(八)——PWM与ADC

一、主要内容PWM——脉宽调制&#xff1b;ADC——模数转换。二、PWMPWM&#xff1a;Pulse Width Modulation&#xff0c;脉宽调制。PWM 是一种通过控制数字信号的高电平持续时间来改变输出效果的技术。PWM 输出的基本波形&#xff1a;┌──────┐ ┌──────┐…

作者头像 李华
网站建设 2026/9/30 4:32:04

Laya模型System 1决策微调实战:从安装到Ollama部署全流程

在开源大模型圈子里&#xff0c;每隔几天就会冒出一个“新王”。前阵子Jev刚火起来的时候&#xff0c;我也跟风折腾了一番&#xff0c;确实在复杂推理任务上有点东西。但等我真正把Laya跑起来&#xff0c;尤其是在System 1决策这类需要快速响应的场景里实测之后&#xff0c;我的…

作者头像 李华
网站建设 2026/9/30 4:29:24

Transformer论文精读:注意力机制与代码复现

2017年那篇《Attention Is All You Need》我前后完整读过四遍&#xff0c;第一遍是2019年刚接触NLP时囫囵吞枣&#xff0c;只记住了“Transformer”这个名词&#xff1b;第二遍是动手复现时逐公式抠细节&#xff1b;第三遍是给别人做分享&#xff0c;被迫把每个“为什么”都讲清…

作者头像 李华