同城配送是这两年本地生活里增长明确的类型:水果店、药店、鲜花店都在把"商家自己找人送"换成专业运力。从工程角度看,配送系统的核心难点不在下单,而在两处:运单状态机怎么设计才不会乱,骑手调度怎么在抢单和派单之间做取舍。本文给出可落地的实现,基于 MySQL 8 + Redis。
一、运单状态机:把非法流转挡在数据库层
配送运单的典型生命周期是:
待接单 → 已接单 → 已取货 → 配送中 → 已送达
↓ ↓
已取消 已妥投异常
最容易犯的错误是把状态更新写成"无条件赋值":UPDATE order SET status = 3 WHERE id = ?。并发场景下,骑手取消和用户取消可能同时到达,配送中订单被改回待接单,运力就丢了。正确做法是把"从哪个状态来"写进 WHERE 条件:
CREATE TABLE delivery_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, pickup_addr VARCHAR(200) NOT NULL, drop_addr VARCHAR(200) NOT NULL, pickup_lat DECIMAL(10,6), pickup_lng DECIMAL(10,6), drop_lat DECIMAL(10,6), drop_lng DECIMAL(10,6), fee INT NOT NULL COMMENT '运费,单位分', rider_id BIGINT NULL, status TINYINT NOT NULL DEFAULT 1, status_detail VARCHAR(200) NULL, promised_at DATETIME NULL COMMENT '承诺送达时间', finished_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_rider_status (rider_id, status), KEY idx_status_created (status, created_at) ); 所有状态流转走统一的条件更新: // 状态常量 const S = { CREATED:1, ACCEPTED:2, PICKED:3, DELIVERING:4, DONE:5, CANCELED:6, ABNORMAL:7 }; // 合法流转表:from -> [允许的 to] const TRANS = { [S.CREATED]: [S.ACCEPTED, S.CANCELED], [S.ACCEPTED]: [S.PICKED, S.CANCELED], [S.PICKED]: [S.DELIVERING, S.ABNORMAL], [S.DELIVERING]: [S.DONE, S.ABNORMAL], [S.ABNORMAL]: [S.DELIVERING, S.DONE], }; async function transit(orderId, from, to, detail) { if (!TRANS[from] || !TRANS[from].includes(to)) { throw new Error(`illegal transit ${from} -> ${to}`); } const r = await db.query( `UPDATE delivery_order SET status = ?, status_detail = ? WHERE id = ? AND status = ?`, [to, detail, orderId, from]); if (r.affectedRows === 0) { throw new Error(`concurrent conflict, order ${orderId} not in ${from}`); } }
条件更新返回 affectedRows = 0 时说明状态已被别人改走,此时应重新读取最新状态再决定后续动作,而不是重试覆盖。状态机的合法流转表单独维护,接口层不写 if-else 硬判断,是这套代码能长期维护的前提。
二、调度模式:抢单与派单的选择
小规模运力(十几个骑手以内)用抢单:新单进入抢单池,骑手在小程序端看到附近订单,点击接单,先到先得。抢单的并发冲突用 Redis 原子操作解决:
async function grab(orderId, riderId) { const key = `grab:${orderId}`; // SET NX EX:只有一个骑手能抢到 const ok = await redis.set(key, riderId, 'NX', 'EX', 30); if (!ok) throw new Error('order already grabbed'); try { await transit(orderId, S.CREATED, S.ACCEPTED, `rider:${riderId}`); await db.query( `UPDATE delivery_order SET rider_id = ? WHERE id = ?`, [riderId, orderId]); } catch (e) { await redis.del(key); // 状态流转失败,释放抢单锁 throw e; } }
注意先抢锁再改状态、失败回滚锁的顺序。反过来先改库再抢锁,会出现库里已接单但抢单池里还能抢的窗口。
两种模式的取舍可以用一张表说清楚:
维度 |
抢单模式 |
派单模式 |
适用规模 |
十几人以内的小运力池 |
数十人以上的规模运力 |
实现成本 |
低, Redis 原子锁即可 |
高,需要算分与指派服务 |
骑手自主性 |
高,自己挑单 |
低,听系统安排 |
订单分配均匀度 |
靠手快,容易扎堆 |
可按负载均衡 |
典型问题 |
好单被抢、差单没人动 |
骑手对派单不满需要申诉 |
派单模式适合规模化运力:系统按"距离 + 顺路 + 负载"算分,指派给最合适的骑手。派单算法第一版不必复杂,按 pickup 点到骑手当前坐标的直线距离排序,加上"当前未完成单数"做惩罚项即可。关键是留出改派能力:骑手超时未接、电量和续航出问题,订单要能回到抢单池,这依赖上面状态机里 ACCEPTED -> CANCELED -> CREATED 的流转路径。
三、费用计算:距离分段 + 时段加价
const BASE = 500; // 起步价 5 元 const STEP = 300; // 每 300m 一档 const STEP_FEE = 100; // 每档加 1 元 function calcFee(distMeters, weightKg, hour) { let fee = BASE; if (distMeters > 1500) fee += Math.ceil((distMeters - 1500) / STEP) * STEP_FEE; if (weightKg > 5) fee += Math.ceil((weightKg - 5) / 3) * 200; if (hour >= 11 && hour < 13) fee += 200; // 午高峰 if (hour >= 22 || hour < 6) fee += 300; // 夜间 return fee; }
费用在下单时计算并落库,配送途中不改价;距离用两坐标的 Haversine 公式算,误差对同城场景可接受。运费以分为单位存整数,这个原则在配送系统和支付系统里同样适用。
结算侧也要一并设计。骑手的报酬常见两种模式:按单提成加超时罚扣,或者底薪加单量提成。无论哪种,系统里都要落"应得账":每单的提成、每次异常的责任判定、每次改派的原骑手补偿,逐笔记录,月底结算时和骑手自己对得上。骑手流动性大,账目含糊的运力池留不住人,这是运营层面反复被验证的教训。
四、轨迹上报:节流比精度重要
骑手端 App 或小程序定时上报坐标,看似简单,实际要注意两点。
第一是节流。骑手一单一趟一小时,按 5 秒上报一次要存七百多个点,完全没必要。合理间隔是 30 秒一次,或者位移超过 200 米才上报:
async function reportLocation(riderId, orderId, lat, lng) { const key = `loc:last:${riderId}`; const last = await redis.hmget(key, 'lat', 'lng', 'ts'); if (last[2] && Date.now() - Number(last[2]) < 25000) return; // 25s 节流 await db.query( `INSERT INTO rider_track (rider_id, order_id, lat, lng) VALUES (?,?,?,?)`, [riderId, orderId, lat, lng]); await redis.hmset(key, { lat, lng, ts: Date.now() }); await redis.expire(key, 3600); }
第二是用户端展示可以算,不必存。用户查"骑手到哪了",用骑手最后一次坐标和目的地做直线估算即可;真要做 ETA 预估,接地图服务商的路径规划接口,不要自己积累路网数据。
五、超时治理:调度系统真正的日常
上线后会发现,大部分日常问题都是超时:骑手超时未接单、超时未到店、超时未送达。做法是给每个关键节点设阈值,由定时任务扫描:
-- 15 分钟未被接单的运单,回到池顶并通知运营 SELECT id, order_no, created_at FROM delivery_order WHERE status = 1 AND created_at < NOW() - INTERVAL 15 MINUTE;
扫描结果先走自动动作(加价重发、通知改派),自动动作无效再转人工。监控面板要按"超时率"而不是"完成单量"考核,完成单量好看不代表体验好。
六、异常件:调度系统真正的日常
正常流程写完只算完成一半,系统好不好用,看的是异常件怎么处理。同城配送最常见的三类异常:收件人联系不上、地址写错、拒收。
联系不上是最常见的。流程上要给骑手明确的操作:到点联系不上,标记"无法联系",系统自动给用户推送通知并开始计时,超过十分钟骑手可以申请返回,运费按规则分担。关键在于让骑手不背锅也不空跑:标记动作带时间戳和定位,后续争议时是凭据。
地址纠错要轻量。用户下单时定位漂移是常态,骑手发现地址不对,在小程序里拍照留言,用户或客服改址后骑手端同步更新。改址要留记录,改过几次、谁改的,结算争议时用得上。
拒收的货物去向要提前定规则:生鲜类当场拍照后按门店指示处理,费用怎么分担写进规则,不要等出了问题再临时协商。异常处理的能力不体现在界面多漂亮,体现在每一种异常都有明确的动作、时限和责任划分。
七、小结
同城配送系统的工程骨架是:状态机条件更新、抢单用 Redis 原子锁、费用下单时锁定、轨迹上报节流、超时靠扫描治理。青海青帝科技在本地配送类项目中,第一版通常只上抢单 + 状态机 + 超时扫描三件套,派单算法等单量到了再迭代——先让系统跑起来,比一开始就做智能调度务实得多。