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

资讯详情

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

同城O2O外卖跑腿系统高并发架构设计与流量治理实战解析

同城O2O外卖跑腿系统高并发架构设计与流量治理实战解析 做同城 O2O 系统尤其是外卖跑腿这类业务最刺激也是最考验架构师的一天永远是平台搞大促、天气突变、午饭晚高峰这三个时刻。平时一千 QPS 跑得很稳的系统可能在 10 秒内被冲到一万甚至几万 QPS。用户点一下“下单”按钮系统要完成商户状态校验、菜品/商品库存预占、价格计算、优惠券核销、支付单生成、骑手运力判断等一系列动作任何一个环节扛不住用户看到的就是转圈、报错、订单丢失。外卖跑腿系统的源码架构本质上不是在解决“能下单”的问题而是在解决“高峰期每秒几万次下单请求系统还能不能保持毫秒级响应、订单不丢、状态不乱、骑手不漏单”的问题。这篇文章我会从源码架构的角度把同城 O2O 外卖跑腿系统在高并发场景下的设计思路、核心模块实现、流量治理方案、高峰故障排查经验一次讲透。无论你是要自研一套同城配送平台还是接手了一套开源 O2O 源码做二次开发这篇文章都能帮你少走很多弯路。1. 先理清这是一张什么样的架构图1.1 一个订单从产生到完成要经过多少个环节很多人以为外卖跑腿系统的核心就是“下单 地图 派单”真去看源码会发现一个订单的生命周期复杂得像一台精密的齿轮机器。以最典型的用户下单为例请求从前端发出后会依次经过 API 网关做鉴权、限流、灰度路由然后进入订单中心。订单中心要做风控校验、防重校验、商户营业状态判断、库存预占、价格试算再调用支付中心创建支付单。支付回调回来后订单状态从“待支付”变成“已支付”此时系统需要向商户端推送新订单提醒同时进入调度中心由调度引擎根据骑手位置、负载、距离、评分等维度计算可配送骑手池触发抢单或派单。骑手接单后还要经历到店、取货、送达等状态流转每一步都要写库、发消息、推送轨迹更新。这些环节在不同的服务里完成每个服务都有自己的数据库、缓存、消息队列。架构师要做的就是把这些服务串联起来同时保证任何一个环节出现瞬间高并发时不会拖垮整条链路。这也是 O2O 系统区别于普通 CRUD 系统的根本原因它的并发压力不是均匀分布的而是呈脉冲状且一次请求要触达多个下游服务。1.2 高并发并不是“访问量大”那么简单在处理类似项目时我发现很多开发对高并发的理解还停留在“QPS 高”这个层面。但在外卖跑腿系统里高并发有三个更让人头疼的特点。第一是瞬时流量冲击极强。饭点前后的流量曲线几乎是垂直拉升的11:30 到 12:30 这一个小时可能承载全天 40% 的订单量。系统必须在几分钟内完成扩容库、缓存、连接池都要能扛住这种陡增。第二是热点集中。外卖场景下经常出现“爆店”现象比如某家店上了爆款菜品或者平台在做大额补贴用户会集中访问同一家商户、同一片配送区域。这类热点会直接导致数据库单行记录被并发更新、Redis 热点 key 被打爆。第三是业务链路长出错代价高。高并发和秒杀系统不太一样它不是“抢到就结束”订单后续还有支付、调度、配送、结算等多个异步环节任何一个环节的消息丢失或者状态错乱都会造成客诉和资损。所以在拆解源码架构时不能光看“并发数”这个指标还要看系统在流量冲击下的可用性、一致性和最终闭环能力。1.3 可以参考的微服务拆分方案现在开源主流的同城 O2O 系统基本都采用微服务架构。一个标准的外卖跑腿源码服务拆分大致是这样的用户服务负责 C 端用户注册、登录、地址簿、会员权益。商户服务负责商户入驻、菜单管理、营业状态、资质审核。订单服务下单、订单状态流转、订单查询、售后。支付服务支付单创建、支付渠道对接、回调处理、退款。调度服务履约服务骑手管理、派单策略、抢单池、配送轨迹。营销服务优惠券、满减、活动配置。消息服务站内信、短信、App Push、WebSocket 实时推送。基础服务地区、地图、风控、配置中心等。这套拆分的好处是不同模块的并发特征差异太大订单服务和商户服务是大流量入口调度服务则对实时性和计算能力要求更高拆开之后可以做针对性的扩容、限流和降级。比如大促时给订单服务加机器调度服务单独做容量评估互不影响。这也是为什么我建议拿到源码后第一件事不是看业务代码而是先把服务边界和依赖关系画出来。2. 核心模块源码级拆解2.1 接入层与网关把流量拦在业务前面外卖跑腿系统最外层通常是 Nginx API 网关的组合。Nginx 负责 TLS 卸载、静态资源处理、基础 IP 黑白名单网关层则承担路由、鉴权、灰度、限流、协议转换。源码里常见的是 Spring Cloud Gateway 或者基于 Netty 自研的网关。网关的核心价值是“前置拦截”。比如同一用户一秒钟重复提交下单请求、某个 IP 在批量刷接口、某个商户回调地址被恶意攻击这些问题如果在网关层拦截掉业务服务会轻松很多。我见过不少团队把限流逻辑写在业务 Service 里导致代码到处都是 try-catch 限流异常这是很糟糕的实践。正确的做法是网关层配置全局限流和接口维度限流业务层只做业务规则校验比如“当前用户是否满足参与活动条件”而不是做“当前请求是否允许进入系统”这种通用控制。网关层还要处理好超时传递。外卖系统的接口链路长经常出现网关超时时间设置得比下游服务超时时间还短的情况导致请求在下游还没执行完网关层就断开了连接。源码层面通常会统一走 RPC 调用的超时参数透传把整个链路的超时时间管理起来。2.2 订单中心源码细节状态机、幂等和防重订单中心是整个系统最核心的部分也是最容易出并发问题的地方。我这里直接说几个源码里常需要重点看的设计点。第一个是订单状态机。订单状态不能是一堆简单的 if-else而应该是一个明确的有限状态机。比如待支付、已支付、待接单、配送中、已完成、已取消、售后中每个状态之间的流转条件必须明确。高并发环境下最怕出现两个线程同时把一个订单从“待支付”改成“已支付”和“已取消”这种状态错乱一旦发生对账就非常痛苦。所以源码里一般会用版本号乐观锁来控制状态更新SQL 里带上where status ? and version ?更新成功行数为 0 时重新读取再处理。第二个是防重和幂等。用户端可能因为网络抖动连续点了两次“提交订单”支付回调也可能因为渠道方重试同一个支付单回调多次。如果源码里没有防重机制就会生成重复订单或者把同一个订单的支付状态重复更新。常见做法是使用业务唯一键比如幂等键加 Redis 分布式锁锁的粒度要控制好不能锁整个用户否则会影响并发下单最好是用户商户某段时间内的窗口做维度。这里给一段简化版的核心防重代码逻辑值得参考public ResultLong createOrder(CreateOrderRequest request) { String lockKey order:create: request.getUserId() : request.getMerchantId() : request.getBizSeq(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (!locked) { return Result.fail(ErrorCode.REPEAT_SUBMIT, 订单提交过于频繁); } try { // 幂等检查同一业务流水号只能创建一次 OrderEntity exist orderMapper.selectByBizSeq(request.getBizSeq()); if (exist ! null) { return Result.success(exist.getId()); } // 核心下单逻辑库存预占、价格计算、插入订单 return orderService.doCreate(request); } finally { redisTemplate.delete(lockKey); } }第三点是订单列表查询的索引设计。用户在 App 里查“进行中的订单”“历史订单”看似简单但高并发下如果索引建得不好订单表的数据量一大慢查询就会拖垮数据库。源码里通常会对user_id status create_time建联合索引并做分页优化禁止深分页用游标方式代替offset limit。2.3 缓存层设计Redis 如何扛住热点与突发O2O 系统的缓存设计面包屑很多。商户菜单、首页推荐、用户购物车、骑手位置等都是典型的缓存场景。Redis 用的好数据库的压力能降低 80% 以上用不好可能出现缓存穿透、击穿、雪崩三个大坑。外卖系统的菜单是“短时间高频读、依赖性强”的数据。用户打开商户页会一次性请求菜单和店铺信息如果每次都查数据库数据库根本扛不住。源码里一般会这样设计商户基础信息存 Hash菜单列表存 String 类型的 JSON 序列化结果并设置合理的过期时间比如菜单变更时才主动失效缓存。更新菜单后要确保缓存和数据库的一致性比较稳妥的套路是先更新数据库再删除缓存配合延迟双删兜底。骑手位置数据则是另一种模式。骑手每几秒上报一次经纬度写入量很大但单个点价值不高一般不会直接写 MySQL而是先写 Redis Geo 或时序数据库再异步聚合成轨迹。在源码里这类数据会采用“内存队列 批量落库”的方式避免高频写操作打爆数据库连接。对于热点 key 问题比如爆款店铺的购物车数据被大量访问一个 key 抗几十万 QPS 很容易把 Redis 单节点打爆。源码里常用的方案是 key 本地缓存 多副本缓存或者把热 key 拆成多个子 key 分摊读压力。这些设计在高并发压测中非常加分。2.4 异步化与消息队列削峰填谷的正确姿势外卖系统里下单后的很多步骤并不需要同步完成。比如支付成功后的推送通知、订单进入调度池、给商户发短信提醒、更新营销活动的参与人数这些操作如果全部耦合在支付回调的主链路里响应时间会明显变长还会因为某个下游抖动导致回调失败。源码里的标准做法是引入消息队列。下单成功后订单服务只保证订单数据和支付状态正确落库然后发送一条“订单已支付”的领域事件到 RocketMQ 或 Kafka。调度服务、消息服务、营销服务各自订阅自己关心的事件异步完成后续操作。这里要特别注意消息的可靠性和幂等性。消息发送方要做到“事务消息”或者“本地消息表 定时补偿”确保消息不丢消费方要做到“消费接口天然幂等”因为 MQ 的“至少一次”投递语义下重复消费是常态。我见过很多跑腿源码在消费端直接用“先查后插”的方式做幂等并发高时会因为竞态插入重复记录正确做法是给业务表加唯一索引消费时捕获 DuplicateKey 异常直接视为成功。3. 高并发场景下的几个关键设计细节3.1 配送调度与抢单场景的并发控制外卖跑腿系统的调度模块是所有模块里并发问题最隐蔽的一个。比如一个商圈同时来了 200 个新订单系统要把这些订单推送给附近的空闲骑手。如果设计得不好可能会出现“一个骑手同时抢到 3 个相同方向订单但系统没发现”或者“一个订单被推给了 200 个骑手但谁都没有真正抢到”。源码中常见的抢单并发控制思路是这样的订单进入抢单池时用 Redis 记录订单的推送状态比如dispatch:order:{orderId} WAITING骑手点击抢单时通过 Lua 脚本原子地修改状态只有状态为“待抢”的请求才能抢单成功并且同一订单只允许一个骑手抢成功。Lua 脚本保证了判断和更新两个操作的原子性避免了分布式环境下“先查状态再更新状态”带来的竞态问题。派单模式的并发控制又不一样。自动派单是后台任务根据算法计算最优骑手再把订单直接指派给某个骑手。这个时候要防止重复指派和并发指派给不同骑手。常用的手段是给订单绑定一个“调度版本号”字段每次指派时执行update order set rider_id ?, dispatch_version dispatch_version 1 where id ? and dispatch_version ?更新失败说明别人已经处理当前任务直接放弃。3.2 位置上报与轨迹存储的高吞吐处理骑手端 App 通常会每 3 到 5 秒上报一次经纬度、方向、速度、订单号。一个中型跑腿平台一天的轨迹点数据量可能达到上亿条。这种数据的写入特征是高频率、小批量、乱序如果直接写 MySQL 单表很快就会出现写入瓶颈和表膨胀问题。源码里高效的做法是分层处理第一层是网关接收位置上报请求直接写入 Redis Stream 或 Kafka不落数据库第二层是轨迹消费服务批量消费数据做清洗、去重、分段然后批量写入 HBase/TiDB/时序数据库或者直接存对象存储比如按“日期/骑手 ID”分目录写文件第三层是订单详情页需要展示轨迹时通过聚合查询接口读取。这样做还有一个好处骑手位置是实时性很强的数据而轨迹记录是查询频率较低但数据量大的数据两者分开存储可以各自选择最合适的存储引擎。源码里如果看到位置服务和轨迹服务共用一张表那大概率撑不过十万骑手的规模。3.3 高并发场景下的实时通信IM/推送设计外卖跑腿系统里有三类实时通信需求用户和骑手聊天、商家接单提醒、骑手位置实时追踪。这类功能在技术上和“高并发 IM”非常相似海量长连接接入、消息实时推送、离线消息补发。源码在实现时一般会有一个独立的消息推送服务底层用 Netty 做长连接网关维持客户端和服务器之间的 WebSocket 连接。服务端要和 Redis或者内存网格保存“用户 ID - 连接通道”的映射关系当订单状态变化时订单服务发送一条 MQ 消息推送服务消费后根据映射关系找到对应长连接把消息推送下去。并发高时这里有两个容易被忽略的点。第一是连接数管理和心跳保活。大量骑手端在弱网环境下频繁断线重连如果每次重连都新建连接而不释放旧连接很容易造成连接泄漏。源码里必须实现连接空闲超时检测和心跳超时清理机制。第二是离线消息。如果用户 App 被系统杀掉了这条“订单已接单”的推送就收不到所以推送服务要存储最近 N 条消息用户重新连接后从离线消息表拉取补发。我在项目里见过把推送服务直接做在订单服务里的设计结果大促时订单服务和长连接互相抢占线程资源整个服务响应都变慢。这种服务一定要独立拆分而且要独立扩容。4. 流量治理实战Sentinel 在 O2O 并发治理中的落地方法4.1 为什么 O2O 系统不能只用 Nginx 限流很多跑腿源码会依赖 Nginx 做最外层的并发限制比如配置limit_req限制每个 IP 每秒请求数。这种方式对小规模系统足够但对业务系统来说它有明显的盲区Nginx 只看 IP 和 URL看不到用户身份、下单行为、商户负载。举个例子多个用户在同一个手机上登录不同账号黄牛刷单场景或者同一用户在一秒内对多个不同商户下单Nginx 很难区分这些请求是否合理。而且用 IP 限流很容易误伤在同一个出口网络下的真实用户午高峰写字楼里几百人共用出口 IP限制太紧会把正常用户也拒掉。所以在业务系统内部还需要一层更精细的流量治理。这点 Alibaba Sentinel 在微服务高并发流量治理里是真正的主流选择它支持按资源名方法名、URL、RPC 接口、按参数、按调用链路来配置限流规则还能做熔断降级和系统自适应保护。把 Sentinel 整合进 O2O 源码本质上是在 Nginx 之外构建第二道、第三道防线。4.2 Sentinel 在订单与支付节点的落地方案在 O2O 系统里Sentinel 最典型的落地场景是订单创建和支付回调两个资源。订单创建接口需要做两种限流一种是全局 QPS 限流防止整个下单入口被打崩另一种是热点参数限流比如针对同一商户的并发下单数做限制防止爆店时单商户流量过大压垮商户服务和数据库。Sentinel 的热点参数规则正好支持按参数值维度去统计和限流比如配置hot_param规则参数索引是商户 ID当同一商户每秒超过 200 次下单请求时直接拒绝多余请求保护下游商户服务和库存系统。支付回调接口则需要配置熔断降级。当第三方支付渠道回调下游处理失败率超过阈值时Sentinel 会触发熔断短时间内直接返回降级结果不再继续执行业务逻辑。等下游恢复后熔断器自动半开再关闭。贴一份常见的 Sentinel 限流规则配置字段含义我在代码后面说[ { resource: createOrder, grade: 1, count: 500, durationInSec: 1, controlBehavior: 0, clusterMode: false }, { resource: createOrder, grade: 1, count: 200, durationInSec: 1, controlBehavior: 0, clusterMode: false, paramFlowItemList: [ { index: 1, paramFlowItem: merchantId#200 } ] } ]grade1表示按 QPS 限流count500是每秒允许请求数controlBehavior0表示快速失败拒绝策略paramFlowItemList是热点参数限流配置index1表示方法参数列表中的第 1 个参数商户 ID后面的#200表示该参数维度每秒只允许 200 次。实际接入时规则最好存在 Nacos 里自动刷新而不是写死在代码里这样可以随时调整阈值而不用发版。4.3 从源码看 Sentinel 规则持久化与动态调整Sentinel 原生规则是基于内存存储的重启应用就丢。这在生产环境是不可接受的所以源码集成时要接动态数据源。常见的做法是把规则配置到 NacosSentinel 客户端监听 Nacos 配置变化自动更新内存中的规则。配置方式大致如下spring: cloud: sentinel: transport: dashboard: localhost:8858 port: 8719 datasource: flow: nacos: server-addr: ${NACOS_ADDR:localhost:8848} >
返回列表