ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot家政预约系统实战:并发控制、数据库设计与部署

2026/9/28 1:11:19 拓冰建站 浏览量
SpringBoot家政预约系统实战:并发控制、数据库设计与部署 每年都有大量家政公司被预约管理搞得焦头烂额客户电话一个接一个阿姨排班全靠Excel时间撞车只能人工协调月底对账更是噩梦。去年我接了一个家政公司的单子核心诉求就是做一个家政保洁预约管理系统让客户能在线选服务、选阿姨、定时间后台能管订单、管排班、管结算。技术选型我直接用了SpringBoot从0到1把整套系统落地并部署到客户服务器上。这篇文章就把整个设计和实现过程拆开揉碎讲清楚从需求分析、表结构设计到核心代码实现、部署上线全部盘一遍给正在做相关毕设或者想快速搭一套预约类系统的朋友做个参考。1. 项目定位与核心需求拆解1.1 家政保洁预约系统到底解决什么问题先说业务背景。家政公司传统模式的痛点很集中预约靠电话和微信信息记录在纸和Excel里同一个保洁阿姨被两个客户重复预订的情况经常发生。客户要改时间必须打电话找客服客服再去翻记录效率极低。老板想统计业绩和阿姨工作量只能手动汇总经常出错。这套系统要解决的就是三件事第一把预约流程线上化客户自助完成服务选择、时间选定、下单支付不用再打电话第二把排班自动化系统自动检测保洁阿姨的时间冲突把合适的人分配到订单上第三把结算和统计数字化订单状态清晰可见每个阿姨干了多少单、收入多少后台一键导出。实际开发之前我项目组内部先做了角色梳理。系统涉及三类角色普通用户客户、保洁阿姨服务提供者、平台管理员。客户需要浏览服务项目、查看阿姨信息、预约下单、支付、取消预约、评价阿姨需要查看自己的工作日程、接单、确认完成服务管理员需要管理服务项目、审核阿姨信息、处理异常订单、查看经营数据。1.2 核心业务流程与功能边界把业务流程画清楚再动手写代码这个习惯救了我很多次。这个系统的核心流程是这样的用户打开小程序或网页浏览服务项目列表选择一个具体服务比如日常保洁、深度保洁、开荒保洁进入预约页面选择上门地址、期望服务时间段系统根据用户所在区域和时间自动推荐可匹配的保洁阿姨用户确认预约并完成支付订单状态变成待服务系统在服务前一天和当天上午分别发送提醒阿姨完成后点击确认完工用户可以对服务质量进行评价。这里有个容易被忽视的需求点取消预约的策略。用户下单后因为各种原因要取消如果阿姨还没排入日程全额退款如果已经排入需要管理员介入审核。这块业务规则不提前定清楚开发到一半就会被迫返工。功能边界上我刻意砍掉了几个看起来应该做但实际价值不高的模块比如自建的即时通讯、复杂的营销优惠系统。第一版本先跑通核心预约链路后续再迭代。2. 技术选型与SpringBoot方案的优势2.1 为什么选定SpringBoot做技术底座说实话这套系统用其他语言也能做但SpringBoot在这个场景下是最稳的选择。核心原因是生态成熟和团队熟悉。SpringBoot的自动装配机制把大量配置内置化我只需要在pom文件里引入依赖框架自动帮你把数据源、事务管理器、Web服务器配置好能省下至少两到三天的环境搭建时间。版本方面我选了SpringBoot 2.7.18这是2.x系列的最后一个维护版本有持续的安全补丁不像3.x那样模块有大变动。网上有很多关于SpringBoot版本太高的吐槽——3.x要求JDK 17很多学校的服务器和生产环境还在用JDK 8部署容易出问题。选2.7.18配JDK 8很多现存的老项目、公共组件都能无缝兼容这对我来说是最低风险的选择。2.2 整体技术栈清单与选型理由持久层框架MyBatis-Plus。为什么不用Spring Data JPA家政预约系统的查询条件非常多订单状态筛选、时间范围查询、多表关联统计。MyBatis的SQL控制力更强MyBatis-Plus又提供了开箱即用的分页插件和条件构造器开发效率高且SQL可控。数据库MySQL 5.7。业务量级远没到上分布式数据库的程度MySQL单库单表配合合理索引完全够用。5.7版本稳定成熟InnoDB行锁和事务能力都能满足预约场景的并发需求。缓存Redis。用来做验证码存储、服务项目缓存、分布式锁预约防并发用和热数据的缓存。认证方案JWT无状态Token。前后端分离架构下JWT很顺手服务端不需要维护Session登录态校验直接解Token就行。定时任务Spring Task的Scheduled注解。订单超时未支付自动取消、服务前提醒通知这两个场景用Quartz会觉得重Spring自带的调度就够用。部署Docker Docker Compose。统一打包环境交付到客户服务器时不用重新装JDK和MySQL一条命令拉起来。这套组合基本是当前中小型Java Web项目的典型配置没有特别激进的技术胜在稳定、资料多、排错容易。3. 数据库设计与核心表结构3.1 六张核心业务表逐一拆解预约系统逃不开资源-时间-人员这三要素我把表结构设计成围绕订单为中心展开。核心是订单表外围挂服务项目、保洁阿姨、客户、时间槽、评价这几张表。给大家看一下每张表的重点字段和设计意图。用户表userid、openid微信登录唯一标识、昵称、手机号必带索引、角色1用户/2阿姨/3管理员、微信头像URL、状态。注意手机号要加唯一索引因为后续登录、找回密码、消息推送都依赖它。服务项目表service_itemid、名称、类型日常保洁/深度保洁/开荒保洁/家电清洗、计价方式按次/按小时、单价、单位、图片URL、描述、排序权重、上架状态。保洁阿姨表workerid、user_id关联用户表、真实姓名、身份证号做展示脱敏、服务区域用区县编码、等级初级/高级/金牌、服务次数、好评率、服务状态。等级和好评率影响自动匹配推荐权重这是后续核心算法的数据底座。预约订单表appointment_order这张表字段最多也是整个系统的关键所在。包括id、订单号唯一业务编号、user_id、worker_id、service_item_id、服务地址、省市区详细地址、预约开始时间、预约结束时间、服务时长分钟、订单金额、支付方式、支付流水号、支付时间、订单状态0待支付/1已支付-待服务/2服务中/3已完成/4已取消/5售后中、取消原因、创建时间、更新时间。评价表reviewid、order_id唯一一个订单只能评价一次、user_id、worker_id、评分1-5分、内容、图片列表、回复内容、创建时间。时间槽表time_slotid、worker_id、日期、开始时间、结束时间、状态可用/已占用/休息。这张表在冲突检测里起到关键作用后面我会细讲。3.2 时间槽设计与预约冲突检测的核心思路预约系统最麻烦的就是时间冲突。最简单的做法是每次预约时查一下某位阿姨在某个时间段是否已有订单但纯靠应用层判断在并发场景下有漏洞——两个用户同时提交都查到空闲然后都插入成功就撞车了。我的方案是双保险。第一层在数据库层面建立唯一约束对worker_id 预约开始时间做唯一索引同一时间同一阿姨只能有一条预约记录。第二层创建订单和预占时间槽放在同一个数据库事务里先插入时间槽记录再更新订单任何一步失败就整体回滚。这样即使并发请求同时进来数据库的唯一索引也会让后到的插入失败从而保证数据一致性。时间槽的粒度我设置为30分钟一个单位。比如阿姨工作时间是9:00到18:00中间去掉午休一天大概生成16个槽位。用户选9:00-10:00就是连续占两个槽位。表结构里不用存所有槽位只需要在用户下单成功时生成对应的槽位占用记录就行这样数据量不会无谓膨胀。3.3 订单号生成策略订单号我用的格式是日期时间戳14位 用户ID后四位4位 随机数4位一共22位。比如2025022110451200324871。这个方案足够唯一而且从订单号能直接看出下单日期和用户排查问题的时候方便。不要纠结用UUID做订单号可读性差索引效率也略低而且拿UUID跟用户沟通体验极差。4. 核心模块实现预约流程的完整链路4.1 下单接口从用户点击到订单落库前端的流程是选择服务项目→选择日期→选择时间段→确认地址→点击支付→微信支付回调。后端接口设计是POST /api/order/create请求体包含服务项目ID、预约日期、开始时间、服务时长、服务地址、用户备注。后端处理逻辑我拆成四个步骤第一步校验参数。检查服务项目是否上架、服务时长是否在合法范围1-8小时、预约日期是否在可预订范围当天不可约最早明天最长提前30天。第二步计算订单金额。金额不是前端传过来的必须后端根据服务项目的单价和服务时长重新计算。前端传金额是安全漏洞的高发点一定要在后端按价格表重新核算。第三步匹配阿姨。根据用户地址所在区域查询状态正常且在该时段空闲的阿姨列表按照好评率权重排序优先推荐好评率高、服务次数多的高级阿姨。第四步事务内创建订单和占槽。先插入时间槽记录再创建订单记录同一个事务提交。这一步如果有任何异常全部回滚。核心代码大致是这个形态Transactional(rollbackFor Exception.class) public OrderResult createOrder(CreateOrderRequest request) { // 1. 校验服务项目 ServiceItem item serviceItemMapper.selectById(request.getItemId()); if (item null || item.getStatus() ! 1) { throw new BizException(服务项目不存在或已下架); } // 2. 计算金额后端重新计算不信任前端传值 BigDecimal amount item.getPrice().multiply( BigDecimal.valueOf(request.getServiceMinutes() / 60.0)); // 3. 匹配可用阿姨 ListWorker availableWorkers workerMapper.findAvailable( request.getDate(), request.getStartTime(), request.getEndTime(), request.getDistrictCode() ); if (CollectionUtils.isEmpty(availableWorkers)) { throw new BizException(该时间段没有可用阿姨请调整时间); } // 按好评率降序选最优阿姨 Worker selectedWorker availableWorkers.get(0); // 4. 锁时间槽 创建订单 TimeSlot slot new TimeSlot(); slot.setWorkerId(selectedWorker.getId()); slot.setDate(request.getDate()); slot.setStartTime(request.getStartTime()); slot.setEndTime(request.getEndTime()); slot.setStatus(1); // 已占用 timeSlotMapper.insert(slot); // 唯一索引兜底并发冲突 AppointmentOrder order buildOrder(request, selectedWorker, amount); orderMapper.insert(order); return new OrderResult(order.getId(), order.getOrderNo(), amount); }4.2 阿姨自动匹配算法够用就好别一听到算法就以为要上机器学习。这个业务的匹配只要做规则加权就能出不错的效果。我设定的权重是距离权重0.4 好评率权重0.35 服务次数权重0.25。三个指标归一化成0-1分后加权求和分数最高的排在前面。距离数据从哪来用户地址和服务区域都是区县级别的编码同区即为1分同市不同区为0.5分。这个颗粒度对于家政保洁场景是合理的——同一个区的阿姨上门成本最低跨区接单意愿本来就不高。真实上线后再根据数据反馈调整权重系数就行。4.3 订单状态机和支付回调的幂等处理订单状态的流转我是用状态机硬编码管理的明确每种状态下允许哪些操作待支付可取消超时30分钟自动关闭已支付可申请取消管理员审核服务前可改期一次服务中无用户操作阿姨确认完工后进入待评价已完成用户可评价、可发起售后已取消终态退款按取消时机自动触发我单独留一个字段refund_status处理退款状态避免跟订单主状态耦合不然状态枚举会爆炸。支付走的是微信支付Native模式扫码支付。支付回调是重点一定要做幂等处理。回调接口POST /api/pay/notify可能因为网络重试被多次调用所以处理逻辑是根据订单号查询订单表如果订单已经处于已支付状态直接返回成功不再重复修改。PostMapping(/pay/notify) public String handlePayNotify(RequestBody String xmlData) { // 1. 验签 MapString, String result WxPayUtil.parseAndVerify(xmlData); if (!SUCCESS.equals(result.get(result_code))) { return FAIL; } // 2. 根据订单号查询订单 String orderNo result.get(out_trade_no); AppointmentOrder order orderMapper.selectByOrderNo(orderNo); // 3. 幂等判断已经支付过就直接返回 if (order ! null order.getStatus() 1) { return SUCCESS; } // 4. 更新订单状态 写入支付流水 orderMapper.updateStatus(orderNo, 0, 1); payLogMapper.insert(buildPayLog(result)); return SUCCESS; }4.4 全局异常处理与参数校验SpringBoot开发里最容易被忽略的就是全局异常处理。如果不做统一处理每层抛出的异常都会变成一堆堆难看的堆栈信息返回给前端。我在项目里用一个RestControllerAdvice统一拦截业务异常返回错误码和提示信息系统异常返回兜底提示并打印完整日志。参数校验用Validated注解组创建订单的DTO上给每个字段标注约束NotNull、Min等一个注解搞定比手写if判断干净得多。5. 部署上线与性能优化实战5.1 Docker Compose一键部署开发环境用mvn spring-boot:run本地跑没问题但交付给客户时不能让他们按着文档装JDK、MySQL、Redis。我直接用Docker Compose把整个环境编排在一起客户服务器上只需要装一个Docker。version: 3.8 services: mysql: image: mysql:5.7 container_name: booking-mysql environment: - MYSQL_ROOT_PASSWORDroot123 - MYSQL_DATABASEbooking_system ports: - 3306:3306 volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql command: - --character-set-serverutf8mb4 redis: image: redis:6.2 container_name: booking-redis ports: - 6379:6379 app: build: . container_name: booking-app depends_on: - mysql - redis ports: - 8080:8080 environment: - SPRING_DATASOURCE_URLjdbc:mysql://mysql:3306/booking_system - SPRING_REDIS_HOSTredisDockerfile里用多阶段构建先Maven打包再用JRE基础镜像运行FROM maven:3.8-openjdk-8 AS build COPY . /app WORKDIR /app RUN mvn clean package -Dmaven.test.skiptrue FROM openjdk:8-jre-slim COPY --frombuild /app/target/booking-system.jar /app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app.jar]这样部署的整个流程就是三行命令安装Docker把项目文件传到服务器然后执行docker compose up -d。客户那边找个人稍懂一点Linux就能自己拉起来后续维护省了我大量远程指导的时间。5.2 Redis缓存降低数据库压力预约系统的高频读接口是服务项目列表。用户每次打开小程序首页都会请求一次如果不做缓存MySQL每天要扛几万次重复的查询请求。我用Redis缓存服务项目列表缓存时间设置为30分钟后台修改服务项目时主动清除缓存保证数据不滞后。另外一个用Redis的关键场景是超时取消订单。用户下单后如果30分钟没支付订单要自动取消并释放占用的时间槽。我用的方案是下单时向Redis写入一个带过期时间的Key比如order:timeout:{orderId}过期时间30分钟同时利用Redis的过期事件监听过期时触发回调去关单。这样比定时任务每分钟扫表效率高得多不会产生大量无效查询。5.3 接口性能优化心得从客户线上反馈来看早期有几个慢接口集中在订单列表和管理后台的统计报表。排查发现根因是SQL里多表join时缺少索引。我在订单表的worker_id、status、create_time上建了联合索引查询效率提升非常明显。统计报表那块我干脆建了一张汇总表每日凌晨由定时任务聚合前一天的数据查询走汇总表而不是实时扫描订单明细这个改动让报表接口从两秒压到一百毫秒以内。一个心得是不要等到接口慢了再去优化在设计表的时候就考虑好查询模式索引提前加上能避免很多后续麻烦。比如订单表你已经知道用户端会查user_id status阿姨端会查worker_id date管理员会查status create_time直接三个联合索引一次到位。6. 实战踩坑记录与代码级避坑指南6.1 并发预约造成的时间冲突问题这个坑我掉进去过。第一版做冲突检测时我只用了先查再插入的逻辑在本地测试单线程怎么跑都没问题。上到测试环境同事用两个浏览器同时创建同一时段订单竟然都成功了。原因就是两个请求同时查询都发现时间槽空闲然后先后插入数据库因为没有唯一约束所以两条记录都进去了。修复方案上文提过给时间槽表加unique(worker_id, date, start_time)唯一索引插入时兜底。另外再把插入时间槽和创建订单放在同一个事务里。这两个措施加在一起并发场景下必然有一个请求插入失败应用层捕获到唯一键冲突后友好提示该时间段已被预订请更换时间问题解决。6.2 MyBatis-Plus分页插件在SpringBoot 2.7的配置坑MyBatis-Plus的分页插件在较新版本里写法有变化。旧教程里写的是PaginationInterceptor但在MyBatis-Plus 3.5里这个类已被废弃需要改用MybatisPlusInterceptor并添加PaginationInnerInterceptor。我一开始照旧写法配置结果分页一直不生效查了半天文档才找到原因。正确写法Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这是一个典型的小配置坑遇到分页不生效先看版本再查插件类名是否匹配。6.3 全局过滤器处理请求参数的XSS问题系统上线前做了安全测试发现一个XSS漏洞用户在评价内容里故意输入了scriptalert(xss)/script刷新页面时弹窗。这表明后端没有对用户输入做过滤。我加了一个全局过滤器拦截所有请求对请求参数中的HTML标签进行转义尤其是script、iframe这类危险标签直接剥离。实现上写一个XssFilter实现Filter接口包装HttpServletRequest重写getParameter和相关方法对参数值做HtmlUtils.htmlEscape处理。核心代码大致是public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req (HttpServletRequest) request; chain.doFilter(new XssHttpServletRequestWrapper(req), response); } } class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { Override public String getParameter(String name) { String value super.getParameter(name); return cleanXss(value); } private String cleanXss(String value) { if (value null) return null; // 移除script标签及事件属性 String cleaned value.replaceAll((?i)script.*?.*?/script, ); cleaned cleaned.replaceAll((?i)onerror\\s*\\s*[\][^\]*[\], ); return cleaned; } }这个过滤器只对普通请求做处理上传文件相关的接口要注意跳过否则会二进制数据误转义。我当时就是在全局过滤里排除掉/api/file/upload路径否则上传的图片文件都被破坏了。6.4 时间显示差8小时的问题这个经典坑不用多介绍但每次都会有人遇到。MySQL的DATETIME默认时区是服务器的系统时区如果服务器是UTC存进去的时间跟北京时间差8小时。我直接在JDBC连接参数上加了serverTimezoneAsia/Shanghai同时在后端写时间统一用LocalDateTime不依赖系统默认时区。排查思路很简单如果发现线上数据时间都对不上先查数据库时区再查应用连接的serverTimezone配置这两个对齐基本就解决了。6.5 与SpringBoot面试相关的几点准备做这个项目的过程顺便把SpringBoot的几大核心机制弄明白了面试基本绕不开这些自动装配原理spring.factories和ConditionalOnXxx条件注解、starter机制spring-boot-starter-parent统一管理版本、核心注解SpringBootApplication由EnableAutoConfiguration、ComponentScan、Configuration组合而来。如果你正在准备毕设答辩务必要搞懂自动装配的原理面试官几乎必问。简单理解就是SpringBoot启动时EnableAutoConfiguration通过AutoConfigurationImportSelector加载META-INF目录下spring.factories里声明的所有自动配置类再根据当前项目引入的依赖和配置条件ConditionalOnClass、ConditionalOnProperty等决定哪些配置类生效。回答清楚这个逻辑就能看出你真的跑过项目而不是照搬博客。7. 这套系统的后续扩展方向第一版上线稳定之后客户提了几个新需求基本都是顺着这个架构能自然扩展的上门服务开始前的GPS签到、阿姨端小程序、阶梯价和优惠券、按周固定保洁的订阅制服务。这些扩展在现有表结构上都只需要加字段或加独立表说明当初的表设计预留了空间。如果现在让我重新做一遍我会在一开始就把阿姨端小程序放进首期范围因为实际运营中阿姨确认工单、上传服务照片这个环节非常依赖移动端。网页端让阿姨拍照传图总归不方便。另外客户端小程序和服务端接口一定要尽早联调后端不要把接口文档写得太细再动手先定好字段名和格式前端可以并行开发能省出不少时间。写在最后的一点经验这套系统从设计到上线前后用了两个月最大的体会是预约类系统的难点不在CRUD本身而在时间资源的并发控制和状态管理的严谨性。只要你把唯一约束、事务边界、状态机这三点想清楚整个骨架就站得住。另外在技术选型时别因为SpringBoot版本太高或者新技术炫酷而去冒险稳定压倒一切尤其当系统要去真实服务用户的时候。做这类项目先把时间和精力砸在业务细节上比换用再新的框架都更值。