Files
lingniu-vehicle-ingest/docs/superpowers/specs/2026-06-29-vehicle-ingest-redesign.zh.md
2026-06-29 12:50:41 +08:00

18 KiB
Raw Blame History

车辆接入重设计方案

背景

当前 GB32960 和 JT808 链路已经能够完成接收、解析、发布和查询,但职责边界被拆散在多条路径里:

  • Netty Handler 生成 RawFrameDispatcher 同时发出原始归档事件和标准化车辆事件。
  • Kafka Envelope 只携带 raw archive 引用,原始 bytes 由 archive sink 另行存储。
  • GB32960 历史查询依赖 raw archive 记录,再回读文件重新解析。
  • JT808 位置历史又单独物化到 TDengine。
  • DuckDB/Parquet、本地 raw archive、Kafka、TDengine 分别承担一部分职责,导致查询语义和持久化边界不好推理。

新设计保留已有协议解析器和部署经验,但重新定义接入、持久化、存储和查询边界。

目标

  1. 保存每一条入站协议原始帧,包括可拿到 bytes 的异常帧。
  2. 支持按车辆、协议、消息类型、时间范围查询 GB32960 和 JT808 历史上报数据。
  3. 新增字段、协议消息、厂商扩展时,不需要重写核心管线。
  4. 支撑 10,000 辆车生产写入,以及 1,000 QPS 历史查询。
  5. 明确 ACK 和持久化规则,避免“已经 ACK 但数据悄悄丢失”。

非目标

  • 不把 Kafka 作为长期历史查询数据库。
  • 不把大块原始 bytes 直接塞进 Kafka topic。
  • 不继续把 DuckDB/Parquet 作为 GB32960/JT808 的生产热查询库。
  • 不做一个掩盖协议差异的万能接口。内部可以统一,外部 API 保持协议语义清晰。

推荐架构

采用 Raw Archive + Kafka + TDengine 作为核心架构。

flowchart LR
    GB["GB32960 TCP"] --> Edge["协议接入边界"]
    JT["JT808 TCP"] --> Edge
    Edge --> RawStore["Raw Archive"]
    RawStore --> RawTopic["Kafka vehicle.raw-frame.v1"]
    Edge --> Decode["协议解析器"]
    Decode --> FactTopic["Kafka vehicle.decoded-fact.v1"]
    RawTopic --> Writer["History Writer"]
    FactTopic --> Writer
    Writer --> TD["TDengine"]
    Writer --> Redis["实时状态"]
    API["历史 API"] --> TD
    API --> RawStore
    RealtimeAPI["实时 API"] --> Redis

架构里只有三类事实:

  • RawFrameFact:每一条入站帧一条记录,不管解析成功还是失败。
  • DecodedFact:从 raw frame 派生出来的零条或多条标准事实。
  • RawArchiveObject:不可变原始 bytes通过 raw_uri 寻址。

raw archive 是排障和回放的事实源。TDengine 是查询事实源。Kafka 是解耦和重放管道。

模块边界

协议接入 APP

APP

  • gb32960-ingest-app
  • jt808-ingest-app

职责:

  • 接收 TCP 连接。
  • 完成帧切分。
  • 执行协议层校验、鉴权、会话绑定和 ACK。
  • 创建 RawFrameFact
  • 通过 RawArchiveWriter 持久化原始 bytes。
  • 发布 raw frame fact 到 Kafka。
  • 解析协议帧并发布 decoded facts。

接入 APP 不负责历史查询、导出,也不直接写业务查询表。

Ingest Core

新增模块:

  • modules/core/ingest-facts

核心类型:

public record RawFrameFact(
        String frameId,
        ProtocolId protocol,
        String vehicleKey,
        String vin,
        String phone,
        int messageId,
        int subType,
        Instant eventTime,
        Instant receivedAt,
        String peer,
        String rawUri,
        String checksum,
        long rawSizeBytes,
        ParseStatus parseStatus,
        String parseError,
        Map<String, String> metadata
) {}
public sealed interface DecodedFact permits
        DecodedFact.Location,
        DecodedFact.Realtime,
        DecodedFact.Alarm,
        DecodedFact.Session,
        DecodedFact.Register,
        DecodedFact.Extension {

    String factId();
    String frameId();
    ProtocolId protocol();
    String vehicleKey();
    String vin();
    Instant eventTime();
    Instant receivedAt();
    String rawUri();
    Map<String, String> metadata();
}

vehicleKey 是稳定分区键:

  • GB32960VIN。
  • JT808已解析到 VIN 时使用 VIN否则使用 jt808:<phone>
  • 未知或异常帧:使用 unknown:<protocol>:<hash>

Raw Archive

新增或重构模块:

  • modules/sinks/raw-archive-store

接口:

public interface RawArchiveWriter {
    RawArchiveReceipt write(RawArchiveWriteRequest request) throws IOException;
}

写入回执:

public record RawArchiveReceipt(
        String rawUri,
        String checksum,
        long sizeBytes
) {}

规则:

  • 原始 bytes 一旦写入,不允许原地修改。
  • 写入路径可以按内容寻址,也可以按 protocol/date/vehicleKey/frameId.bin 生成。
  • 本地存储需要支持 fsync 能力,接口要预留对象存储实现。
  • rawUri 必须能被历史 API 解析,用于单帧回放。
  • 异常帧只要有 bytes也必须写入并记录 parseStatus=FAILED

部署约束:

  • Raw archive、TDengine、history 服务可以不在同一台 ECS但应在同一 VPC 和同一可用区内通过内网访问。
  • 高频历史查询只访问 TDengine不访问 raw archive。
  • Raw archive 只进入单帧详情、问题排查、回放和异步导出路径。
  • rawUri 必须是全局可解析地址,不能是某台 ECS 私有的本地绝对路径。
  • 第一版本地 raw archive 需要通过可替换接口封装,后续可以切换到 OSS、MinIO 或其它对象存储。

Kafka Topic

使用稳定的版本化 topic

  • vehicle.raw-frame.v1
  • vehicle.decoded-fact.v1
  • vehicle.dlq.v1

Kafka key

<protocol>:<vehicleKey>

vehicle.raw-frame.v1 的 payload 是不含 raw bytes 的 RawFrameFact,携带 rawUri、checksum、byte size、message id、parse status 和 metadata。

vehicle.decoded-fact.v1 的 payload 是单条 DecodedFact,必须引用 frameIdrawUri

vehicle.dlq.v1 保存存储失败、发布失败、解析失败和写入失败事件,并携带足够元数据,尽量支持从 raw archive 重放。

History Writer

新增或重构 APP

  • vehicle-history-app

职责:

  • 消费 vehicle.raw-frame.v1
  • 消费 vehicle.decoded-fact.v1
  • 批量写 TDengine。
  • 更新实时状态存储。
  • 对非法 payload 或存储失败写 DLQ。

Writer 写入需要幂等:

  • raw fact 以 (frame_id) 幂等。
  • decoded fact 以 (fact_id) 幂等。

TDengine 模型

TDengine 是生产历史查询主库。

原始帧超级表

CREATE STABLE IF NOT EXISTS raw_frames (
  ts TIMESTAMP,
  frame_id NCHAR(64),
  received_at TIMESTAMP,
  message_id INT,
  sub_type INT,
  event_time TIMESTAMP,
  raw_uri NCHAR(512),
  checksum NCHAR(128),
  raw_size_bytes BIGINT,
  parse_status NCHAR(16),
  parse_error NCHAR(512),
  peer NCHAR(128),
  metadata_json NCHAR(4096)
) TAGS (
  protocol NCHAR(16),
  vehicle_key NCHAR(128),
  vin NCHAR(64),
  phone NCHAR(32)
);

子表命名:

raw_<protocol>_<hash(vehicleKey)>

主要查询模式:

  • 按车辆和时间查询原始帧。
  • 按协议、消息 ID、解析状态和时间查询原始帧。
  • frame_idraw_uri 查询单帧。

位置超级表

CREATE STABLE IF NOT EXISTS vehicle_locations (
  ts TIMESTAMP,
  fact_id NCHAR(64),
  frame_id NCHAR(64),
  received_at TIMESTAMP,
  longitude DOUBLE,
  latitude DOUBLE,
  altitude_m DOUBLE,
  speed_kmh DOUBLE,
  direction_deg DOUBLE,
  alarm_flag BIGINT,
  status_flag BIGINT,
  total_mileage_km DOUBLE,
  raw_uri NCHAR(512),
  metadata_json NCHAR(4096)
) TAGS (
  protocol NCHAR(16),
  vehicle_key NCHAR(128),
  vin NCHAR(64),
  phone NCHAR(32)
);

实时数据超级表

CREATE STABLE IF NOT EXISTS vehicle_realtime (
  ts TIMESTAMP,
  fact_id NCHAR(64),
  frame_id NCHAR(64),
  received_at TIMESTAMP,
  speed_kmh DOUBLE,
  total_mileage_km DOUBLE,
  battery_soc DOUBLE,
  total_voltage_v DOUBLE,
  total_current_a DOUBLE,
  running_mode NCHAR(64),
  vehicle_state NCHAR(64),
  charging_state NCHAR(64),
  raw_uri NCHAR(512),
  fields_json NCHAR(8192),
  metadata_json NCHAR(4096)
) TAGS (
  protocol NCHAR(16),
  vehicle_key NCHAR(128),
  vin NCHAR(64),
  phone NCHAR(32)
);

fields_json 存放稀疏全字段值,用于低频排查。高频查询字段应提升为明确列,或放入专用扩展表。

报警超级表

CREATE STABLE IF NOT EXISTS vehicle_alarms (
  ts TIMESTAMP,
  fact_id NCHAR(64),
  frame_id NCHAR(64),
  received_at TIMESTAMP,
  alarm_level NCHAR(32),
  alarm_code BIGINT,
  alarm_name NCHAR(128),
  longitude DOUBLE,
  latitude DOUBLE,
  raw_uri NCHAR(512),
  fields_json NCHAR(8192),
  metadata_json NCHAR(4096)
) TAGS (
  protocol NCHAR(16),
  vehicle_key NCHAR(128),
  vin NCHAR(64),
  phone NCHAR(32)
);

会话和注册表

CREATE STABLE IF NOT EXISTS vehicle_sessions (
  ts TIMESTAMP,
  fact_id NCHAR(64),
  frame_id NCHAR(64),
  received_at TIMESTAMP,
  session_type NCHAR(32),
  result_code INT,
  raw_uri NCHAR(512),
  fields_json NCHAR(8192),
  metadata_json NCHAR(4096)
) TAGS (
  protocol NCHAR(16),
  vehicle_key NCHAR(128),
  vin NCHAR(64),
  phone NCHAR(32)
);

JT808 注册详情先进入 vehicle_sessions,后续可以投影到 MySQL 车辆身份绑定表,用于持久身份解析。

ACK 和持久化规则

对需要协议 ACK 的帧:

  1. Netty 接收到 frame bytes。
  2. 原始 bytes 写入 raw archive。
  3. RawFrameFact 发布到 Kafka。
  4. 发送协议 ACK。
  5. 解码和 DecodedFact 发布可以在 ACK 前或 ACK 后执行,但失败必须发 DLQ并更新 raw frame 解析状态。

对 GB32960 实时上报和补发帧,这条规则保证不会 ACK 一条无法恢复原始 bytes 的帧。

对 JT808 注册、鉴权、心跳,也遵循相同 raw archive 和 raw fact 边界。注册 ACK 可以先生成 token但在 durability boundary 成功前不能 flush 到 channel。

如果 raw archive 写入失败,不返回成功 ACK。具体是关闭连接还是保留连接由协议容忍度决定。

如果 Kafka raw fact 发布失败durable ACK 帧不返回成功 ACK。非 durable 帧可以写本地 DLQ。

协议解析和扩展

使用插件契约:

public interface ProtocolFrameDecoder {
    ProtocolId protocol();
    DecodeResult decode(RawFrameFact rawFact, byte[] rawBytes);
}
public record DecodeResult(
        ParseStatus status,
        List<DecodedFact> facts,
        String errorMessage,
        Map<String, String> metadata
) {}

GB32960

  • 命令帧映射为会话、实时、位置、报警和扩展事实。
  • 厂商扩展按平台账号、VIN 或配置 profile 选择。
  • 全量 raw replay 仍通过读取 rawUri 并运行当前解析器完成。

JT808

  • 消息 ID 映射为注册、鉴权、心跳、位置、批量位置、多媒体元数据和扩展事实。
  • 身份解析会更新 vehicleKey
  • 注册事实携带省、市、厂商、设备 ID、车牌、车牌颜色和 auth token metadata。

新增协议消息通常不需要改 TDengine schema除非它成为高频查询维度。未知字段先作为 extension fact 存入 fields_json

查询 API

API 保持明确,不做一个含糊的统一接口。

原始帧查询:

  • GET /api/history/raw-frames
  • 过滤条件:protocolvinphonevehicleKeymessageIdparseStatusdateFromdateTopageSizecursor
  • 返回 frame metadata、raw URI、checksum、parse status以及可选的紧凑解析摘要。

单帧详情:

  • GET /api/history/raw-frames/{frameId}
  • 返回 raw metadata 和解码详情。
  • 支持 includeRawHex=true,但必须有严格大小限制。

位置历史:

  • GET /api/history/locations
  • 过滤条件:protocolvinphonevehicleKey、时间范围、cursor。

实时历史:

  • GET /api/history/realtime
  • 查询历史时序数据。

实时状态:

  • GET /api/realtime/vehicles/{vehicleKey}
  • 从 Redis 或内存 latest state 读取,不扫 TDengine。

导出:

  • 导出必须异步。
  • 请求创建 export job。
  • Worker 将 TDengine 查询结果流式写入对象存储或本地导出文件。
  • API 返回 job 状态和下载 URI。

分页

高 QPS 历史查询使用 cursor pagination不使用深 offset。

Cursor 字段:

ts, received_at, fact_id

倒序查询谓词:

WHERE (
  ts < :cursorTs
  OR (ts = :cursorTs AND received_at < :cursorReceivedAt)
  OR (ts = :cursorTs AND received_at = :cursorReceivedAt AND fact_id < :cursorFactId)
)

默认 page size 为 100上限为 1000。

容量设计

假设:

  • 10,000 辆车。
  • 常规上报间隔 5 到 10 秒。
  • 预计写入 1,000 到 2,000 frames/s。
  • 峰值突发系数 3 倍。
  • 历史查询目标 1,000 requests/s。

写路径:

  • Netty worker 保持 CPU-light。
  • Raw archive 写入使用有界异步 worker。
  • Kafka producer 开启压缩、批量、幂等,并按 vehicle key 分区。
  • History writer 按超级表和子表批量写 TDengine。
  • TDengine 子表按 vehicle key 分区,使单车查询足够便宜。

读路径:

  • 高频查询必须带 vehicle key 和时间范围。
  • 无边界跨车查询仅限管理接口,并强制限流和限制返回量。
  • 实时 latest state 从 Redis 或内存状态读取。
  • Raw 详情只读取单个 raw object 并按需解析。
  • 字典和元数据接口使用内存缓存。

背压:

  • Raw archive worker queue 固定上限。
  • Kafka publish future 必须被跟踪。
  • Durability boundary 超时时durable ACK 失败channel 关闭或被限流。
  • History writer lag 通过 Kafka consumer lag 和 TDengine batch latency 观察。

迁移计划

阶段 1在现有代码旁边建立新的 fact model。

  • 新增 ingest-facts
  • 新增 raw archive receipt contract。
  • 新增 raw frame 和 decoded fact 的 Kafka 序列化。
  • 为 stable id、vehicle key、raw URI 生成规则加测试。

阶段 2强化 raw durability boundary。

  • 重构 GB32960 和 JT808 handler让 ACK 等待 raw archive 和 raw fact publish。
  • 保留现有解析器。
  • 向新 Kafka topic 发 decoded facts。

阶段 3构建 TDengine writer。

  • 创建 TDengine schema manager。
  • 写入 raw frame facts。
  • 写入 location、realtime、alarm、session facts。
  • 按 stable id 实现幂等写入。

阶段 4构建查询 API。

  • Raw frame 查询。
  • 单帧回放。
  • 位置历史查询。
  • 实时历史查询。
  • 最新实时状态查询。

阶段 5切换并下线旧热路径。

  • GB32960/JT808 生产热历史不再依赖 DuckDB/Parquet。
  • 历史迁移期间保留 raw replay 兼容。
  • TDengine 覆盖验证完成后,移除协议特定的临时历史存储。

测试策略

单元测试:

  • RawFrameFact 校验和 vehicle key 派生。
  • Raw archive 路径生成和 checksum。
  • GB32960 golden frame 到 decoded facts。
  • JT808 sample frame 到 decoded facts。
  • ACK boundary 成功和失败场景。
  • TDengine SQL 生成。

集成测试:

  • 使用 mocked Kafka producer 和 fake raw archive 启动 APP。
  • 发送 GB32960 sample frame验证 raw fact、decoded fact 和 raw archive receipt。
  • 发送 JT808 注册、鉴权、位置帧,验证 registration、session、location facts。
  • 模拟 raw archive 失败,验证不发送成功 ACK。
  • 模拟 Kafka publish 失败,验证 durable ACK 失败。

存储测试:

  • TDengine schema 初始化。
  • 批量写入 raw frames 和 decoded facts。
  • 按 vehicle key 和时间范围查询。
  • Cursor 分页正确性。
  • rawUri 单帧回放。

压测:

  • 持续 30 分钟 2,000 frames/s 写入。
  • 持续 5 分钟 6,000 frames/s 突发写入。
  • 1,000 QPS location/realtime 历史查询p95 延迟受控。
  • 校验 raw archive receipt、Kafka raw fact、TDengine raw frame row 三者数量无缺口。

运维要求

指标:

  • TCP 连接数。
  • Frame 接收速率。
  • Raw archive 写入延迟和失败数。
  • Kafka 发布延迟和失败数。
  • 按协议和 message id 统计 decoder 成功/失败数。
  • TDengine batch size、延迟和失败数。
  • 各查询 endpoint 的 QPS 和延迟。
  • Kafka consumer lag。

日志:

  • 每个失败帧输出一条结构化日志,包含 frameId、protocol、vehicle key、message id、raw URI 和 error。
  • 正常日志不输出完整 raw bytes。
  • Raw hex 只允许在显式 debug 工具里输出,并限制大小。

告警:

  • Raw archive 写入失败。
  • Durable ACK 超时。
  • Kafka producer 失败。
  • History writer lag。
  • TDengine 写入失败。
  • Raw archive 数量和 TDengine raw frame row 数量不一致。

验收标准

重设计只有满足以下条件才算完成:

  1. 每一条被接收的 GB32960 和 JT808 帧都会创建 raw archive object 和 TDengine raw_frames 行。
  2. 有 bytes 的异常帧可以从 raw history 查询到,包含 parse status 和错误原因。
  3. GB32960 和 JT808 位置历史可以按车辆和时间范围 cursor 分页查询。
  4. GB32960 实时历史可以按车辆和时间范围查询。
  5. JT808 注册和会话数据可查询,并且可以更新 identity binding。
  6. 单帧详情可以通过 rawUri 重新读取 raw bytes并使用当前解析器解码。
  7. 需要 ACK 的帧,在 raw archive 和 raw fact publish 成功前,不会发送成功 ACK。
  8. 新协议字段可以通过新增 decoder/projector 扩展;只有成为高频查询字段时才需要 TDengine 表迁移。
  9. 压测证明可以持续 2,000 frames/s 写入,并支持 1,000 QPS 有边界历史查询。
  10. GB32960/JT808 生产查询不再依赖旧 DuckDB/Parquet 热历史路径。

已固定决策

第一版实现固定以下选择:

  • 先使用本地 raw archive实现接口时预留对象存储。
  • TDengine 是 GB32960/JT808 唯一生产热历史查询库。
  • 使用协议明确的查询 API不做一个泛化万能查询 API。
  • 高频历史查询统一使用 cursor pagination。
  • Kafka payload 保持轻量,只传 raw 引用,不传 raw bytes。