ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

外卖系统技术架构深度拆解:从下单到配送的核心实现

外卖系统技术架构深度拆解:从下单到配送的核心实现 有一说一外卖系统的代码量在电商行业里谈不上最大但它绝对是链路最长、角色最多、实时要求最复杂的业务之一。用户在小程序里点一份黄焖鸡米饭到骑手敲门把餐送到中间要经历用户端、商家端、骑手端、平台调度端四个角色的联动背后还藏着支付、消息推送、位置追踪、智能派单等一系列技术组件。我前后跟过两套接近生产级的配送外卖系统源码一套是贴近业务的模块化单体一套是按订单、配送、结算拆分的微服务架构今天把整套技术骨架完整拆开聊一聊。这篇文章不是枯燥的架构图背诵我会从真实业务链路出发把“从下单到配送”这条主线上每一步的技术实现、数据流转和坑点都讲清楚。适合三类人看一是简历上写了外卖项目、准备去面Java后端的人二是想用开源源码搭一套外卖系统做毕设或创业MVP的人三是纯粹想了解一套多角色实时协作系统是怎么被设计出来的技术爱好者。读完后你至少能回答出三个问题订单状态机怎么设计才不会乱扣库存怎么避免超卖骑手位置是怎么被实时推送到用户手机上的1. 先画一张全景图配送外卖系统的功能棋盘1.1 六个角色与四条核心链路一个完整的配送外卖体系首先不是技术问题而是角色和流程问题。你在系统设计文档里看到的角色模型在实际代码里会落地成不同的模块、不同的权限体系和不同的接口边界。角色模型基本是这样六个端C端用户、商家、骑手、平台运营、平台客服、财务结算。用户端负责浏览、下单、支付、评价商家端负责菜品管理、接单、出餐、营业状态控制骑手端负责抢单、取餐、送达、位置上报运营端管理商家入驻、骑手审核、活动配置客服端处理售后、申诉财务端跑对账、结算、退款。注意这个顺序它本身就对应着订单的流转方向。核心的链路只有四条其他所有功能都是挂在这四条链路上的辅助设施下单链路用户浏览商家 → 加购选规格 → 提交订单 → 支付 → 订单生成。这条链路的主战场在用户端和订单中心。履约链路商家接单 → 备餐 → 出餐 → 通知取餐。这条链路的主战场在商家端它和用户感知直接相关出餐慢不慢、接单灵不灵敏全看这里做得好不好。配送链路骑手抢单/派单 → 到店取餐 → 骑行配送 → 用户签收。主战场在调度中心、骑手端、位置服务。售后链路退款、取消、投诉、超时赔付、申诉仲裁。主战场在客服中心、结算中心。如果你在看一套配送外卖系统源码我建议你先在代码里找到这四条链路的入口类或核心Service然后顺着它们往下读。大多数开源项目的模块划分虽然五花八门但这四条主线一定是存在的。1.2 模块化单体还是微服务关键看规模很多新手一看外卖系统就忍不住想用微服务订单一个服务、用户一个服务、骑手一个服务、支付一个服务、物流一个服务恨不得拆出十几个Spring Boot应用来。我的建议是除非你的项目目标是“演示微服务技术”否则在业务早期阶段模块化单体比分布式微服务要靠谱得多。为什么外卖系统虽然角色多但真正的核心问题是状态流转和实时通信而不是“某个模块需要独立扩容”。你在单体应用里用Maven多模块把订单、配送、商家、用户、支付拆成不同的Module边界划清楚接口定义好一样能达到逻辑解耦的效果。等到日订单量真正到了几万单再按“订单域”“履约域”“用户域”逐步拆出独立的应用服务完全来得及。我看到很多开源的“配送外卖系统源码”其实是单体应用带前后端分离这恰恰是合理的起步架构。微服务带来的分布式事务、服务调用链追踪、多环境部署成本在小规模阶段是纯粹的负担。记住一句话服务拆得越细你的分布式难题就越多而这些难题并不会因为拆分而被解决只是被转移了。2. 技术选型哪些中间件在真正干活2.1 前后端技术栈按团队熟悉度来定先给一套我比较推荐的主流技术栈清单这套组合在开源性、社区活跃度、托管成本三个维度上都比较均衡后端主框架用Spring Boot Spring Cloud Alibaba。理由很简单外卖系统离不开分布式配置、注册发现、限流熔断Nacos和Sentinel在这套体系里能非常自然地嵌进去。如果只是单体项目那Spring Boot单框架也够用Nacos可以先不引入。前端分两端管理后台用Vue 3 Element Plus用户端和骑手端用小程序原生或uni-app。外卖业务的C端流量入口几乎都在微信小程序骑手端由于要频繁上报位置、接单抢单对Map组件、长连接稳定性要求更高用原生小程序或者uni-app框架都行但要做好地图SDK的适配测试。数据库选型上业务主库用MySQL 8.x缓存用Redis 6.x消息队列用RocketMQ搜索服务用Elasticsearch对象存储用OSS兼容服务。搜索这块很关键因为用户端搜索“黄焖鸡”“奶茶”等关键词时直接用数据库LIKE查询性能会很难看ES做商家和菜品的索引是标配。2.2 解决核心问题的三驾马车Redis、MQ、数据库Redis在外卖系统里承担的任务远比一般业务重我大致数了一下至少有六个地方要用它用户登录态和Token缓存商家营业状态缓存因为下单接口要被高频读取菜品库存预扣减这个在第五章重点讲用户购物车临时数据保存抢单模式的“订单候选池”订单超时未支付的延迟队列用Redis的过期回调或者ZSET实现。消息队列是异步解耦的核心。RocketMQ解决的不只是“把支付成功消息发给商家端”这种简单通知更关键的是事务消息能力。为什么用事务消息因为外卖系统里“本地数据库操作”和“通知其他服务”必须保证最终一致。比如订单服务收到支付成功回调后要更新订单状态还要通知商家端“来新单了”这两个操作如果不放在一个事务语义里就会出现“库里订单已支付但商家端没收到消息”的血案。RocketMQ的事务消息机制天然支持这种先发半消息、再执行本地事务、提交后消息才可见的流程。Kafka和RabbitMQ在这块做得都不如RocketMQ顺手。数据库是万物的底座。需要注意的一点是外卖系统是典型的读多写少、写多读更多的系统用户看商家列表、看菜品、跟踪骑手位置都是高频读。所以MySQL的读写分离从第一版就要规划好起码主库写订单、从库跑查询避免报表统计和日常查询把主库的连接池拖垮。2.3 地图与实时通信外卖系统最独特的两个依赖这两块几乎是外卖系统区别于普通商城系统的“分水岭”技术。地图服务一般引入高德/腾讯地图的Web服务API和小程序SDK核心能力包括逆地理编码把GPS坐标转成可读地址、路径规划算骑行距离和预计时长、地理围栏判断某个地址是否在商家配送范围内、骑行导航。在这一块别自己造轮子直接用成熟的地图服务商除非你有自建地图团队。实时通信方面客户端和服务器之间需要一条双向长连接通道。最常用的方案是WebSocket骑手端位置上报、订单状态变更通知、商家接单提醒都可以通过这条长连接实时推送。如果项目规模大通常会在网关层做WebSocket的集群管理和路由分发让连接落在不同的Netty服务上再通过Redis记录“用户ID → 连接所在节点”的路由关系。这块在第六章会具体展开。3. 从下单到配送核心链路的每一步数据流3.1 下单前的三道闸门营业状态、配送范围与预下单用户在用户端提交订单之前后端其实已经跑了一段复杂的校验逻辑。我把它总结成“三道闸门”任何一道没通过订单都创建不了。第一道是商家营业状态校验。这里不是简单查数据库而是查Redis缓存里的营业状态标记——商家手动切换营业/休息或者被系统判定“疑似出餐异常”自动闭店都会更新这个标记。下单接口在读这个标记时要容忍缓存和DB之间的短暂不一致比如允许30秒内的状态延迟避免商家刚好在切换状态的瞬间用户下单报错。第二道是配送范围校验。用户地址和商家位置之间要做距离计算判断是否超出配送半径。这里我踩过一个坑直接用GPS球面距离算出来可能是5公里但实际骑行路线可能是8公里用户下了单骑手却不想接。所以生产级的做法是调用地图服务的路径规划接口拿到真实的骑行距离再和配送半径比较。如果担心地图接口QPS太高可以本地先用球面距离粗筛只有边界模糊的订单才调精确接口。第三道是预下单校验包括菜品是否在售、库存是否充足、价格是否变动、用户优惠券是否可用。这个阶段不会真正锁库存只是做一次读操作目的是尽早拦截无效订单减少后续的异常处理和退款纠纷。到真正提交订单时才执行库存扣减。3.2 下单那一刻订单状态机与支付回调订单一旦创建就进入了一条严格的状态轨道。我见过很多不成熟系统最喜欢在这里犯错——用一堆if-else散落着改状态。正确的姿势是设计一个订单状态机把合法状态迁移集中管理起来。一个完整的外卖订单状态机大概长这样状态码含义触发事件下一步合法状态CREATED待支付用户提交订单PAID / CANCELLEDCANCELLED已取消用户取消或超时未支付终态PAID已支付待接单支付平台回调CONFIRMED / REFUNDINGCONFIRMED商家已接单商家确认或自动接单PREPARING / CANCELLED_BY_MERCHANTPREPARING备餐中商家点出餐READYREADY待取餐骑手已取餐DELIVERINGDELIVERING配送中骑手开始配送COMPLETEDCOMPLETED已完成用户确认收货或系统自动确认终态REFUNDING退款中售后退款申请REFUNDED / 回到原状态REFUNDED已退款退款完成终态状态机落地的代码写法推荐用一个枚举类定义状态和允许的迁移关系然后每次状态变更都写一张状态流转日志表order_status_log。这张表很有用客服查“订单为什么变成了这个状态”、风控判断异常订单、运营分析转化率全都依赖它。下单接口的核心编排逻辑大致是Transactional public OrderVO createOrder(CreateOrderRequest request) { // 1. 校验商家营业状态Redis checkMerchantOpen(request.getMerchantId()); // 2. 校验配送范围路径规划接口 checkDeliveryRange(request.getAddressId()); // 3. 计算订单金额菜品总价 配送费 餐盒费 - 满减 - 优惠券 PriceResult price calculatePrice(request.getItems(), request.getCouponId()); // 4. 扣减库存防超卖 boolean deducted stockService.deductStock(request.getItems()); if (!deducted) throw new StockNotEnoughException(部分菜品库存不足); // 5. 创建订单主表 订单明细状态为CREATED Order order saveOrder(price, request); // 6. 发起支付预留支付单号组装签名参数 String payParams paymentService.createPrepay(order.getOrderSn(), price.getPayAmount()); // 7. 发送延迟消息30分钟后检查是否未支付未支付自动关单 delayedCheckService.scheduleCancel(order.getOrderSn(), 30 * 60 * 1000L); return OrderVO.of(order, payParams); }注意第4步和第5步之间有分布式一致性的问题这会在第五章细讲。第7步很关键超时未支付的自动关单不是靠定时任务扫表实现的而是用延迟消息或Redis延迟队列触发到期只处理用消息里指定的订单号。支付回调的处理有几个硬要求验签、幂等、事务。验签必须严格校验支付平台返回的签名防止伪造回调幂等是防止支付平台重复回调导致订单状态重复流转事务是确保“更新订单状态为PAID 写入支付流水 发送事务消息给商家端”要么全部成功要么全部回滚。回调接口永远不要直接在原订单上做“先查再更新”的非原子逻辑一定用乐观锁或幂等表做保护。3.3 商家侧接单、出餐与超时保护支付成功后订单服务通过RocketMQ发送一条“新订单通知”给商家端。商家端有两种接单模式自动接单和手动接单。自动接单模式下商家端收到消息后系统直接按预设规则把订单状态从PAID流转到CONFIRMED然后调用消息服务通知骑手调度中心“有新订单进入候选池”。手动模式下商家端在限定时间一般是1到3分钟内点击“接单”按钮超时未处理则自动流转到CANCELLED_BY_MERCHANT同时退款给用户。这里有一个非常容易踩坑的细节商家侧看到的“订单列表”和用户侧看到的“订单状态”必须是同一个数据源。有些系统图省事商家端和用户端各查各的表最后就出现“这里已发货、那里还在备餐”的信息错乱。生产级做法是商家端收到消息后直接重查订单主表和状态字段不做本地状态存储。备餐阶段有一个比较实用的功能设计出餐状态由商家手动更新。点“开始制作”后进入PREPARING点“出餐完成”后进入READY。这两个动作会直接影响骑手的取餐判断和用户的等待时间预期。如果商家长时间不更新状态系统要能监控到触发超时预警一方面提醒商家另一方面在用户端展示“商家正在加急备餐”的安抚文案。3.4 骑手侧抢单和派单的规则与算法骑手和订单的匹配关系是整个外卖系统最动态的部分。常见的方式有两种抢单模式和派单模式。抢单模式在技术上比派单简单逻辑是新订单产生后系统筛选出“附近一定范围内的候选骑手”向他们的WebSocket连接推送一条抢单卡片谁先点击谁获得订单。这里需要设计好候选骑手筛选规则比如-- 找到距离商家3公里内、当前未满载、在线状态的骑手 SELECT rider_id, (6371000 * acos( cos(radians(?merchant_lat)) * cos(radians(lat)) * cos(radians(lng) - radians(?merchant_lng)) sin(radians(?merchant_lat)) * sin(radians(lat)) )) AS distance FROM rider_location WHERE online 1 AND current_load max_load HAVING distance 3000 ORDER BY distance ASC LIMIT 20;注意这个SQL只是最简单版本。生产环境不会直接在主库上跑这种带ACOS函数的全表扫描一般会把骑手位置放在Redis的GEO结构里用GEORADIUS命令按中心点、半径筛选候选骑手性能和准确度都更好。筛选出候选骑手后通过消息队列或WebSocket把抢单任务推送出去先到先得。派单模式则复杂一些核心是一个策略评分器。系统给每一对“订单-候选骑手”算一个综合得分综合了以下因素骑手与商家的距离、骑手当前已接单数、骑手的配送路线重叠度、骑手历史准时率、当前订单预计超时风险。一个示意的评分公式score w1 * (1 - distance / maxDistance) w2 * (1 - currentLoad / maxLoad) w3 * historyCompletionRate w4 * (1 - etaRisk)权重w1到w4需要根据业务目标动态调节。如果你所在区域运力紧张就调大w1和w2让距离近的骑手优先如果你更关注准时率就调大w3和w4。派单完成后要把“骑手-配送单”的绑定关系写入配送单表并推送消息通知骑手。派单模式失败的兜底策略也很重要——如果评分最高的骑手拒绝了派单系统要能自动降级到次优骑手或转成抢单模式重新发单。3.5 配送中轨迹回传与ETA计算骑手点击“我已取餐”后订单进入DELIVERING状态这时用户端开始展示“骑手距你还有XX米预计还有XX分钟送达”的页面。这背后涉及两套不同频率的机制。一是骑手端GPS位置上报。骑手端小程序或者App通过地图SDK获取GPS坐标按照一定策略比如每5秒或者每移动50米上报到后端。后端收到坐标后一方面是写入配送轨迹表另一方面通过WebSocket实时推送给用户端并在用户端地图上绘制骑手移动轨迹。注意不要每条位置都写一次数据库那会压垮存储实际做法是先写Redis缓存一个短时间窗口内的最新位置再异步批量落库做轨迹回放。二是ETA预计到达时间计算。美团、饿了么这类系统会用上复杂的机器学习模型。自研系统一般使用地图服务的骑行路径规划接口拿到路径距离再结合骑手的平均骑行速度估算时间etaMinutes routeDistance / (averageRiderSpeed * roadCoefficient)roadCoefficient用来修正红灯、坡道、拥堵路况的影响默认值取0.7到0.9。如果你没有真实的骑手历史速度数据可以先从1.0开始等积累了数据再调整为分位值。3.6 送达后结算、退款与售后用户点击“确认收货”或者系统在送达后自动确认提前设置好超时时间订单进入COMPLETED状态。此时要触发几件事订单完成事件通知商家端和骑手端进入结算流程生成商家和骑手的结算流水清理订单相关的Redis缓存数据购物车、候选池等如果用了优惠券进行券状态的核销。退款和售后是外卖系统里最容易出幺蛾子的地方。退款分为仅退款和退款并取消订单。仅退款常见于餐品少送、洒漏骑手还在配送中但用户申请退款这时候客服介入系统要支持锁定订单状态防止骑手送达时状态冲突。退款金额的计算要支持按商品明细退、按部分商品退退还到原支付渠道。因为外卖客单价低退款流程不能设计得太繁重自动化比例要尽量高。4. 数据库设计实战核心表结构与分库分表策略4.1 订单主表、订单明细表与订单快照外卖订单表和普通电商订单表最大的区别是字段中的商家维度、地址维度、配送维度信息特别多。下面是订单主表的核心字段设计字段类型说明idbigint主键order_snvarchar(32)业务订单号需要唯一索引user_idbigint下单用户merchant_idbigint商家statustinyint订单状态码total_amountdecimal(10,2)商品总价delivery_feedecimal(10,2)配送费package_feedecimal(10,2)餐盒费discount_amountdecimal(10,2)满减优惠券总额pay_amountdecimal(10,2)实际应付pay_channeltinyint支付渠道delivery_address_idbigint配送地址IDaddress_snapshotvarchar(500)地址快照JSONrecipient_name / recipient_phonevarchar收件人信息expected_delivery_timedatetime预计送达时间merchant_remark / rider_remarkvarchar备注create_time / update_timedatetime时间字段注意address_snapshot这个字段。用户地址在订单创建后可能被修改或删除但订单必须保留下单那一刻的地址快照否则售后和补发都会没着落。类似的原则也适用于菜品——订单明细中要冗余菜品名称、图片、价格快照不能只存dish_id。菜品改价、改名是常态如果明细表只是关联菜品ID历史订单的展示和数据口径就会全乱。菜品本身的表结构设计也有讲究。外卖菜品有规格属性比如“珍珠奶茶”有“大杯/中杯”“半糖/全糖”“加珍珠/加椰果”如果做成SPUSKU穷举组合的表商家后台维护起来会痛不欲生。实战中的做法是dish表存SPU基础信息dish_sku表只管理有库存和价格差异的SKU口味规格用JSON字段存储渲染时前端解析JSON即可。4.2 配送单、骑手位置与结算流水配送单表delivery_order是连接订单和骑手的桥梁核心字段如下字段类型说明delivery_idbigint配送单IDorder_idbigint关联订单rider_idbigint骑手statustinyint配送状态assign_typetinyint派单/抢单merchant_lng / merchant_latdecimal商家坐标user_lng / user_latdecimal用户坐标pickup_timedatetime取餐时间deliver_timedatetime送达时间route_distanceint实际配送距离米eta_minutesint预计时长分钟actual_minutesint实际耗时分钟骑手位置表rider_location是高频写入表按骑手ID和时间戳建索引只保留最近一段时间的全量轨迹历史轨迹做归档或采样存储。轨迹数据不要只存坐标最好带上speed速度、heading方向角、accuracy定位精度、report_time上报时间有了这些才能在地图上正确还原骑行轨迹。结算流水表settlement_record记录每笔订单给商家、骑手的款项变动字段包括account_type商家/骑手、biz_type配送费/货款/提现/罚款、amount、order_id、status。结算必须能够按订单维度追溯清楚退款、取消、超时赔付都会触发多条结算流水需要一张结算明细表 一张汇总表配合使用。4.3 数据量上亿之后怎么分库分表外卖系统的订单数据量增长非常快一天几万单不出半年就是几千万行。我建议的演进路径是这样的第一年单库单表MySQL主从复制从库跑查询和报表。大部分外卖SaaS项目在这个阶段可以很舒服地跑。第二年订单表按订单号或用户ID分表。外卖系统最适合的拆分方式是分库分表同时做订单主表按user_id取模拆分到多个库再把订单明细表、订单状态日志表、配送单表都带上order_id或user_id的冗余字段跟着订单主表一起落到同一个分片上。这样的好处是“按用户查订单”永远只查一个分片关联查询也都在本地完成。第三年引入历史订单冷热分离。一个用户的订单前90天内的都是高频访问的“热数据”超过半年的订单基本只有用户主动翻查时才会用到。把完成状态且超过N天的订单定时归档到冷数据库或OSS上的离线表业务主表只保留活跃订单查询性能和存储成本都能得到显著优化。5. 高并发与一致性外卖系统最容易被问倒的细节5.1 防止超卖库存扣减的三种方案对比“超卖”指用户支付成功了但商家制作时说菜品已售罄。这在大学生做的课程设计里是高频bug在真实生产里是事故。库存扣减有三种常见实现方案我把它们放在一起对比方案实现方式优点缺点适用场景数据库乐观锁UPDATE dish SET stockstock-1 WHERE id? AND stock0实现简单绝对不超卖高并发下对DB压力大需要重试中小规模系统Redis预扣减Lua脚本原子扣减库存再异步同步DB抗高并发响应快需要处理DB和Redis之间的对账促销、高峰时段MQ串行化同一商家的扣库存请求发到同一个队列串行执行完全可控不会超卖下单响应延迟增加对一致性要求极高的场景我实际落地时的组合方案是Redis预扣减 数据库兜底对账。下单请求进来后用一段Lua脚本对Redis中的菜品库存做原子扣减-- keys[1]: dish:stock:{dishId} -- argv[1]: 扣减数量 if redis.call(GET, KEYS[1]) false then return -1 end if tonumber(redis.call(GET, KEYS[1])) tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 end return -1如果返回1则允许下单返回-1则直接提示库存不足。之后通过异步任务把Redis中的扣减结果同步到MySQL的dish表同步过程要做幂等记录一条扣减流水用于重启、断网后的数据对账。对账脚本定期扫描Redis和MySQL中的库存差异发现不一致自动修复。这套方案我在压测时单接口撑到了8000TPS在商家数量不多、菜品库存变动不频繁的外卖业务里完全够用。5.2 分布式事务下单、扣库存、更新配送单怎么保持一致外卖系统属于典型的分布式业务系统一个完整操作往往跨订单服务、库存服务、消息服务、结算服务。全链路强一致是不可能的也是没必要的。核心原则是优先保证最终一致性且把不一致的窗口压缩到最小。我把事务边界画成两条线第一条线下单链路内部的强一致。创建订单主表、写入订单明细、扣减库存、发送延迟关单消息这几个操作要么全部成功要么全部失败。单体架构下用本地事务加Transactional就能解决微服务架构下则需要分布式事务。两个常用方案Seata AT模式和RocketMQ事务消息。Seata AT模式对代码侵入最小一个GlobalTransactional注解就能包住跨服务的事务但性能损失比较大不适合高并发核心链路。事务消息方案性能好但要求你手动处理回查逻辑。下单场景用RocketMQ事务消息比较顺流程是// 1. 发送半消息Half Message TransactionSendResult result rocketMQTemplate.sendMessageInTransaction( order-create-topic, MessageBuilder.withPayload(orderDTO).build(), orderDTO ); // 2. 执行本地事务创建订单 扣减库存 写状态日志 // 3. 本地事务成功 - 提交消息下游服务可见 // 4. 本地事务失败 - 回滚消息下游永远收不到这一段代码解决了“创建订单”和“通知下游”的一致性问题。消息服务会在本地事务执行期间检查半消息状态超时未确认的会回调到指定接口回查订单是否存在。第二条线订单完成后的跨服务通知。比如支付成功通知商家端、商家接单通知调度中心、骑手送达通知结算中心这些都是下游不要求实时、只要求最终做到的事件。直接用MQ的普通消息配合消费端的幂等处理就行不需要引入大的分布式事务框架。5.3 订单防重幂等设计的落地写法外卖系统中幂等问题无处不在我把最容易踩的三个点列出来支付回调重复。微信/支付宝回调可能因网络原因重复推送两次以上。处理方式是查本地“支付流水表”如果流水号已经存在则直接返回成功不再执行订单状态更新。MQ消费重复。MQ保证不丢消息但不保证不重复。消费端必须以业务主键做去重最简单的实现是“处理记录表 唯一索引”public void onMessage(OrderPaidMessage message) { // 用消息体中的order_sn做幂等键 Boolean success processedRecordService.tryMarkProcessed(message.getOrderSn()); if (!success) { log.warn(重复消息已忽略. orderSn{}, message.getOrderSn()); return; } // 真正的业务处理 merchantNotifier.notifyNewOrder(message.getOrderSn()); }这里tryMarkProcessed的核心是一句带唯一约束的INSERT IGNORE语句要么插入成功要么抛唯一键冲突都不会出现重复处理。用户重复提交下单请求。用户在弱网环境下猛点“提交订单”按钮如果没有防重系统会创建出多个相同订单。解决方案是前端按钮置灰 后端生成幂等令牌下单前调一个接口获取idempotent_token提交订单时携带后端用这个token在Redis里做SETNX同一个token的请求只允许成功一次。6. 实时位置与配送调度的技术实现6.1 WebSocket推送从点单到送达的全程直播外卖系统的实时性要求贯穿整条链路每一条状态变化都要“实时”出现在用户的屏幕上。实现这层“实时性”的通用方案就是WebSocket长连接。一个比较完整的设计是客户端通过WebSocket连接后端网关网关把当前连接注册到连接管理服务中记录userId - serverNode - connectionId的映射关系连接状态存在Redis。当订单状态有变化时业务服务把消息发到MQMQ的消费者查询出该用户当前连接的服务器节点再通过该节点上的WebSocket Session推送数据。这个设计解决了三个实际运维问题水平扩展多台WebSocket服务器、连接迁移用户断线重连后能找到新节点、消息路由同一个订单状态变更只推给正确的人。心跳机制也很重要客户端每30秒发送一次ping服务端连续两次未收到心跳就主动断开连接并清理路由缓存不然服务器上会趴着一大批半死连接白白占用内存。6.2 骑手轨迹采集GPS上报与地图匹配的细节骑手端的位置上报要考虑三个问题上报频率、上报精度、轨迹还原。上报频率上固定频率模式很容易造成资源浪费或者轨迹断裂。实际项目里做的是动态调整骑手停驻时每15秒上报一次骑行时每3秒上报一次或者按位移阈值触发比如每移动50米才上报一次再结合方向变化判断是否需要额外上报。上报精度上GPS坐标本身有误差在楼宇密集区域误差可能到几十米。生产级做法是骑手端在拿原始GPS坐标后进行一次地图偏移纠偏国内地图坐标系使用GCJ-02服务端再做一次简单的轨迹过滤剔除明显跳跃的“飞点”——比如上一秒在A点下一秒跑到5公里外的B点这种坐标要被修正或丢弃。轨迹还原如果用得上可以把一天内的轨迹数据转成GeoJSON保存到对象存储里需要时按时间段范围查询渲染成骑手配送路线图。这块对智能调度和客服仲裁都有很大价值。6.3 调度引擎把订单分给“最合适”的骑手派单引擎是整个系统里技术含量最高的模块之一核心是“候选骑手筛选 打分排序 派单决策 失败兜底”四步走。候选骑手筛选刚才讲过了用Redis GEO圈定商家附近若干公里内的在线骑手。打分排序就是前面提到的评分公式但要注意真实的引擎不是对所有候选骑手打一次分就结束的它还要考虑未来订单的预留量。比如某个骑手虽然当前空闲但周边未来5分钟内预计会产生大量订单那么系统可能优先派给另一个距离稍远但区域竞争小的骑手避免“一窝蜂全空”。派单决策一般是异步的防止阻塞下单链路。订单进入调度池后调度服务根据规则决定立即派单还是进入等待池比如高峰期每隔几秒统配一次。派单成功后要同时更新配送单状态、通知骑手端、在订单状态机上流转。最后一步兜底逻辑也非常重要派单到某个骑手后如果骑手在N秒内没有接受系统要把订单重新放回调度池调低当前骑手的优先级再选下一个候选。如果连续几次派不出去订单升级为“全场广播抢单”所有符合条件的骑手都能看到。7. 读源码的路线图与扩展思考如果你拿到了一套开源的配送外卖系统源码不知道怎么下手我给你一条实际验证过的阅读路线。第一步先跑起来再说。很多新手喜欢一开始就深挖源码逻辑其实效率很低。把项目克隆下来把MySQL、Redis、MQ依次启动把前端跑起来自己走一遍“用户下单 → 商家接单 → 骑手配送”的完整流程。亲眼看到数据在表里如何变化比读十篇源码解析都有用。第二步从订单状态机切入。找到订单状态枚举类和状态流转方法用断点跟踪一次完整状态变更。当你理解了“一次支付成功回调 → 状态PAID → 通知商家 → 商家确认 → 状态CONFIRMED → 推送调度中心”这个全过程时你就已经抓住了系统的中枢神经。第三步沿着MQ消息追链路。看一遍代码里所有的MQ生产和消费点画一张“消息流向图”。这套图会比任何模块架构图都接近真实的系统设计——因为代码里的消息就代表真实的调用发生点。第四步去读读测试用例和压测报告。开源项目里测试用例往往能给你展示边界条件和异常处理路径这是源码里最珍贵的部分。比如“库存为0的下单”“支付回调重复”“骑手超时未接单”这些case会告诉你系统的防御能力有多强。当你把这几步走完你对配送外卖系统的理解就超过大部分停留在“我会写增删改查”的开发者了。配送系统的魅力正在于此它不像社交产品那样纯粹比并发也不像电商后台那样纯粹比数据它是一只真正的混合体动物需要状态机、实时推送、地理位置计算、智能调度和分布式事务的协同配合。正因为如此复杂它才那么适合作为架构能力的试金石——你要是把这套系统讲得清楚利落面试官基本不会再怀疑你的工程能力。最后分享几个我在实际项目中觉得特别加分的细节设计。一是订单状态流转日志一定要有它看起来只是几张表等到你排查线上订单异常、处理用户投诉的时候就知道它有多值钱。二是做好金额计算的统一出口满减、优惠券、餐盒费都放在一个独立的计算服务里别散落在各个Controller里不然改一次活动规则要动十几处代码。三是从第一版开始就给Redis缓存和MQ消息加好监控报警不然业务量上来的时候惨案基本是迟早的事。你如果准备开发或者二次开发一套外卖系统把这几个点提前做好会省掉后面绝大部分的麻烦。
返回列表