县城外卖怕的是稀疏运力派不出去,校园外卖怕的是整点脉冲与楼栋履约;同城外卖的矛盾更像「商圈密度高、午晚双峰陡、一单可跨多店、骑手可改派、配送费会溢价」。用户在 CBD 或住宅商圈 11:40 同时点两家店,支付成功后商家要接单出餐,履约侧要在 GEO 候选池里指派、超时改派,资金侧还要扛住支付回调重放与部分退款——任何一层用「一张订单表硬改状态」顶住,高峰都会在同一分钟把故障放大。
失败形态高度集中——下单卡、派单飘、改派乱、退款双花、观测盲——背后往往是同一类工程缺口:订单与运单耦合、购物车跨店没有行项目快照、调度只有指派没有改派契约、动态配送费事后改价无审计、支付回调与通知同事务、CLS 只堆日志没有业务指标。本文按云上工程边界拆:领域分层与腾讯云组件选型、订单/运单状态机、GEO 指派与改派、表结构草图、REST/事件契约、双峰时序、幂等支付退款、失败模式与观测、测试矩阵。不讨论首页怎么好看,也不讨论商务交付形态;只讨论系统怎么在同城双峰里削得住、派得准、退得清。

同城外卖建议按域切开,而不是按「小程序页面」堆接口。一层逻辑对应一类扩展与一类故障面:
分层 | 职责 | 腾讯云侧常见落点(举例) |
|---|---|---|
接入层 | 鉴权、限流、路由、WAF、签名 | API 网关 |
交易域 | 多店购物车、下单、支付回调、退款 | 云托管 / 云函数 + 云数据库 MySQL |
商品域 | 商家、菜单、营业态、售罄、配送范围 | MySQL + Redis |
计价域 | 基础配送费、距离阶梯、高峰溢价、满减 | 云托管计价服务 + Redis 规则缓存 |
履约域 | 运单、GEO 候选、指派/改派、轨迹 | 云托管 + TDMQ/CMQ + Redis GEO |
异步与通知 | 支付后处理、订阅消息、超时/改派扫描 | TDMQ/CMQ + 云函数消费者 |
对象与日志 | 商品图、售后凭证、链路与业务日志 | COS + CLS |
配置与多城 | 城市开关、商圈、溢价时段、运力池 | 配置中心 / 环境变量 / CloudBase 环境隔离 |
小程序或 H5 前端可用 CloudBase 静态托管与云开发能力做轻量落地;核心交易、计价与调度建议放到云托管(容器),便于按午晚双峰水平扩展。同城日单量与 QPS 通常明显高于县城/校园,默认单地域起步,但要按商圈分片与热点表归档,不要为了「看起来像大厂」一上来拆成十余个微服务。硬约束四条:
rider_id。同城外卖的核心对象是「订单实例 + 运单实例(可一对多)」,不是「商家工作台截图」。订单状态建议至少覆盖:
状态 | 含义 | 允许的下一跳 |
|---|---|---|
| 已创建待支付 | → |
| 支付成功,待商家确认 | → |
| 全部商家已接单,制作中 | → |
| 多店场景部分商家已接 | → |
| 出餐齐套或可分批发运 | → |
| 履约中(由运单驱动) | → |
| 全部送达完成 | 终态(售后走独立单据) |
| 履约异常 | → |
| 退款中 / 完成 | 按资金结果收敛 |
| 售后处理中 | → |
运单(waybill)建议独立状态机,与订单用事件对齐,禁止骑手端直接 UPDATE orders SET status:
运单状态 | 含义 | 允许的下一跳 |
|---|---|---|
| 待调度 | → |
| 已指派待接 | → |
| 改派中 | → |
| 骑手已接 | → |
| 已取餐 | → |
| 配送中 | → |
| 已送达 | 终态 |
| 异常进人工台 | → |
| 运单取消 | 终态(订单侧另走退款或重建运单) |
多店购物车:订单行按 merchant_id 分组;可「一单一运单合并取」或「一店一运单」。两条硬约束:
waybill_id + action + idempotency_key 重放应返回同一业务回执。PARTIAL_ACCEPTED 与 REASSIGNING 是同城相对县城/校园更不能省的中间态:少了前者,多店场景只能「全接或全退」;少了后者,高峰拒单只能靠人工改库,事故复盘永远缺证据。
同城商圈密度高,调度要先做 geo shard(商圈/网格),再在 shard 内做候选排序;不要每次 GEORADIUS 扫全市。推荐模型:
zone:zone_id、多边形或网格、高峰时段、基础 ETA 区间、溢价系数版本。zone_id + 计价快照:后续调度、限流、运力池、报表都按商圈维度。GEOADD rider:geo:{zone_id};负载与拒单率另存 Hash;指派时 GEORADIUS 取候选后按「距离 + 当前负载 + 完单率 + 新手保护」打分。ASSIGNED 超时进入 REASSIGNING,写入改派原因码,重新选人并发 waybill.dispatch.reassigned;连续 N 次失败进 EXCEPTION 人工台。不要无限扩半径把空驶打穿。SET reassign:{waybill_id} NX EX,防止超时扫描与人工台双改派。动态配送费:计价服务根据距离阶梯、天气/高峰规则版本计算,结果写入订单 fee_snapshot_json;骑手端展示的「本单预计收入」也来自快照,避免调度改派时费用漂移引发纠纷。
早期可单体部署,表与接口按域切开,后续拆服务少搬家。
订单 city_order
id, city_id, zone_id, user_idcart_mode(SINGLE / MULTI_MERCHANT)status, pay_amount, delivery_fee, surcharge_fee, discount_amountfee_snapshot_json, price_rule_versionaddress_id, address_snapshoteta_from, eta_toidempotency_key, version订单行 city_order_item
order_id, merchant_id, sku_id, qty, price_snapshotmerchant_accept_status(多店接单进度)运单 waybill
id, order_id, zone_id, merchant_id(可空表示合并取)rider_id(可空), statusdispatch_attempt, assign_score_snapshotassigned_at, accepted_at, picked_at, delivered_atreassign_reason_code, exception_codeversion改派日志 waybill_reassign_log
waybill_id, from_rider_id, to_rider_id, reason_code, operator, at骑手位置(热):Redis GEO + 最近心跳;冷轨迹可异步落库或 COS 对象,避免写爆 MySQL。
退款 refund_ticket
id, order_id, refund_request_id(唯一), amount, scope(整单/行项目), status审计 order_audit_log
order_id / waybill_id, from_status, to_status, reason_code, operator, at索引至少覆盖:(zone_id, status, created_at)、(merchant_id, status, created_at)、(rider_id, status)、(user_id, created_at)、(status, assigned_at)(超时改派扫描)、refund_request_id 唯一。

用户端 / 商家端 / 骑手端打到同一后端,用角色鉴权拆开:
POST /api/v1/carts/quote # 多店询价(含动态配送费)
POST /api/v1/orders # 创建订单,Header: Idempotency-Key
POST /api/v1/orders/{id}/pay-callback # 支付回调(验签后投递队列)
POST /api/v1/orders/{id}/merchants/{mid}/accept
POST /api/v1/orders/{id}/ready # 齐套或分批发运触发运单
POST /api/v1/waybills/{id}/assign # 系统/人工指派
POST /api/v1/waybills/{id}/reassign # 改派(幂等)
POST /api/v1/waybills/{id}/accept # 骑手接单
POST /api/v1/waybills/{id}/pickup # 取餐,幂等
POST /api/v1/waybills/{id}/deliver # 送达,幂等
POST /api/v1/waybills/{id}/exception
POST /api/v1/refunds # 退款(幂等键)
GET /api/v1/riders/me/waybills:pull领域事件(TDMQ/CMQ Topic 示例名):
order.paid / order.merchant_accepted / order.readywaybill.created / waybill.dispatch.assigned / waybill.dispatch.reassignedwaybill.dispatch.timeout / waybill.picked / waybill.delivered / waybill.exceptionfee.quoted(可选审计) / refund.succeeded支付回调只做验签、落「已支付意图」、投递 order.paid;真正的商家推送、运单创建、订阅消息由消费者处理。双峰回调堆积时靠队列削峰,保证最终一致,而不是把网关线程拖死。
假设场景:工作日 11:30–12:45 与 17:30–19:15,核心商圈 QPS 脉冲,多店购物车占比升高,骑手拒单率随空驶上升。推荐主路径时序:
POST /orders 与 POST /carts/quote 做城市级令牌桶 + 用户级配额;超限返回业务码 RATE_LIMITED。菜单与商圈配置走 CDN/Redis。fee_snapshot → 创建 CREATED。幂等键:user_id + cart_hash + 时间窗。order.paid → 快速 200。orders:pull 批量拉,而不是一单一推打爆通道。ASSIGNED 超时单,进入 REASSIGNING。pickup/deliver 幂等;异常码标准化。refund_ticket,不在骑手 App 里直接改资金字段。云上拼装示例(按需取舍):接入用 API 网关;交易/计价/调度跑云托管;尖峰消费者、超时改派、对账用云函数订 TDMQ;菜单/售罄/GEO/限流计数进 Redis;订单运单进云数据库 MySQL;图片与凭证进 COS;网关错误率、下单成功率、队列堆积、改派率、退款失败率进 CLS。CloudBase 适合管理端或原型;调度与计价复杂度上来后,核心路径放到可水平扩的云托管更可控。
Idempotency-Key 唯一索引;冲突返回原单。transaction_id 去重;已处理直接 ACK。refund_request_id 唯一;多店部分退按行项目与运单维度回滚配送费分摊,规则写进快照,避免「退菜不退溢价」扯皮无据。waybill_id + attempt_no 只产生一次有效指派结果。失败模式要提前进手册:支付成功但消费者失败 → 队列重试 + 对账;改派成功但旧骑手仍显示在途 → 以服务端状态为准强制拉单;GEO 点漂移 → 以 zone 归属为主、距离为辅;溢价规则热更新 → 只影响新询价,不影响已落快照订单。
故障 | 表现 | 工程应对 |
|---|---|---|
双峰下单打满 | 网关超时、询价变慢 | 令牌桶 + 询价缓存短 TTL + 可读限流 |
支付回调堆积 | 已付款未见单 | 快 ACK + TDMQ 削峰 + 对账 |
骑手拒单潮 | 出餐积压 | 超时改派 + 限制扩半径 + 人工台 |
改派双写 | 一单两骑手 | 改派锁 + 落库唯一在途约束 |
多店部分失败 | 用户只收到一半 |
|
配送费纠纷 | 客服无法解释 |
|
重复送达 | 状态错乱 | 动作级幂等 |
弱网商家端 | 接单丢失 | 本地补拉 + 补打小票接口 |
观测(CLS)分级建议:
容量:云托管按午晚定时扩缩;Redis 按 zone 分 key;MySQL 热点订单与历史归档分离。安全侧:退款/改价/改派审计;演练伪造 zone_id、越权接单、刷改派。
Idempotency-Key 重复下单不双建单 常见工程坑:把订单状态直接当运单用,导致改派与多店无法建模;动态配送费不落快照;只有指派没有改派事件;把订阅消息和积分写进支付同步链;UTC 与业务时区混用导致高峰窗统计错乱。把这些写进运维手册,成本远低于第一个双峰客服救火。
同城相对县城的关键增量,不只是「骑手更多」,而是计价、改派、多店三条链路同时变热:任何一条用临时字段或后台手工改库顶住,双峰时的客服工单都会呈指数上涨。把规则版本、改派日志与行级退款从第一天写进模型,比事后补表便宜一个数量级。

同城外卖在工程上要优先保证五件事:订单/运单解耦且可审计、多店购物车行级状态清晰、GEO 分片下的指派+改派可回放、动态配送费快照化、支付与通知用消息队列削峰并让退款幂等。API 网关、云托管、云函数、TDMQ/CMQ、Redis GEO、云数据库 MySQL、COS、CLS,以及可选 CloudBase,是按边界拼装的零件,用「例如」对齐能力即可。UI 与运营位是触达手段;削不住峰、派不准人、退不清钱的系统,菜单再漂亮也只是把故障放大。先把一两个核心商圈的状态机、改派与双峰限流跑稳,再复制到下一商圈,才是可持续的扩展方式。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。