fix: stabilize jt808 fanout and dedup
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -103,8 +103,9 @@ public final class DisruptorEventBus implements AutoCloseable {
|
|||||||
if (e == null || !sink.accepts(e)) return;
|
if (e == null || !sink.accepts(e)) return;
|
||||||
try {
|
try {
|
||||||
publishToSinkAndTrack(sink, e);
|
publishToSinkAndTrack(sink, e);
|
||||||
} finally {
|
} catch (Throwable t) {
|
||||||
if (endOfBatch) slot.clear();
|
failed.incrementAndGet();
|
||||||
|
log.warn("sink {} publish failed", sink.name(), t);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import org.springframework.core.Ordered;
|
|||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 Caffeine 的本地幂等去重。
|
* 基于 Caffeine 的本地幂等去重。
|
||||||
*
|
*
|
||||||
* <p>Key 构造:{@code protocolId + vin + command + seq/fingerprint}。如果上游已经在
|
* <p>Key 构造:{@code protocolId + vehicle identity + command + seq/fingerprint}。vehicle identity
|
||||||
* {@link RawFrame#sourceMeta()} 里带了 {@code seq},优先用真实流水号;否则退化为 raw bytes
|
* 优先使用已解析 VIN;VIN 未解析时使用 phone/deviceId/terminalId/plate,避免 JT808 多终端在
|
||||||
* fingerprint,避免设备重连或 TCP 重发导致同一帧重复进入 DuckDB/Archive。
|
* {@code vin=unknown} 时同流水号互相去重。如果上游已经在 {@link RawFrame#sourceMeta()} 里带了
|
||||||
|
* {@code seq},优先用真实流水号;否则退化为 raw bytes fingerprint,避免设备重连或 TCP 重发导致
|
||||||
|
* 同一帧重复进入 DuckDB/Archive。
|
||||||
*
|
*
|
||||||
* <p>当前缓存是进程内的,只能保证单实例去重。32960 若做多副本水平扩容,需要把这个位置替换成
|
* <p>当前缓存是进程内的,只能保证单实例去重。32960 若做多副本水平扩容,需要把这个位置替换成
|
||||||
* Redis/集中式幂等键,否则不同实例仍可能各自接收一次相同原始包。
|
* Redis/集中式幂等键,否则不同实例仍可能各自接收一次相同原始包。
|
||||||
@@ -33,13 +36,14 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||||
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
|
Map<String, String> meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta();
|
||||||
String seq = frame.sourceMeta().get("seq");
|
String identity = identityKey(frame, meta);
|
||||||
|
String seq = meta.get("seq");
|
||||||
if (seq == null || seq.isBlank()) {
|
if (seq == null || seq.isBlank()) {
|
||||||
// 某些入口没有硬件流水号,使用原始字节哈希作为保底键,至少能挡住同连接内的重复帧。
|
// 某些入口没有硬件流水号,使用原始字节哈希作为保底键,至少能挡住同连接内的重复帧。
|
||||||
seq = rawFingerprint(frame);
|
seq = rawFingerprint(frame);
|
||||||
}
|
}
|
||||||
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
|
String key = frame.protocolId() + ":" + identity + ":" + frame.command() + ":" + seq;
|
||||||
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
|
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
|
||||||
ctx.abort("duplicate:" + key);
|
ctx.abort("duplicate:" + key);
|
||||||
return false;
|
return false;
|
||||||
@@ -52,6 +56,20 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
|
|||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String identityKey(RawFrame frame, Map<String, String> meta) {
|
||||||
|
String vin = meta.getOrDefault("vin", "unknown").trim();
|
||||||
|
if (!vin.isBlank() && !"unknown".equalsIgnoreCase(vin)) {
|
||||||
|
return "vin:" + vin;
|
||||||
|
}
|
||||||
|
for (String key : new String[]{"phone", "deviceId", "terminalId", "plate"}) {
|
||||||
|
String value = meta.get(key);
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return key + ":" + value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "raw:" + rawFingerprint(frame);
|
||||||
|
}
|
||||||
|
|
||||||
private static String rawFingerprint(RawFrame frame) {
|
private static String rawFingerprint(RawFrame frame) {
|
||||||
byte[] rawBytes = frame.rawBytes();
|
byte[] rawBytes = frame.rawBytes();
|
||||||
if (rawBytes != null && rawBytes.length > 0) {
|
if (rawBytes != null && rawBytes.length > 0) {
|
||||||
|
|||||||
@@ -45,10 +45,33 @@ class DisruptorEventBusAwaitTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publishFansOutEveryEventToEveryAcceptingSink() throws Exception {
|
||||||
|
int count = 1000;
|
||||||
|
CountingSink rawArchive = new CountingSink("raw-archive", count);
|
||||||
|
CountingSink kafka = new CountingSink("kafka", count);
|
||||||
|
|
||||||
|
try (DisruptorEventBus bus = new DisruptorEventBus(1024, "blocking", List.of(rawArchive, kafka))) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
bus.publish(event("heartbeat-" + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(rawArchive.received.await(3, TimeUnit.SECONDS)).isTrue();
|
||||||
|
assertThat(kafka.received.await(3, TimeUnit.SECONDS)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(rawArchive.count.get()).isEqualTo(count);
|
||||||
|
assertThat(kafka.count.get()).isEqualTo(count);
|
||||||
|
}
|
||||||
|
|
||||||
private static VehicleEvent.Heartbeat event() {
|
private static VehicleEvent.Heartbeat event() {
|
||||||
|
return event("heartbeat-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VehicleEvent.Heartbeat event(String eventId) {
|
||||||
Instant now = Instant.parse("2026-06-23T10:00:00Z");
|
Instant now = Instant.parse("2026-06-23T10:00:00Z");
|
||||||
return new VehicleEvent.Heartbeat(
|
return new VehicleEvent.Heartbeat(
|
||||||
"heartbeat-1",
|
eventId,
|
||||||
"VIN001",
|
"VIN001",
|
||||||
ProtocolId.GB32960,
|
ProtocolId.GB32960,
|
||||||
now,
|
now,
|
||||||
@@ -78,4 +101,27 @@ class DisruptorEventBusAwaitTest {
|
|||||||
return CompletableFuture.completedFuture(null);
|
return CompletableFuture.completedFuture(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class CountingSink implements EventSink {
|
||||||
|
private final String name;
|
||||||
|
private final AtomicLong count = new AtomicLong();
|
||||||
|
private final CountDownLatch received;
|
||||||
|
|
||||||
|
private CountingSink(String name, int expected) {
|
||||||
|
this.name = name;
|
||||||
|
this.received = new CountDownLatch(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||||
|
count.incrementAndGet();
|
||||||
|
received.countDown();
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ class DedupInterceptorTest {
|
|||||||
assertThat(ctx.aborted()).isTrue();
|
assertThat(ctx.aborted()).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotDeduplicateDifferentJt808PhonesWhenVinIsUnknownAndSequenceMatches() {
|
||||||
|
DedupInterceptor interceptor = new DedupInterceptor(100, 60);
|
||||||
|
|
||||||
|
RawFrame firstPhone = jt808Frame("13079960001", "1000");
|
||||||
|
RawFrame secondPhone = jt808Frame("13079960002", "1000");
|
||||||
|
RawFrame duplicateFirstPhone = jt808Frame("13079960001", "1000");
|
||||||
|
|
||||||
|
assertThat(interceptor.before(firstPhone, new IngestContext("trace-1"))).isTrue();
|
||||||
|
assertThat(interceptor.before(secondPhone, new IngestContext("trace-2"))).isTrue();
|
||||||
|
IngestContext duplicateContext = new IngestContext("trace-3");
|
||||||
|
assertThat(interceptor.before(duplicateFirstPhone, duplicateContext)).isFalse();
|
||||||
|
assertThat(duplicateContext.abortReason()).contains("duplicate");
|
||||||
|
}
|
||||||
|
|
||||||
private static RawFrame frame(byte[] rawBytes) {
|
private static RawFrame frame(byte[] rawBytes) {
|
||||||
return new RawFrame(
|
return new RawFrame(
|
||||||
ProtocolId.GB32960,
|
ProtocolId.GB32960,
|
||||||
@@ -37,4 +52,15 @@ class DedupInterceptorTest {
|
|||||||
Map.of("vin", "TESTSTATVIN000001"),
|
Map.of("vin", "TESTSTATVIN000001"),
|
||||||
Instant.parse("2026-06-22T13:00:00Z"));
|
Instant.parse("2026-06-22T13:00:00Z"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RawFrame jt808Frame(String phone, String seq) {
|
||||||
|
return new RawFrame(
|
||||||
|
ProtocolId.JT808,
|
||||||
|
0x0200,
|
||||||
|
0,
|
||||||
|
new Object(),
|
||||||
|
new byte[]{0x02, 0x00, 0x00, 0x1c},
|
||||||
|
Map.of("vin", "unknown", "phone", phone, "seq", seq),
|
||||||
|
Instant.parse("2026-06-22T13:00:00Z"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user