
去年有个朋友找我做一套本地生活类的系统需求是按摩养生门店的预约与会员管理。聊了三次之后我意识到这类同城服务系统看着简单真正落地时牵扯的细节远比其他管理系统复杂服务人员是动态排班的、订单是预约制的、核销要防重复、佣金要按次结算稍不留神就是线上体验翻车、线下对不上账。把整套系统的源码逻辑拆开讲一讲聊聊用Java怎么把这些业务落地应该能帮到正在做类似O2O项目的朋友。这套系统的关键词可以概括为Spring Boot MyBatis Plus MySQL Redis具备多门店支持、技师排班、预约订单、次卡核销、优惠券营销、佣金结算等模块。前后端分离后台管理端面向运营与门店店长用户端负责浏览、预约、支付和评价。接下来按我实际开发时的思路从业务边界、架构选型、数据模型、订单链路、营销结算、踩坑复盘这几条线完整拆解。1. 项目冷启动从零梳理同城按摩养生系统的核心业务边界做项目最忌讳一上来就建表写接口。同城服务系统表面是预约 支付 评价实际业务边界远比想象的宽。1.1 先分清服务主体和交易主体按摩养生领域的同城服务市场上主要分成到店和上门两种。这两种模式的基础业务逻辑差异很大维度到店服务上门服务预约维度门店 技师 时段用户地址 技师 时段服务半径无技师可接单范围如5km定价逻辑门店统一定价基础价 距离附加费排班复杂度按门店分班技师自由接单/平台派单核销方式到店扫码核销上门定位/验证码核销我做的这套系统同时支持两种模式核心做法是抽象了service_type字段区分在订单创建、履约、结算三个环节分别做分支处理而不是拆成两套独立系统。拆两套系统的坑很明显会员资产、优惠券资产、技师佣金规则都要维护两份后期对账会疯。1.2 明确系统角色和权限边界一套完整的同城服务系统至少涉及五类角色平台管理员管理所有门店、审核技师、全局营销配置、抽佣比例设置。门店店长管理本店的排班、服务项目价格、门店评价处理。技师查看排班、接单、核销订单、查看个人业绩和佣金。用户浏览门店/服务项目、预约下单、支付、核销、评价。财务/运营对账、提现审核、优惠券核销统计。权限这块我用的是RBAC 数据权限范围的组合方案。角色控制能不能做数据范围控制能做哪些数据。典型例子平台管理员能看到所有门店的营收报表而店长登录后只能看自己门店的数据。注意数据权限如果用普通的字段过滤每次查询都拼store_id代码会越来越混乱。建议用 MyBatis Plus 的拦截器做数据权限自动拼接或者通过自定义注解 AOP 在 Service 层统一处理。1.3 服务的完整业务链路一套按摩养生系统的完整链路从用户视角看是浏览门店/技师 - 选择服务项目 - 选择时段 - 下单支付 - 到店/上门履约 - 核销 - 评价 - 技师佣金结算从平台视角看还需要加一层商家入驻 - 服务项目上架 - 技师入驻和排班 - 营销活动配置 - 订单履约监控 - 分账结算 - 对账报表所以这个项目在功能规划上我划分成了7个核心模块用户中心、门店管理、技师管理含排班、服务项目与价格、订单交易、营销中心优惠券/次卡、财务结算。每个模块拆解下来工作量最重的反而不是下单支付而是技师排班和佣金结算这两个是业务强耦合的逻辑。2. 技术选型与工程骨架为什么是Spring Boot MyBatis Plus Redis很多初学者会纠结技术选型总觉得要用最新框架才有亮点。我在这套系统上选择了比较稳健的组合原因很现实同城服务系统的核心是业务逻辑不是技术炫技稳定、好招人、好维护比什么都重要。2.1 框架选型的实际考量Spring Boot 2.7.x / 3.x生态最成熟无论招人还是后期接第三方服务支付、短信、地图都很方便。Java 17 的长期支持版本更稳妥。MyBatis Plus单表CRUD不需要手写SQL复杂统计再用XML原生SQL。它最大的价值在于代码生成器能直接把实体、Mapper、Service、Controller一层全生出来这类管理系统大概70%的接口是标准单表操作。MySQL 8.0存储所有业务数据。预约、订单、会员这类强事务数据MySQL 是绝对的稳。Redis扛热数据缓存和分布式锁。技师时段锁、优惠券库存、首页热门服务推荐都靠它。XXL-Job 或 Spring Task用于定时任务比如超时未支付自动关单、次日凌晨生成技师佣金账单。我见过有人把 Elasticsearch、RabbitMQ、Seata 全塞进这类项目里。说句实话对于日订单量在几千到几万量级的同城服务系统这些组件带来的运维成本和复杂度远超收益。KISS原则在这个项目里非常适用。2.2 工程分层和模块划分工程结构我采用标准的Maven 多模块 单应用部署tongcheng-service ├── tongcheng-common # 通用工具、统一返回、异常处理 ├── tongcheng-system # 用户、门店、技师、服务项目管理 ├── tongcheng-order # 订单、预约、核销 ├── tongcheng-marketing # 优惠券、次卡、营销活动 ├── tongcheng-settlement # 佣金、对账、提现 ├── tongcheng-admin-api # 管理端接口 └── tongcheng-app-api # 用户端接口按业务域拆模块而不是按技术层拆是我在实践中总结的比较实用的方式。直接按controller/service/mapper分层的话订单模块的查询容易把门店模块的Service也搅进来后期循环依赖会非常严重。2.3 统一基础设施设计工程骨架里我认为最有必要的三个东西统一返回结构ResultT带 code、message、data。管理端和用户端共用。全局异常处理用RestControllerAdvice处理业务异常、参数校验异常、兜底异常。业务异常要定义成带错误码的枚举前端根据错误码做不同提示。Jackson配置统一Long类型转String输出避免前端JS精度丢失问题。这在订单号、用户ID这种超过2^53的场景是必踩的坑。3. 核心数据模型设计把服务项目、技师、排班落到表上这套系统的数据模型是业务落地的基础。我按模块把最有代表性的几张表结构列出来附上关键字段和设计理由。3.1 服务项目表与多门店定价同一个服务项目在不同门店可能有不同价格所以需要把项目信息和门店定价分开。CREATE TABLE service_item ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, category_id BIGINT NOT NULL COMMENT 分类ID如推拿/足疗/精油SPA, name VARCHAR(100) NOT NULL COMMENT 服务项目名称, description TEXT COMMENT 服务介绍, cover_url VARCHAR(500) COMMENT 封面图, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1上架 0下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT服务项目表; CREATE TABLE store_service ( id BIGINT NOT NULL AUTO_INCREMENT, store_id BIGINT NOT NULL COMMENT 门店ID, service_item_id BIGINT NOT NULL COMMENT 服务项目ID, price DECIMAL(10,2) NOT NULL COMMENT 门店售价, duration_minutes INT NOT NULL COMMENT 服务时长分钟, status TINYINT NOT NULL DEFAULT 1 COMMENT 门店上下架状态, PRIMARY KEY (id), UNIQUE KEY uk_store_service (store_id, service_item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT门店服务定价表;这里有个实际原因平台统一上架服务项目模板门店可以基于模板上下架并调整自己的价格和时长。有的门店推拿60分钟收198有的收168这很正常所以必须要拆两张表而不是在项目表里直接写死价格。3.2 技师表与动态排班设计技师信息除了基本的姓名、头像、简介、资质证书还需要关联门店和技能标签擅长泰式按摩、肩颈理疗等。CREATE TABLE technician ( id BIGINT NOT NULL AUTO_INCREMENT, store_id BIGINT NOT NULL COMMENT 所属门店, name VARCHAR(50) NOT NULL COMMENT 技师姓名, avatar VARCHAR(500) COMMENT 头像, title VARCHAR(50) COMMENT 职称如高级技师/金牌技师, service_years INT COMMENT 从业年限, introduction VARCHAR(1000) COMMENT 个人简介, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可预约 0休息/停用, sort INT NOT NULL DEFAULT 0 COMMENT 排序权重, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_store (store_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT技师表; CREATE TABLE technician_skill ( id BIGINT NOT NULL AUTO_INCREMENT, technician_id BIGINT NOT NULL, skill_tag_id BIGINT NOT NULL COMMENT 技能标签ID, PRIMARY KEY (id), UNIQUE KEY uk_tech_skill (technician_id, skill_tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT技师技能关联表;排班表是整个系统里比较关键的设计。CREATE TABLE technician_schedule ( id BIGINT NOT NULL AUTO_INCREMENT, technician_id BIGINT NOT NULL, work_date DATE NOT NULL COMMENT 日期, start_time TIME NOT NULL COMMENT 开始时间如09:00, end_time TIME NOT NULL COMMENT 结束时间如18:00, max_orders INT NOT NULL DEFAULT 10 COMMENT 时段内最大可接单数, booked_orders INT NOT NULL DEFAULT 0 COMMENT 已预约订单数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 0停用, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_schedule (technician_id, work_date, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT技师排班表;排班有两种模式固定周期排班和单日排班。我建议两套并存固定周排班用于生成默认班次单日排班用于人工调整。用户下单时实际查询的是今天/明天该技师可预约的时间段而可预约的依据就是排班表中当天剩余可接单数。关于并发控制用户同时约同一个技师同一个时间段时booked_orders的自增必须用乐观锁或Redis分布式锁来防止超卖。我用的是UPDATE ... SET booked_orders booked_orders 1 WHERE id ? AND booked_orders max_orders这个SQL本身就是一个原子操作可以替代分布式锁。3.3 订单主表状态字段的精细设计订单表是同城服务系统的核心表状态字段的划分直接影响后续交易流程的复杂度。CREATE TABLE service_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL COMMENT 用户ID, store_id BIGINT NOT NULL COMMENT 门店ID, technician_id BIGINT NOT NULL COMMENT 技师ID, service_type TINYINT NOT NULL COMMENT 1到店 2上门, appointment_date DATE NOT NULL COMMENT 预约日期, appointment_time VARCHAR(20) NOT NULL COMMENT 预约时段如14:00-15:00, user_address VARCHAR(500) COMMENT 上门服务用户地址, lng DECIMAL(10,6) COMMENT 用户地址经度, lat DECIMAL(10,6) COMMENT 用户地址纬度, service_item_id BIGINT NOT NULL, service_name VARCHAR(100) NOT NULL COMMENT 服务名称下单时快照, price DECIMAL(10,2) NOT NULL COMMENT 实付金额, original_price DECIMAL(10,2) NOT NULL COMMENT 原价, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 支付状态0未支付 1已支付 2已退款, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待付款 1待服务 2已完成 3已取消 4售后中, cancel_reason VARCHAR(255) COMMENT 取消原因, coupon_id BIGINT COMMENT 使用的优惠券ID, settlement_status TINYINT NOT NULL DEFAULT 0 COMMENT 结算状态0未结算 1已结算, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME COMMENT 支付时间, completed_at DATETIME COMMENT 完成时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id), KEY idx_store (store_id), KEY idx_technician (technician_id), KEY idx_appointment (appointment_date, appointment_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT服务订单表;订单号不要直接用数据库自增ID。对外暴露的订单号我用的是时间戳 用户ID后四位 随机数再通过Redis自增序列保证每天唯一。整体是yyyyMMddHHmmss 序列号长度控制在32位以内方便回显和索引。字段快照在订单里极其重要。service_name、price、original_price必须在下单那一刻固定下来不能关联查询服务项目表的当前价格。因为门店调价后历史订单的展示金额和结算金额会直接乱掉。3.4 资源预约冲突数据库层解决还是代码层解决预约场景最核心的技术问题就一个词冲突。两个人不能约同一个技师同一个时间段。我采用了两层防护数据库唯一约束通过预约时间 技师ID做唯一索引从根上保证同一个时间段同一个技师只能有一个订单。Redis分布式锁 双重检查用户点击立即预约时先对technician_id appointment_time加锁锁内检查排班剩余名额再创建订单。这个组合方案实测下来比较稳高并发场景下也不会出现超卖。如果只靠数据库唯一索引订单表会因频繁的唯一键冲突抛异常影响体验如果只靠Redis锁又存在锁过期或节点故障后的不确定性。两层都上才敢上线。4. 订单主线拆解从预约、支付到核销的完整状态流转订单模块是核心链路。按摩养生系统的订单比电商订单多了预约时段和核销两个动作这个差异决定了状态机设计要更细致。4.1 订单创建接口的设计要点创建订单接口是用户端调用频次最高的接口之一我拆成了两步预下单 确认下单。预下单做三件事校验门店和技师的服务项目是否可预约。校验所选时段是否在技师排班范围内。计算价格返回前端订单确认页信息含可用优惠券。确认下单做核心动作校验预约时段是否仍然可用双重检查。冻结优惠券这个很关键券要锁定不能被其他订单并发用掉。创建订单状态为待付款。写入Redis预约占位设置20分钟过期。4.2 超时未支付自动关单的实现预约类订单天然需要占位限时。我的方案是创建订单时在Redis存一个keyorder:timeout:{orderNo}TTL设为20分钟。定时任务扫描过期订单其实不一定需要扫描Redis能延时通知但并不可靠。关单时恢复优惠券、释放预约占位把订单状态改成已取消。这里有一个比较隐蔽的坑关单操作必须做幂等。因为定时任务和用户主动取消可能同时触发一定要用数据库乐观锁做状态校验boolean cancelled orderService.cancel(orderNo, userId);ServiceImpl里这样处理int rows this.update( new LambdaUpdateWrapperServiceOrder() .eq(ServiceOrder::getOrderNo, orderNo) .eq(ServiceOrder::getOrderStatus, OrderStatus.PENDING_PAYMENT.getCode()) .set(ServiceOrder::getOrderStatus, OrderStatus.CANCELLED.getCode()) );update返回的rows等于1才说明关单成功否则说明状态已经被别人改过了。4.3 核销逻辑防重复核销和技师操作权限到店模式的核销流程是用户出示订单二维码技师或店长扫码核销。上门模式是技师到达后在App端点击开始服务服务结束时再点完成服务。核销接口最重要的特性是幂等和防重复。用户二维码每30秒刷新一次同一个订单在有效期内预约日当天只能核销一次。我用的方案是UPDATE service_order SET order_status 2, completed_at NOW() WHERE order_no #{orderNo} AND order_status 1核销成功返回true处于已核销状态则直接返回业务提示码订单已核销但不能报错。另外核销人必须是当前技师或门店店长这个校验放在接口的第一层通过上下文获取当前登录人的ID和角色。4.4 评价体系与订单联动用户只有在订单完成且未评价时才能发起评价。评价表需要关联订单ID并加唯一约束从数据库层防止同一订单多次评价。评价除了评分和内容还建议加上标签维度的快评比如手法专业环境干净准时上门。标签的数据在后面对技师排行、门店质量分计算很有用。这是我的一个经验之谈评价数据越结构化后续做运营报表越方便。5. 多门店与技师接单让同城服务真正同城可约同城服务的核心特征是地理属性。用户打开App应该看到附近的门店/技师而不是全部城市的大杂烩。5.1 门店列表按距离排序的两种实现排序我的首选方案是MySQL计算球面距离SELECT id, store_name, address, ROUND(6371 * 2 * ASIN(SQRT( POWER(SIN((#{lat} - lat) * PI() / 180 / 2), 2) COS(#{lat} * PI() / 180) * COS(lat * PI() / 180) * POWER(SIN((#{lng} - lng) * PI() / 180 / 2), 2) )), 2) AS distance FROM store WHERE status 1 AND lat BETWEEN #{latMin} AND #{latMax} AND lng BETWEEN #{lngMin} AND #{lngMax} ORDER BY distance ASC LIMIT 20这个SQL用到了经纬度范围过滤可以走数据库索引。距离排序适合几千家门店体量的系统。如果门店规模到了几十万家就得考虑用ES的 geo_distance 查询或接入地图服务的地域检索能力。以同城服务的体量来说MySQL绰绰有余。5.2 技师接单范围的限定逻辑上门服务场景下技师接单有范围限制。我的设计是给每个技师设置一个服务半径如5公里用户下单时按门店 距离双重过滤推荐技师。计算用户在3公里内是否有可接单技师SQL类似这样SELECT technician_id, ROUND(6371 * 2 * ASIN(...)) AS distance FROM technician WHERE store_id #{storeId} AND status 1 HAVING distance 5 ORDER BY distance选址逻辑上有一个业务小技巧用户地址的经纬度优先用高德或腾讯地图的Web服务API转换不要依赖前端传的坐标。前端定位可能偏移几十米到几百米直接影响匹配到的技师范围用户会觉得明明很近却约不到。5.3 同店换技师排班变动时的订单调整预约类业务免不了遇到技师临时请假。我的处理方式不是直接取消订单而是提供订单改约能力用户可以换技师、换时段但保留已支付金额和优惠券信息。这个流程用一张order_change_log表记录变更轨迹订单号不变只在明细中更新技师ID、时段、操作人、操作原因。改约本质上是取消原预约 创建新预约的组合动作必须放在同一个数据库事务里并且在改约期间对原时段加锁防并发占用。6. 营销与结算次卡、优惠券和技师佣金怎么算才不出乱子营销和结算模块虽然排在功能列表靠后位置却是决定项目是否赚钱的两块。营销负责拉新促活结算负责算清楚每一分钱。6.1 优惠券系统的核心数据设计优惠券我分成两种平台券和门店券。平台券由运营发放全平台通用门店券由门店自己配置成本由门店承担。CREATE TABLE coupon_template ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 券名称, type TINYINT NOT NULL COMMENT 1满减券 2折扣券 3无门槛现金券, threshold_amount DECIMAL(10,2) COMMENT 满减门槛0表示无门槛, discount_amount DECIMAL(10,2) COMMENT 减免金额, discount_rate DECIMAL(5,2) COMMENT 折扣率如0.85, total_count INT NOT NULL COMMENT 发行总量, received_count INT NOT NULL DEFAULT 0 COMMENT 已领取量, valid_days INT COMMENT 领取后有效天数, start_time DATETIME COMMENT 固定有效期开始, end_time DATETIME COMMENT 固定有效期结束, scope_type TINYINT NOT NULL COMMENT 1全部门店 2指定门店, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT优惠券模板表;发券数据是用户和券的关联表领券时要保证不超发。我用的是事务中UPDATE coupon_template SET received_count received_count 1 WHERE id ? AND received_count total_count同样走数据库原子更新的路子。6.2 次卡设计按摩养生行业的锁客利器同城服务行业最有粘性的营销品其实是次卡比如肩颈理疗10次卡。次卡的账户模型是这样的CREATE TABLE member_card ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, card_template_id BIGINT NOT NULL COMMENT 次卡模板ID, total_times INT NOT NULL COMMENT 总次数, remaining_times INT NOT NULL COMMENT 剩余次数, expire_time DATETIME NOT NULL COMMENT 过期时间, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 0已过期/已退, PRIMARY KEY (id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员次卡表;次卡核销和普通订单核销一样必须防并发。用户如果用次卡下单扣减操作的SQL是UPDATE member_card SET remaining_times remaining_times - 1 WHERE id #{cardId} AND remaining_times 0 AND expire_time NOW() AND status 1受影响行数为0说明次卡余额不足或已过期订单创建就要失败。次卡与现金支付的订单在结算逻辑上完全不同现金订单的钱进平台或门店账户次卡的钱在购买次卡时已经结算过一次所以核销时不能再结算一次只需要记录核销流水。6.3 技师佣金分角色、分场景的结算规则按摩养生系统的技师佣金结算是很多初接触这类项目的人容易搞乱的环节。核心原因是佣金不是按订单金额统一提成而是分服务项目、分门店、分支付方式差异化处理。佣金设置我采用了项目维度百分比 保底金额 阶梯激励三者结合的方案规则参数说明commission_rate按订单实付金额的百分比如30%minimum_guarantee保底佣金如该服务项目佣金不低于60元tier_threshold月度完成订单数阈值如满80单后提成上浮5%每个月月底生成佣金账单流程是汇总本月已完成且未结算的订单。按订单所属门店、服务项目、技师分别计算佣金。生成technician_settlement主记录明细存technician_settlement_detail。提交财务审核审核通过后进入提现/打款流程。这里最需要注意的坑是退款订单的佣金回冲。用户购买服务后如果发起售后并退款成功这笔订单已经结算给技师的佣金必须冲正。我的做法是把退款订单的佣金生成负数明细而不是直接修改已生成的账单记录这样对账时能看到完整轨迹。7. 高并发与数据一致性的实战取舍单机MySQL打天下的时代过去了但也不是非要上一套微服务。同城服务系统的特点是下单瞬间有并发峰值平时流量相对平稳所以处理并发和一致性要分场景侧重。7.1 热点时段技师预约并发控制每周一上午10点很多门店集中放下一周的排班这时候会有大量用户在抢约热门技师。我用的是分段策略Redis预占下单时先尝试写入技师时段key写入成功才允许创建订单。MySQL最终校验事务内更新技师排班的booked_orders利用booked_orders max_orders做条件更新防止Redis锁失效。失败降级Redis异常时直接降级为纯MySQL校验虽然慢一点但数据不会错。7.2 缓存的一致性问题门店服务和技师排班首页和详情页都有大量高频读取缓存策略上我做了两级第一级缓存服务项目列表、门店信息、技师介绍变更不频繁缓存时间可以设到30分钟。第二级缓存可预约时段是动态查询缓存时间只设30秒或者直接不缓存因为排班本身的查询已经走索引压力不大。套餐项目详情页的用户行为是浏览多、下单少所以详情页缓存策略应该是服务项目基础信息缓存、可约状态实时查询这样下单成功率和体验都比较理想。缓存更新不采用主动删缓存而是DB更新后通过本地事件或消息队列异步清除相关缓存有短暂的不一致可接受这类场景没有对账需求。7.3 分布式事务能不做就不做同城服务系统里最需要一致性保证的场景是核销同时扣次卡、退款同时回冲佣金。这些操作我在实现时都控制在单个数据库事务内完成没有引入分布式事务框架。举例核销订单里扣减次卡两个动作本身共享同一个事务。真正跨服务、跨库的场景在该系统里几乎没有所以用本地事务 状态机 补偿任务就足够稳定了。引入分布式事务框架会显著增加开发和运维成本在同城服务这个体量下没必要。8. 项目实测中的踩坑记录与优化空间最后聊聊这套系统从开发到上线压测我实际遇到的问题和几个值得继续投入的方向。8.1 排班可用性查询的SQL性能问题上线初期门店详情页展示技师三天内可预约时段每次请求要查三张表然后按时间聚合一旦门店技师超过20人页面接口就会变慢。优化方案将技师维度三天排班数据在门店详情接口中一次性查全用Java内存对象分组而不是逐技师循环查询数据库。接口耗时从800ms降到了120ms左右这算是一次典型的N1查询转join/批量查询优化。8.2 优惠券并发发放的超卖问题早期领券接口是先查余量再insert用户券线上并发一上来必然超发。后来把所有券的领取逻辑统一改成单SQL原子扣减库存 唯一索引约束重复领取问题才根除。经验只要是库存类操作一律在SQL层面用条件更新解决不要先查后写。先查后写的代码在并发压测下一定会出问题这不是概率问题是时间问题。8.3 预约时段格式统一问题前端传来的时间是2024-05-20 14:00:00这种完整时间格式而业务真正关心的是2024-05-20 14:00-15:00这个可显示的时段字符串。中间转换逻辑分散在Controller和Service各处后期排查时段冲突问题时非常痛苦。后期我统一封装了一个TimeSlotUtil把可预约时段定义为一个标准化的字符串区间如14:00-15:00所有订单、排班、核销逻辑都基于这个格式交互数据库层面保证同一技师同一日期同一时段唯一。从这之后时段相关的bug基本绝迹。8.4 值得继续投入的优化方向这套系统跑通之后还有三个方向值得深入门店/技师的推荐排序模型目前基于距离、评分、销量简单加权。可以引入用户历史偏好记录用户常点的服务品类做更精准的个性化排序能明显提升下单转化率。工单化的售后服务流程目前售后是手动处理退款。可以做成用户发起售后 → 门店确认 → 平台仲裁的标准工单流转减少运营人工介入。多门店经营报表订阅店长每天需要一个简单的经营日报推送到企业微信或钉钉群。这个需求看似简单但对门店侧的使用粘性提升非常有帮助技师排班、营收、新增会员、待处理退款都汇总到一张图里。做这类系统的感受就是业务细节远重于技术选型。排班冲突、核销幂等、佣金对账每一处都需要把规则想清楚再落到代码。希望这篇拆解能帮你少走我走过的弯路。