网约车司机端实战:状态管理、偏好配置与多维度订单发现

网约车司机端实战:状态管理、偏好配置与多维度订单发现
目录一、司机端架构总览二、状态切换在线/离线机制2.1 状态机设计 2.2 心跳保活与断线检测 2.3 Redis 状态同步三、模式偏好灵活的工作模式配置3.1 模式枚举与匹配策略 3.2 偏好持久化与实时生效四、多维度订单发现4.1 实时列表滚动加载与增量更新 4.2 地图视图Marker 聚合与动态刷新 4.3 筛选排序多条件组合查询五、工程化踩坑5.1 断线重连导致状态回滚 5.2 地图 Marker 内存泄漏 5.3 筛选条件缓存一致性六、总结一、司机端架构总览司机端是整个网约车系统的核心参与方需要同时处理状态管理、偏好配置、订单发现三类职责。后端采用 Go 1.24 Gin 框架司机相关接口统一挂载在 /api/driver/ 路由组下司机端 App │ ├── HTTP REST ──── Gin Router ──── Handler 层 ──── Service 层 │ │ │ │ ├── PUT /api/driver/status ├── 状态服务 │ ├── PUT /api/driver/preference ├── 偏好服务 │ ├── GET /api/driver/orders ├── 订单发现 │ └── GET /api/driver/orders/nearby └── 附近订单 │ └── WebSocket ─── ws://host:8088/ws/driver ─── 实时推送新订单/状态变更选型考量方案优势劣势结论REST 轮询实现简单、无连接状态延迟高、带宽浪费不选用REST WebSocket 推送实时性好、双向通信需要连接管理选用gRPC 双向流性能最优、类型安全移动端穿透复杂后续演进方向二、状态切换在线/离线机制2.1 状态机设计司机状态仅两个合法值但围绕它们有一整套生命周期管理┌──────────┐ PUT /api/driver/status ┌──────────┐ │ OFFLINE │ ◄──────────────────────► │ ONLINE │ └─────┬─────┘ { status: online } └─────┬─────┘ │ │ │ • 不接收新订单 │ • 加入订单匹配池 │ • 断开 WebSocket │ • 建立 WebSocket 长连 │ • 取消进行中订单需二次确认 │ • 启动心跳 │ │ ▼ ▼ 强制切换 异常切换 • 账户冻结 • WebSocket 断连 90s • 平台处罚 • 心跳超时 3 次 → 自动 OFFLINE状态枚举定义gotype DriverStatus int const ( StatusOffline DriverStatus iota // 0: 离线 StatusOnline // 1: 在线 ) type Driver struct { ID string json:id Status DriverStatus json:status LastSeenAt time.Time json:last_seen_at Preference Preference json:preference } // 状态切换接口 func (s *DriverService) SetStatus(driverID string, to DriverStatus) error { driver, err : s.repo.FindByID(driverID) if err ! nil { return fmt.Errorf(driver not found: %w, err) } switch to { case StatusOnline: // 上线加入匹配池 建立 WebSocket s.matcher.AddDriver(driver) s.wsHub.Register(driverID) case StatusOffline: // 离线从匹配池移除 断开 WebSocket s.matcher.RemoveDriver(driverID) s.wsHub.Unregister(driverID) } driver.Status to driver.LastSeenAt time.Now() return s.repo.Update(driver) }2.2 心跳保活与断线检测司机在线期间客户端每 30 秒发送一次心跳。服务端连续 3 次90 秒未收到心跳自动将司机标记为离线gotype HeartbeatTracker struct { mu sync.RWMutex beats map[string]time.Time // driverID → 最后心跳时间 timeout time.Duration // 90 秒 } func (h *HeartbeatTracker) Start(cleanup func(driverID string)) { ticker : time.NewTicker(30 * time.Second) go func() { for range ticker.C { h.mu.Lock() now : time.Now() for id, last : range h.beats { if now.Sub(last) h.timeout { delete(h.beats, id) go cleanup(id) // 异步回调下线该司机 } } h.mu.Unlock() } }() } func (h *HeartbeatTracker) ReceivePing(driverID string) { h.mu.Lock() h.beats[driverID] time.Now() h.mu.Unlock() }2.3 Redis 状态同步在多实例部署场景下司机状态需要跨进程同步。使用 Redis Hash 存储状态TTL 作为隐式心跳gofunc (s *DriverService) SyncToRedis(driver *Driver) error { key : fmt.Sprintf(driver:%s, driver.ID) return s.redis.HSet(ctx, key, status, driver.Status, last_seen, driver.LastSeenAt.Unix(), ).Err() } // Redis key 设置 120s TTL写入即续期 // 若 key 过期120s 无写入监听过期事件的 Worker 自动触发离线逻辑三、模式偏好灵活的工作模式配置3.1 模式枚举与匹配策略司机可配置两种维度的偏好组合生效gotype Preference struct { OrderMode OrderMode json:order_mode // 接单模式 PriorityMode PriorityMode json:priority_mode // 优先策略 } type OrderMode int const ( ModeAll OrderMode iota // 0: 全部订单 ModeReserved // 1: 仅预约单 ) type PriorityMode int const ( PriorityNone PriorityMode iota // 0: 无偏好 PriorityHighScore // 1: 优先高分乘客 PriorityNearby // 2: 优先近距离 )匹配引擎在执行订单分配时先按偏好过滤候选订单再按路程/评分排序gofunc (m *Matcher) FindOrdersFor(driver *Driver, available []Order) []Order { // 第一层模式过滤 filtered : available if driver.Preference.OrderMode ModeReserved { filtered filterByType(filtered, OrderTypeReserved) } // 第二层优先级排序 sort.Slice(filtered, func(i, j int) bool { switch driver.Preference.PriorityMode { case PriorityHighScore: return filtered[i].PassengerScore filtered[j].PassengerScore case PriorityNearby: return filtered[i].Distance filtered[j].Distance default: return filtered[i].Distance filtered[j].Distance // 默认按距离 } }) return filtered }3.2 偏好持久化与实时生效偏好变更后立即持久化到 MySQL同时通过 WebSocket 推送确认消息给司机端gofunc (s *DriverService) UpdatePreference(driverID string, pref Preference) error { if err : s.repo.UpdatePreference(driverID, pref); err ! nil { return err } // 通知匹配引擎刷新该司机的候选池 s.matcher.RefreshDriver(driverID) // WebSocket 推送确认 s.wsHub.SendTo(driverID, Message{ Type: preference_updated, Data: pref, }) return nil }四、多维度订单发现4.1 实时列表滚动加载与增量更新订单列表接口采用游标分页 增量更新模式避免传统 offset 分页在数据变动时的重复/遗漏gotype NearbyOrdersRequest struct { DriverID string form:driver_id binding:required Lat float64 form:lat binding:required Lng float64 form:lng binding:required Radius int form:radius default:5000 // 搜索半径米 Cursor string form:cursor // 游标上次最后一条的 order_id Limit int form:limit default:20 } type NearbyOrdersResponse struct { Orders []Order json:orders NextCursor string json:next_cursor // 为空表示已到末尾 Total int json:total // 当前半径内总数 }游标分页 SQL避免深分页性能问题sqlSELECT id, passenger_name, pickup_address, dropoff_address, estimated_fare, passenger_score, created_at, lat, lng FROM orders WHERE status 0 -- 待接单 AND ST_Distance_Sphere(point(lng, lat), point(?, ?)) ? -- 半径过滤 AND id ? -- 游标 ORDER BY id ASC LIMIT ?后端计算距离使用 Haversine 公式避免每单都调地图 APIgofunc (s *OrderService) EnrichDistance(orders []Order, driverLat, driverLng float64) { for i : range orders { orders[i].Distance Haversine( driverLat, driverLng, orders[i].Lat, orders[i].Lng, ) // 格式化为 2.3km orders[i].DistanceText formatDistance(orders[i].Distance) } }4.2 地图视图Marker 聚合与动态刷新地图模式下前端使用百度/高德地图 SDK 渲染 Marker。后端提供轻量接口只返回坐标和摘要gotype MapMarker struct { OrderID string json:order_id Lat float64 json:lat Lng float64 json:lng Fare float64 json:fare // 预估费用 Score float32 json:score // 乘客评分 Distance float64 json:distance // 距离司机米 } func (s *OrderService) GetMapMarkers(driverID string, lat, lng float64, radius int) ([]MapMarker, error) { orders, err : s.repo.FindNearby(lat, lng, radius) if err ! nil { return nil, err } markers : make([]MapMarker, len(orders)) for i, o : range orders { markers[i] MapMarker{ OrderID: o.ID, Lat: o.Lat, Lng: o.Lng, Fare: o.EstimatedFare, Score: o.PassengerScore, Distance: Haversine(lat, lng, o.Lat, o.Lng), } } return markers, nil }前端 Marker 聚合策略由司机端 App 负责缩放级别 ≥ 14 → 显示单个 Marker含预估费用气泡 缩放级别 14 → 显示聚合点数量 平均费用服务端在司机位置变更时通过 WebSocket 主动推送附近订单数变化go// 司机每移动 200 米触发一次推送 func (h *DriverHub) OnLocationChanged(driverID string, lat, lng float64) { prev : h.lastLocation[driverID] if Haversine(prev.Lat, prev.Lng, lat, lng) 200 { return // 移动距离不足不推送 } h.lastLocation[driverID] LatLng{lat, lng} count : h.orderRepo.CountNearby(lat, lng, 5000) h.SendTo(driverID, Message{ Type: nearby_count_changed, Data: map[string]int{count: count}, }) }4.3 筛选排序多条件组合查询筛选条件通过 query 参数组合传入后端动态构建 SQLgotype OrderFilter struct { SortBy string form:sort_by // distance / fare / score / time Order string form:order // asc / desc MinFare *float64 form:min_fare MaxFare *float64 form:max_fare MinScore *float32 form:min_score OrderType string form:order_type // immediate / reserved } func (r *OrderRepository) FindByFilter(driverLat, driverLng float64, filter OrderFilter, cursor string, limit int) ([]Order, error) { q : r.db.Table(orders).Where(status ?, StatusCreated) if filter.OrderType reserved { q q.Where(order_type ?, reserved) } if filter.MinFare ! nil { q q.Where(estimated_fare ?, *filter.MinFare) } if filter.MinScore ! nil { q q.Where(passenger_score ?, *filter.MinScore) } if cursor ! { q q.Where(id ?, cursor) } // 排序距离列需要计算用子查询或应用层排序 switch filter.SortBy { case fare: q q.Order(clause.OrderByColumn{Column: clause.Column{Name: estimated_fare}, Desc: filter.Order desc}) case score: q q.Order(clause.OrderByColumn{Column: clause.Column{Name: passenger_score}, Desc: filter.Order desc}) default: // 默认按时间倒序 q q.Order(created_at DESC) } var orders []Order err : q.Limit(limit).Find(orders).Error return orders, err }距离排序在应用层完成因为距离依赖司机坐标无法在 SQL 层预计算goif filter.SortBy distance { sort.Slice(orders, func(i, j int) bool { di : Haversine(driverLat, driverLng, orders[i].Lat, orders[i].Lng) dj : Haversine(driverLat, driverLng, orders[j].Lat, orders[j].Lng) if filter.Order desc { return di dj } return di dj }) }五、工程化踩坑5.1 断线重连导致状态回滚现象司机在线时网络闪断WebSocket 重连成功后后端将司机状态重置为 OFFLINE导致司机需要手动重切 ONLINE。根因WebSocket 的 unregister 回调中直接调用了 SetStatus(…, OFFLINE)未区分主动下线和被动断连go// ❌ 错误写法 case client : -h.unregister: driverService.SetStatus(client.driverID, StatusOffline)修复引入断线缓冲期——断连后 90 秒内若重连成功保持 ONLINE 状态不变go// ✅ 修复后 case client : -h.unregister: h.pendingOffline[client.driverID] time.Now().Add(90 * time.Second) // 心跳扫描协程 for id, deadline : range h.pendingOffline { if time.Now().After(deadline) { driverService.SetStatus(id, StatusOffline) delete(h.pendingOffline, id) } } // 重连时检查 func (h *DriverHub) Register(driverID string) { if _, pending : h.pendingOffline[driverID]; pending { delete(h.pendingOffline, driverID) // 保持 ONLINE不触发热重载 } }5.2 地图 Marker 内存泄漏现象司机在地图视图下长时间拖拽内存持续增长最终 OOM。根因每次 GET /api/driver/orders/nearby 请求都返回完整 Marker 列表前端未清理旧 Marker 直接叠加新 Markerjs// ❌ 每次请求直接 addOverlay旧 Marker 未移除 fetchNearbyMarkers().then(markers { markers.forEach(m map.addOverlay(new BMap.Marker(m))); });修复前端维护 Marker 引用池每次更新先 clearOverlays 再批量添加js// ✅ 修复后 let currentMarkers []; function refreshMarkers(markers) { map.clearOverlays(); currentMarkers markers.map(m { const marker new BMap.Marker(new BMap.Point(m.lng, m.lat)); map.addOverlay(marker); return marker; }); }同时后端增加结果集上限max_markers50防止市区密集区域返回数千个 Marker。5.3 筛选条件缓存一致性现象司机设置了仅预约单 优先高分乘客后切换城市筛选条件未重置导致新城市看不到订单。根因筛选条件存在客户端本地 Storage城市切换后旧条件仍生效。修复后端的 FindByFilter 增加城市维度校验同时前端监听城市变更事件重置筛选gofunc (r *OrderRepository) FindByFilter(cityCode string, …) ([]Order, error) { q : r.db.Where(city_code ?, cityCode) // 强制城市过滤 // …其他条件 }六、总结维度技术决策踩过的坑关键收获状态管理状态机 Redis 同步断连误触发下线90s 缓冲期区分主动/被动断连模式偏好枚举 策略模式切换城市偏好未重置后端强制城市维度校验订单列表游标分页 Haversine深分页性能劣化游标替代 offset距离应用层计算地图 Marker轻量接口 前端聚合内存泄漏前端 maintain 引用池后端设上限筛选排序动态 SQL 应用层排序距离排序 SQL 无法直接完成混合策略静态字段 SQL 排动态字段应用层排实时推送WebSocket 200m 阈值移动中高频推送空间阈值削减无效推送司机端最核心的挑战不是单一功能的实现而是状态一致性——在线/离线、偏好配置、订单列表三个模块交叉影响任何一环的状态不同步都会导致司机看到错误的数据。Go 的 channel goroutine 模型在处理这种多状态源协调时比传统锁模型更容易写出正确的并发逻辑。