干这行七年多接过的外包项目里家政相关的不算少但真正把“家政服务”和“智能家居设备联动”揉进一个系统的这是我第一次碰到。客户的需求很直白要一套能跑的、基于Spring Boot的现代化家政管理系统能管用户、管阿姨、管订单还要让家里的智能门锁、烟感报警器能和工单系统联动起来。听起来不算难实际落地的时候从数据库表设计到处分派单的并发问题再到Spring Boot 3和MyBatis-Plus的版本兼容每一步都有让人想删库跑路的瞬间。这篇文章就把整个项目的设计思路、关键模块的实操代码、以及踩过的坑完整记录下来给正在做同类系统的朋友一个可参考的样本。如果你刚接触Spring Boot或者正在做类似的家政/O2O服务类系统这篇文章适合你。我会尽量把“为什么这么设计”讲清楚而不是只贴一段能跑的代码完事。从环境搭建到核心模块实现再到部署监控和问题排查整个过程都会涉及内容偏向实战不搞虚的。1. 项目缘起与整体设计思路1.1 为什么家政管理系统首选Spring Boot最开始摆在面前的候选方案有三个Spring Boot、Node.jsNestJS、PythonFastAPI。客户团队里Java背景的人占多数而且后续的维护方明确说不想碰Node、不想碰Python那技术栈基本就锁定了Java生态。Spring Boot在这个场景下确实是合理选择自动配置能省掉大量XML配置内嵌Tomcat让部署变得简单Spring Cloud体系也为后期拆微服务留了后路。更重要的是Spring Boot的生态位。家政管理系统的核心是订单流转和人员调度这类业务对事务一致性要求很高而Spring Boot自带声明式事务配上MyBatis-Plus的乐观锁、悲观锁机制处理并发订单比较顺手。相比之下FastAPI的异步模型虽然在IO密集型场景下有优势但事务控制和后台管理系统的成熟度还是不如Spring生态这在业务系统里是很现实的考量。还有一个容易忽略的点招人。市面上Java/Spring Boot的开发者数量远多于其他两个方向客户后续要自己组建团队维护系统选择Spring Boot意味着招聘成本更低、踩坑的参考资料更多。这个决定从技术角度和商业角度都是稳妥的。1.2 业务模块拆解与数据库设计思路家政管理系统的核心业务并不复杂无非是用户下单、平台派单、阿姨接单、完工结算这条链路。但加上智能家居联动之后复杂度就上来了。我把整个系统拆成了六个核心模块用户端注册登录、服务预约、订单管理、支付退款、评价投诉家政端阿姨端接单/拒单、服务日程、完工确认、收入结算管理后台服务项目管理、阿姨审核/管理、订单调度、价格规则配置设备联动智能设备绑定、设备状态上报、异常告警触发工单消息中心短信通知、站内信、WebSocket实时推送营销活动优惠券、新客礼包、推荐有礼数据库设计是这个项目里最耗时的一环。订单表设计了状态机字段用tinyint存状态值0待支付、1待派单、2已派单、3服务中、4待验收、5已完成、6已取消、7退款中。为什么要单拎一个状态字段而不是用多张表去表达因为订单状态的流转非常频繁单字段加上状态机校验写一个StateMachine类统一处理比多表关联高效得多也便于在代码里控制非法流转。家政人员表单独拆出来不跟用户表混在一起。客户想得比较清楚用户和家政人员的字段差异很大——用户需要偏好标签家政人员需要技能证书、评分、接单数、档期混在一张表里会显得臃肿而且后续如果要做考勤、社保这些功能独立表扩展空间更大。设备表单独建一张记录设备ID、类型、绑定用户、状态上报时间和阈值配置。1.3 技术选型为什么是Spring Boot 3 MyBatis-Plus Redis技术栈选型这件事我的原则是稳定优先生态优先花活少整。最终确定的是Spring Boot 3.2.x MyBatis-Plus 3.5.x MySQL 8.0 Redis 7.0 SpringDocOpenAPI 3。持久层选了MyBatis-Plus而不是Spring Data JPA。原因有三第一家政系统的查询场景复杂订单列表要按时间、状态、阿姨、服务类型多条件组合筛选MyBatis-Plus的LambdaQueryWrapper可以动态拼接条件代码写起来直观第二MyBatis-Plus代码生成器能直接从数据库表生成实体类和Mapper对于后台管理系统这类CRUD偏重的项目开发效率提升非常明显第三团队里多数人熟悉MyBatis的XML写法遇到复杂的报表SQL可以直接写XML可控性更强。Redis在这个项目里承担了四个职责验证码存储、用户Token缓存、热门服务项目的缓存、以及定时任务的分布式锁。一开始没打算用Redis缓存服务列表但压测时发现首页接口在高并发下响应时间超过2秒加了一层缓存之后直接降到200毫秒以内效果立竿见影。分布式锁使用Redis的SETNX实现用在每天的自动派单任务和优惠券发放上避免多个实例同时执行重复操作。2. 核心功能模块设计与实现2.1 用户与家政人员双端认证JWT 自定义注解家政系统的认证模块和普通单端应用不太一样因为用户端、家政端、管理后台三个角色混在一套接口里权限控制必须细粒度。我采用Spring Security JWT的方案但并没有完全依赖Spring Security默认的过滤器链而是自定义了一个Token认证过滤器。具体实现思路登录接口验证账号密码后生成JWT Token载荷里存userId和roleUSER、WORKER、ADMIN三个枚举值。客户端每次请求在Header里带上Authorization: Bearer token自定义过滤器解析Token把用户信息放入SecurityContextHolder。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtTokenProvider jwtTokenProvider; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null jwtTokenProvider.validateToken(token)) { Long userId jwtTokenProvider.getUserId(token); String role jwtTokenProvider.getRole(token); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userId, null, List.of(new SimpleGrantedAuthority(role))); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }角色鉴权用自定义注解RequireRole实现加了注解的接口会自动校验当前用户角色。这个方案比在Controller里写一堆if (!role.equals(ADMIN))清爽得多也方便后续维护Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); } // 使用方式 RequireRole({ADMIN, WORKER}) PostMapping(/order/confirm) public Result confirmOrder(RequestBody ConfirmOrderDTO dto) { // 业务逻辑 }踩坑提示JWT的密钥必须放到配置文件里不要写死在代码中。项目上线前有次代码审查发现同事把密钥硬编码在常量类里后来换成了环境变量注入的方式。另外JWT的有效期不能设太长用户端Token我设置的是7天家政端是24小时管理后台2小时并在Redis里做了对应的Token黑名单用户退出登录或修改密码后立即失效避免Token泄漏后无法撤回的尴尬。2.2 订单全流程从预约到结算的状态机设计订单模块是整个系统的灵魂也是最容易出bug的地方。状态流转我专门写了一个OrderStateMachine来处理不允许在Service层里随意order.setState(5)这种写法否则订单状态会乱成一锅粥。订单状态机定义了所有合法流转路径待支付 - 待派单支付成功回调触发待派单 - 已派单自动或人工派单成功待派单 - 已取消用户主动取消或超时未接单已派单 - 服务中阿姨到达并开始服务服务中 - 待验收阿姨提交完工待验收 - 已完成用户确认验收待验收 - 待重做用户验收不通过生成重做单待支付/待派单 - 退款中 - 已退款 / 已取消用代码来约束状态流转核心是一个Map维护合法路径Component public class OrderStateMachine { private static final MapInteger, ListInteger LEGAL_TRANSITIONS new HashMap(); static { LEGAL_TRANSITIONS.put(0, Arrays.asList(1, 6)); // 待支付 - 待派单/已取消 LEGAL_TRANSITIONS.put(1, Arrays.asList(2, 6, 7)); // 待派单 - 已派单/已取消/退款中 LEGAL_TRANSITIONS.put(2, Arrays.asList(3, 6)); // 已派单 - 服务中/已取消 LEGAL_TRANSITIONS.put(3, Arrays.asList(4)); // 服务中 - 待验收 LEGAL_TRANSITIONS.put(4, Arrays.asList(5)); // 待验收 - 已完成 // 其余流转不合法 } public void validateTransition(int currentState, int targetState) { ListInteger legalTargets LEGAL_TRANSITIONS.get(currentState); if (legalTargets null || !legalTargets.contains(targetState)) { throw new IllegalStateException(非法订单状态流转: currentState - targetState); } } }这样设计的好处是所有状态变更都经过同一个校验入口日志里可以完整跟踪订单的每一步流转。线上遇到“订单状态莫名其妙变成已完成”这类问题通过状态机日志能快速定位到是哪个接口、哪个用户在什么时间操作导致的。订单结算环节涉及金额计算我单独封装了一个OrderSettlementService。家政服务的计价模型是总价 基础服务费 时长费(按小时) - 优惠券抵扣 远程费(超出服务范围时收取)。为了避免浮点数精度问题所有金额字段都用BigDecimal数据库里用decimal(10,2)代码里禁止用double参与金额计算。这个建议在开发初期就跟团队强调过防止写代码的时候顺手用了double导致对不上账。2.3 智能派单基于距离、评分与负载均衡的调度算法派单是家政系统里最具技术含量的模块。最初想直接按“谁离用户近就派给谁”的贪心策略但很快发现这是个陷阱——离得近的阿姨会被拼命派单远一点的阿姨永远接不到单用户差评率还很高因为忙不过来的阿姨服务态度和质量都会下降。最终实现的派单算法综合考虑了三个维度并给每个维度分配了权重距离权重40%根据用户小区坐标和阿姨当前坐标计算直线距离调用高德地图API的路径规划接口拿到实际距离服务评分30%取阿姨近30天平均评分满分5分作为服务质量参考负载系数30%当前待服务订单数 未来3日内预定订单数衡量忙碌程度综合得分 距离得分 × 0.4 评分系数 × 0.3 负载系数 × 0.3。得分最高的阿姨获得派单机会。public class DispatchStrategy { public DispatchResult evaluateDispatch(ListWorkerScore candidates) { candidates.sort((a, b) - Double.compare( calculateTotalScore(b), calculateTotalScore(a))); return DispatchResult.success(candidates.get(0)); } private double calculateTotalScore(WorkerScore worker) { double distanceScore 100.0 / (1 worker.getDistanceKm()); double ratingScore worker.getRating() / 5.0 * 100; double loadScore 100.0 - worker.getPendingOrders() * 20; return distanceScore * 0.4 ratingScore * 0.3 loadScore * 0.3; } }派单方式提供自动和人工两种模式。自动模式由定时任务扫描“待派单”状态的订单每5分钟跑一次调用上面的算法生成候选阿姨列表然后通过WebSocket推送接单通知。人工模式是运营后台的超级管理员手动选择指定阿姨用于处理用户指定服务人员等特殊场景。这里有一个并发问题值得提醒如果同一个阿姨被两个订单同时派中会出现“双单冲突”。我的处理方案是派单前使用UPDATE worker SET current_order_id ? WHERE id ? AND current_order_id IS NULL这条原子SQL来抢锁更新影响行数为0说明阿姨已被其他订单锁定自动换下一个候选人。这一招比在Java层用synchronized可靠得多因为分布式环境下synchronized只对单实例有效而数据库行锁天然支持跨实例的互斥。2.4 智能家居联动设备告警自动生成家政工单这是项目名里“智能家居”落到实处的模块。客户本身是做智能家居硬件起家的系统里要接入的设备类型包括智能门锁、烟雾报警器、水浸传感器、燃气报警器。设备通过MQTT协议上报状态到IoT网关网关再通过HTTP回调把数据写入我们的家政系统。整体链路是这样设备上报异常 - MQTT Broker - 规则引擎判断告警等级 - 生成告警记录 - 根据设备绑定的用户自动创建家政工单 - 推送给用户确认 - 派单给合适的服务人员。规则引擎我最初想用Drools但考虑到只有十几种告警规则引入一套规则引擎有点杀鸡用牛刀最后用Spring Boot的注解策略模式实现了一个轻量级规则处理器。public interface AlertRule { boolean matches(DeviceAlert alert); Order createOrder(DeviceAlert alert); } Component public class SmokeAlertRule implements AlertRule { Override public boolean matches(DeviceAlert alert) { return smoke_sensor.equals(alert.getDeviceType()) alert.getLevel() 2; } Override public Order createOrder(DeviceAlert alert) { Order order new Order(); order.setType(SMOKE_ALERT_REPAIR); order.setAddress(alert.getAddress()); order.setDeviceId(alert.getDeviceId()); order.setUrgencyLevel(2); // 高优 return order; } }规则处理器在启动时通过Spring的ApplicationContext.getBeansOfType(AlertRule.class)自动收集所有规则Bean告警消息到达后顺序判断命中则自动生成订单。新设备类型接入时只需新增一个实现AlertRule接口的Bean不需要改动已有代码符合开闭原则。设备离线检测用定时任务实现每隔5分钟扫描最近15分钟没有上报心跳的设备标记为离线状态同时向用户推送告警消息。这里不直接用设备“最后一次上报时间跟当前时间比”那么简单因为有些设备上报频率本身就是30分钟一次需要为每种设备类型维护独立的心跳间隔配置。2.5 双端消息推送WebSocket 模板消息家政服务场景里消息及时性很重要——用户下单后希望立刻知道谁接单了阿姨希望第一时间收到新订单提醒。普通的轮询方案体验太差最后用了WebSocket Redis发布订阅的组合方案。WebSocket服务监听Redis的Channel当业务服务处理完订单派发后发送一条消息到Redis的order:dispatch频道WebSocket服务收到后把消息推送到对应用户的连接上。这套方案支持多实例部署A实例处理的业务消息也能通过Redis推送到B实例上的WebSocket连接解决了单机WebSocket无法跨实例通信的问题。Component public class RedisMessagePublisher { Autowired private StringRedisTemplate stringRedisTemplate; public void publishOrderDispatch(Long workerId, Long orderId) { String message JSONUtil.toJsonStr(Map.of( type, ORDER_DISPATCH, workerId, workerId, orderId, orderId )); stringRedisTemplate.convertAndSend(order:dispatch, message); } }WebSocket连接的鉴权也要注意握手阶段就要校验Token是否有效不能在建立连接后再处理权限问题。我在HandshakeInterceptor里从query参数取token解析失败直接拒绝握手。3. 关键模块实操与踩坑记录3.1 Spring Boot 3 MyBatis-Plus的版本适配问题先说一个必须提醒的事情Spring Boot 3.x和Spring Boot 2.x在底层上有很大变化最典型的是javax包名全部切换到jakarta。如果直接拿Spring Boot 2时代的MyBatis-Plus旧版本用启动时会报ClassNotFoundException。我们的配置是Spring Boot 3.2.1 MyBatis-Plus 3.5.6。这两个版本搭配实测没有问题。如果项目需要用到MyBatis-Plus的分页插件注意配置类的写法3.5.x版本里分页插件的类名是MybatisPlusInterceptor而不是旧版常用的PaginationInterceptor以下是正确配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }另一个坑是JDK版本。Spring Boot 3要求JDK 17以上如果公司服务器上还装着JDK 8得先做升级。别想着在JDK 8上跑Spring Boot 3这是硬性要求无论如何都要先把运行时版本升上来。我们项目组有台老服务器就是JDK 8第一次部署都没起来换了JDK 21指定路径才正常启动。3.2 Redis缓存与数据库一致性的取舍家政系统的数据可以粗略分为两类强一致数据订单金额、支付状态和弱一致数据服务项目列表、阿姨简介、公告资讯。前者直接查数据库后者走缓存。缓存策略是Cache-Aside读请求先查Redis命中直接返回未命中查数据库再回填Redis并设置过期时间。写操作发生时先更新数据库然后删除Redis里的对应缓存。为什么不直接更新Redis因为更新操作涉及复杂计算直接用删除让下次读请求重新回填实现更简单也避免了并发下写缓存顺序不一致的问题。服务列表的key设计为service:list:type:{typeId}过期时间设置为30分钟。运营后台修改服务价格后调用RedisTemplate.delete(service:list:type:*)按模式删除相关缓存。注意Redis的delete操作不支持通配符模糊删除需要用keys命令查出所有匹配的key再逐个删除但生产环境禁止使用keys命令会阻塞Redis单线程应该使用SCAN命令来遍历public void clearServiceCache(Long typeId) { String pattern service:list:type: typeId :*; ScanOptions options ScanOptions.scanOptions().match(pattern).count(100).build(); try (Cursorbyte[] cursor redisConnection.scan(options)) { while (cursor.hasNext()) { String key new String(cursor.next()); redisTemplate.delete(key); } } }说句实在话微信支付回调通知和数据库订单状态更新之间的数据一致性是我在这个项目里花时间最多的地方。典型的失败场景是用户发起支付 - 支付成功 - 回调把订单状态置为待派单 - 但回调时应用正好重启 - 状态没更新。解决方案是引入一张payment_callback_record表把回调请求先落库再通过异步任务重试更新订单状态保证最终一致。这套方案比XA分布式事务轻量得多对家政系统这种实时性要求不高的业务足够用了。3.3 接口幂等性与并发控制实践生产环境里最容易踩的坑就是接口重复提交。用户手抖点了两次“提交订单”或者网络超时后客户端自动重发都会导致下单接口执行两次生成两笔订单。解决方式是在数据库层面做约束。订单表上建了一个client_request_no唯一索引客户端每次下单时生成一个UUID作为请求流水号。订单创建时把client_request_no写入数据库如果第二次请求到达插入时触发唯一索引冲突捕获DuplicateKeyException后直接返回第一次创建成功的订单信息而不是报错。public Order createOrder(OrderCreateDTO dto) { try { Order order new Order(); order.setClientRequestNo(dto.getRequestNo()); order.setUserId(dto.getUserId()); order.setServiceId(dto.getServiceId()); order.setStatus(0); orderMapper.insert(order); return order; } catch (DuplicateKeyException e) { return orderMapper.selectByRequestNo(dto.getRequestNo()); } }并发扣减库存或者说扣减阿姨档期的问题也值得单独说说。家政服务跟电商库存不完全一样阿姨每天可接的单量是有限的比如每天最多8单但订单可能同时从App端和管理后台进来。我用的是数据库的乐观锁版本号机制Update(UPDATE worker_schedule SET remaining_slots remaining_slots - 1, version version 1 WHERE worker_id #{workerId} AND schedule_date #{date} AND remaining_slots 0 AND version #{version}) int deductSlots(Param(workerId) Long workerId, Param(date) LocalDate date, Param(version) Integer version);UPDATE影响行数为0时说明阿姨档期已满或者版本号不对程序返回“该阿姨当前时段已满”并重新走派单逻辑。这个方案在高并发下不需要显式加锁性能比SELECT FOR UPDATE好不少。3.4 定时任务与分布式锁家政系统里有两个关键定时任务自动派单每5分钟和订单超时自动取消每1分钟。测试环境单实例跑没有并发问题但线上部署了两台实例后同一个定时任务会同时执行两次。第一次遇到这个问题是在上线第二天用户投诉说收到了两条派单通知。解决方案是引入Redis分布式锁保证同时只有一个实例执行定时任务public void executeAutoDispatch() { // 尝试获取分布式锁10秒过期 String lockKey lock:auto:dispatch; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, worker, Duration.ofSeconds(10)); if (!locked) { log.info(自动派单任务已被其他实例执行跳过本次); return; } try { // 派单业务逻辑 doDispatch(); } finally { // 使用Lua脚本保证删除操作的原子性 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(luaScript, Long.class), Collections.singletonList(lockKey), worker); } }这里有一个很经典的隐患如果业务执行时间超过了锁的过期时间10秒第二个实例会获取到锁并执行重复任务。所以锁的过期时间必须根据任务的最长执行时间来设置我这里的自动派单任务实测平均执行时间是3秒10秒的过期时间有充分余量。如果任务真的可能超时可以考虑用Redisson的看门狗机制自动续期但为了一个定时任务引入Redisson稍显重手动估算过期时间已经够用。4. 部署、监控与性能优化4.1 多环境配置与打包部署项目提供三个运行环境本地开发dev、测试环境test、生产环境prod。配置文件按application-{profile}.yml命名通过启动参数--spring.profiles.activeprod切换。数据库密码、Redis密码、第三方接口密钥全部使用环境变量注入不直接写在配置文件中。生产环境的部署架构是两台应用服务器 负载均衡 MySQL主从 Redis哨兵。应用使用Maven的spring-boot-maven-plugin打成可执行jar包通过systemd服务托管日志使用logback滚动策略按天切割保留30天。有一点很多人会忽略Spring Boot默认的配置文件打包进去了生产环境的明文信息。我在构建脚本里把application-prod.yml排除在构建产物之外通过部署目录外的配置文件覆盖。这样就算j包泄露生产数据库的密码也不会跟着暴露。4.2 使用Spring Boot Admin监控应用状态标题热搜词里Spring Boot Admin出现了好几次这个组件确实值得说。它本身也是一个Spring Boot应用通过HTTP协议注册被监控的客户端可以查看所有微服务的状态、健康检查、线程池、内存、日志级别动态调整等。依赖配两个一个服务端一个客户端!-- 服务端 pom.xml -- dependency groupIdde.codecentric/groupId artifactIdspring-boot-admin-starter-server/artifactId version3.2.0/version /dependency !-- 客户端 pom.xml -- dependency groupIdde.codecentric/groupId artifactIdspring-boot-admin-starter-client/artifactId version3.2.0/version /dependency服务端启动类加上EnableAdminServer注解客户端在application.yml里配置服务端地址。监控面板上我最常用的是“动态日志级别”功能——线上遇到某个接口报错但日志级别是INFO时直接在Admin面板把指定类的日志级别动态调整为DEBUG级别不用重启应用就能看到详细错误日志。等排查完再调回INFO非常方便。Spring Boot Actuator的端点要小心暴露生产环境我只开放了health和info两个端点其余全部关闭避免敏感信息泄露。可以通过配置management.endpoints.web.exposure.includehealth,info来控制同时为Actuator单独配置了一个只有内网才能访问的端口。4.3 性能优化从接口响应时间说起项目初期压测发现有几个接口性能明显不达标其中“订单列表”接口在数据量达到10万条时响应时间超过3秒。排查过程很有代表性。第一步定位SQL。用MyBatis-Plus的SQL日志插件打印出执行SQL发现订单列表的查询走的是全表扫描因为order_time、status、worker_id这三个字段都没有索引。补上联合索引idx_status_order_time (status, order_time)之后响应时间降到800毫秒。这里我遇到一个有意思的踩坑原本打算用idx_order_time (order_time)单独索引但查询时用的是status和order_time两个条件单独索引只能生效一个联合索引才真正生效。第二步发现N1问题。订单列表分页查出来后循环遍历每一笔订单查询对应的用户信息。这就是典型的“循环查询数据库”改成查一次用户表再在内存里组装关系后响应时间再降到200毫秒。MyBatis-Plus的分页插件不会自动做关联查询所以编写Service层时要特别注意批量查询替代循环查询。第三步加了Redis缓存。列表前10页的查询结果缓存30秒响应时间稳定在150毫秒以内。但缓存不能乱加涉及用户特定数据的查询比如“我的订单”不适合缓存因为每个用户的缓存命中率太低反而浪费Redis内存。4.4 异步化把非核心逻辑挪出主线程家政系统有大量非关键同步操作发送短信通知、推送WebSocket消息、更新用户积分、写操作日志。这些操作如果都在请求线程里串行处理会影响接口响应时间甚至拖垮主业务。我用Spring的Async注解把这些操作异步化。先在配置类上开启EnableAsync然后在异步方法上添加Async注解。但直接使用默认线程池是大忌——Spring默认的SimpleAsyncTaskExecutor每次都新建线程并发高时会创建大量线程导致OOM。必须自定义线程池Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setThreadNamePrefix(async-exec-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }CallerRunsPolicy这个拒绝策略很关键当线程池队列满了新任务不会丢弃而是由调用者线程执行保证消息不丢。异步任务里如果有数据库操作记得Async方法所在类不能是自身调用否则注解不生效。异步也带来了新问题调用方拿不到返回值无法感知异步任务失败。我的处理方案是异步方法内部捕获所有异常并记录日志同时通过告警通知企业微信机器人推送上报错误。宁可消息晚到也不能悄悄消失。5. 常见问题排查与避坑清单5.1 高频问题速查表这一节整理我在前后端联调、测试和线上运维阶段遇到最多的几个问题给出直接的排查思路和解决方案可以当做一个速查手册来用。问题现象可能原因排查方案接口返回401但Token有效自定义Filter没放行OPTIONS预检请求添加if (OPTIONS.equals(request.getMethod()))直接放行Redis缓存穿透导致数据库压力大缓存空值未处理查询结果为null时也缓存空对象并设置短过期时间MyBatis-Plus分页失效未配置MybatisPlusInterceptor或分页插件版本不兼容按前面3.1节配置PaginationInnerInterceptor定时任务重复执行多实例部署且未加分布式锁使用Redis SETNX加锁任务结束后Lua脚本释放锁接口幂等性被破坏请求流水号重复数据库唯一索引兜底 捕获DuplicateKeyException后返回历史结果金额计算出现偏差使用了double/float金额字段一律用BigDecimal数据库用decimal(10,2)异步通知丢失异步方法异常被吞异步方法内捕获所有异常记录日志并推送告警接口响应慢SQL未走索引或存在N1查询开启慢SQL日志排查执行计划列表查询批量替换循环查询设备告警未及时生成工单MQTT消息消费失败查看消费者日志确认消费失败是否进入重试队列跨天订单结算异常使用LocalDateTime时区问题统一使用系统默认时区Asia/Shanghai数据库连接串带上serverTimezone5.2 排查工具与定位思路排查问题时我的原则是“先看日志再看监控最后看代码”。很多新手一上来就翻代码其实效率很低。日志链路追踪用MDC实现。在拦截器里生成一个traceId放入SLF4J的MDC上下文然后logback配置里在pattern中输出%X{traceId}。这样一次请求从Controller到Service到Mapper的所有日志都带同一个traceId查问题时按traceId过滤日志几秒钟就能把一次请求的完整调用链拉出来。线上遇到用户投诉“订单状态一直没更新”我的排查步骤是这样的先根据用户手机号查出订单ID再去日志里按订单ID搜一遍找到最后一次状态变更的日志看是支付回调没触发还是状态机校验被拦截了。如果日志显示回调已经收到但数据库没更新就检查回调落库表和重试任务的执行情况。对于第三方接口地图API、短信服务、微信支付的调用失败我会在Service层统一封装调用结果失败时记录下来并支持手动重放。这些集成点往往是线上问题最多的位置不能只靠错误堆栈去猜。5.3 实战避坑清单最后分享几条只有做了这个项目才能真正体会到的经验第一千万别在订单状态上直接加字段扩展“创建时间”之外的语义比如用某个特殊状态值表示“已取消但已支付待退款”这会让状态机校验形同虚设。该加状态位就加状态位该加子表就加子表不要硬塞。第二家政阿姨的接单能力上限一定要在派单算法里实时计算不能只写在数据库表的档期数字里。页面展示可用档期和生产环境的实际档期可能不一致运营后台人工调整过缓存过期时间设短一点或者干脆每个派单循环都实时查一次数据库的档期数据。第三设备告警生成工单必须有人工确认环节。我测试的时候用烟雾传感器触发了一次告警系统两秒钟内自动给阿姨派了单阿姨都上门了用户才反应过来自己不在家——这放生产环境就是事故。后来在自动生成工单前增加了一个用户确认步骤收到告警通知后用户可以在30分钟内决定是派单还是仅查看告警详情超时未处理才会自动派单。第四所有定时任务的时间参数比如“超过15分钟未接单自动取消”都要做成可配置的放到系统参数表里不要硬编码。运营人员在后台随时调整规则是家政行业很常见的需求特别是节假日前后供需变化大派单超时时间可能要临时改短。当初硬编码的“15分钟”在春节前被客户临时要求改成“10分钟”只能紧急发版吃了教训。第五日志里打出来的手机号、地址、身份证号等敏感信息要做脱敏处理。这个项目里家政人员的身份证号和手机号在日志和接口返回值中都要打码否则等报隐私泄露问题就晚了。用logback的替换规则或者返回值的Json序列化自定义注解都能实现。第六多环境部署后一定要校验每个环境的spring.profiles.active是否生效。我们组有次把dev环境配置打成生产包导致生产环境连上了开发数据库——因为Maven打j包时把application-dev.yml也打进去了而部署脚本没有显式指定--spring.profiles.activeprod。事后我在启动脚本里强制拼接了这个参数并增加了启动自检逻辑应用启动时检查当前环境配置前缀是否与预期一致不一致直接fail-fast退出。项目做完交付之后客户又提了两轮需求先加了优惠券模块后来又要求接入企业微信的家庭服务号通知。每一次改动我都发现当初在基础架构层面留的扩展点策略模式的派单算法、注解式鉴权、状态机校验都让新增功能变得顺滑很多。这也是我对这个项目最满意的地方——不是功能有多华丽而是每一层设计都对后续变化保持了足够宽容的态度。如果这篇博客能帮你少踩两个坑那这篇文章写得就值了。做业务系统就是这样踩坑踩得多了把坑填平后面的人就能走得更快一点。