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;

View File

@@ -36,6 +36,7 @@ public final class EnvelopeMapper {
.setIngestTimeMs(event.ingestTime().toEpochMilli())
.setIngestNodeId(nodeId);
if (event.metadata() != null) b.putAllMetadata(event.metadata());
// telemetrySnapshot 是跨事件类型的统一字段视图,消费者可先读它,必要时再读具体 payload。
VehicleEventTelemetrySnapshotMapper.toSnapshot(event)
.map(EnvelopeMapper::buildTelemetrySnapshot)
.ifPresent(b::setTelemetrySnapshot);
@@ -70,6 +71,8 @@ public final class EnvelopeMapper {
int size = ra.rawBytes() == null ? 0 : ra.rawBytes().length;
String key = rawArchiveKey(ra);
String uri = rawArchiveUri(ra, key);
// RAW envelope 只携带引用信息,不把完整原始字节塞进 Kafka避免 topic 膨胀。
// 注意 KafkaEventSink 当前仍过滤 RawArchive这段用于未来放开 raw 引用转发或消费者测试。
b.putMetadata(RawArchiveKeys.META_KEY, key);
b.putMetadata(RawArchiveKeys.META_URI, uri);
b.putMetadata(RawArchiveKeys.META_EVENT_ID, ra.eventId());

View File

@@ -33,6 +33,8 @@ public final class KafkaEnvelopeConsumerFactory {
List<KafkaEnvelopeConsumerWorker> workers = new ArrayList<>();
for (Map.Entry<String, SinkMqProperties.Binding> entry : bindings.entrySet()) {
String processorBeanName = entry.getKey();
// binding 的 key 必须和 Spring Bean 名一致;这样配置只声明 topic/group
// 实际处理逻辑仍由各业务模块自己的 EnvelopeConsumerProcessor 承接。
EnvelopeConsumerProcessor processor = processors.get(processorBeanName);
SinkMqProperties.Binding binding = entry.getValue();
if (processor == null || binding == null || !binding.isEnabled()) {
@@ -56,6 +58,8 @@ public final class KafkaEnvelopeConsumerFactory {
if (configured != null && !configured.isEmpty()) {
return configured;
}
// 默认绑定保留老派生模块的消费能力32960 主链路不依赖这些派生表,
// 历史查询以 event-file-store 的 RAW 索引和 archive .bin 为准。
SinkMqProperties.Topics topics = props.getTopics();
Map<String, SinkMqProperties.Binding> defaults = new LinkedHashMap<>();
defaults.put("eventHistoryEnvelopeConsumerProcessor", binding(
@@ -108,6 +112,8 @@ public final class KafkaEnvelopeConsumerFactory {
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class.getName());
p.put(ConsumerConfig.GROUP_ID_CONFIG, groupId(binding, processorBeanName));
p.put(ConsumerConfig.CLIENT_ID_CONFIG, consumer.getClientIdPrefix() + "-" + processorBeanName);
// 手动提交 offset只有本批次至少有一条记录被处理器接收后才 commit
// 避免轮询到空批次或未绑定 topic 时推进消费位点。
p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, consumer.getAutoOffsetReset());
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, consumer.getMaxPollRecords());

View File

@@ -45,6 +45,7 @@ public final class KafkaEnvelopeConsumerRunner implements SmartLifecycle, AutoCl
if (!running.compareAndSet(false, true)) {
return;
}
// 每个 worker 一条后台线程,避免某个处理器阻塞时拖慢其他消费组。
executor = Executors.newFixedThreadPool(workers.size(), r -> {
Thread thread = new Thread(r, "kafka-envelope-consumer");
thread.setDaemon(true);
@@ -60,6 +61,8 @@ public final class KafkaEnvelopeConsumerRunner implements SmartLifecycle, AutoCl
try {
worker.pollOnce(pollTimeout);
} catch (RuntimeException ex) {
// Kafka/处理器异常不让 Spring 生命周期退出;退避后继续消费,
// 具体坏消息由 EnvelopeConsumerProcessor 写入 DLQ。
log.warn("Kafka envelope consumer poll failed; the worker will retry after backoff", ex);
sleepBackoff();
}

View File

@@ -37,8 +37,11 @@ public final class KafkaEnvelopeConsumerWorker implements AutoCloseable {
for (ConsumerRecord<String, byte[]> record : records) {
EnvelopeConsumerProcessor processor = processorsByTopic.get(record.topic());
if (processor == null) {
// worker 可能订阅多个 topic没有显式绑定处理器的 topic 不参与提交语义。
continue;
}
// EnvelopeConsumerProcessor 内部会把解析或业务错误转成 DLQ 记录,
// 这里保持 Kafka worker 的职责单一:轮询、分发、成功后提交 offset。
processor.process(new EnvelopeConsumerRecord(
record.topic(),
record.partition(),
@@ -48,6 +51,7 @@ public final class KafkaEnvelopeConsumerWorker implements AutoCloseable {
processed++;
}
if (processed > 0) {
// commitSync 放在批次末尾,保证同一个 poll 批次内的消息按 Kafka offset 一起确认。
consumer.commitSync();
}
return processed;

View File

@@ -34,6 +34,7 @@ public final class KafkaEnvelopeDeadLetterSink implements EnvelopeDeadLetterSink
throw new IllegalArgumentException("record must not be null");
}
ProducerRecord<String, byte[]> out = new ProducerRecord<>(topic, record.key(), record.payload());
// DLQ payload 保持原始 Kafka value定位信息全部放 header便于后续重放或人工排查。
header(out, "dlq-service", record.service());
header(out, "dlq-source-topic", record.topic());
header(out, "dlq-source-partition", Integer.toString(record.partition()));

View File

@@ -43,9 +43,9 @@ public final class KafkaEventSink implements EventSink, AutoCloseable {
}
/**
* 拒绝 {@link VehicleEvent.RawArchive} —— 原始报文体积大、属于冷存域,本期由
* {@code ArchiveEventSink} 独占处理。未来若需要将 raw archive URI 回填到 Kafka 的
* {@code vehicle.raw.archive} topic再放开此过滤并在 Envelope 里填 uri/checksum
* 拒绝 {@link VehicleEvent.RawArchive} —— 当前生产 Kafka sink 只投递实时、会话、告警等轻量事件。
* {@link EnvelopeMapper} 和 {@link TopicRouter} 已具备 RAW envelope/topic 的映射能力,但这里仍
* 显式过滤;如需把 32960 raw 引用转发到 {@code vehicle.raw.archive},应先移除此过滤并补充测试
*/
@Override
public boolean accepts(VehicleEvent event) {

View File

@@ -30,6 +30,10 @@ import java.util.Properties;
* <li>{@code lingniu.ingest.sink.mq.type=kafka}(默认 kafka—— 后端选择,预留
* rocketmq/pulsar 等扩展。
* </ol>
*
* <p>Producer 和 Consumer 是两个独立开关:{@code sink.mq.enabled=true} 只表示可以创建
* Kafka producer/sink是否从 Kafka 拉 envelope 还要单独开启
* {@code lingniu.ingest.sink.mq.consumer.enabled=true} 并配置 bindings。
*/
@AutoConfiguration
@EnableConfigurationProperties(SinkMqProperties.class)
@@ -84,6 +88,7 @@ public class SinkMqAutoConfiguration {
TopicRouter router,
SinkMqProperties props,
CircuitBreaker breaker) {
// KafkaEventSink 是 EventBus 的生产端出口;它不会启动任何 Kafka 消费线程。
return new KafkaEventSink(producer, mapper, router, props.getTopics().getDlq(), breaker);
}
@@ -101,6 +106,7 @@ public class SinkMqAutoConfiguration {
@ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq.consumer", name = "enabled", havingValue = "true")
public KafkaEnvelopeConsumerRunner kafkaEnvelopeConsumerRunner(Map<String, EnvelopeConsumerProcessor> processors,
SinkMqProperties props) {
// Consumer runner 根据 processor bean 名和配置 binding 生成 worker没有 binding 时直接失败,避免静默不消费。
List<KafkaEnvelopeConsumerWorker> workers = new KafkaEnvelopeConsumerFactory().createWorkers(processors, props);
if (workers.isEmpty()) {
throw new IllegalStateException("no kafka envelope consumer workers created; check consumer bindings");

View File

@@ -20,12 +20,14 @@ public class SinkMqProperties {
/** MQ 后端类型。目前仅支持 kafka预留 rocketmq/pulsar 等。 */
private String type = "kafka";
/** Kafka bootstrap servers生产环境应通过环境变量覆盖不建议使用默认开发地址。 */
private String bootstrapServers = "114.55.58.251:9092";
private String compressionType = "zstd";
private int lingerMs = 20;
private int batchSize = 65536;
private String acks = "all";
private boolean enableIdempotence = true;
/** 写入 envelope 的节点标识,进入 Protobuf 字段 ingest_node_id便于追踪多实例来源。 */
private String nodeId = "ingest-local";
private Topics topics = new Topics();
private Consumer consumer = new Consumer();
@@ -54,12 +56,16 @@ public class SinkMqProperties {
public void setConsumer(Consumer consumer) { this.consumer = consumer; }
public static class Topics {
/** 实时遥测事件 topicGB32960 RAW-only 架构下可逐步弱化该 topic。 */
private String realtime = "vehicle.realtime";
/** 位置事件 topic通常可由 realtime/RAW 派生。 */
private String location = "vehicle.location";
private String alarm = "vehicle.alarm";
private String session = "vehicle.session";
private String mediaMeta = "vehicle.media.meta";
/** RAW 归档引用 topicpayload 应携带 archive URI/size不建议携带完整 raw bytes。 */
private String rawArchive = "vehicle.raw.archive";
/** producer 熔断或 consumer 处理失败时的死信 topic。 */
private String dlq = "vehicle.dlq";
public String getRealtime() { return realtime; }
@@ -79,6 +85,7 @@ public class SinkMqProperties {
}
public static class Consumer {
/** Kafka 消费总开关。与 producer 总开关分离,默认 false避免单体服务意外自消费。 */
private boolean enabled = false;
private boolean autoStartup = true;
private String clientIdPrefix = "lingniu-envelope-consumer";
@@ -86,6 +93,12 @@ public class SinkMqProperties {
private int loopBackoffMillis = 1000;
private String autoOffsetReset = "earliest";
private int maxPollRecords = 500;
/**
* Processor bean name -> Kafka binding。
*
* <p>示例 key{@code eventHistoryEnvelopeConsumerProcessor}、
* {@code vehicleStateEnvelopeConsumerProcessor}、{@code vehicleStatEnvelopeConsumerProcessor}。
*/
private Map<String, Binding> bindings = new LinkedHashMap<>();
public boolean isEnabled() { return enabled; }
@@ -107,8 +120,11 @@ public class SinkMqProperties {
}
public static class Binding {
/** 单个 processor binding 开关,用于临时停某个下游消费者而不关整个 consumer runner。 */
private boolean enabled = true;
/** Kafka consumer group id。不同服务要独立消费同一 topic 时必须使用不同 group。 */
private String groupId;
/** 该 processor 订阅的 topic 列表。 */
private List<String> topics = new ArrayList<>();
public boolean isEnabled() { return enabled; }