轨迹规划与存储

09 · 核心亮点

亮点九 · 一条轨迹几万个点,怎么存怎么画

GeoJSON LineString + 途经点规划 + 位置上报。设计上有一个被明确记录的性能隐患,源码里就留着 TODO。

涉及模块:chain-cloud-ms-track-service :18097 + transport-info :18087

亮点九 · 一条轨迹几万个点,怎么存怎么画

业务问题: 用户下单后能在 App 上看到"你的包裹现在在哪"。 司机每 30 秒上报一次 GPS,一次跨省运输 20 小时 ≈ 2400 个点; 如果还要画出"规划路线"的折线,坐标点更多。 怎么存?存哪?四端怎么查?


一、业务场景

1.1 轨迹有两类,性质完全不同

类型数据来源特点数据量
规划轨迹(plan)高德算好的路线折线一次算出来,不变一个跨省路线可能上万个点
实时位置(lastPoint)司机/快递员 GPS 上报持续更新,高频每次上报只写 1 个点

这个区分是整个模块设计的关键:

java
// chain-cloud-ms-track-service entity/TrackEntity.java
@Data
@Document("cc_track")
public class TrackEntity {

    @Id @JsonIgnore private ObjectId id;

    @Indexed private String transportOrderId;          // 运单号

    /** 规划轨迹点线 */
    private GeoJsonLineString planGeoJsonLine;         // ← 不变

    /** 总距离,米 */
    private Double distance;

    /** 最新位置(x=经度,y=纬度) */
    private GeoJsonPoint lastPoint;                    // ← 覆盖更新

    private TrackStatusEnum status;                    // NEW(1) / COMPLETE(4)
    private TrackTypeEnum type;                        // DRIVER(2) / COURIER(3)

    private Long created;
    private Long updated;
}

注意:lastPoint 是覆盖,不是追加。 所以这个文档的大小是有界的,不会随着时间无限增长。 这是一个很关键的存储决策:

text
如果设计成 pointList 数组追加:
  2400 个点 × 32 字节/点 ≈ 76KB → 远超 MongoDB 单文档 16MB 限制的风险,
  而且每次上报都要重写整个数组(更新放大)

设计成 lastPoint 覆盖:
  文档大小恒定,上报是 O(1) 的点更新
  → 读取时"历史轨迹"和"规划轨迹"分开取

代价是:丢失了完整的移动历史。 如果业务要求"能回看包裹某个时刻在哪",就必须另外设计历史轨迹表。

这个取舍值得在面试里讲,因为它是"业务没要求就不做"的正确判断。 但前提是要确认过业务真的不需要—— 如果物流合规要求留存轨迹(某些快递确实有),那就必须做分表存储。

1.2 为什么用 MongoDB 而不是 MySQL

MySQLMongoDB
地理类型POINT / GEOMETRYGeoJSON 原生(Point / LineString / Polygon)
Java 映射需要自己写 TypeHandlerGeoJsonPoint / GeoJsonLineString 直接映射
前端消费要转成前端地图库的格式标准 GeoJSON,前端直接用

MongoDB 的 GeoJsonLineString 序列化出来就是标准 GeoJSON:

json
{
  "type": "LineString",
  "coordinates": [
    [116.46, 39.92],
    [116.47, 39.93],
    ...
  ]
}

Leaflet / Mapbox / 高德 JS API 都能直接消费这个格式,零转换。


二、方案设计

2.1 创建轨迹:先查后插(幂等)

java
// chain-cloud-ms-track-service TrackServiceImpl#create
@Override
public boolean create(String transportOrderId) {
    Query query = Query.query(Criteria.where("transportOrderId").is(transportOrderId));
    TrackEntity trackEntity = this.mongoTemplate.findOne(query, TrackEntity.class);
    if (ObjectUtil.isNotEmpty(trackEntity)) {
        throw new CCException(TrackExceptionEnum.TRACK_ALREADY_EXISTS);   // 幂等
    }
    TrackEntity track = new TrackEntity();
    track.setTransportOrderId(transportOrderId);
    track.setCreated(System.currentTimeMillis());
    track.setUpdated(track.getCreated());
    track.setStatus(TrackStatusEnum.NEW);
    track.setPlanGeoJsonLine(this.queryPlanGeoJsonLineString(transportOrderId, track));
    this.mongoTemplate.save(track);
    return true;
}

"先查后插 + 已存在则抛异常"是典型的非原子幂等(和亮点 3 的 saveOrUpdate 同类问题)。 并发调用时两个线程都可能查到 null,然后都插入。

正确做法是唯一索引:

java
@Document("cc_track")
public class TrackEntity {
    @Indexed(unique = true)                 // ← 加 unique
    private String transportOrderId;
}

2.2 核心:把转运节点当作"途经点"

这是这个模块最有业务含量的一段。

java
// TrackServiceImpl#queryPlanGeoJsonLineString
// 通过运单号查询运单,将转运节点作为途经点
TransportOrderDTO transportOrderDTO = this.transportOrderFeign.findById(transportOrderId);
String transportLine = transportOrderDTO.getTransportLine();
String wayPoints = null;                        // 途经点,分号分割
if (StrUtil.isNotEmpty(transportLine)) {
    // transportLine 是亮点 2 里算出来的完整路径(JSON)
    // 从 nodeList 里取出每个节点的经纬度
    ... nodeList[*].longitude/latitude, 0 值 → "err" → filter ...
    wayPoints = StrUtil.join(";", pointList);
} else {
    // 收件人与发件人在同一个网点,没有转运路线
    // → 将当前机构id作为途经点
    OrganDTO organDTO = this.organFeign.queryById(transportOrderDTO.getCurrentAgencyId());
    if (ObjectUtil.isNotEmpty(organDTO)) {
        wayPoints = StrUtil.format("{},{}", organDTO.getLongitude(), organDTO.getLatitude());
    } else {
        //理论上没有这种情况,如果出现这种情况就不设置途经点了
    }
}

OrderLocationDTO orderLocationDTO = this.orderFeign.findOrderLocationByOrderId(transportOrderDTO.getOrderId());
Map<String, Object> param = MapUtil.<String, Object>builder()
        .put(ObjectUtil.isNotEmpty(wayPoints), "waypoints", wayPoints)
        .put("show_fields", "polyline")
        .build();

String driving = this.eagleMapTemplate.opsForDirection()
        .driving(ProviderEnum.AMAP, new Coordinate(origin), new Coordinate(destination), param);

核心思路:

text
用户下单时,亮点 2 已经在 Neo4j 里规划出了一条完整路径:
  北京朝阳网点 → 通州分拣中心 → 北京转运中心 → 天津转运中心 → 天津网点

现在要做轨迹,不能让高德"直线规划",
而是要把这条路径上的【每个转运节点】作为途经点传给高德,
让高德沿着一整条真实运输路线画折线。

为什么这一步很关键?

如果不传途经点,高德会按最短路径规划, 可能走一条和实际运输完全不同的路(比如翻山而不是走高速)。 用户看到"你的包裹在这",实际车在 20 公里外,这就是投诉来源。

传入的格式:高德要求的 waypoints 是 经度,纬度;经度,纬度;...

java
wayPoints = StrUtil.join(";", pointList);

这里的细节值得讲:

java
// 0 值 → "err" → filter

为什么要过滤 0 值? 因为转运节点的经纬度如果缺失(Node 没写 location), 会得到 null,拼进字符串就成了 null,39.9 或者 0,0, 高德会因为非法坐标报错或规划出离谱的路线。 所以要过滤掉整个无效的途经点(而不是填 0)。

2.3 解析 polyline

java
JSONArray steps = jsonObject.getByPath("route.paths[0].steps", JSONArray.class);

// 将所有的坐标点收集起来
List<Point> points = new ArrayList<>();
List<Object> polyLines = CollUtil.getFieldValues(steps, "polyline");
for (Object polyLine : polyLines) {
    List<GeoJsonPoint> list = StrUtil.split(Convert.toStr(polyLine), ';')
            .stream().map(coordinateStr -> {
                double[] ds = Convert.convert(double[].class, StrUtil.splitTrim(coordinateStr, ','));
                return new GeoJsonPoint(ds[0], ds[1]);
            }).collect(Collectors.toList());
    points.addAll(list);
}

//TODO 如果路线规则的节点比较多,可能会出现内存溢出的情况,学生思考下解决方案

return new GeoJsonLineString(points);

高德的 polyline 是一段压缩字符串,格式是: 经度,纬度;经度,纬度;... (注意经纬度顺序:lng 在前)

代码通过 steps[].polyline 取出每一段的 polyline, 再把每段的点 addAll 到一个总的 List<Point>。

这里的性能问题(源码里就写着 TODO):

java
//TODO 如果路线规则的节点比较多,可能会出现内存溢出的情况,学生思考下解决方案

为什么会 OOM?

text
一次跨省长途(比如京藏线 1200km):
  高德返回的 steps 可能有几千个
  每个 step 的 polyline 可能有几百个点
  总点数:几万到十几万个

每个 GeoJsonPoint 对象(JVM 内存开销约 40~60 字节)
  10 万个点 ≈ 5MB 对象 + 序列化过程中的临时字符串可能上百 MB

如果并发创建轨迹(多个司机同时上报),
  每个请求都持有一个几 MB 的 List
  → Tomcat 堆内存直接被打爆

2.4 位置上报

java
// TrackServiceImpl#upload
private boolean upload(List<String> transportOrderIds, double lng, double lat,
                       TrackTypeEnum trackTypeEnum) {
    if (CollUtil.isEmpty(transportOrderIds)) return false;

    // 更新的条件
    Query query = Query.query(Criteria.where("transportOrderId")
            .in(transportOrderIds)
            .and("status").is(TrackStatusEnum.NEW));
    // 更新的数据
    Update update = Update.update("lastPoint", new GeoJsonPoint(lng, lat))
            .set("type", trackTypeEnum)
            .set("updated", System.currentTimeMillis());
    UpdateResult updateResult = this.mongoTemplate.updateMulti(query, update, TrackEntity.class);
    return updateResult.getModifiedCount() > 0;
}

三个设计点:

  1. updateMulti 批量更新 —— 一次上报可能对应多个运单

(一个司机的车上有十几个包裹),所以是 in (ids) 批量更新, 而不是循环单条更新。

  1. status = NEW 作为 WHERE 条件 —— 这是状态机幂等:

轨迹一旦 complete(),后续上报就自动不生效了, 不需要在 Java 里写 if 判断。

  1. updateMulti 而不是 save —— 避免读取-修改-写回的竞态,

也不需要把整个文档读出来。

司机上报的入口:

java
// 一个运输任务对应多个运单
@Override
public boolean uploadFromTruck(Long transportTaskId, double lng, double lat) {
    // 通过运输任务查询订单号列表
    List<String> list = this.transportTaskFeign.queryTransportOrderIdListById(transportTaskId);
    return upload(list, lng, lat, TrackTypeEnum.DRIVER);
}

2.5 与 transport-info 的分工

项目里其实有两个轨迹相关的服务,容易混:

服务端口存储职责
chain-cloud-ms-track-service18097MongoDB cc_track计算轨迹(调高德画折线)+ 实时位置上报
chain-cloud-ms-transport-info-service18087MongoDB cc_transport_info存储和查询物流跟踪信息(文字描述)

为什么分成两个?

text
track:计算密集(调高德),数据量小(一个运单一条)
transport-info:写入密集(每个节点事件一条),数据量大,查询场景多

transport-info 有一个明显的缺口:

java
// TransportInfoMQListener
@RabbitListener(/* TRANSPORT_INFO_APPEND */)
public void listenTransportInfoAppend(String msg) {
    // 没有任何实现
}

而消息里有一个字段从来没被赋值:

java
// common/vo/TransportInfoMsg.java
public class TransportInfoMsg extends BaseMsg {
    private Long organId;           // ← 没有任何生产者设置它
    private String transportOrderId;
    private String status;
    private String info;
}

后果:四端查物流轨迹,恒为空。 这是 ARCHITECTURE.md 里列的主链路阻塞点之一。


三、难点拆解

难点 1:怎么让"规划轨迹"和"实际运输"对得上

难点在哪: 高德规划的路径是基于路网的理论最优, 而实际运输是按物流线路走的(可能绕行去中转场装货)。

如果两者不一致,用户会觉得"轨迹在骗我"。

怎么解(当前做法): 把物流路径上的转运节点作为途经点强制高德经过。

这个方案的优点: 实现简单,且大部分情况够用。

它的局限(值得主动说):

text
1. 高德最多支持 16 个途经点
   代码的 Javadoc 里写了"(高德地图最多支持16个途经点)"
   但代码【没有做截断】!
   超过 16 个的高德会报错,整条轨迹创建失败

2. 途经点只约束"经过哪些点",不约束"走哪条边"
   从 A 中转场到 B 中转场,高德仍可能规划出另一条路

3. 如果中转节点的实际装货位置和节点坐标有偏差(比如网点很大),
   轨迹会有几十米的误差

如果重做:

java
// 1. 途经点截断 + 降级
private static final int MAX_WAYPOINTS = 15;   // 高德限制 16,留一个余量

private String buildWayPoints(List<OrganDTO> nodes) {
    List<String> points = nodes.stream()
            .filter(n -> n.getLongitude() != null && n.getLatitude() != null)
            .map(n -> StrUtil.format("{},{}", n.getLongitude(), n.getLatitude()))
            .collect(Collectors.toList());

    if (points.size() <= MAX_WAYPOINTS) {
        return StrUtil.join(";", points);
    }

    // 超过上限:均匀采样(保留首尾),并记录降级标记
    List<String> sampled = sampleEvenly(points, MAX_WAYPOINTS);
    log.warn("途经点 {} 个超过高德限制 {},已均匀采样降级", points.size(), MAX_WAYPOINTS);
    return StrUtil.join(";", sampled);
}

// 2. 分段规划 + 拼接
//    如果途经点真的很多(比如跨 5 个省),
//    正确做法是【分成多段】逐段调高德,然后把折线拼接起来
List<GeoJsonLineString> segments = new ArrayList<>();
for (int i = 0; i < legPoints.size() - 1; i++) {
    String leg = callAmap(legPoints.get(i), legPoints.get(i + 1));
    segments.add(parse(leg));
}
return concat(segments);        // 拼接时去掉相邻段的重叠点

分段拼接是必须的——因为跨越大半个中国的运输,途经点天然超过 16 个。 这不只是"边界情况",而是长途运输的常态。

难点 2:polyline 的压缩与文档大小

难点在哪: 高德的 polyline 是压缩过的(类似 Google 的 polyline 编码), 但代码是直接按字符串 split 逗号处理的:

java
double[] ds = Convert.convert(double[].class, StrUtil.splitTrim(coordinateStr, ','));
return new GeoJsonPoint(ds[0], ds[1]);

这说明高德在 show_fields=polyline 模式下返回的是明文坐标 (如果是压缩格式,这里就会得到乱码)。

真正的难点是文档大小:

数量级点数序列化后大小风险
同城500~15KB安全
省内3,000~90KB安全(Mongo 支持 16MB)
跨省20,000~600KB安全但慢
跨多个省长途100,000+~3MB⚠️ 单文档过大,BSON 限制 16MB,查询和更新都很慢

而且更严重的是:lastPoint 的每次上报都要读写这个文档。

text
一次 doc 更新 = 读 3MB + 改 3MB + 写 3MB
每 30 秒一次上报 × 几百个车辆
→ MongoDB 的 IO 和网络开销都会非常大

这个设计有内在矛盾:

  • planGeoJsonLine 让文档很大(一次性写入,之后不变)
  • lastPoint 让文档被高频更新(每次都要搬运整个文档)

如果重做:拆成两个集合。

text
cc_track       —— 轻量,高频更新
  { _id, transportOrderId, lastPoint, type, status, updated }

cc_track_plan  —— 重量,写一次读多次
  { transportOrderId, planGeoJsonLine, distance }

或者:把规划折线存成 GridFS / 对象存储。

text
超过阈值(比如 1000 个点)时:
  1. 把完整的折线序列化后上传到对象存储 / GridFS
  2. cc_track_plan 里只存一个引用 key + 包围盒(bounding box)
  3. 查询时按需拉取,地图缩放到一定级别时只下发抽稀后的简化版

难点 3:抽稀(Douglas-Peucker)——源码里明确留下的 TODO

事实: 全仓库搜索 抽稀 / thinning / simplify / Douglas, 在所有 .java 文件里零命中。 对应的代码标记就是那行 TODO。

"抽稀"是地图领域的标准优化:

Douglas-Peucker 算法(1973):用一条线段近似一串点, 保留"偏离这条线最远"的点,递归处理两侧。 时间复杂度 O(n log n),可以在保持形状的前提下把点数减少 80%~95%。

为什么轨迹必须抽稀?

text
一条跨省路线的原始折线:10 万个点 ≈ 3MB
用户实际能看到的:整个屏幕就那么几个像素宽度
→ 传到前端也画不出区别,只是浪费带宽和卡顿

抽稀后:1000 个点 ≈ 30KB,形状几乎无损

实现(面试时可以现场写):

java
/**
 * Douglas-Peucker 折线抽稀
 * @param points 原始点集
 * @param epsilon 阈值(度)。约 1e-5 ≈ 1 米
 */
public static List<GeoJsonPoint> simplify(List<GeoJsonPoint> points, double epsilon) {
    if (points == null || points.size() < 3) return points;

    boolean[] keep = new boolean[points.size()];
    keep[0] = true;
    keep[points.size() - 1] = true;      // 首尾必须保留

    simplifySection(points, 0, points.size() - 1, epsilon, keep);

    List<GeoJsonPoint> result = new ArrayList<>();
    for (int i = 0; i < points.size(); i++) {
        if (keep[i]) result.add(points.get(i));
    }
    return result;
}

private static void simplifySection(List<GeoJsonPoint> pts, int first, int last,
                                    double epsilon, boolean[] keep) {
    if (last <= first + 1) return;

    // 找到偏离首尾连线最远的点
    double maxDist = 0;
    int index = -1;
    GeoJsonPoint a = pts.get(first);
    GeoJsonPoint b = pts.get(last);
    for (int i = first + 1; i < last; i++) {
        double d = perpendicularDistance(pts.get(i), a, b);
        if (d > maxDist) { maxDist = d; index = i; }
    }

    // 偏离超过阈值就保留,并递归处理两侧
    if (maxDist > epsilon && index != -1) {
        keep[index] = true;
        simplifySection(pts, first, index, epsilon, keep);
        simplifySection(pts, index, last, epsilon, keep);
    }
}

/** 点到线段的垂距(简化版:点到直线) */
private static double perpendicularDistance(GeoJsonPoint p, GeoJsonPoint a, GeoJsonPoint b) {
    double x = a.getX(), y = a.getY();
    double dx = b.getX() - x, dy = b.getY() - y;
    if (dx == 0 && dy == 0) return DistanceCalculator.calculateDistance(
            p.getX(), p.getY(), x, y);

    double t = ((p.getX() - x) * dx + (p.getY() - y) * dy) / (dx * dx + dy * dy);
    t = Math.max(0, Math.min(1, t));            // 投影到线段上
    double projX = x + t * dx, projY = y + t * dy;
    return DistanceCalculator.calculateDistance(p.getX(), p.getY(), projX, projY);
}

还可以叠加"按缩放级别分级抽稀":

java
// 地图缩放到全国级别时给 500 个点
// 缩放到城市级别时给 5000 个点
// 缩放到街道级别时给 50000 个点
public List<GeoJsonPoint> pointsForZoom(int zoomLevel) {
    double epsilon = switch (zoomLevel) {
        case 0, 1, 2, 3 -> 1e-3;      // 全国:约 100 米精度
        case 4, 5, 6    -> 1e-4;      // 城市:约 10 米
        case 7, 8, 9    -> 1e-5;      // 街道:约 1 米
        default         -> 0.0;       // 最高级别:全量
    };
    return simplify(originalPoints, epsilon);
}

这是标准的地图分级渲染策略,代价是要预先保存多个精度的版本 (或者每次请求实时抽稀——10 万个点的抽稀大概几十毫秒,可以接受)。


四、实际遇到的困难与踩坑

坑 1:TODO 里写着 OOM 风险,但没人处理

java
//TODO 如果路线规则的节点比较多,可能会出现内存溢出的情况,学生思考下解决方案
return new GeoJsonLineString(points);

这个 TODO 的意思是"我知道这里有性能问题,但我留给你解决"。

从工程角度看,这段代码有三个具体的隐患:

隐患说明
临时对象爆炸StrUtil.split(...).stream().map(...).collect() 产生海量临时对象,GC 压力大
String → double[] → double 三次转换Convert.convert(double[].class, String.split(',')) 走反射,十万次调用的开销不可忽略
异常坐标坐标字符串格式不对时(比如高德返回空 polyline),ds[0] 会数组越界

修复方向(三选一):

java
// 方案 A:抽稀(在写入前就把点数降下来)—— 首选
// 解决 90% 的问题,成本最低

// 方案 B:流式处理,减少中间对象
private static void parsePolyline(String polyline, List<GeoJsonPoint> target) {
    int len = polyline.length();
    int start = 0;
    for (int i = 0; i <= len; i++) {
        if (i == len || polyline.charAt(i) == ';') {
            target.add(parsePoint(polyline, start, i));
            start = i + 1;
        }
    }
}
private static GeoJsonPoint parsePoint(String s, int start, int end) {
    int comma = -1;
    for (int i = start; i < end; i++) {
        if (s.charAt(i) == ',') { comma = i; break; }
    }
    if (comma < 0) return null;                    // ← 防御:格式不对就跳过
    return new GeoJsonPoint(
        Double.parseDouble(s.substring(start, comma)),
        Double.parseDouble(s.substring(comma + 1, end)));
}

// 方案 C:拆集合(解决文档过大)—— 见难点 2

推荐 A + B 组合:抽稀把点数降到几千,剩下的小量用流式解析。

坑 2:白名单不完整,transport-info 的消费端是空的

java
// TransportInfoMQListener.java
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = Constants.MQ.Queues.TRANSPORT_INFO_APPEND),
        exchange = @Exchange(name = Constants.MQ.Exchanges.TRANSPORT_INFO,
                             type = ExchangeTypes.TOPIC, delayed = Constants.MQ.DELAYED),
        key = Constants.MQ.RoutingKeys.TRANSPORT_INFO_APPEND))
public void listenTransportInfoAppend(String msg) {
    // 空方法体
}

而且消息里的 organId 从来没被生产者赋值:

java
// work / web-courier 构造 TransportInfoMsg 时
// 只设置了 transportOrderId / status / info,没设置 organId

后果:即使消费端实现了,缺 organId 的物流信息也不知道该归属哪个机构, 四端查询仍然会缺数据。

这是一个典型的"契约设计不完整"的例子: TransportInfoMsg 定义了 4 个字段,但生产者和消费者关心的字段不一致。

修复:把契约补齐,并加一个契约测试。

java
// 建议在 common 里加一个消息契约测试
// 遍历所有 TransportInfoMsg 的生产者,断言必填字段都有值
@Test
void 物流跟踪消息必须包含机构id() {
    TransportInfoMsg msg = buildTransportInfoMsg(order);
    assertThat(msg.getOrganId()).isNotNull();
    assertThat(msg.getTransportOrderId()).isNotBlank();
}

坑 3:轨迹创建的幂等是非原子的

java
if (ObjectUtil.isNotEmpty(trackEntity)) {
    throw new CCException(TrackExceptionEnum.TRACK_ALREADY_EXISTS);
}

同亮点 3 的 saveOrUpdate: 并发调用会创建两条轨迹。 而且没有 @Indexed(unique = true)。

更糟的是抛异常的方向反了:

text
MQ 消费场景下,"已存在"恰恰说明【这条消息是重复投递】,
应该直接【跳过并 ack】,而不是抛异常。

抛异常 → 消费者重试 3 次 → 每次都抛"已存在"
  → 重试耗尽 → 进死信队列
  → 运维去死信队列里一看:全是"轨迹已存在"的正常情况

这是一个"把正常业务分支当成异常"的经典错误。

正确做法:

java
@Override
public boolean create(String transportOrderId) {
    // 直接用 upsert,不存在才计算轨迹(因为计算轨迹要调高德,很贵)
    Query query = Query.query(Criteria.where("transportOrderId").is(transportOrderId));
    if (mongoTemplate.exists(query, TrackEntity.class)) {
        log.info("运单 {} 的轨迹已存在,跳过", transportOrderId);
        return true;                       // ← 正常返回,不抛异常
    }
    TrackEntity track = new TrackEntity();
    ...
    try {
        mongoTemplate.insert(track);
        return true;
    } catch (DuplicateKeyException e) {
        return true;                       // ← 唯一索引兜底,视为成功
    }
}

坑 4:起终点坐标的来源容易被忽略

java
Coordinate origin = new Coordinate(originLng, originLat);      // 来自 order_location
Coordinate destination = new Coordinate(destLng, destLat);    // 来自 order_location

而 order_location 的存储格式是:

java
// OrderServiceImpl#getAgencyId
DecimalFormat df = new DecimalFormat("#.######");
String location = StrUtil.format("{},{}", df.format(lng), df.format(lat));
// → "116.460000,39.920000"(字符串,逗号分隔)

然后 track 里又要解析回来:

java
String[] split = orderLocation.getSendLocation().split(",");
double lnt = Double.parseDouble(split[0]);
double lat = Double.parseDouble(split[1]);

经纬度用逗号分隔的字符串存,然后到处 split。

问题:

  1. 精度只有 6 位小数 —— 大约 0.1 米的精度,够用但不是最优;
  2. 没有存字段名 —— split(",") 的顺序完全靠约定,

一旦某处写反了(lat, lng)就又是经纬度互换;

  1. 解析没有防御 —— split 出 1 个元素时 split[1] 会数组越界。

更好的做法:

text
方案 A:存两个独立字段(推荐)
  send_longitude DECIMAL(10,7)
  send_latitude  DECIMAL(10,7)

方案 B:存 GeoJSON Point 类型的 JSON
  send_location = {"type":"Point","coordinates":[116.4600000,39.9200000]}

方案 C:保持字符串,但加字段名分隔符
  "lng:116.460000,lat:39.920000"

五、如果重做,我会怎么改

优先级改动收益
P0写入前做 Douglas-Peucker 抽稀(epsilon ≈ 1e-5)点数降 90%+,直接解决 TODO
P0transportOrderId 加唯一索引;"已存在"改为返回 true 而非抛异常消除并发重复 + 死信噪音
P1途经点超过 15 个时分段规划 + 拼接支持跨多省长途
P1cc_track 拆成"轻量轨迹表 + 规划折线表"消除高频更新大文档
P1polyline 解析改流式 + 加格式防御消除 OOM 隐患和数组越界
P2按缩放级别分级抽稀前端渲染性能
P2经纬度拆成独立字段(DECIMAL(10,7))消除 split 约定
P2补齐 TransportInfoMQListener 的实现 + organId让四端能查到轨迹

六、面试问答

Q1:轨迹数据怎么存?为什么用 MongoDB?

A: 分两种数据分开说:

规划轨迹 —— 高德算好的折线,一次写入、永不修改, 存成 GeoJsonLineString(标准 GeoJSON 格式,前端地图库直接消费)。

实时位置 —— 司机每 30 秒上报,只存最新一个点(lastPoint), 覆盖更新,不追加历史。

为什么用 MongoDB:

  1. GeoJSON 原生支持 —— GeoJsonPoint / GeoJsonLineString

直接映射到 Java 对象,序列化出来就是标准 GeoJSON,前端零转换;

  1. BSON 文档大小灵活 —— MySQL 的 TEXT 也有大字段,

但 MySQL 传大对象时需要多次网络往返,MongoDB 一次文档读写搞定;

  1. 更新成本可控 —— updateMulti 只更新指定字段,

$set: {lastPoint} 是 MongoDB 的局部更新, 不会重写整个文档的其它字段。

追问:那为什么 lastPoint 不追加历史? 这是有意的取舍。追加历史会导致:

  • 文档无限增长,最终撞上 16MB 限制;
  • 每次上报都要重写整个数组(MongoDB 的数组更新是文档级操作),

2400 次上报 × 平均 38KB 数组 = 巨大 IO。

业务上如果不需要"回看包裹某时刻在哪",覆盖就够了。 如果物流合规要求留存完整轨迹,就必须分表: cc_track_point(transportOrderId, seq, point, time), 按运单分片 + 按时间倒序查询。

Q2:怎么让轨迹和实际运输路线对上?

A: 把物流路径上的转运节点作为途经点传给高德。

java
// 运单上存着亮点 2 算出来的完整路径(JSON)
String transportLine = transportOrderDTO.getTransportLine();
// 提取每个节点的经纬度,拼成高德的 waypoints 格式
wayPoints = StrUtil.join(";", pointList);

params.put("waypoints", wayPoints);
params.put("show_fields", "polyline");

为什么必须这么做: 如果不传途经点,高德会按最短路径规划, 可能走一条和实际运输完全不同的路(翻山而不是走高速)。 用户看到"包裹在这",实际车在 20 公里外——这就是投诉来源。

追问:高德最多支持 16 个途经点,超过怎么办? 当前代码没有做处理,这是个缺口。正确做法是:

  • 少于 15 个:直接传;
  • 超过 15 个:分段规划 + 折线拼接——

每次传最多 15 个途经点算一段,把多段的折线拼起来(去掉相邻段的重叠端点)。

这不只是边界情况,跨多个省的运输途经点天然会超过 16 个。

Q3:一条轨迹几万个点,会有什么问题?

A: 三个问题,从轻到重:

1. 网络和内存 一次跨省长途可能有 10 万个点 ≈ 3MB。 当前代码是全部攒在内存里再构造 GeoJsonLineString, 并发创建多个轨迹时会占满堆内存。源码里那行 TODO 说的就是这个。

2. 文档过大 3MB 的文档,每次 lastPoint 上报都要读改写这个文档, 高频更新 + 大文档 = IO 灾难。

3. 前端渲染 10 万个点的折线,前端画起来会卡;而且用户在整个屏幕宽度上 根本看不出 10 万个点和 1000 个点的区别。

对应的解法:

问题解法
内存写入前抽稀(Douglas-Peucker),点数降 90%+
文档过大拆成 cc_track(轻量,高频)+ cc_track_plan(重,只写一次)
前端渲染按缩放级别分级抽稀,返回对应精度的版本

抽稀算法(Douglas-Peucker)我会现场写一遍: 用一条线段近似一串点,保留偏离最远的点,递归两侧。 时间复杂度 O(n log n),阈值 epsilon 约 1e-5 度(≈1 米)。 跨省路线通常能从 10 万点降到 2000 点以内,形状几乎无损。

Q4:位置上报的幂等是怎么做的?

A: 用状态机做条件更新,而不是先查后改:

java
Query query = Query.query(Criteria.where("transportOrderId")
        .in(transportOrderIds)              // 批量:一个司机的多个包裹
        .and("status").is(TrackStatusEnum.NEW));   // ★ 只更新未完成的
Update update = Update.update("lastPoint", new GeoJsonPoint(lng, lat))
        .set("type", trackTypeEnum)
        .set("updated", System.currentTimeMillis());
mongoTemplate.updateMulti(query, update, TrackEntity.class);

三个设计点:

  1. status = NEW 放在 WHERE 里 —— 轨迹 complete() 之后,

后续上报自动不生效,不需要在 Java 里写 if 判断。 这是"把幂等下沉到查询条件"的写法。

  1. updateMulti 批量 —— 一次上报对应多个运单,

用 in (ids) 一次搞定,而不是循环单条更新。

  1. $set 只改指定字段 —— 不读改写整个文档。

但轨迹创建那边的幂等是反的:

java
if (ObjectUtil.isNotEmpty(trackEntity)) {
    throw new CCException(TrackExceptionEnum.TRACK_ALREADY_EXISTS);
}

"已存在"在 MQ 消费场景下恰恰说明是重复投递,应该直接跳过并 ack, 而不是抛异常。 抛异常会导致重试 3 次全部失败, 然后把这条"完全正常"的消息推进死信队列,给运维制造噪音。


七、源码定位速查

文件行作用
track/entity/TrackEntity.java—cc_track 文档(planGeoJsonLine + lastPoint)
track/service/impl/TrackServiceImpl.java60-77create(先查后插,非原子;已存在抛异常)
同上81-157queryPlanGeoJsonLineString 途经点 + polyline 解析
同上152⚠️ //TODO ...可能会出现内存溢出的情况
同上175-215upload 位置上报(条件更新 + 批量)
同上240+complete(置 COMPLETE,后续上报自动失效)
track/mq/MQListener.java33监听运单创建(但没有生产者)
transportinfo/mq/TransportInfoMQListener.java16⚠️ 空实现
transport-info/.../ExceptionEnum.java—NOT_FOUND(1001, 404, ...)(正确设置了 HTTP 状态)
common/vo/TransportInfoMsg.java—⚠️ organId 无生产者赋值
oms/service/impl/OrderServiceImpl.java288-293经纬度存成 "lng,lat" 字符串
transport/utils/DistanceCalculator.java—Haversine(可用于抽稀的垂距计算)