尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java同城上门预约系统源码:订单状态机与并发防重设计全解析

Java同城上门预约系统源码:订单状态机与并发防重设计全解析 前阵子整理自己的项目仓库翻出一套早前做的同城服务类系统——按摩养生上门预约平台。这套系统用纯Java技术栈从零搭起来从用户端下单、技师接单、后台派单到支付回调、订单状态机、排班冲突检测前后写了两万多行代码。不少朋友私信问同城服务的源码结构怎么设计、哪些点容易踩坑今天干脆把核心部分的源码设计思路拆开讲一遍给正在做类似LBS预约类项目的Java开发者一点参考。源码这种纯Java的后端工程跑起来你就知道真正难的不是CRUD而是那些看不见的并发边界。1. 项目骨架这套按摩养生系统到底做了什么1.1 同城服务的本质以及按摩养生这个场景的特殊性同城服务听着简单做起来才知道它本质上是一次“LBS 交易 履约”三件事的组合。线下订单不是电商那样把包裹发出去就完事而是要在一个确定的城市、确定的时间段内把服务人员送到用户指定的地点。这意味着系统里必须同时处理地理位置、实时状态、预约时段的冲突检测单靠增删改查是撑不起真实业务的。按摩养生场景和搬家公司、保洁、维修这类同城服务又不太一样。它的核心资产是人——技师而且是高度依赖排班和时段的技师。技师今天下午两点在A小区服务三点必须赶到B小区这中间的路程耗时、服务时长以及是否存在重复预约都是系统必须考虑的业务约束。我在设计这套源码时把“技师”当成了一种需要并发保护的资源而不是普通的主键记录来对待这一点是整个系统设计里最关键的思路。如果按摩养生系统把技师当成普通表来做并发一上来重复接单几乎是必然的。1.2 角色、流程与核心业务链路从角色上看系统分三个终端用户小程序端、技师端、管理后台端对应三类角色用户、技师、平台运营。用户在小程序里浏览服务项目全身推拿、肩颈放松、足底按摩等选择上门服务还是到店服务选定预约时间、填写地址后下单支付。技师在技师端看到平台派给自己的订单确认接单后按约定时间上门或准备接待。管理后台则负责服务项目管理、技师审核、订单监管、佣金结算和运营配置。核心业务链路就八步用户注册登录 → 选择服务项目与技师 → 选择时间与地址 → 下单支付 → 系统派单/用户指定 → 技师接单 → 服务执行核销 → 平台结算。源码里所有的表设计、接口设计、状态机设计都是围绕这条链路展开的。链路中任何一个环节一旦脱节比如支付成功但订单没生成、技师接单但用户没收到通知就会直接变成客诉所以每个环节我都要求有状态落库和补偿机制。1.3 为什么是Java技术栈而不是Python或者Node.js技术选型上我用的是Spring Boot MyBatis Plus MySQL Redis这套Java生态没有用Python、Node.js也没有上Spring Cloud微服务。原因很简单这是一个业务逻辑复杂度远高于并发量的系统订单状态机、排班冲突检测、佣金结算、对账逻辑这些都属于“业务密集型”代码Java在表达这类复杂规则时类型系统、工程化能力、团队协作效率都是最稳的。Python适合快速原型但订单流水、金额计算的精度控制和工程约束做起来要小心的地方太多。单体架构在初期完全够用我预留了模块边界而不是一上来就拆微服务。同城服务的用户量和订单量都是有明显波峰波谷的忙时可能就是午休、晚间那几个小时单体加缓存、加合理的索引压到几千并发并没有问题。真正到了需要拆分的时候可以按用户、订单、营销、支付四个域去拆源码里的Maven模块结构已经按这个方向做了铺垫而不是等到业务跑了半年再重构。2. 数据库设计与状态机源码里最值得抄的表结构和状态流转2.1 核心表结构解析先把最核心的几张表讲清楚。用户表、技师表、服务项目表、订单表、日程排班表、优惠券表、资金流水表这七张表支撑了整个业务闭环。我会重点讲订单表和排班表因为这两张表的字段设计直接决定后面所有业务逻辑的写法。订单表的字段必须是订单号和状态驱动的。order_no用雪花ID生成业务上展示给用户的是一个短编号内部关联时用雪花ID。核心字段包括user_id、technician_id、service_item_id、appointment_time、address、lng/lat、amount、pay_status、order_status、cancel_reason、version。其中version字段是乐观锁版本号后面讲并发控制时会用到。amount用decimal(10,2)存绝对不用float这是钱相关的铁律。排班表是按摩养生系统区别于普通电商系统的关键。technician_id、work_date、start_time、end_time、status可预约/已占/休息。这张表必须加唯一约束我后面实施的是technician_id, work_date, start_time, end_time四字段联合唯一索引。这个约束看上去简单但它是防止超卖的最后一道物理防线数据库级别拦住了代码有bug也不会造成重复预约。2.2 订单状态机设计与流转规范我强烈建议所有订单类系统都显式定义状态机而不是在代码里散落着魔法数字。源码里用了一个OrderStatus枚举定义了八个状态待支付、已支付、待服务、服务中、已完成、已取消、退款中、已退款。状态流转不是随便跳的比如待支付状态只能跳转到已支付或者已取消绝不可以从已支付跳回待支付。当前状态允许流转到触发动作待支付已支付、已取消用户支付成功 / 超时系统取消已支付待服务、退款中系统确认派单 / 用户申请退款待服务服务中、已取消技师开始服务 / 商家取消订单服务中已完成技师核销完成退款中已退款、待服务平台审核通过 / 审核驳回状态机的好处是无论前端如何乱点、接口如何重发、回调如何延迟订单状态始终能保持业务上的自洽。我在每个状态的流转入口都做了判断一律走统一的状态流转方法保证同一次操作拿到的是最新状态并且记录状态变更日志。这个状态日志表在排查客诉时价值巨大用户说“我明明付了钱”运营打开状态日志一看是走到已支付后又被某个定时任务错误取消了问题一秒定位。2.3 技师日程排班与可用时段的设计排班与订单的关系是这套按摩养生系统里最大的难点。一个技师一天可服务的时段是有限的一个时段被占用后就不能再被别的订单抢占。这里我用的方案是技师的常规工作时间配置生成排班模板按周生成每日的时段片段用户选择的预约时间必须落在一个“可预约”的排班片段内下单支付成功后代码把该排班片段状态改成“已占”并写入订单的预约时间。时段片段的长短并不都相同有的一小时有的一小时半这和具体服务项目有关。所以排班表里同时存start_time和end_time不是为了好看而是为了让不同项目的时长差异落到同一张表里。如果只存一个开始时间就无法处理“项目A需要60分钟、项目B需要90分钟”共存的情况。预约时间选择的校验逻辑也很关键用户选15:00服务时长90分钟那么结束时间是16:30必须检查15:00到16:30这个区间内的排班片段是否全部可预约而不是只看15:00这个单点否则就会出现两个订单在时间上重叠的bug。3. 核心代码实现几个关键业务的源码级拆解3.1 就近推荐与距离计算同城服务的核心体验是“离我近”推荐技师列表必须按距离排序。坐标计算这块我用的是Haversine公式计算球面两点距离。很多初学者喜欢直接套用平面距离公式在同城这个尺度几十公里内误差看起来不大但真要较真高纬度地区的偏差很明显。Haversine在几十公里范围内精度完全够用代码实现也不复杂。public static double distance(double lat1, double lng1, double lat2, double lng2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double a radLat1 - radLat2; double b Math.toRadians(lng1) - Math.toRadians(lng2); double s 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2) )); return s * 6371.0 * 1000; // 单位米 }这里有一个坐标系的坑必须提醒你前端用的高德地图、腾讯地图返回的是GCJ-02坐标而GPS原始经纬度是WGS-84坐标两者差异在城市范围内通常有两三百米。如果在存库时混用了坐标系算出来的距离和地址展示就会对不上用户明明就在楼下系统却显示技师在一公里外。源码里我统一在入库前把坐标转换成GCJ-02并且在这套距离计算的方法注释里明确标注了坐标系假设避免后续接手的人踩坑。技师经常是在移动中的如果实时地理位置直接用表字段存储查询性能会比较差。线上的做法是技师端每隔一段时间上报位置后台缓存到Redis查推荐列表时先取缓存坐标再算距离。这样即使用户刷新列表也不会每次去戳数据库。配一个技师位置上报接口入参是技师ID、经纬度、上报时间服务端校验时间戳过期数据直接丢弃防止乱序上报覆盖新坐标。3.2 防止技师被重复预约数据库唯一索引、乐观锁与分布式锁这是整个系统最值得看的一段代码逻辑。并发的核心场景是两个用户几乎同时选择同一个技师、同一个时段下单系统必须保证只有一个能成功。我在三层做了防护。第一层是数据库唯一索引上面已经提过排班表四字段联合唯一索引这层负责兜底。一旦插入冲突数据库直接报DuplicateKeyException程序捕获后转成友好提示“该时段已被预约”。第二层是乐观锁更新排班状态时带上version条件update语句先比对version匹配才更新返回影响行数为0说明被并发修改需要重试或者提示失败。第三层是Redis分布式锁在“用户点击下单”这个动作入口处加锁锁的key由技师ID加时段组成防止同一个技师同一个时段被并发创建订单。三层叠加后我在压测环境里用50个线程同时对同一时段发起预约最终入库的只有1条记录其余全部正确失败。锁定key的构建逻辑类似这样String lockKey lock:order:technician: technicianId : startTime : endTime; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { throw new BizException(该时段正在被预约请稍后重试); } try { // 校验排班状态、创建订单、扣减时段 } finally { redisTemplate.delete(lockKey); }加锁的关键是设置合理过期时间防止持有锁的线程宕机导致死锁。我在项目里统一用30秒远超下单事务的执行时间同时锁的粒度一定要细锁的是“某个技师某个时段”而不是“整个技师”否则不同时段的订单互相阻塞白白浪费性能。多服务实例部署时这套加锁逻辑天然支持分布式不需要额外引入Redisson重锁真实业务下setIfAbsent加finally释放已经够用。3.3 订单超时取消与定时任务用户下单后如果一直不支付订单不能无限挂着。日常做法是支付超时取消我设置了15分钟扫描频率每1分钟一次。这里有一个常见的坑在分布式环境下多个服务实例部署时定时任务会在每个实例上都执行一遍导致同一个超时任务被处理多次。如果取消逻辑没有做幂等就可能出现重复取消、甚至把用户刚支付的订单错误取消掉。方案是在任务执行前先获取一把Redis分布式锁拿到锁的实例才执行清扫任务执行完释放。即便只有一个实例也要在任务内部对每个订单做状态判断只有当前状态是“待支付”的订单才允许取消这也是状态机设计带来的直接好处。另外定时任务的扫描SQL一定不能扫全表要用索引覆盖预约时间字段并且每次只取一批比如200条处理完再取否则数据量大了之后一次扫描会把数据库拖垮。Scheduled(fixedDelay 60000) public void cancelExpiredOrders() { String lockKey lock:task:cancelExpiredOrders; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 300, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { log.info(cancelExpiredOrders task skipped, another instance is running); return; } try { ListOrder expiredOrders orderMapper.selectExpiredOrders(15, 200); for (Order order : expiredOrders) { orderService.cancel(order.getId(), 支付超时自动取消); } } finally { redisTemplate.delete(lockKey); } }3.4 支付回调与对账逻辑支付是整个交易系统的信任根基。接入支付渠道时回调通知可能延迟、乱序、重复甚至丢失。源码里对支付回调的处理核心是“幂等”回调进来后先按order_no查询订单判断当前状态是否已经是“已支付”如果是直接返回成功整条逻辑不加锁也不会出错。这一步判断看着简单实际是避免重复更新金额、重复通知技师的关键。另外一个细节是回调通知内容必须验签用支付平台下发的密钥校验签名校验失败直接拒绝防止伪造回调。对账方面每天凌晨跑一个定时任务把本地支付成功订单和支付平台账单做比对发现状态不一致的进入人工处理队列。这类问题真到客诉才排查就晚了。我在源码里放了一个简单的对账任务实现核心就是两拨数据互相查缺本地有支付成功记录但账单里没有的极可能是本地状态更新异常账单有扣款但本地没有的基本是回调丢失需要主动拉单补状态。4. 性能、安全与数据一致性的硬骨头4.1 高并发下的时段扣减同城服务虽然整体量级不大但忙时会出现局部热点一个金牌技师一个热门时段涌进来的可能同时有几十个请求。前面那个三层防重复方案主要保证正确性从性能角度看频繁抢同一把锁会把Redis压得很高。这里我采用了一种“先扣减后下单”的思路用户点进下单页时前端先请求一个接口占住时段Redis里对时段做扣减计数有效期5分钟过期自动释放。这样真正下单时锁冲突的概率已经降下来了数据库层承受的压力也小很多。占住时段、占座这类逻辑是一个典型的限流场景也特别适合用Redis的原子操作来表达。扣减用decr释放用incr配合过期时间。不要用先get再set的方式中间隔了几毫秒就可能被别人插进来必须用原子操作。这个设计和电商库存扣减是同一套思路学会了可以复用到很多业务里比如会议室预约、工位预订、名额抢购本质完全一样。4.2 接口安全与敏感数据保护同城服务涉及用户真实手机号、家庭地址、身份证信息这些数据一旦泄露比订单金额损失严重得多。源码里有两层处理存储层对身份证做加密存储手机号做脱敏展示普通的服务端返回只输出脱敏后的字符串权限控制层用户、技师、运营三类角色的接口严格分离JWT令牌里带上角色信息每个接口handler都做角色校验而不是光靠前端隐藏按钮。脱敏工具方法可以这样写所有返回给前端的用户信息统一走这里public static String maskPhone(String phone) { if (phone null || phone.length() ! 11) { return phone; } return phone.substring(0, 3) **** phone.substring(7); }另外要小心的是同城服务类系统很容易被脚本扫接口。下单接口、验证码接口必须有频率限制我在网关层和业务层同时做了限流业务层的限流用拦截器实现按用户ID维度限制接口每分钟的调用次数超过直接返回429。短信验证码更是重灾区同一个手机号一分钟最多发一次一天最多发几次防止被刷短信轰炸接口。这些防护在源码里都是可以直接复用的拦截器类改一下具体参数就能接入你自己的项目。4.3 缓存的一致性穿透防护技师列表、服务项目列表、首页推荐这类高频读数据代码里都做了Redis缓存。但缓存和数据库的数据一致性写不好就是一个接一个的隐形bug。我是采用“先更新数据库、再删缓存”的策略配合延迟双删做补偿兜底。具体来说更新业务数据后删除缓存隔几百毫秒再删一次防止第一次删除前有旧请求把旧数据塞回缓存。这套组合不能说百分百完美但业务上可接受能覆盖绝大多数不一致的场景。缓存穿透也必须处理。所谓穿透就是大量请求查询一个不存在的技师ID缓存里没有数据库里也没有每次请求都会打到数据库。我在查技师详情的方法里对不存在的数据补一个空值缓存过期时间设置成很短比如3分钟。实际线上我还在前置加了一层布隆过滤器但新手可以直接把空值缓存这个方案用起来成本低见效快。还有一个反向经验空值缓存千万别设置太长过期时间否则技师真正上线后用户要等好几分钟才能看到新数据那段时间的客诉会特别密集。5. 从源码到上线环境部署与配置要点5.1 环境准备与项目结构跑这套源码之前先把环境搞定。JDK用8或11都行我建议11Spring Boot对11的支持很成熟数据库用MySQL 8.0Redis 6.x构建工具用Maven 3.6以上。源码模块划分是标准的多模块工程common模块放公共工具、domain模块放实体和Mapper、service模块放业务逻辑、web模块放Controller和启动类。这比把所有代码塞在一个包里要清晰得多新人接手也不用在几千个类里翻来找去。数据库初始化脚本在sql目录下包含建表语句、索引和基础数据。有一个细节我要提醒建表脚本里我专门加了排班表四字段唯一索引、订单表order_no唯一索引这些索引是业务正确性的底牌初始化数据库的时候千万别嫌麻烦把它们删掉删了后面并发问题全回来了。启动项目之前先把sql脚本在本地数据库执行一遍然后用测试账号登录这是最快验证环境是否就绪的方式。5.2 打包部署与配置分离配置分离是我特别想强调的一点。开发环境、测试环境、生产环境的数据库地址、Redis地址、支付参数完全不一样千万不要写死在代码里也不要每次上线前手动改。我用的是application.yml加环境后缀的方式启动时通过--spring.profiles.activeprod指定环境部署时环境变量注入敏感信息比如数据库密码走环境变量而不是写在配置文件里。这样不同环境的切换只是一条命令的事。打包步骤就是标准的Maven命令mvn clean package -DskipTests生成jar包后部署到服务器。用systemd或Docker方式启动都可以我个人的习惯是Docker Compose一把梭把MySQL、Redis、应用服务编排在同一个compose文件里本地开发一条命令起整套环境新同事入职当天就能跑起来不用花一个下午装环境。记住一个原则代码里不要出现任何环境的真实密码哪怕只是测试环境的也不要密码走环境变量否则git仓库一旦泄露全站密码跟着一起裸奔。5.3 上线前必须检查的几项上线前我有个固定检查清单第一数据库索引是否和脚本一致重点看唯一索引第二Redis配置的密码和持久化策略是否适合生产建议开启AOF第三支付回调地址是否配置成外网可访问的HTTPS地址第四定时任务在集群环境下是否有分布式锁保护第五对外接口是否全部走的HTTPS敏感接口是否做了限流。这些检查项看着不起眼但每一个都对应着线上出过的事故按清单扫一遍能省掉很多上线夜里的紧急修复。6. 常见问题与排障实录6.1 并发预约当天就测出来了重复订单这个是我印象最深的一次。第一版代码上线当天内部测试同事两个人同时点同一个技师的同一个时段居然都下单成功。查到最后发现排班表的唯一索引没有建成功原因是我的建表脚本里唯一索引字段顺序和实际表字段顺序不一致MySQL对多字段唯一索引的重复判断是受字段顺序影响的。这条教训后来被我写进了团队的数据库规范唯一索引字段的顺序必须和业务语义一致建好后用insert测试一遍不要想当然。6.2 定时任务在测试环境跑了两遍本地单实例测试怎么测都正常部署到两台服务器的测试环境后超时取消任务居然把订单取消了两遍。排查下来就是分布式环境下两个实例同时执行定时任务没有锁保护导致的。后来加的Redis分布式锁解决了问题但还有一个更隐蔽的坑任务执行时长超过锁过期时间导致另一个实例在锁过期后抢到锁重复执行。解决方法是让任务的锁时间比最长执行时间长很多同时在任务内部继续做状态判断这个双保险缺一不可。6.3 用户GPS坐标与地图坐标偏移有个用户反馈明明选的是“上门服务”技师端看到的用户位置偏了两条街。这是因为用户手机上报的是GPS原始坐标WGS-84而技师端用的是高德地图GCJ-02坐标系两者没有统一。后来所有前端上报的坐标都先经过高德坐标转换接口转换成GCJ-02再入库这个问题才算彻底解决。坐标系的坑在文档里不明显但实际遇到的概率很高做LBS开发的朋友一定提前意识到最好在接口文档里就写明坐标系的约定。6.4 支付回调成功但订单没变已支付有段时间用户支付完成后订单迟迟不更新客诉不断。后来查到是支付回调的并发重复导致回调通知先到了处理线程在更新状态时发现排班已被占另一笔订单占了就直接抛异常退出而支付平台判断回调失败重发了一次但此时数据库中订单还是待支付重试的这一次才真正更新成功。实际上前面那次更新订单表没有做幂等判断状态已经处于中间态。问题解决方式是回调处理逻辑进入时就判断订单当前状态如果已经是“已支付”直接返回成功不再走业务处理。6.5 快速排查速查表现象可能原因排查方向同一时段重复预约成功唯一索引缺失或字段顺序不一致检查排班表唯一索引定时任务重复执行分布式环境无锁保护检查是否加了分布式锁用户距离显示不准坐标混用两套坐标系统一转GCJ-02支付成功订单状态未更新回调幂等判断缺失检查回调处理逻辑首位状态判断接口响应慢慢SQL或缓存穿透检查数据库慢日志和缓存命中率技师列表为空GEO搜索范围太小或缓存失效检查默认搜索半径参数这套源码整理下来我最深的体会不是某个算法多精妙而是所有看似简单的业务一旦放到并发、分布式、真实网络环境里都会冒出意想不到的边界情况。做同城服务项目不能只盯着功能能不能跑通要把更多精力放在“两个请求同时来怎么办”“回调重发了怎么办”“数据对不上怎么办”这些问题上。如果你现在正在跑类似的Java源码项目建议先把数据库唯一索引和状态机这两样东西补上它们是最便宜、最可靠的两道防线。最后分享一个小习惯每次修改这类业务逻辑前先画一遍状态流转图和并发时序图代码写起来会顺手很多。
返回列表