docs: add detailed 32960 pipeline comments
This commit is contained in:
@@ -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._-]", "_");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user