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

资讯详情

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

Java实战:同城外卖跑腿团购系统的高并发与LBS架构解析

Java实战:同城外卖跑腿团购系统的高并发与LBS架构解析 做同城外卖跑腿团购系统之前我觉得它无非就是下单、派单、结算三件事。真正把订单量跑起来之后才发现这套系统最难的从来不是CRUD接口而是并发、状态一致性、位置实时性这三座大山。这篇文章就把我基于Java生态从零搭建这套系统的完整思路、核心实现和上线后踩过的坑一次性讲清楚。项目涉及的代码和技术栈都是实际应用中验证过的不是PPT架构。无论你是准备用Java做同城交易类系统的开发者还是正在准备Java面试想找真实项目案例的人这篇都能给你一些拿来就能用的东西。1. 先搞清楚同城外卖跑腿团购到底是一套什么系统1.1 角色链路和核心业务闭环同城外卖跑腿团购听起来是个大杂烩实际上可以拆成三条相互独立又共享底层能力的业务线外卖配送、跑腿代购、团购核销。外卖配送的核心链路是用户下单—商家出餐—骑手取餐—送达。跑腿业务则是用户发布任务—骑手接单—执行任务—交付确认。团购业务的链路更偏向电商用户购买团购券—到店核销/配送核销。这三条业务线共享的东西非常多用户体系、支付体系、骑手运力池、LBS位置服务、消息通知。所以在架构上不要拆成三个独立系统而是做一个配送中台加上轻量的业务前端。我把订单表设计成了统一结构用order_type字段区分外卖、跑腿、团购配送这样骑手端、结算端、对账端都不用重复开发。1.2 系统的三个核心难点第一个难点是高并发下的订单一致性。饭点高峰期一个商圈瞬间涌入几千单用户点完支付、骑手抢单、商家接单这些操作全部是并发的。如果控制不好就会出现超卖、重复接单、订单状态错乱。第二个难点是骑手位置的实时性。骑手定位不能靠客户端上传一次就完事必须持续上报、实时计算距离还要考虑轨迹回放和围栏判断。第三个难点是多角色状态流转。一个订单要经历待支付、已支付、待接单、配送中、已完成等十几个状态每个状态谁来触发、谁能触发、触发了要做什么事必须形成闭环。这些难点直接决定了技术选型和代码结构这也是我为什么坚持用Java这套成熟生态来做底层支撑的原因。1.3 为什么选Java而不是其他语言我认真比较过Go和Node.js的可行性。Node.js在IO密集场景确实轻快但工程化、事务管理、成熟框架生态不如JavaGo在并发模型上很优秀但招人成本和团队上手速度是个问题。Java在这些年的积累下Spring Boot把开发效率拉到了很高的水平同时JVM的稳定性、监控体系JMX、Arthas、Prometheus都是久经考验的。尤其到了Java 17之后虚拟线程Virtual Threads的引入补齐了高并发IO场景的最后一块短板。这套系统上线后用JDK 17跑既保留了Java的类型安全和生态优势又在并发能力上不再输给Go。从长期维护的角度看Java是我能想到的最稳的选择。2. 从零搭建时的技术选型和工程初始化2.1 核心组件清单组件选型用途开发框架Spring Boot 3.2 Spring Cloud Alibaba微服务基础能力服务发现、配置中心JDKOpenJDK 17长期支持版本虚拟线程等新语法支持ORMMyBatis-Plus单表CRUD效率高分页插件好用缓存Redis 7热点数据、分布式锁、LBS GEO操作消息队列RabbitMQ订单事件异步解耦、最终一致性数据库MySQL 8.0读写分离核心业务数据存储实时通信Netty WebSocket骑手位置实时上行、订单状态推送定时任务XXL-Job超时订单扫描、对账、报表接口文档Knife4jSwagger增强前后端联调用这套组合的好处是生态非常成熟社区资料多遇到问题基本都能搜到现成的解决方案。很多人纠结是否需要微服务我的建议是一开始就按模块拆好边界但不要一开始就拆成几十个微服务。我的做法是拆了订单、用户、骑手、营销、支付五个核心服务再往下就过度设计了。2.2 工程初始化中容易忽略的环节环境配置是第一道坎。团队里新同学经常在环境上卡一天所以我专门整理了一份环境变量配置文档JDK安装后必须配置JAVA_HOME并把%JAVA_HOME%\bin加到PATH里Linux下还要注意/etc/profile和~/.bashrc的区别。有不少报错完全是因为java -version指向了旧版本的JDK导致的——编译代码用的是17运行时却跑在1.8上这种诡异问题排查起来非常浪费时间。Maven的settings.xml也要提前统一。私服地址、镜像源、JDK编译版本全部固定避免不同人构建出来的包行为不一致。我在项目里强制使用了maven-enforcer-plugin代码里出现Java 8语法上的兼容问题会在构建期直接报错。还有一个细节是统一返回体和全局异常处理。很多团队的项目跑了半年接口返回值还是五花八门前端联调天天吵架。我从第一天就定义了ResultT统一包装配合RestControllerAdvice做全局异常捕获业务异常通过自定义异常码抛出系统异常统一兜底。这里还顺手做了一个基于Spring AOP和动态代理的日志切面所有接口的入参、出参、耗时全部自动记录。动态代理在这个场景下特别好用不需要侵入业务代码就能完成全链路日志采集。3. 订单状态机设计第一个硬骨头3.1 状态定义与流转规则订单状态我用枚举来管理而不是散落在代码里的魔法数字。外卖订单的核心状态包括待支付、已支付待接单、商家已接单、骑手已取餐、配送中、已完成以及异常状态已取消、退款中、售后中。每个状态之间的流转必须通过事件触发不允许直接修改状态字段。这就说到了状态机设计。我当时纠结要不要引入一套状态机框架比如Spring Statemachine。其实对于订单这种业务来说自己用状态模式实现更可控框架反而太重。我定义了一个订单状态机处理器接口每个状态转移都对应一个实现类public interface OrderStateHandler { // 当前状态 OrderState currentState(); // 处理事件 void handle(OrderContext context); }Spring启动时把这些Handler注入到Map中状态流转时直接根据当前状态 事件查表获取处理器。这样加一个新的状态流转只需要新增一个Handler实现类不需要改动原来的代码完全符合开闭原则。这套设计在做Java面试复盘的时候也经常被拿来当作设计模式中状态模式和策略模式结合的经典案例。3.2 抢单场景的并发控制外卖和跑腿业务里最刺激的就是抢单。用户下单后订单进入待接单池子几十个骑手同时对同一单发起抢单请求。如果普通地先查订单状态再更新两个请求可能同时读到待接单然后同时更新成功一个订单就被两个骑手抢了。我的方案是Redis分布式锁加数据库乐观锁双保险。Redis侧用SET key value NX EX保证同一时间只有一个骑手能进入抢单流程拿到锁后查订单状态确认是待接单才执行更新。数据库侧用乐观锁UPDATE order SET version version 1 WHERE id ? AND version ?影响行数为0说明已经被别人抢了。操作完成后手动释放锁并且给锁设了合理的过期时间防止异常情况下锁不释放。这里要注意锁的粒度问题。如果锁的key设计成订单维度那么不同订单的抢单完全不冲突并发能力很高。如果锁设计成全局限流那高峰期整个订单池都会被拖慢。抢单接口压测下来Redis锁的开销大概只有几毫秒但换来的是准确无误的分配结果。3.3 支付回调和幂等处理支付回调是订单系统最容易出问题的环节。支付平台的回调请求会重试多次如果接口不做幂等处理一笔支付会被反复处理导致订单状态被覆盖、优惠券被重复发放。我的做法是支付回调处理前先查支付流水表用payment_no transaction_id做唯一索引插入成功才继续业务逻辑如果插入冲突说明这个回调已经处理过直接返回成功。同时订单状态更新也加了状态机校验只有待支付状态才能流转到已支付状态不对就拒绝处理。这套幂等机制上线后支付相关的资损问题一次都没出现过。3.4 并发等待批量任务的使用场景订单支付成功后我需要在同一时刻做几件异步的事通知商家、通知骑手、发送优惠券、记录营销流水。如果顺序执行总耗时是所有任务的累加。这里我用了CountDownLatch来实现线程等待都完成的效果int taskCount 4; CountDownLatch latch new CountDownLatch(taskCount); ExecutorService executor Executors.newFixedThreadPool(taskCount); executor.submit(() - { sendToMerchant(order); latch.countDown(); }); executor.submit(() - { sendToCourier(order); latch.countDown(); }); executor.submit(() - { issueCoupon(order); latch.countDown(); }); executor.submit(() - { saveMarketingFlow(order); latch.countDown(); }); latch.await(3, TimeUnit.SECONDS); // 最多等3秒注意这里一定要用带超时时间的await不然任何一个子任务异常卡住主线程就会一直挂起。后来我把这段逻辑优化成了CompletableFuture的allOf版本代码更简洁这里就不展开了。4. 骑手派单与LBS实时定位实现4.1 附近骑手搜索GeoHash和Redis GEO的取舍派单时首先要解决找出骑手附近3公里的可用骑手。这个功能如果直接在MySQL里算sqrt((x1-x2)^2(y1-y2)^2)数据量小没问题但骑手数量到了几千上万全表扫描的性能就非常难看。我实现了两套方案。第一套是GeoHash编码把经纬度降维成字符串骑手位置更新时计算出GeoHash值存到MySQL搜索附近骑手时通过GeoHash前缀匹配锁定一个矩形区域再在应用层做精确距离过滤。这个方案适合需要持久化存储、翻页查询的场景。第二套是Redis的GEO命令GEOADD维护骑手位置GEOSEARCH以某个经纬度为圆心搜索半径内的骑手性能非常高。实际线上跑下来Redis GEO方案直接把附近骑手搜索的耗时从几百毫秒压到了个位数毫秒。但Redis GEO有个明显的缺点数据在内存里重启后会丢所以我的方案是MySQL存全量位置轨迹做数据归档Redis GEO存实时活跃骑手位置用于派单计算两者配合使用。4.2 骑手位置实时上报通道骑手端App每隔3秒上报一次定位。最初我用的是普通的HTTP接口上报结果发现这个上报频率下Tomcat线程很快被占满整个系统的接口响应都跟着变慢。后来换成WebSocket长连接骑手App和服务端建立一条持久连接所有定位数据走这条通道上行服务端也可以直接下行推送新订单提醒。连接管理用的Netty实现每个骑手对应一个Channel用ChannelGroup统一管理断线时通过心跳检测自动清理。高峰期同时在线5000个骑手Netty处理起来非常轻松这也侧面验证了Netty在长连接场景下的统治力。4.3 派单策略距离不是唯一标准派单算法一开始很简单谁离用户近就派给谁。跑了一段时间发现效果很差原因是有些人虽然近但手里已经有5单了强行派单导致所有订单都超时。后来我改成综合评分制评分由四部分构成距离分50%、骑手当前负载分30%、顺路程度分15%、历史接单率分5%。这里我在代码里用了一个策略模式每种评分规则是一个独立策略类通过Spring注入一个评分策略列表循环累加得到总分。计算时我会把骑手的全部待执行订单轨迹做一次路径匹配如果新订单在老订单路径附近顺路分就高。这个派单策略上线后平均配送时长缩短了大概15%超时投诉率下降很明显。4.4 超时未接单的兜底逻辑不管派单算法多好总有骑手不接单的情况。我的兜底方案是一个三级策略订单进入待接单状态后先按最优评分派给3个骑手5分钟内无人接单系统自动扩大搜索半径从2公里扩到4公里10分钟还没人接单触发加价补贴并重新进入派单池。整个兜底流程由XXL-Job定时扫描驱动每30秒扫描一次所有超时未接单的订单。这个机制保证了极端情况下运力能自动调节不会让订单挂在系统里没人管。5. 团购库存扣减与防超卖设计5.1 三种库存扣减方案对比团购业务的核心是秒杀和限量购库存扣减做不好就会出现超卖。我把常见的三种方案都实现过一遍最终的取舍如下方案原理优点缺点数据库乐观锁UPDATE stock SET stockstock-1 WHERE stock0实现简单、绝对不回超卖并发高时数据库压力大数据库悲观锁SELECT ... FOR UPDATE强一致锁竞争严重性能差Redis Lua脚本内存中原子扣减性能极高、支持回滚要处理库存同步问题我的选择是Redis Lua脚本做前置扣减数据库做最终落库校验。秒杀场景的流量先打到Redis由Lua脚本保证原子性扣减成功后通过消息队列异步把订单落库。数据库层的乐观锁仍然保留作为最后一道防线。这样设计后单台Redis实例可以支撑每秒上万次的扣减操作数据库不会被秒杀流量打垮。5.2 Lua脚本的原子扣减实现Redis的Lua脚本我用了下面这个核心结构-- KEYS[1] 商品库存key -- KEYS[2] 已售数量key -- ARGV[1] 本次购买数量 local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock tonumber(ARGV[1]) then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) redis.call(incrby, KEYS[2], ARGV[1]) return 1Lua脚本在Redis内部是原子执行的期间不会插入其他命令所以并发扣减不会出现超卖。注意一个细节DECRBY之后库存如果变成负数说明逻辑有问题所以脚本里先比较再扣减是最稳妥的。这个脚本测试时我模拟了200个线程同时抢购100件商品最终实际售出数量精确等于100没有一次超卖。5.3 下单后的异步解耦流程库存扣减成功只是开始后续还有创建订单、优惠券锁定、积分累计、短信通知等一串操作。如果这些全部同步执行下单接口的RT会非常高。我改成扣减库存成功后直接返回下单成功然后通过RabbitMQ发一条订单创建消息由下游消费者异步处理剩余流程。异步流程最大的问题是数据一致性。我的处理方案是消费者处理失败时消息进入重试队列重试3次仍失败就进入死信队列由人工介入处理。同时在订单主表里维护一个process_status字段记录异步流程的完成进度对账任务每隔一段时间扫描未完成的订单进行补偿。消息队列的引入让下单接口的P99耗时从800毫秒降到了200毫秒以内。5.4 购买多件时的CAS设计团购商品允许用户一次买多件这时候如果简单循环扣减会存在中间状态。我的做法是在Lua脚本里传入购买数量一次判断一次扣减彻底避开先扣一件、再扣一件、中间别人把库存抢完的尴尬。对于同一用户重复提交的场景我在Redis里用用户ID加商品ID做防止重复提交的标记标记存在时直接拦截避免了用户手抖点两次购买导致的下单重复。6. 上线后踩过的坑三个典型线上问题排查全记录6.1 外卖高峰期接口大面积超时线程池被打满上线后的第一个晚高峰监控面板上订单接口的RT飙升到了十几秒紧接着开始有调用超时的报警。我第一反应是慢SQL结果数据库监控显示CPU和慢查询都很正常。后来用jstack抓了一下线程快照发现大量HTTP线程都阻塞在Redis连接池的获取操作上。根因是Redis连接池参数配置得过小默认8个连接根本扛不住高峰期的流量线程全部在等待获取连接。修复方案是将最大连接数调到200同时优化了代码把一些不必要的Redis操作合并成Pipeline批量执行。调整之后接口RT回落到正常水平。这个坑说明线程池满不一定就是线程数不够有时候是下游连接池成为瓶颈排查时一定要结合线程dump和链路追踪一起看。6.2 Java Stream并行流导致数据库连接耗尽有一次上了一个新功能需要在订单列表页同时查询订单、商家、骑手三份数据我图省事用了parallelStream做并行查询。上线后数据库连接池立刻被打满整个应用陷入假死状态。原因很简单parallelStream用的是公共的ForkJoinPool并行度默认是CPU核数我当时机器是16核等于瞬间产生了16倍的数据库查询并发而连接池最大只有50全部被占满后其他请求全部排队。修复方案有两个选择一是把并行流改回顺序流二是自定义一个可控的线程池跑并行任务。最终我选择了自定义线程池加CompletableFuture的方案既保留了并行查询的速度又能精确控制并发度。这个坑给我的教训是不要轻易用并行流做IO操作它更适合CPU密集型的纯计算场景。6.3 相同代码在不同机器上表现不一致环境变量的锅团队里有个新同学拉代码后启动服务功能始终报错但其他同事的电脑上完全正常。一开始怀疑是代码分支问题对比后发现代码一模一样。最后发现是他电脑上的JAVA_HOME指向了某个软件自带的JDK 11而我们项目要求的是JDK 17。编译虽然在Maven层面通过了但运行时某些API行为不一致导致莫名的空指针异常。这个问题的排查花了不少时间因为在日志里看到的报错是业务层面的空指针谁都不会第一时间往JDK版本上想。后来我养成了一个习惯所有项目启动脚本里第一行就打印java -version并加上版本检查版本不对直接拒绝启动。环境变量配置看似是基本功但在多人协作项目里这块不规范带来的坑比想象中多得多。6.4 重复通知导致的重复发券问题运营做活动时调用了发券接口由于上游系统超时重试同一批用户收到了两次发券通知导致部分用户账户里出现了两张相同的券。这个问题的本质还是幂等缺失。修复方案是给每次活动批次生成一个唯一的batch_no发券时在券表里加唯一约束同一个批次同一个用户只能插入一条记录后续重复请求直接被数据库挡住。这个修复让我意识到凡是涉及资金、券、库存的操作数据库唯一约束永远是最可靠的兜底。7. 性能压测与调优复盘7.1 JMeter压测下的性能瓶颈定位正式上线前我用JMeter做了三轮压测。第一轮结果惨不忍睹下单接口在200并发下QPS只有230平均响应时间已经超过2秒。通过Arthas和链路追踪逐个排查发现耗时大头依次是数据库慢查询占40%、Redis序列化开销占25%、业务代码中的循环调用占20%。慢SQL的主要问题是订单表查询没有走索引我在user_id、order_status、create_time三个字段上建立了联合索引单表查询从几百毫秒降到了十几毫秒。Redis序列化从JDK自带序列化改成了Fastjson2性能提升了近一倍。循环调用那部分把原来在for循环里逐条查询商家信息的代码改成了Java Stream加Collectors.groupingBy批量查询数据库交互次数从N次降为1次。7.2 关键配置参数的经验值经过反复压测我沉淀了一套适用于中小规模同城交易系统的参数配置参考值配置项经验值说明Tomcat最大线程数400超过后排队配合虚拟线程可更高MySQL连接池最大数50与实例规格匹配避免过多连接切换Redis最大连接数200高峰时需要更多连接处理缓存RocketMQ/RabbitMQ消费者并发20按实际消息量调整JVM堆内存4G根据服务拆分规模设置XXL-Job线程池16避免定时任务抢占业务资源这些参数不是固定的每台服务器配置不一样、业务流量不一样都需要压测后调整。我的做法是每个核心接口都做了独立的压测脚本每次发版前后对比QPS、RT、错误率三个核心指标有变化就定位原因形成完整的性能回归机制。第三轮压测时下单接口的QPS稳定在了1800左右P99耗时控制在250毫秒以内系统总体的TPS足够支撑一个中型城市的高峰期外卖流量。这套系统从需求梳理到上线前后花了几个月时间。做完之后再回头看那些Java基础面试题和八股文里讲的东西比如动态代理、线程池、Stream、状态模式、策略模式突然发现它们不再是抽象概念而是真真切切在代码里落地过的工具。对技术人来说最好的学习方式永远是拿一个真实业务去打磨这个过程中踩过的每一个坑都会变成你下一份代码里的直觉。如果这篇文章对你有帮助或者你在做类似系统时碰到了不一样的问题欢迎在评论区交流后续我也可以接着分享这套系统在数据监控和灰度发布方面的实践经验。
返回列表