From 190d62cd3f20441a44d17b2585fdd9a8d388a88a Mon Sep 17 00:00:00 2001 From: sladro Date: Mon, 2 Mar 2026 13:02:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=80=9F=E5=BA=A6=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E6=9C=8D=E5=8A=A1=EF=BC=8C=E4=BC=98=E5=8C=96=E6=B4=BB?= =?UTF-8?q?=E8=B7=83=E5=AF=B9=E8=B1=A1=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=95=B0=E6=8D=AE=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../飞机GIS链路稳定性优化审查报告_20260302.md | 122 ++++++++++++++++++ .../service/DataCollectorService.java | 70 ++++++++-- .../service/DataProcessingService.java | 103 ++++++--------- .../WebSocketMessageBroadcaster.java | 13 +- 4 files changed, 231 insertions(+), 77 deletions(-) create mode 100644 doc/work/飞机GIS链路稳定性优化审查报告_20260302.md diff --git a/doc/work/飞机GIS链路稳定性优化审查报告_20260302.md b/doc/work/飞机GIS链路稳定性优化审查报告_20260302.md new file mode 100644 index 0000000..fa9ebcd --- /dev/null +++ b/doc/work/飞机GIS链路稳定性优化审查报告_20260302.md @@ -0,0 +1,122 @@ +# 飞机GIS链路稳定性与前端显示优化审查报告 + +- 审查日期: 2026-03-02 +- 审查方式: 静态代码审查(未启动服务、未运行测试) +- 审查范围: GIS接收 -> 速度/角度计算 -> 平滑/防跳点 -> WebSocket下发 + +## 1. 审查结论(先说结论) +当前链路已经有“去抖 + 限跳 + 合并推送”的基础能力,但距离“专业级稳定显示”还有关键缺口。 + +最主要风险集中在3点: +1. 处理线程与采集线程并发写同一状态对象,存在旧数据回写覆盖新数据的可能,会导致前端回跳、抖动。 +2. 非飞机目标在“异常跳点重获”时采用“丢2次后直接接受原始点”的策略,仍可能出现突然跳远。 +3. 速度/航向在不可计算时使用固定默认值(40/25/20 km/h, heading=0),会向前端输出伪物理状态。 + +## 2. 关键问题清单(按严重度) + +### P0-1 并发回写导致轨迹回跳(高风险) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:122` + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:201` + - `qaup-collision/src/main/java/com/qaup/collision/common/config/SchedulerConfig.java:19` +- 现象: 先拍快照再计算,计算后再 `put` 回缓存;在多线程调度下,采集线程可能已经写入更新位置,随后被处理线程“旧快照对象”覆盖。 +- 直接后果: 前端位置回退、路径折返、局部抖动。 + +### P0-2 非飞机对象“重获”策略仍可能瞬移(高风险) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:522` + - `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:533` + - `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:536` +- 现象: 非飞机跳点超限时,前2次丢弃,第3次直接接受原始坐标。 +- 直接后果: 仍可能“一下子跳出去很远”,尤其在短时GPS漂移或上游坐标抖动时。 + +### P0-3 输出伪速度/伪航向(高风险) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:170` + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:190` + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:234` +- 现象: 速度/方向算不出来时,直接下发默认速度和朝向(40/25/20、0度)。 +- 直接后果: 前端若按速度做插值,会出现“方向突变”或“虚假运动”。 + +### P1-1 飞机同批去重只按 flightNo,时间戳并列时无稳定择优(中高) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:415` + - `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:425` + - `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:438` +- 现象: 同 flightNo 多点时只看 `sourceTimestampMs`,并列或空时间戳时选择不稳定。 +- 直接后果: 同一周期内点位可能来回切换,路径“绕一下”或折线异常。 + +### P1-2 速度历史缓存缺少生命周期对齐(中高) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/SpeedCalculationService.java:32` + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/SpeedCalculationService.java:174` + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/SpeedCalculationService.java:181` +- 现象: `positionHistory` 长驻;对象在活跃缓存被清理后,速度历史未同步清理。 +- 直接后果: 对象重现时可能引用过旧历史,造成首帧速度/方向不稳定。 + +### P1-3 时间戳单位双轨(微秒/毫秒)增加前端误用风险(中) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/websocket/event/PositionUpdateEvent.java:32` + - `qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java:260` + - `qaup-collision/src/main/java/com/qaup/collision/websocket/message/UniversalMessage.java:28` +- 现象: 外层消息 `timestamp` 为微秒,payload `timestamp` 为毫秒。 +- 直接后果: 前端若误用外层时间做动画插值,会出现时间比例错误和轨迹异常。 + +### P2-1 非飞机滤波参数未配置化落地,依赖代码默认值(中) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:58` + - `qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java:130` + - `qaup-admin/src/main/resources/application-dev.yml:80` + - `qaup-admin/src/main/resources/application-prod.yml:58` +- 现象: 平滑参数大量由 `@Value` 默认值承载,环境缺少显式配置基线。 +- 直接后果: 不同机场/地图比例下难以精细调参,稳定性受限。 + +### P2-2 x/y 与 lat/lon 语义依赖约定,缺少强校验(中) +- 证据: + - `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java:361` + - `qaup-collision/src/main/java/com/qaup/collision/datacollector/service/VehicleStatusAggregationService.java:64` +- 现象: 某些来源使用 x/y,代码按“x=lat,y=lon”解释;若上游调整语义,容易引入反转问题。 +- 直接后果: 轨迹漂移、跳点、方向异常。 + +## 3. 专业级优化方案(不改代码版建议) + +### P0 立即改(先止血) +1. 统一“位置状态单写者”模型。 + - 采集线程只写原始缓存,处理线程只读不回写位置。 + - 速度/航向另设派生字段缓存,避免覆盖位置主状态。 +2. 调整非飞机重获策略。 + - 禁止“第3次直接接收原始点”;改为“限步过渡”直到收敛。 + - 这样可消除大跳点。 +3. 去掉默认伪速度/伪航向下发。 + - 不可计算时下发 `null` 或 `valid=false` 标记。 + - 前端接到无效值时保持上一帧方向,位置只按真实点更新。 + +### P1 本周内改(提升稳定) +1. 飞机去重引入稳定择优键。 + - 建议排序键: `sourceTimestampMs` -> `trackNumber` -> 与上一点距离最小。 +2. 速度历史增加TTL并与对象清理联动。 + - 对象清理时同步 `clearHistory(objectId)`。 +3. 统一消息时间语义。 + - 约定只让前端使用 `payload.timestamp(ms)` 做运动插值。 + - 外层时间仅用于日志/排序。 + +### P2 持续优化(工程化) +1. 把 `websocket.position.filter.*` 全量放入 `application-*.yml`,分环境调参。 +2. 增加坐标语义守卫。 + - 对 x/y 来源记录 schema 版本并做运行时一致性检测。 +3. 输出质量指标。 + - 每对象统计 `drop_rate`, `outlier_count`, `max_step_meter`, `reacquire_count`。 + +## 4. 前端“专业级稳定显示”验收指标(建议) +1. 单帧位移上限: 非飞机 <= 20m(可按业务调),飞机 <= 80m。 +2. 跳点率: `outlier_drop / total_points < 1%`。 +3. 抖动率: 静止目标在30秒窗口内位移标准差 <= 1m。 +4. 轨迹连续性: 不允许出现“反向回跳再前进”锯齿(除非上游真实回退)。 +5. 时间一致性: 前端插值仅使用毫秒时间戳,严禁混用微秒。 + +## 5. 其它观察 +- 已抽查关键文件编码均为 UTF-8 无 BOM,编码规范执行良好。 +- 发现调试日志以 `error` 级别输出的临时内容,建议后续清理,避免掩盖真实异常。 + +## 6. 结语 +该项目已经具备基础防抖和限跳框架,但要达到你要求的“前端稳定、专业级显示”,核心是先解决并发回写与重获瞬移策略,再统一速度/时间语义。按本报告的 P0 -> P1 顺序推进,可以明显降低“跳出去很远、路径绕行、频繁抖动”问题。 \ No newline at end of file diff --git a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java index 0e74c9f..141f3c8 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/datacollector/service/DataCollectorService.java @@ -95,6 +95,9 @@ public class DataCollectorService { @Autowired private com.qaup.collision.dataprocessing.service.DataProcessingService dataProcessingService; // 注入数据处理服务 + + @Autowired + private com.qaup.collision.dataprocessing.service.SpeedCalculationService speedCalculationService; @Autowired private VehicleLocationFilter vehicleLocationFilter; // 注入车辆位置过滤器 @@ -137,6 +140,15 @@ public class DataCollectorService { activeMovingObjectsLastUpdatedAtMs.put(movingObject.getObjectId(), System.currentTimeMillis()); } + private void removeActiveMovingObject(String objectId) { + if (objectId == null || objectId.isBlank()) { + return; + } + activeMovingObjectsCache.remove(objectId); + activeMovingObjectsLastUpdatedAtMs.remove(objectId); + speedCalculationService.clearHistory(objectId); + } + // 无人车ID列表(来自上游 HTTP:GET /api/vehicle_details) private final Set unmannedVehicleIds = ConcurrentHashMap.newKeySet(); @@ -445,16 +457,53 @@ public class DataCollectorService { Long existingTs = existing.getSourceTimestampMs(); Long candidateTs = candidate.getSourceTimestampMs(); - if (existingTs == null && candidateTs == null) { - return true; + int timestampCompare = compareNullableLong(candidateTs, existingTs); + if (timestampCompare != 0) { + return timestampCompare > 0; } - if (existingTs == null) { - return true; + + // 时间戳并列时,引入稳定择优键,避免同批次点位来回切换。 + int trackCompare = compareNullableText(candidate.getTrackNumber(), existing.getTrackNumber()); + if (trackCompare != 0) { + return trackCompare > 0; } - if (candidateTs == null) { - return false; + + return false; + } + + private int compareNullableLong(Long left, Long right) { + if (left == null && right == null) { + return 0; } - return candidateTs >= existingTs; + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return Long.compare(left, right); + } + + private int compareNullableText(String left, String right) { + String normalizedLeft = left == null ? null : left.trim(); + String normalizedRight = right == null ? null : right.trim(); + if (normalizedLeft == null || normalizedLeft.isEmpty()) { + normalizedLeft = null; + } + if (normalizedRight == null || normalizedRight.isEmpty()) { + normalizedRight = null; + } + + if (normalizedLeft == null && normalizedRight == null) { + return 0; + } + if (normalizedLeft == null) { + return -1; + } + if (normalizedRight == null) { + return 1; + } + return normalizedLeft.compareTo(normalizedRight); } // Fixed-delay scheduling prevents overlap without dropping cycles. @@ -1247,6 +1296,7 @@ public class DataCollectorService { activeMovingObjectsLastUpdatedAtMs.entrySet().removeIf(entry -> { Long last = entry.getValue(); if (last == null || last < cutoff) { + speedCalculationService.clearHistory(entry.getKey()); activeMovingObjectsCache.remove(entry.getKey()); return true; } @@ -1262,10 +1312,7 @@ public class DataCollectorService { .sorted(java.util.Map.Entry.comparingByValue()) .limit(needRemove) .map(java.util.Map.Entry::getKey) - .forEach(key -> { - activeMovingObjectsCache.remove(key); - activeMovingObjectsLastUpdatedAtMs.remove(key); - }); + .forEach(this::removeActiveMovingObject); log.warn("activeMovingObjectsCache 超过最大限制 {},已丢弃最旧 {} 条以保持实时性(当前: {})", maxCacheSize, needRemove, activeMovingObjectsCache.size()); } @@ -1312,6 +1359,7 @@ public class DataCollectorService { activeMovingObjectsCache.clear(); activeMovingObjectsLastUpdatedAtMs.clear(); flightNotificationCache.clear(); + speedCalculationService.clearAllHistory(); log.info("缓存已清理"); } } diff --git a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java index c9b62ae..4c40b53 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java +++ b/qaup-collision/src/main/java/com/qaup/collision/dataprocessing/service/DataProcessingService.java @@ -44,9 +44,6 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.PrecisionModel; -import java.math.BigDecimal; -import java.math.RoundingMode; - /** * 数据处理服务 * @@ -134,8 +131,8 @@ public class DataProcessingService { log.info("开始周期性数据处理,活跃对象数量: {}", activeMovingObjectsCache.size()); - // 获取所有活跃对象的快照 - List currentActiveObjects = new ArrayList<>(activeMovingObjectsCache.values()); + // 获取所有活跃对象的快照,避免处理线程回写覆盖采集线程最新状态 + List currentActiveObjects = createProcessingSnapshot(activeMovingObjectsCache.values()); // 第一步:计算速度和方向 calculateSpeedAndDirectionForAllObjects(currentActiveObjects); @@ -189,44 +186,12 @@ public class DataProcessingService { movingObject.getCurrentPosition().getX(), // longitude currentTime); - // 处理速度值 - 如果计算返回null,使用合理默认值 - Double finalSpeed = calculatedSpeed; - if (finalSpeed == null) { - switch (movingObject.getObjectType()) { - case AIRCRAFT: - finalSpeed = 40.0; // 航空器默认速度 - break; - case SPECIAL_VEHICLE: - case NORMAL_VEHICLE: - finalSpeed = 25.0; // 机场车辆默认速度 - break; - case UNMANNED_VEHICLE: - finalSpeed = 20.0; // 无人车默认速度 - break; - default: - finalSpeed = 0.0; - } - log.debug("对象 {} 速度计算返回null,使用默认值: {} km/h", - movingObject.getObjectId(), finalSpeed); - } - - // 处理方向值 - 如果计算返回null,使用合理默认值 - Double finalDirection = calculatedDirection; - if (finalDirection == null) { - finalDirection = 0.0; // 默认朝北 - log.debug("对象 {} 方向计算返回null,使用默认值: 0.0度", - movingObject.getObjectId()); - } - // 更新MovingObject的速度和方向 - movingObject.setCurrentSpeed(finalSpeed); - movingObject.setCurrentHeading(finalDirection); - - // 更新缓存中的对象 - activeMovingObjectsCache.put(movingObject.getObjectId(), movingObject); + movingObject.setCurrentSpeed(calculatedSpeed); + movingObject.setCurrentHeading(calculatedDirection); log.debug("对象 {} 计算完成: 速度={} km/h, 方向={}度", - movingObject.getObjectId(), finalSpeed, finalDirection); + movingObject.getObjectId(), movingObject.getCurrentSpeed(), movingObject.getCurrentHeading()); } catch (Exception e) { log.error("计算对象 {} 的速度和方向时发生异常", movingObject.getObjectId(), e); @@ -256,31 +221,8 @@ public class DataProcessingService { .longitude(movingObject.getCurrentPosition().getX()) .build(); - // 处理速度值 - 如果为null,使用合理的默认值而不是0 Double finalSpeed = movingObject.getCurrentSpeed(); - if (finalSpeed == null) { - // 根据对象类型使用合理的默认速度 - switch (movingObject.getObjectType()) { - case AIRCRAFT: - finalSpeed = 40.0; // 航空器默认速度 - break; - case SPECIAL_VEHICLE: - case NORMAL_VEHICLE: - finalSpeed = 25.0; // 机场车辆默认速度 - break; - case UNMANNED_VEHICLE: - finalSpeed = 20.0; // 无人车默认速度 - break; - default: - finalSpeed = 0.0; - } - } - - // 处理方向值 - 如果为null,使用合理的默认值而不是0 Double finalHeading = movingObject.getCurrentHeading(); - if (finalHeading == null) { - finalHeading = 0.0; // 默认朝北 - } long payloadTimestamp = movingObject.getSourceTimestampMs() != null ? movingObject.getSourceTimestampMs() @@ -291,7 +233,7 @@ public class DataProcessingService { .objectType(movingObject.getObjectType().name()) .position(positionPayload) .heading(finalHeading) - .speed(new BigDecimal(finalSpeed).setScale(2, RoundingMode.HALF_UP).doubleValue()) + .speed(roundTo2(finalSpeed)) .timestamp(payloadTimestamp) .build(); @@ -313,6 +255,39 @@ public class DataProcessingService { log.info("位置更新消息发送完成,发送数量: {}", activeObjects.size()); } + private List createProcessingSnapshot(java.util.Collection sourceObjects) { + List snapshot = new ArrayList<>(sourceObjects.size()); + for (MovingObject source : sourceObjects) { + if (source == null || source.getObjectId() == null || source.getObjectType() == null) { + continue; + } + Point position = source.getCurrentPosition(); + if (position == null) { + continue; + } + + snapshot.add(MovingObject.builder() + .objectId(source.getObjectId()) + .objectType(source.getObjectType()) + .objectName(source.getObjectName()) + .currentPosition(geometryFactory.createPoint(new org.locationtech.jts.geom.Coordinate( + position.getX(), position.getY()))) + .currentSpeed(source.getCurrentSpeed()) + .currentHeading(source.getCurrentHeading()) + .altitude(source.getAltitude()) + .sourceTimestampMs(source.getSourceTimestampMs()) + .build()); + } + return snapshot; + } + + private Double roundTo2(Double value) { + if (value == null) { + return null; + } + return Math.round(value * 100.0) / 100.0; + } + /** * 执行违规检测 */ diff --git a/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java b/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java index e998027..339feb6 100644 --- a/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java +++ b/qaup-collision/src/main/java/com/qaup/collision/websocket/broadcaster/WebSocketMessageBroadcaster.java @@ -533,8 +533,17 @@ public class WebSocketMessageBroadcaster { return null; } else { state.rejectedCount = 0; - filteredLatitude = latitude; - filteredLongitude = longitude; + double transitionStepLimit = Math.max(maxAllowedJump, adaptive.jitterMeter() * 2.0); + if (rawDistance > transitionStepLimit) { + double ratio = transitionStepLimit / rawDistance; + filteredLatitude = state.lastFilteredLatitude + ratio * (latitude - state.lastFilteredLatitude); + filteredLongitude = state.lastFilteredLongitude + ratio * (longitude - state.lastFilteredLongitude); + log.warn("[TMP-POS-FIX] reason=non_aircraft_jump_step_limit objectId={} rawLatitude={} rawLongitude={} jumpMeter={} allowedStepMeter={}", + safeObjectId(payload), rawLatitude, rawLongitude, rawDistance, transitionStepLimit); + } else { + filteredLatitude = latitude; + filteredLongitude = longitude; + } } }