亮点九 · 一条轨迹几万个点,怎么存怎么画
业务问题: 用户下单后能在 App 上看到"你的包裹现在在哪"。 司机每 30 秒上报一次 GPS,一次跨省运输 20 小时 ≈ 2400 个点; 如果还要画出"规划路线"的折线,坐标点更多。 怎么存?存哪?四端怎么查?
一、业务场景
1.1 轨迹有两类,性质完全不同
| 类型 | 数据来源 | 特点 | 数据量 |
|---|---|---|---|
| 规划轨迹(plan) | 高德算好的路线折线 | 一次算出来,不变 | 一个跨省路线可能上万个点 |
| 实时位置(lastPoint) | 司机/快递员 GPS 上报 | 持续更新,高频 | 每次上报只写 1 个点 |
这个区分是整个模块设计的关键:
// 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 是覆盖,不是追加。 所以这个文档的大小是有界的,不会随着时间无限增长。 这是一个很关键的存储决策:
如果设计成 pointList 数组追加:
2400 个点 × 32 字节/点 ≈ 76KB → 远超 MongoDB 单文档 16MB 限制的风险,
而且每次上报都要重写整个数组(更新放大)
设计成 lastPoint 覆盖:
文档大小恒定,上报是 O(1) 的点更新
→ 读取时"历史轨迹"和"规划轨迹"分开取代价是:丢失了完整的移动历史。 如果业务要求"能回看包裹某个时刻在哪",就必须另外设计历史轨迹表。
这个取舍值得在面试里讲,因为它是"业务没要求就不做"的正确判断。 但前提是要确认过业务真的不需要—— 如果物流合规要求留存轨迹(某些快递确实有),那就必须做分表存储。
1.2 为什么用 MongoDB 而不是 MySQL
| MySQL | MongoDB | |
|---|---|---|
| 地理类型 | POINT / GEOMETRY | GeoJSON 原生(Point / LineString / Polygon) |
| Java 映射 | 需要自己写 TypeHandler | GeoJsonPoint / GeoJsonLineString 直接映射 |
| 前端消费 | 要转成前端地图库的格式 | 标准 GeoJSON,前端直接用 |
MongoDB 的 GeoJsonLineString 序列化出来就是标准 GeoJSON:
{
"type": "LineString",
"coordinates": [
[116.46, 39.92],
[116.47, 39.93],
...
]
}Leaflet / Mapbox / 高德 JS API 都能直接消费这个格式,零转换。
二、方案设计
2.1 创建轨迹:先查后插(幂等)
// 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,然后都插入。
正确做法是唯一索引:
@Document("cc_track")
public class TrackEntity {
@Indexed(unique = true) // ← 加 unique
private String transportOrderId;
}2.2 核心:把转运节点当作"途经点"
这是这个模块最有业务含量的一段。
// 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);核心思路:
用户下单时,亮点 2 已经在 Neo4j 里规划出了一条完整路径:
北京朝阳网点 → 通州分拣中心 → 北京转运中心 → 天津转运中心 → 天津网点
现在要做轨迹,不能让高德"直线规划",
而是要把这条路径上的【每个转运节点】作为途经点传给高德,
让高德沿着一整条真实运输路线画折线。为什么这一步很关键?
如果不传途经点,高德会按最短路径规划, 可能走一条和实际运输完全不同的路(比如翻山而不是走高速)。 用户看到"你的包裹在这",实际车在 20 公里外,这就是投诉来源。
传入的格式:高德要求的 waypoints 是 经度,纬度;经度,纬度;...
wayPoints = StrUtil.join(";", pointList);这里的细节值得讲:
// 0 值 → "err" → filter为什么要过滤 0 值? 因为转运节点的经纬度如果缺失(Node 没写 location), 会得到 null,拼进字符串就成了 null,39.9 或者 0,0, 高德会因为非法坐标报错或规划出离谱的路线。 所以要过滤掉整个无效的途经点(而不是填 0)。
2.3 解析 polyline
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):
//TODO 如果路线规则的节点比较多,可能会出现内存溢出的情况,学生思考下解决方案为什么会 OOM?
一次跨省长途(比如京藏线 1200km):
高德返回的 steps 可能有几千个
每个 step 的 polyline 可能有几百个点
总点数:几万到十几万个
每个 GeoJsonPoint 对象(JVM 内存开销约 40~60 字节)
10 万个点 ≈ 5MB 对象 + 序列化过程中的临时字符串可能上百 MB
如果并发创建轨迹(多个司机同时上报),
每个请求都持有一个几 MB 的 List
→ Tomcat 堆内存直接被打爆2.4 位置上报
// 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;
}三个设计点:
updateMulti批量更新 —— 一次上报可能对应多个运单
(一个司机的车上有十几个包裹),所以是 in (ids) 批量更新, 而不是循环单条更新。
status = NEW作为 WHERE 条件 —— 这是状态机幂等:
轨迹一旦 complete(),后续上报就自动不生效了, 不需要在 Java 里写 if 判断。
updateMulti而不是save—— 避免读取-修改-写回的竞态,
也不需要把整个文档读出来。
司机上报的入口:
// 一个运输任务对应多个运单
@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-service | 18097 | MongoDB cc_track | 计算轨迹(调高德画折线)+ 实时位置上报 |
chain-cloud-ms-transport-info-service | 18087 | MongoDB cc_transport_info | 存储和查询物流跟踪信息(文字描述) |
为什么分成两个?
track:计算密集(调高德),数据量小(一个运单一条)
transport-info:写入密集(每个节点事件一条),数据量大,查询场景多transport-info 有一个明显的缺口:
// TransportInfoMQListener
@RabbitListener(/* TRANSPORT_INFO_APPEND */)
public void listenTransportInfoAppend(String msg) {
// 没有任何实现
}而消息里有一个字段从来没被赋值:
// common/vo/TransportInfoMsg.java
public class TransportInfoMsg extends BaseMsg {
private Long organId; // ← 没有任何生产者设置它
private String transportOrderId;
private String status;
private String info;
}后果:四端查物流轨迹,恒为空。 这是 ARCHITECTURE.md 里列的主链路阻塞点之一。
三、难点拆解
难点 1:怎么让"规划轨迹"和"实际运输"对得上
难点在哪: 高德规划的路径是基于路网的理论最优, 而实际运输是按物流线路走的(可能绕行去中转场装货)。
如果两者不一致,用户会觉得"轨迹在骗我"。
怎么解(当前做法): 把物流路径上的转运节点作为途经点强制高德经过。
这个方案的优点: 实现简单,且大部分情况够用。
它的局限(值得主动说):
1. 高德最多支持 16 个途经点
代码的 Javadoc 里写了"(高德地图最多支持16个途经点)"
但代码【没有做截断】!
超过 16 个的高德会报错,整条轨迹创建失败
2. 途经点只约束"经过哪些点",不约束"走哪条边"
从 A 中转场到 B 中转场,高德仍可能规划出另一条路
3. 如果中转节点的实际装货位置和节点坐标有偏差(比如网点很大),
轨迹会有几十米的误差如果重做:
// 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 逗号处理的:
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 的每次上报都要读写这个文档。
一次 doc 更新 = 读 3MB + 改 3MB + 写 3MB
每 30 秒一次上报 × 几百个车辆
→ MongoDB 的 IO 和网络开销都会非常大这个设计有内在矛盾:
planGeoJsonLine让文档很大(一次性写入,之后不变)lastPoint让文档被高频更新(每次都要搬运整个文档)
如果重做:拆成两个集合。
cc_track —— 轻量,高频更新
{ _id, transportOrderId, lastPoint, type, status, updated }
cc_track_plan —— 重量,写一次读多次
{ transportOrderId, planGeoJsonLine, distance }或者:把规划折线存成 GridFS / 对象存储。
超过阈值(比如 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%。
为什么轨迹必须抽稀?
一条跨省路线的原始折线:10 万个点 ≈ 3MB
用户实际能看到的:整个屏幕就那么几个像素宽度
→ 传到前端也画不出区别,只是浪费带宽和卡顿
抽稀后:1000 个点 ≈ 30KB,形状几乎无损实现(面试时可以现场写):
/**
* 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);
}还可以叠加"按缩放级别分级抽稀":
// 地图缩放到全国级别时给 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 风险,但没人处理
//TODO 如果路线规则的节点比较多,可能会出现内存溢出的情况,学生思考下解决方案
return new GeoJsonLineString(points);这个 TODO 的意思是"我知道这里有性能问题,但我留给你解决"。
从工程角度看,这段代码有三个具体的隐患:
| 隐患 | 说明 |
|---|---|
| 临时对象爆炸 | StrUtil.split(...).stream().map(...).collect() 产生海量临时对象,GC 压力大 |
String → double[] → double 三次转换 | Convert.convert(double[].class, String.split(',')) 走反射,十万次调用的开销不可忽略 |
| 异常坐标 | 坐标字符串格式不对时(比如高德返回空 polyline),ds[0] 会数组越界 |
修复方向(三选一):
// 方案 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 的消费端是空的
// 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 从来没被生产者赋值:
// work / web-courier 构造 TransportInfoMsg 时
// 只设置了 transportOrderId / status / info,没设置 organId后果:即使消费端实现了,缺 organId 的物流信息也不知道该归属哪个机构, 四端查询仍然会缺数据。
这是一个典型的"契约设计不完整"的例子: TransportInfoMsg 定义了 4 个字段,但生产者和消费者关心的字段不一致。
修复:把契约补齐,并加一个契约测试。
// 建议在 common 里加一个消息契约测试
// 遍历所有 TransportInfoMsg 的生产者,断言必填字段都有值
@Test
void 物流跟踪消息必须包含机构id() {
TransportInfoMsg msg = buildTransportInfoMsg(order);
assertThat(msg.getOrganId()).isNotNull();
assertThat(msg.getTransportOrderId()).isNotBlank();
}坑 3:轨迹创建的幂等是非原子的
if (ObjectUtil.isNotEmpty(trackEntity)) {
throw new CCException(TrackExceptionEnum.TRACK_ALREADY_EXISTS);
}同亮点 3 的 saveOrUpdate: 并发调用会创建两条轨迹。 而且没有 @Indexed(unique = true)。
更糟的是抛异常的方向反了:
MQ 消费场景下,"已存在"恰恰说明【这条消息是重复投递】,
应该直接【跳过并 ack】,而不是抛异常。
抛异常 → 消费者重试 3 次 → 每次都抛"已存在"
→ 重试耗尽 → 进死信队列
→ 运维去死信队列里一看:全是"轨迹已存在"的正常情况这是一个"把正常业务分支当成异常"的经典错误。
正确做法:
@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:起终点坐标的来源容易被忽略
Coordinate origin = new Coordinate(originLng, originLat); // 来自 order_location
Coordinate destination = new Coordinate(destLng, destLat); // 来自 order_location而 order_location 的存储格式是:
// OrderServiceImpl#getAgencyId
DecimalFormat df = new DecimalFormat("#.######");
String location = StrUtil.format("{},{}", df.format(lng), df.format(lat));
// → "116.460000,39.920000"(字符串,逗号分隔)然后 track 里又要解析回来:
String[] split = orderLocation.getSendLocation().split(",");
double lnt = Double.parseDouble(split[0]);
double lat = Double.parseDouble(split[1]);经纬度用逗号分隔的字符串存,然后到处 split。
问题:
- 精度只有 6 位小数 —— 大约 0.1 米的精度,够用但不是最优;
- 没有存字段名 ——
split(",")的顺序完全靠约定,
一旦某处写反了(lat, lng)就又是经纬度互换;
- 解析没有防御 —— split 出 1 个元素时
split[1]会数组越界。
更好的做法:
方案 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 |
| P0 | transportOrderId 加唯一索引;"已存在"改为返回 true 而非抛异常 | 消除并发重复 + 死信噪音 |
| P1 | 途经点超过 15 个时分段规划 + 拼接 | 支持跨多省长途 |
| P1 | cc_track 拆成"轻量轨迹表 + 规划折线表" | 消除高频更新大文档 |
| P1 | polyline 解析改流式 + 加格式防御 | 消除 OOM 隐患和数组越界 |
| P2 | 按缩放级别分级抽稀 | 前端渲染性能 |
| P2 | 经纬度拆成独立字段(DECIMAL(10,7)) | 消除 split 约定 |
| P2 | 补齐 TransportInfoMQListener 的实现 + organId | 让四端能查到轨迹 |
六、面试问答
Q1:轨迹数据怎么存?为什么用 MongoDB?
A: 分两种数据分开说:
规划轨迹 —— 高德算好的折线,一次写入、永不修改, 存成 GeoJsonLineString(标准 GeoJSON 格式,前端地图库直接消费)。
实时位置 —— 司机每 30 秒上报,只存最新一个点(lastPoint), 覆盖更新,不追加历史。
为什么用 MongoDB:
- GeoJSON 原生支持 ——
GeoJsonPoint/GeoJsonLineString
直接映射到 Java 对象,序列化出来就是标准 GeoJSON,前端零转换;
- BSON 文档大小灵活 —— MySQL 的
TEXT也有大字段,
但 MySQL 传大对象时需要多次网络往返,MongoDB 一次文档读写搞定;
- 更新成本可控 ——
updateMulti只更新指定字段,
$set: {lastPoint} 是 MongoDB 的局部更新, 不会重写整个文档的其它字段。
追问:那为什么 lastPoint 不追加历史? 这是有意的取舍。追加历史会导致:
- 文档无限增长,最终撞上 16MB 限制;
- 每次上报都要重写整个数组(MongoDB 的数组更新是文档级操作),
2400 次上报 × 平均 38KB 数组 = 巨大 IO。
业务上如果不需要"回看包裹某时刻在哪",覆盖就够了。 如果物流合规要求留存完整轨迹,就必须分表: cc_track_point(transportOrderId, seq, point, time), 按运单分片 + 按时间倒序查询。
Q2:怎么让轨迹和实际运输路线对上?
A: 把物流路径上的转运节点作为途经点传给高德。
// 运单上存着亮点 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: 用状态机做条件更新,而不是先查后改:
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);三个设计点:
status = NEW放在 WHERE 里 —— 轨迹complete()之后,
后续上报自动不生效,不需要在 Java 里写 if 判断。 这是"把幂等下沉到查询条件"的写法。
updateMulti批量 —— 一次上报对应多个运单,
用 in (ids) 一次搞定,而不是循环单条更新。
$set只改指定字段 —— 不读改写整个文档。
但轨迹创建那边的幂等是反的:
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.java | 60-77 | create(先查后插,非原子;已存在抛异常) |
| 同上 | 81-157 | queryPlanGeoJsonLineString 途经点 + polyline 解析 |
| 同上 | 152 | ⚠️ //TODO ...可能会出现内存溢出的情况 |
| 同上 | 175-215 | upload 位置上报(条件更新 + 批量) |
| 同上 | 240+ | complete(置 COMPLETE,后续上报自动失效) |
track/mq/MQListener.java | 33 | 监听运单创建(但没有生产者) |
transportinfo/mq/TransportInfoMQListener.java | 16 | ⚠️ 空实现 |
transport-info/.../ExceptionEnum.java | — | NOT_FOUND(1001, 404, ...)(正确设置了 HTTP 状态) |
common/vo/TransportInfoMsg.java | — | ⚠️ organId 无生产者赋值 |
oms/service/impl/OrderServiceImpl.java | 288-293 | 经纬度存成 "lng,lat" 字符串 |
transport/utils/DistanceCalculator.java | — | Haversine(可用于抽稀的垂距计算) |