实时接单系统后端设计与踩坑

实时接单系统后端设计与踩坑
目录一、业务背景与架构总览二、订单详情服务路线预览与耗时预估2.1 路线规划接入2.2 预计时间的动态修正三、一键抢单并发竞速与订单锁定3.1 为什么不用数据库行锁3.2 Redis 分布式锁方案3.3 锁超时与自动释放四、拒绝订单与重新分配4.1 拒绝原因的结构化存储4.2 重新分配策略五、实时提醒推送通道设计与降级5.1 WebSocket 推送主通道5.2 厂商推送通道兜底5.3 消息可靠性保证六、工程化踩坑6.1 锁未释放导致订单僵死6.2 推送风暴与背压七、总结一、业务背景与架构总览接单是即时配送/网约车场景下的核心环节。系统需要在秒级内完成司机查看订单详情 → 决策抢单 → 锁定分配 → 推送通知这一完整闭环。涉及的关键能力包括路线预览、并发竞速控制、拒绝回退以及多通道实时推送。整体架构采用 Go 后端 Redis 分布式锁 WebSocket 推送核心模块划分如下骑手端 App │ ├── HTTP REST ──── Gin Router ──── Handler 层 ──── Service 层 │ │ │ │ ├── /api/order/detail ├── 路线预估服务 │ ├── /api/order/grab ├── 抢单锁服务 │ ├── /api/order/reject ├── 拒单回退服务 │ └── /api/order/status └── 状态查询服务 │ └── WebSocket ──── ws://host/ws/push ────────── Hub 广播 ────── Redis Pub/Sub选型考量方案优势劣势结论Redis SETNX 锁原子操作、天然 TTL单点依赖选用抢单并发量可控数据库乐观锁无需额外组件UPDATE 竞争激烈时性能差不选用Zookeeper 临时节点强一致性运维成本高、过度工程化不选用二、订单详情服务路线预览与耗时预估2.1 路线规划接入骑手在接单前需要看到从当前位置到取货点、再到送达点的完整路线及预计耗时。后端接入第三方地图 API 完成路径规划前端通过地图 SDK 渲染。go// RouteService 路线规划服务 type RouteService struct { mapClient *MapClient cache *redis.Client } type RouteDetail struct { Distance float64 json:distance // 总里程公里 Duration int json:duration // 预计耗时分钟 Polyline string json:polyline // 路线折线编码 TollFee float64 json:toll_fee // 过路费 } func (s *RouteService) GetRoute(origin, dest GeoPoint) (*RouteDetail, error) { cacheKey : fmt.Sprintf(route:%f,%f:%f,%f, origin.Lat, origin.Lng, dest.Lat, dest.Lng) // 优先查缓存避免重复调用地图 API if cached, err : s.cache.Get(ctx, cacheKey).Result(); err nil { var detail RouteDetail json.Unmarshal([]byte(cached), detail) return detail, nil } detail, err : s.mapClient.Direction(origin, dest) if err ! nil { return nil, fmt.Errorf(route query failed: %w, err) } data, _ : json.Marshal(detail) s.cache.Set(ctx, cacheKey, data, 10*time.Minute) // 路线缓存 10 分钟 return detail, nil }关键设计路线结果以起止坐标对为 key 做短时缓存。同一区域内的骑手查看同一订单时共享缓存大幅降低地图 API 调用量。2.2 预计时间的动态修正地图 API 返回的预计耗时基于静态路况实际场景中需要叠加动态因子gofunc (s *RouteService) EstimateDuration(route *RouteDetail, orderTime time.Time) int { base : route.Duration // 时段系数早晚高峰 1.3x夜间 0.9x hour : orderTime.Hour() var peakFactor float64 1.0 if hour 7 hour 9 || hour 17 hour 19 { peakFactor 1.3 } else if hour 22 || hour 5 { peakFactor 0.9 } // 天气系数雨雪天 1.2x从天气服务获取 weatherFactor : s.weatherService.GetFactor(orderTime) return int(float64(base) * peakFactor * weatherFactor) }三、一键抢单并发竞速与订单锁定3.1 为什么不用数据库行锁一键抢单的本质是多骑手对单一订单的并发抢占。初版方案使用数据库乐观锁sqlUPDATE orders SET status ACCEPTED, rider_id ?, accepted_at NOW() WHERE id ? AND status PENDING在并发量不大的场景下可用但当同一订单被数十名骑手同时点击时大量 UPDATE 竞争 InnoDB 行锁请求排队严重。实测 50 并发下 P99 延迟飙升至 800ms远超业务要求的 200ms 以内。3.2 Redis 分布式锁方案改用 Redis SETNX 实现抢单锁将竞速前置到 Redis 层go// GrabService 抢单服务 type GrabService struct { rdb *redis.Client } const ( grabLockKey order:grab:%s // order:grab:12345 grabLockTTL 30 * time.Second // 锁超时 30 秒 ) func (s *GrabService) Grab(orderID, riderID string) (*GrabResult, error) { key : fmt.Sprintf(grabLockKey, orderID) // SET key riderID NX EX 30 ok, err : s.rdb.SetNX(ctx, key, riderID, grabLockTTL).Result() if err ! nil { return nil, fmt.Errorf(redis error: %w, err) } if !ok { return GrabResult{Success: false, Reason: 订单已被其他骑手抢走}, nil } // 锁获取成功执行数据库状态变更 err s.orderRepo.AcceptOrder(orderID, riderID) if err ! nil { // 数据库失败释放 Redis 锁允许其他骑手重试 s.rdb.Del(ctx, key) return nil, fmt.Errorf(accept order failed: %w, err) } // 推送通知给下单用户 s.notifyUser(orderID, riderID) return GrabResult{Success: true, OrderID: orderID}, nil }核心逻辑SETNX 的原子性保证了同一订单只有一个骑手能拿到锁。拿到锁的骑手继续走数据库确认未拿到的直接返回已被抢。3.3 锁超时与自动释放锁 TTL 设为 30 秒是基于查看详情 → 决策 → 点击抢单这一完整流程的耗时上限。若骑手拿到锁后在 30 秒内未完成后续操作如 App 崩溃、网络断开锁自动过期释放订单恢复可抢状态避免订单僵死。go// 续约机制业务处理中可延长锁 func (s *GrabService) RenewLock(ctx context.Context, orderID, riderID string) error { key : fmt.Sprintf(grabLockKey, orderID) // Lua 脚本保证原子性只有锁持有者才能续约 script : if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(EXPIRE, KEYS[1], ARGV[2]) else return 0 end return s.rdb.Eval(ctx, script, []string{key}, riderID, grabLockTTL.Seconds()).Err() }四、拒绝订单与重新分配4.1 拒绝原因的结构化存储骑手拒绝订单时必须填写原因用于事后分析和骑手画像。拒绝原因按枚举值存储gotype RejectReason int const ( RejectDistanceTooFar RejectReason iota 1 // 1: 距离太远 RejectPriceTooLow // 2: 价格太低 RejectRouteUnfamiliar // 3: 路线不熟悉 RejectTimeConflict // 4: 时间冲突 RejectOther // 5: 其他 ) type RejectRecord struct { OrderID string json:order_id RiderID string json:rider_id Reason RejectReason json:reason Remark string json:remark // 自定义备注≤200字 CreatedAt time.Time json:created_at }4.2 重新分配策略拒单后订单立即回到待分配池系统按以下策略重新派单gofunc (s *AssignService) OnReject(orderID string, rejectRiderID string) error { // 1. 将拒绝骑手加入该订单的黑名单本轮不重复推送 s.addToBlacklist(orderID, rejectRiderID) // 2. 从附近骑手池中重新筛选候选人 candidates, err : s.findNearbyRiders(orderID, rejectRiderID) if err ! nil || len(candidates) 0 { // 扩大搜索半径重试 candidates, err s.findNearbyRidersWithRadius(orderID, 2.0) // 半径扩大至 2 倍 } // 3. 并行推送订单给所有候选人 for _, rider : range candidates { s.pushOrderToRider(rider.ID, orderID) } return nil }关键设计拒绝记录写入骑手画像长期拒单率过高会触发降权减少对该骑手的推送优先级。五、实时提醒推送通道设计与降级5.1 WebSocket 推送主通道新订单到达、订单被抢、订单取消等事件需要实时推送给骑手。主通道使用 WebSocket 长连接gotype PushHub struct { clients map[string]*Client // riderID → Client register chan *Client unregister chan *Client mu sync.RWMutex } type PushMessage struct { Type string json:type // new_order / order_grabed / order_cancelled Payload interface{} json:payload } func (h *PushHub) PushToRider(riderID string, msg PushMessage) { h.mu.RLock() client, ok : h.clients[riderID] h.mu.RUnlock() if !ok { return // 客户端不在线降级到厂商推送 } data, _ : json.Marshal(msg) select { case client.send - data: default: // 发送缓冲区满强制断连 close(client.send) h.mu.Lock() delete(h.clients, riderID) h.mu.Unlock() } }5.2 厂商推送通道兜底当骑手 App 处于后台或 WebSocket 断开时降级到厂商推送通道APNs / FCM / 华为推送gofunc (s *PushService) Push(orderID, riderID string, msg PushMessage) error { // 优先走 WebSocket if s.hub.IsOnline(riderID) { s.hub.PushToRider(riderID, msg) return nil } // 降级到厂商通道 deviceToken, err : s.deviceRepo.GetToken(riderID) if err ! nil { return fmt.Errorf(device token not found: %w, err) } return s.pushClient.Send(PushRequest{ Token: deviceToken, Title: s.buildTitle(msg.Type), Body: s.buildBody(msg), Sound: order_new.aac, // 自定义提示音 Vibrate: true, // 震动 }) }5.3 消息可靠性保证WebSocket 推送的消息可能因网络波动丢失。关键消息如订单被取消采用 ACK 确认机制go// 发送端每条消息带唯一 seq type ReliableMessage struct { Seq int json:seq Type string json:type Payload interface{} json:payload } // 客户端收到后回复 ACK // {seq: 42, type: ack} // 服务端未收到 ACK 则重发最多 3 次间隔 2s/4s/8s六、工程化踩坑6.1 锁未释放导致订单僵死初版上线后出现偶发性问题某订单被抢后长时间处于锁定状态其他骑手无法抢单但实际并无骑手在服务。原因骑手拿到 Redis 锁后 App 崩溃锁未手动释放必须等 30 秒 TTL 过期。业务反馈 30 秒的等待时间过久。解决将锁 TTL 从 30 秒缩短至 10 秒同时在骑手端每次操作时自动续约。骑手持续在查看详情页面时前端每 5 秒发一次心跳请求后端收到后续约到 10 秒go// 心跳续约接口 func (h *OrderHandler) Heartbeat(c *gin.Context) { orderID : c.Query(order_id) riderID : c.GetString(rider_id) err : h.grabService.RenewLock(c.Request.Context(), orderID, riderID) if err ! nil { c.JSON(410, gin.H{code: 410, msg: 锁已过期订单已释放}) return } c.JSON(200, gin.H{code: 0}) }锁 TTL 短 心跳续约的组合既避免了僵死订单的长时间等待又保证了活跃骑手的操作连续性。6.2 推送风暴与背压午高峰期间调度系统一次性向 200 名骑手广播新订单每个 WebSocket 连接写入一条消息。某次网络抖动导致部分骑手连接变慢Hub 的 broadcast 循环被慢客户端阻塞所有推送延迟飙升。根因Hub 中对所有客户端串行写入一个慢客户端拖慢全量推送。修复为每个客户端的 send channel 增加写入超时保护gofunc (h *PushHub) Broadcast(roomID string, msg PushMessage) { data, _ : json.Marshal(msg) h.mu.RLock() defer h.mu.RUnlock() for _, client : range h.clients { if client.roomID ! roomID { continue } // 独立 goroutine 超时控制避免慢客户端阻塞广播 go func(c *Client) { select { case c.send - data: case -time.After(500 * time.Millisecond): // 500ms 内发不出去直接断开 close(c.send) h.mu.Lock() delete(h.clients, c.riderID) h.mu.Unlock() } }(client) } }每次广播改为并行写入 超时断开单点慢连接不再影响全局。七、总结维度技术决策踩过的坑关键收获并发抢单Redis SETNX 分布式锁锁 TTL 过长导致订单僵死短 TTL 心跳续约组合路线预估地图 API 坐标缓存静态耗时偏离实际时段/天气动态修正因子实时推送WebSocket 主通道 厂商兜底慢客户端拖慢全量广播并行写入 超时断开背压处理拒单回退黑名单 半径扩大重试重复推送给已拒骑手本轮黑名单机制消息可靠性ACK 确认 指数退避重试网络波动导致丢消息重试最多 3 次接单流程的四个核心动作——查看详情、一键抢单、拒绝订单、实时提醒——分别对应了系统的读取、写入、回退和推送能力。每一环的可靠性都直接影响骑手体验和平台成单率任何单点瓶颈都会被峰值流量放大。Redis 锁的短 TTL 设计、WebSocket 推送的背压处理、以及线程安全的抢单原子操作是这套方案能够稳定运行的三个关键支柱。