docs: add detailed 32960 pipeline comments

This commit is contained in:
kkfluous
2026-06-23 13:17:37 +08:00
parent ba68ffe061
commit a096e4ce0e
125 changed files with 493 additions and 14 deletions

View File

@@ -24,6 +24,13 @@ import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
/**
* 基于 Parquet 文件 + DuckDB sidecar 索引的历史事件库。
*
* <p>写入时按 {@code protocol/date/vehicle/header} 分区保存 Parquet便于按单车、按天直接读取。
* 同时维护一个轻量 DuckDB 索引文件 {@code events.duckdb},用于无 VIN 查询、rawArchiveUri
* 精确定位和索引重建。设计目标是把 32960 查询的热路径限制在“单车若干天的文件”内。
*/
public final class DuckDbParquetEventFileStore implements EventFileStore {
private static final TypeReference<Map<String, String>> STRING_MAP =
@@ -68,9 +75,11 @@ public final class DuckDbParquetEventFileStore implements EventFileStore {
@Override
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
// 有 VIN 时直接扫该车分区 parquet避免先查总索引再和车辆文件 join。
if (query.vin() != null) {
return queryParquet(query);
}
// 无 VIN 的管理类查询走 DuckDB 索引,代价更高但不影响高频单车查询路径。
ensureIndexInitialized();
return queryIndex(query);
}
@@ -121,6 +130,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore {
if (query.eventTimeTo() != null) {
predicates.add("event_time_ms <= " + query.eventTimeTo().toEpochMilli());
}
// 这里的 SQL 只拼接经过 escape 的内部值;外部 rawArchiveUri 查询使用 PreparedStatement。
String where = predicates.isEmpty() ? "" : "WHERE " + String.join(" AND ", predicates) + "\n";
String sql = """
SELECT event_id, protocol, event_type, vin,
@@ -255,6 +265,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore {
ps.executeBatch();
}
if (Files.exists(partFile)) {
// DuckDB 不能原地追加 parquet先 UNION 旧文件和本批数据,再原子替换分区文件。
statement.execute("""
CREATE TEMPORARY TABLE combined_records AS
SELECT event_id, protocol, event_type, vin,
@@ -301,6 +312,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore {
Statement statement = connection.createStatement()) {
createIndexSchema(statement);
if (isIndexEmpty(statement)) {
// 新部署或删除 index 后,可从现有 parquet 自动回填索引,避免历史数据不可查。
backfillIndexFromParquet(statement);
}
indexInitialized = true;
@@ -381,6 +393,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore {
Path dir = query.vin() == null
? partitionDir(query.protocol(), date)
: partitionDir(query.protocol(), date).resolve("vehicle=" + storageName(query.vin()));
// 目录结构把 VIN 放在 date 下,单车多日查询只需要遍历目标日期内的目标车辆目录。
if (Files.isDirectory(dir)) {
try (Stream<Path> stream = Files.walk(dir)) {
stream.filter(path -> path.getFileName().toString().endsWith(".parquet"))
@@ -471,6 +484,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore {
if (vin == null || vin.isBlank()) {
return "_unknown";
}
// VIN 会进入文件路径,保守替换特殊字符,避免路径穿越和跨平台文件名问题。
return vin.replaceAll("[^A-Za-z0-9._-]", "_");
}

View File

@@ -5,6 +5,12 @@ import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.time.LocalDate;
/**
* 文件历史库查询条件。
*
* <p>{@code dateFrom/dateTo} 用于定位分区目录,{@code eventTimeFrom/eventTimeTo}
* 用于在分区内做精确时间过滤。这样既支持按天快速裁剪,也支持接口精确到秒的查询。
*/
public record EventFileQuery(
ProtocolId protocol,
LocalDate dateFrom,
@@ -34,6 +40,7 @@ public record EventFileQuery(
if (eventTimeFrom != null && eventTimeTo != null && eventTimeTo.isBefore(eventTimeFrom)) {
throw new IllegalArgumentException("eventTimeTo must not be before eventTimeFrom");
}
// 归一化可选字段,避免空字符串进入 SQL 谓词或文件分区路径。
order = order == null ? Order.ASC : order;
limit = limit <= 0 ? 100 : limit;
vin = vin == null || vin.isBlank() ? null : vin.trim();

View File

@@ -10,6 +10,9 @@ import java.util.Map;
*
* <p>{@code payloadJson} 保存统一 telemetry snapshot JSON公共列只保留排序、分区、
* 追溯和通用展示需要的最小字段。
*
* <p>对 GB32960 来说,{@code rawArchiveUri} 是最关键的追溯列:通用查询可以直接展示
* payloadJson专用全字段查询则通过 rawArchiveUri 回读原始包重新解码。
*/
public record EventFileRecord(
String eventId,
@@ -35,6 +38,7 @@ public record EventFileRecord(
if (ingestTime == null) {
throw new IllegalArgumentException("ingestTime must not be null");
}
// 允许 eventType/vin/rawArchiveUri 为空字符串便于保存平台登录、RAW 索引等非车辆事件。
eventType = eventType == null ? "" : eventType;
vin = vin == null ? "" : vin;
rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri;

View File

@@ -3,16 +3,29 @@ package com.lingniu.ingest.eventfilestore;
import java.io.IOException;
import java.util.List;
/**
* 历史明细文件库抽象。
*
* <p>写入侧只接受已经标准化的 {@link EventFileRecord};具体实现可以落 Parquet、维护 DuckDB
* sidecar 索引或在测试中用内存实现。32960 专用 snapshot 查询会先用这里按 VIN/日期找到
* rawArchiveUri再回读原始 .bin 解码完整字段。
*/
public interface EventFileStore {
/** 单条追加的便捷方法,最终仍走批量写入路径,保证实现只维护一种落盘语义。 */
default void append(EventFileRecord record) throws IOException {
appendAll(List.of(record));
}
void appendAll(List<EventFileRecord> records) throws IOException;
/** 按协议、日期、VIN、事件类型等条件查询标准化历史记录。 */
List<EventFileRecord> query(EventFileQuery query) throws IOException;
/**
* 按 rawArchiveUri 回查索引记录。默认实现返回 null允许轻量测试实现不维护该索引。
* 生产 DuckDB/Parquet 实现必须覆盖,用于 snapshots 的 sourceFrames 反查。
*/
default EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException {
return null;
}

View File

@@ -22,8 +22,12 @@ import org.slf4j.LoggerFactory;
/**
* EventBus 出口:把解析后的 {@link VehicleEvent} 明细写入 Parquet 文件库。
*
* <p>{@link VehicleEvent.RawArchive} 写可查询索引,不写原始 bytes原始 bytes 本体仍由
* sink-archive 独立冷存。
* <p>{@link VehicleEvent.RawArchive} 在这里仅写可查询索引和 {@code archive://...} 引用,不写
* 原始 bytes。本体必须已经由上游归档链路落成 .bin否则 snapshot/frame 查询只能找到索引,
* 但回读原始包时会报缺失。
*
* <p>32960 生产链路当前只把 RAW_ARCHIVE 作为历史查询事实源REALTIME 和 LOCATION
* 是从 RAW 派生出来的轻量事件,不再重复写入 event-history避免历史库里出现两套含义相近的数据。
*/
public final class EventFileStoreSink implements EventSink, AutoCloseable {
@@ -73,6 +77,7 @@ public final class EventFileStoreSink implements EventSink, AutoCloseable {
@Override
public boolean accepts(VehicleEvent event) {
// REALTIME/LOCATION 可以由 RAW 重放得到;历史库只保留 RAW_ARCHIVE 和少量非遥测事件索引。
return event != null
&& !(event instanceof VehicleEvent.Realtime)
&& !(event instanceof VehicleEvent.Location);
@@ -134,6 +139,7 @@ public final class EventFileStoreSink implements EventSink, AutoCloseable {
synchronized (buffer) {
buffer.add(record);
if (buffer.size() >= batchSize) {
// 拷贝后释放锁,避免慢 I/O 阻塞 EventBus 后续写入线程。
toFlush = new ArrayList<>(buffer);
buffer.clear();
}
@@ -189,6 +195,7 @@ public final class EventFileStoreSink implements EventSink, AutoCloseable {
if (raw.metadata() != null) {
metadata.putAll(raw.metadata());
}
// metadata 是 HTTP 查询和 raw 文件回查共用的关联字段,缺失时在这里补齐。
metadata.putIfAbsent(RawArchiveKeys.META_EVENT_ID, raw.eventId());
metadata.putIfAbsent(RawArchiveKeys.META_KEY, rawArchiveKey);
metadata.putIfAbsent(RawArchiveKeys.META_URI, rawArchiveUri);

View File

@@ -15,6 +15,18 @@ import org.springframework.context.annotation.Bean;
import java.nio.file.Path;
import java.time.ZoneId;
/**
* EventFileStore 自动装配。
*
* <p>开启 {@code lingniu.ingest.event-file-store.enabled=true} 后会创建两类 Bean
* <ul>
* <li>{@link EventFileStore}DuckDB sidecar index + Parquet 文件库,用于历史查询
* <li>{@link EventFileStoreSink}EventBus sink把可接收事件转换为 {@code EventFileRecord}
* </ul>
*
* <p>32960 当前生产路径里event-file-store 主要保存 RAW_ARCHIVE 的可查询索引;
* 原始 bytes 本体由 sink-archive 写入 archive 根目录。
*/
@AutoConfiguration
@EnableConfigurationProperties(EventFileStoreProperties.class)
@ConditionalOnProperty(
@@ -28,6 +40,7 @@ public class EventFileStoreAutoConfiguration {
public EventFileStore eventFileStore(EventFileStoreProperties properties,
ObjectProvider<ObjectMapper> objectMapper) {
ObjectMapper mapper = mapper(objectMapper);
// zoneId 只影响文件分区日期payload 中 eventTime/ingestTime 仍保持 Instant/UTC 表示。
return new DuckDbParquetEventFileStore(
Path.of(properties.getPath()),
ZoneId.of(properties.getZoneId()),
@@ -48,6 +61,7 @@ public class EventFileStoreAutoConfiguration {
private static ObjectMapper mapper(ObjectProvider<ObjectMapper> provider) {
ObjectMapper base = provider.getIfAvailable(ObjectMapper::new);
// 使用 copy避免全局 ObjectMapper 被本模块注册 JavaTimeModule 时产生隐式副作用。
return base.copy().registerModule(new JavaTimeModule());
}
}

View File

@@ -12,21 +12,30 @@ public class EventFileStoreProperties {
/**
* Parquet 文件库根路径。
*
* <p>实际结构为 {@code protocol=GB32960/date=yyyy-MM-dd/vehicle=VIN/header=event-records-v1/events.parquet}
* 另有 {@code events.duckdb} sidecar index 放在根路径下。
*/
private String path = "./event-store";
/**
* 按事件时间分区时使用的业务时区。
*
* <p>该配置只决定“某条事件落入哪一天目录”,不改变接口响应里的 UTC 时间字符串。
*/
private String zoneId = "Asia/Shanghai";
/**
* Sink 缓冲多少条事件后批量写一个 Parquet part 文件。
*
* <p>值越大写入吞吐越好,但异常退出时内存缓冲里尚未 flush 的事件越多。
*/
private int batchSize = 500;
/**
* 未达到 batchSize 时的最长刷盘间隔。
*
* <p>用于低流量车辆:即使没有攒满 batch也会定期把缓冲写入文件降低查询延迟。
*/
private long flushIntervalMillis = 1000;