目录
- 一、司机端架构总览
- 二、状态切换:在线/离线机制
- 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状态枚举定义:
go
type 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 秒)未收到心跳,自动将司机标记为离线:
go
type 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 作为隐式心跳:
go
func (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 模式枚举与匹配策略
司机可配置两种维度的偏好,组合生效:
go
type 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: 优先近距离 )匹配引擎在执行订单分配时,先按偏好过滤候选订单,再按路程/评分排序:
go
func (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 推送确认消息给司机端:
go
func (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 分页在数据变动时的重复/遗漏:
go
type 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(避免深分页性能问题):
sql
SELECT 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 公式,避免每单都调地图 API:
go
func (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。后端提供轻量接口,只返回坐标和摘要:
go
type 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 参数组合传入,后端动态构建 SQL:
go
type 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 层预计算):
go
if 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 直接叠加新 Marker:
js
// ❌ 每次请求直接 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_markers=50),防止市区密集区域返回数千个 Marker。
5.3 筛选条件缓存一致性
现象:司机设置了"仅预约单 + 优先高分乘客"后切换城市,筛选条件未重置,导致新城市看不到订单。
根因:筛选条件存在客户端本地 Storage,城市切换后旧条件仍生效。
修复:后端的 FindByFilter 增加城市维度校验,同时前端监听城市变更事件重置筛选:
go
func (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 模型在处理这种多状态源协调时,比传统锁模型更容易写出正确的并发逻辑。