feat: 新增设备 API 拉取采集及配套处理能力

- 接入 NDRServer 告警和流量日志,支持凭据与任务管理
- 支持分页拉取、窗口拆分、调度优先级及 WAL 持久化后推进断点
- 增加迟到告警规则重放,优化多条泛化规则选择和事件时间处理
- 补充回归测试、数据库升级脚本及部署文档
This commit is contained in:
王绍宏
2026-09-06 16:46:21 +08:00
parent b61ec2e37c
commit 00c0a1fb7f
141 changed files with 11318 additions and 467 deletions
@@ -95,6 +95,7 @@ public class LogNormalProcessor {
private final NormalizationShadowComparator shadowComparator;
private final NormalizationShadowExecutor shadowExecutor;
private final NormalizationRuleMetrics globalMetrics;
private final RuleSelectionEngine ruleSelectionEngine;
private final Map<String, AtomicLong> shadowLogWindows = new ConcurrentHashMap<>();
/**
@@ -111,6 +112,7 @@ public class LogNormalProcessor {
String strSyslogTopic;
LocalDateTime fallbackFirstReceivedAt;
LocalDateTime initialReceivedAt;
LocalDateTime eventAt;
boolean isSaveNonNormal = false;
SyslogNonNormalMessage syslogNonNormalMessage = new SyslogNonNormalMessage();
List<RuleSource> dmNormalizeRuleList;
@@ -209,6 +211,8 @@ public class LogNormalProcessor {
this.shadowComparator = shadowComparator;
this.shadowExecutor = shadowExecutor;
this.globalMetrics = globalMetrics;
this.ruleSelectionEngine = new RuleSelectionEngine(
compiledRuleCache, optimizationProperties, globalMetrics);
}
private static CompiledRuleCache defaultCompiledRuleCache() {
@@ -230,10 +234,9 @@ public class LogNormalProcessor {
/**
* 处理一条日志,并显式传入 Kafka 链路已解析好的首次接收时间。
*
* <p>该时间会同时写入标准化表的 created_at 和 log_time。created_at 表示日志首次进入系统,
* log_time 是现场标准化表的 RANGE 分区键。原始业务代码用本次处理时间覆盖 log_time;这里改为
* 稳定的首次接收时间,使 Raw、Retry 和 Replay 始终进入同一日分区。设备事件时间继续由
* collect_time_ts 保存,不与分区时间混用。</p>
* <p>标准化表的 {@code created_at} 使用首次接收时间,{@code log_time} 使用设备事件时间。
* 现场两张标准化表都按 {@code log_time} 做 RANGE 分区,因此 API 历史拉取会进入事件发生日分区;
* TCP/UDP 的 event_time 等于 receive_time,原有分区行为保持不变。</p>
*/
public ProcessingOutcome process(String logMsg, String syslogUUID, String syslogTopic,
LocalDateTime fallbackFirstReceivedAt) {
@@ -310,6 +313,7 @@ public class LogNormalProcessor {
? kafkaConsumerProperties.getTopic() : input.getTopic();
ctx.fallbackFirstReceivedAt = input.getReceivedAt();
ctx.initialReceivedAt = input.getReceivedAt();
ctx.eventAt = input.getEventAt();
ctx.strKafkaMessage = input.getFullMessage();
ctx.strLogMsg = input.getSyslogBody();
ctx.strDeviceInfo = input.getDeviceHeader();
@@ -354,6 +358,9 @@ public class LogNormalProcessor {
ctx.initialReceivedAt = resolveInitialReceivedAt(
mapdev.get("receive_time"), ctx.fallbackFirstReceivedAt);
}
if (ctx.eventAt == null) {
ctx.eventAt = resolveEventAt(mapdev.get("event_time"), ctx.initialReceivedAt);
}
long deviceID = Long.parseLong(mapdev.get("device_id"));
logger.debug("标准化开始: eventId={}, deviceId={}", ctx.strSyslogUUID, deviceID);
ctx.strDataType = mapdev.get("data_type");
@@ -368,6 +375,14 @@ public class LogNormalProcessor {
// 因此只跳过达梦标准化并允许 ACK;系统依赖和规则执行异常仍由下方异常链处理。
return NormalizationResult.filtered();
}
NormalizationOptimizationProperties.RuleSelection selectionConfig =
optimizationProperties.getRuleSelection();
if (ctx.dmNormalizeRuleList.size() > 1 && selectionConfig.isEnabled()) {
return executeSelected(ctx, deviceID);
}
if (ctx.dmNormalizeRuleList.size() > 1 && selectionConfig.isShadowEnabled()) {
shadowSelect(ctx);
}
if (optimizationProperties.getCompiledRule().isEnabled()) {
return executeCompiled(ctx, deviceID);
}
@@ -395,6 +410,217 @@ public class LogNormalProcessor {
return finish(ctx);
}
/**
* 多规则唯一命中路径:所有规则只做无副作用解析,只有最终胜出规则进入映射及后续阶段。
*/
private NormalizationResult executeSelected(ProcessContext ctx, long deviceId) {
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = collectCandidates(ctx);
RuleSelectionEngine.Decision<ExecutionPlan> decision =
ruleSelectionEngine.select(candidates, ctx.strLogMsg, false);
if (!decision.isSelected()) {
saveSelectionFailure(ctx, deviceId, "未命中规则",
"同设备多规则均未形成可用归属候选");
// 规则均未命中是确定性的业务终态;非标保存成功后直接 ACK,避免 Retry
// 为同一个 syslog_uuid 重复写入随机 ID 的非标记录。保存失败仍会由上层异常链重试。
return NormalizationResult.filtered();
}
RuleSelectionEngine.Candidate<ExecutionPlan> selected = decision.getWinner();
BusinessResult prepared = null;
if (decision.isAmbiguous()) {
if (optimizationProperties.getRuleSelection().isStrictAmbiguity()) {
List<BusinessResult> results = new ArrayList<>();
Map<Long, BusinessResult> byRule = new LinkedHashMap<>();
for (RuleSelectionEngine.Candidate<ExecutionPlan> tied
: decision.getTiedCandidates()) {
BusinessResult result = evaluateBusiness(ctx, tied.getPayload());
results.add(result);
byRule.put(tied.getSource().getId(), result);
}
if (!businessResultsEquivalent(results)) {
globalMetrics.ruleSelection("conflicting_tie");
saveSelectionFailure(ctx, deviceId, "多规则归属歧义",
"候选评分相同但业务结果不同, candidateRuleIds="
+ candidateRuleIds(decision.getTiedCandidates()));
return NormalizationResult.filtered();
}
globalMetrics.ruleSelection("equivalent_tie");
prepared = byRule.get(selected.getSource().getId());
}
if (optimizationProperties.getRuleSelection().isDetailLogEnabled()) {
logger.warn("同设备多规则归属并列: eventHash={}, candidateRuleIds={}, selectedRuleId={}, mode={}",
shadowComparator.eventHash(ctx.strSyslogUUID),
candidateRuleIds(decision.getTiedCandidates()),
selected.getSource().getId(), decision.getMode());
}
}
if (prepared == null) prepared = evaluateBusiness(ctx, selected.getPayload());
if (prepared.filtered) {
ctx.filteredCount++;
return finish(ctx);
}
assemble(ctx, deviceId, selected.getSource(), prepared.destination);
return finish(ctx);
}
/** 关闭切换开关时仅做归属影子计算,不改变旧的多结果行为。 */
private void shadowSelect(ProcessContext ctx) {
try {
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = collectCandidates(ctx);
ruleSelectionEngine.select(candidates, ctx.strLogMsg, true);
} catch (Exception failure) {
// 影子链路不能影响生产结果。
logger.debug("规则归属影子计算失败: eventHash={}, error={}",
shadowComparator.eventHash(ctx.strSyslogUUID),
failure.getClass().getSimpleName());
}
}
private List<RuleSelectionEngine.Candidate<ExecutionPlan>> collectCandidates(ProcessContext ctx) {
List<RuleSelectionEngine.Candidate<ExecutionPlan>> candidates = new ArrayList<>();
boolean compiledEnabled = optimizationProperties.getCompiledRule().isEnabled();
for (RuleSource source : ctx.dmNormalizeRuleList) {
try {
ExecutionPlan plan;
if (compiledEnabled) {
try {
CompiledRule compiled = compiledRuleCache.get(source, ctx.executionMetrics);
long parseStarted = System.nanoTime();
ParsedMessage parsed = ctx.messageParseContext.parse(
compiled, ctx.strLogMsg, ctx.executionMetrics);
ctx.executionMetrics.addMessageParseNanos(System.nanoTime() - parseStarted);
if (parsed == null || parsed.getFields().isEmpty()) continue;
plan = ExecutionPlan.compiled(source, compiled, parsed);
} catch (CompiledRuleCompiler.RuleCompilationException incompatibleRule) {
plan = legacyCandidate(ctx, source);
}
} else {
plan = legacyCandidate(ctx, source);
}
if (plan != null && !plan.parsedFields.isEmpty()) {
candidates.add(new RuleSelectionEngine.Candidate<>(
source, plan.parsedFields, plan));
}
} catch (Exception candidateFailure) {
// 单个候选失败不是整条日志失败;全部失败后由唯一的非标出口处理。
logger.debug("规则候选解析失败: eventHash={}, ruleId={}, error={}",
shadowComparator.eventHash(ctx.strSyslogUUID), source.getId(),
candidateFailure.getClass().getSimpleName());
}
}
return candidates;
}
private ExecutionPlan legacyCandidate(ProcessContext ctx, RuleSource source) {
long parseStarted = System.nanoTime();
globalMetrics.ruleJsonParse();
LinkedHashMap<String, Object> parsed = getMessageToMap(
source.asLegacyMap(), ctx.strLogMsg, source.getDataType());
ctx.executionMetrics.addMessageParseNanos(System.nanoTime() - parseStarted);
return parsed == null || parsed.isEmpty() ? null
: ExecutionPlan.legacy(source, parsed);
}
private BusinessResult evaluateBusiness(ProcessContext ctx, ExecutionPlan plan) {
long mappingStarted = System.nanoTime();
Map<String, Object> destination;
if (plan.compiledRule != null) {
destination = plan.compiledRule.map(plan.compiledParsed);
} else {
globalMetrics.ruleJsonParse();
List<HashMap<String, Object>> destinationColumns =
getRuleContentMappers(plan.source.asLegacyMap());
if (destinationColumns == null) {
throw new IllegalArgumentException("规则 mappers 无法解析: ruleId=" + plan.source.getId());
}
destination = getColumnMap(getNormalColumnList(
new LinkedHashMap<>(plan.parsedFields), destinationColumns));
}
ctx.executionMetrics.addFieldMappingNanos(System.nanoTime() - mappingStarted);
long filterStarted = System.nanoTime();
boolean filtered;
if (plan.compiledRule != null) {
filtered = plan.compiledRule.isFiltered(destination);
if (!filtered) plan.compiledRule.complete(destination);
} else {
globalMetrics.ruleJsonParse();
filtered = logDataFilterService.evaluateFilterRule(
plan.source.getRuleContent(), destination);
if (!filtered) {
globalMetrics.ruleJsonParse();
logDataCompleteService.processDataCompletion(
plan.source.getRuleContent(), destination);
}
}
ctx.executionMetrics.addFilterCompleteNanos(System.nanoTime() - filterStarted);
return new BusinessResult(filtered, destination);
}
private static boolean businessResultsEquivalent(List<BusinessResult> results) {
if (results.isEmpty()) return true;
BusinessResult first = results.get(0);
for (int i = 1; i < results.size(); i++) {
BusinessResult current = results.get(i);
if (first.filtered != current.filtered) return false;
if (!first.filtered && !Objects.equals(first.destination, current.destination)) return false;
}
return true;
}
private static List<Long> candidateRuleIds(
List<? extends RuleSelectionEngine.Candidate<?>> candidates) {
List<Long> ids = new ArrayList<>();
for (RuleSelectionEngine.Candidate<?> candidate : candidates) {
ids.add(candidate.getSource().getId());
}
return ids;
}
private void saveSelectionFailure(ProcessContext ctx, long deviceId,
String reason, String detail) {
ctx.syslogNonNormalMessage.setReason(reason);
ctx.syslogNonNormalMessage.setReasonDetail(detail);
if (!ctx.shadowMode && !ctx.isSaveNonNormal) {
SaveNonNormalMessage(ctx, deviceId, DateTime.now());
}
}
private static final class ExecutionPlan {
private final RuleSource source;
private final CompiledRule compiledRule;
private final ParsedMessage compiledParsed;
private final Map<String, Object> parsedFields;
private ExecutionPlan(RuleSource source, CompiledRule compiledRule,
ParsedMessage compiledParsed, Map<String, Object> parsedFields) {
this.source = source;
this.compiledRule = compiledRule;
this.compiledParsed = compiledParsed;
this.parsedFields = Collections.unmodifiableMap(new LinkedHashMap<>(parsedFields));
}
private static ExecutionPlan compiled(RuleSource source, CompiledRule rule,
ParsedMessage parsed) {
return new ExecutionPlan(source, rule, parsed, parsed.getFields());
}
private static ExecutionPlan legacy(RuleSource source, Map<String, Object> parsed) {
return new ExecutionPlan(source, null, null, parsed);
}
}
private static final class BusinessResult {
private final boolean filtered;
private final Map<String, Object> destination;
private BusinessResult(boolean filtered, Map<String, Object> destination) {
this.filtered = filtered;
this.destination = destination;
}
}
private NormalizationResult executeCompiled(ProcessContext ctx, long deviceId) {
for (RuleSource source : ctx.dmNormalizeRuleList) {
CompiledRule rule;
@@ -561,6 +787,7 @@ public class LogNormalProcessor {
shadow.strSyslogTopic = source.strSyslogTopic;
shadow.fallbackFirstReceivedAt = source.fallbackFirstReceivedAt;
shadow.initialReceivedAt = source.initialReceivedAt;
shadow.eventAt = source.eventAt;
shadow.dmNormalizeRuleList = source.dmNormalizeRuleList;
shadow.batchEnrichmentContext = source.batchEnrichmentContext;
shadow.executionMetrics = new RuleExecutionMetrics();
@@ -793,11 +1020,9 @@ public class LogNormalProcessor {
}
//补全日志基础信息
columnMap.put("device_id", deviceId);
// 现场两张标准化主表均按 log_time 做 RANGE 分区。原始业务代码会无条件用 DateTime.now()
// 覆盖 log_time,其实际意图是按系统接收/处理日期分区,而不是按设备事件日期分区。
// 这里把 log_time 和 created_at 都固定为最初 receive_time:既保持原来的分区口径,
// 又避免 Kafka 积压、Retry/Replay 或跨日重试改变分区。设备事件时间保留在 collect_time_ts。
applyInitialReceiveTime(columnMap, ctx.initialReceivedAt);
// log_time 是两张标准化表的 RANGE 分区键,必须使用稳定的设备事件时间;created_at
// 记录日志首次进入系统的时间。这样跨日 API 回补进入事件发生日分区,同时仍能计算接入延迟。
applyIngressTimes(columnMap, ctx.eventAt, ctx.initialReceivedAt);
// 输入字符串只用于计算固定 128 位 UUID,最终 id 仍是与旧版相同长度的 36 字符 UUID。
// 同一 event_id + 规则 + 版本在重试/回放时生成相同 id,与稳定 log_time 组成每日本地唯一键。
columnMap.put("id", deterministicResultId(ctx.strSyslogUUID, normalizeRuleId, LEGACY_RULE_VERSION));
@@ -822,8 +1047,9 @@ public class LogNormalProcessor {
}
}
static void applyInitialReceiveTime(Map<String, Object> columnMap, LocalDateTime initialReceivedAt) {
columnMap.put("log_time", initialReceivedAt);
static void applyIngressTimes(Map<String, Object> columnMap, LocalDateTime eventAt,
LocalDateTime initialReceivedAt) {
columnMap.put("log_time", eventAt);
columnMap.put("created_at", initialReceivedAt);
}
@@ -902,7 +1128,9 @@ public class LogNormalProcessor {
normalMessage.setCollectTaskId(collect_id);
if (collectTask != null)
normalMessage.setCollectTaskName(collectTask.getTaskName());
this.messageService.saveMessage(normalMessage);
if (!this.messageService.saveMessage(normalMessage)) {
throw new IllegalStateException("非标日志服务返回保存失败");
}
ctx.isSaveNonNormal = true;
} catch (Exception e) {
logger.error("SaveNonNormalMessage 失败:");
@@ -937,6 +1165,14 @@ public class LogNormalProcessor {
return LocalDateTime.now(RECEIVE_TIME_ZONE);
}
/** 新消息使用 event_time;历史 TCP/UDP 或旧 Kafka 消息缺失时回退到 receive_time。 */
static LocalDateTime resolveEventAt(String eventTime, LocalDateTime initialReceivedAt) {
if (eventTime == null || eventTime.trim().isEmpty()) {
return initialReceivedAt;
}
return IngressReceiveTimeUtil.parse(eventTime);
}
public static void main(String[] args) {
// 旧入口保留,仅用于本地测试
}
@@ -5,13 +5,11 @@ import com.Modules.NormalData.compiled.BatchRuleSnapshot;
import com.Modules.NormalData.compiled.RuleExecutionMetrics;
import com.Modules.NormalData.compiled.PreparedNormalizationInput;
import com.common.util.SyslogParser;
import com.common.util.IngressReceiveTimeUtil;
import com.config.AppProperties;
import com.config.KafkaConsumerProperties;
import com.common.service.DmStandardizedBatchWriter;
import com.common.service.IngressStatsProjectionWriter;
import com.common.entity.IngressStatsEvent;
import com.influxdb.client.domain.WritePrecision;
import com.influxdb.client.write.Point;
import com.influx.raw.InfluxRawPointFactory;
import com.influx.raw.PointTimeResolver;
@@ -41,20 +39,13 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import com.common.util.Sm4Util;
@Slf4j
@Component
public class SysLogProcessor {
private static final ZoneId RECEIVE_TIME_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter MEASUREMENT_DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMdd");
@Autowired
@Qualifier("logNormalProcessorExecutor")
private Executor taskExecutor;
@@ -363,36 +354,37 @@ public class SysLogProcessor {
}
String receiveTime = deviceInfo.get("receive_time");
ReceiveTimeParser.ParsedReceiveTime parsed = ReceiveTimeParser.parse(receiveTime);
ReceiveTimeParser.ParsedReceiveTime parsedReceiveTime = ReceiveTimeParser.parse(receiveTime);
String eventTime = effectiveEventTime(deviceInfo.get("event_time"), receiveTime);
ReceiveTimeParser.ParsedReceiveTime parsedEventTime = ReceiveTimeParser.parse(eventTime);
boolean headerHasPointTime = deviceInfo.containsKey("point_time_ns");
Long envelopePointTime = messageContext.getPointTimeNs();
Long headerPointTime = pointTimeResolver.resolve(headerHasPointTime,
deviceInfo.get("point_time_ns"), parsed.getEpochMillis(), false);
deviceInfo.get("point_time_ns"), parsedEventTime.getEpochMillis(), false);
if (envelopePointTime != null) {
PointTimeResolver.validate(envelopePointTime, parsed.getEpochMillis());
PointTimeResolver.validate(envelopePointTime, parsedEventTime.getEpochMillis());
if (headerPointTime != null && !headerPointTime.equals(envelopePointTime)) {
throw new IllegalArgumentException("Retry/Replay point_time_ns 与原始 Kafka 载荷不一致");
}
}
Long pointTimeNs = envelopePointTime != null ? envelopePointTime : headerPointTime;
if (pointTimeNs == null && rawStorageProperties.getWriteSchema() == RawStorageProperties.WriteSchema.V2) {
pointTimeNs = pointTimeResolver.resolve(false, null, parsed.getEpochMillis(), true);
pointTimeNs = pointTimeResolver.resolve(false, null, parsedEventTime.getEpochMillis(), true);
}
messageContext.setPointTimeNs(pointTimeNs);
String originalTopic = messageContext.getOriginalTopic();
InfluxRawPointFactory.RawPoint rawPoint = rawPointFactory.create(eventId, decryptedMessage,
receiveTime, deviceInfo.get("device_id"), deviceInfo.get("device_collect_id"),
originalTopic, parsed, pointTimeNs);
ResolvedInfluxTime influxTime = new ResolvedInfluxTime(parsed.getEpochMillis(),
parsed.getMeasurementDate(), parsed.getLocalDateTime());
originalTopic, parsedEventTime, pointTimeNs);
Long deviceId = optionalLong(deviceInfo.get("device_id"));
Long collectId = optionalLong(deviceInfo.get("device_collect_id"));
PreparedNormalizationInput normalizationInput = new PreparedNormalizationInput(
eventId, originalTopic, influxTime.getReceivedAt(), deviceId, collectId,
eventId, originalTopic, parsedReceiveTime.getLocalDateTime(),
parsedEventTime.getLocalDateTime(), deviceId, collectId,
deviceInfo.get("data_type"), SyslogParser.substringAfterFirstCloseBracket(decryptedMessage),
decryptedMessage, deviceHeader, deviceInfo);
return new PreparedMessage(record, messageContext, decryptedMessage, eventId, rawPoint.getPoint(),
rawPoint.getBucket(), rawPoint.isV2(), influxTime.getReceivedAt(), deviceId, collectId,
rawPoint.getBucket(), rawPoint.isV2(), parsedReceiveTime.getLocalDateTime(), deviceId, collectId,
startTime, normalizationInput);
}
@@ -631,7 +623,14 @@ public class SysLogProcessor {
body = SyslogParser.substringAfterFirstCloseBracket(message);
info = SyslogParser.parseKeyValuePairs(header);
}
return new PreparedNormalizationInput(eventId, null, receivedAt, deviceId, collectId,
LocalDateTime eventAt = receivedAt;
if (!info.isEmpty()) {
String rawEventTime = effectiveEventTime(info.get("event_time"), info.get("receive_time"));
if (rawEventTime != null && !rawEventTime.trim().isEmpty()) {
eventAt = ReceiveTimeParser.parse(rawEventTime).getLocalDateTime();
}
}
return new PreparedNormalizationInput(eventId, null, receivedAt, eventAt, deviceId, collectId,
info.get("data_type"), body,
message, header, info);
}
@@ -764,26 +763,28 @@ public class SysLogProcessor {
return partitions.toString();
}
/**
* 为 InfluxDB 选择跨 Kafka Retry/Replay 稳定的时间戳。
* 优先使用接入端 receive_time;旧消息缺失或格式异常时,回退到首次 Kafka 接收时间。
*/
static ResolvedInfluxTime resolveInfluxTime(String receiveTime, Instant ignoredFallbackTime) {
ReceiveTimeParser.ParsedReceiveTime parsed = ReceiveTimeParser.parse(receiveTime);
/** 新消息使用 event_time;历史 Kafka 消息缺失该字段时回退到 receive_time。 */
static ResolvedInfluxTime resolveInfluxTime(String eventTime, String receiveTime) {
ReceiveTimeParser.ParsedReceiveTime parsed = ReceiveTimeParser.parse(
effectiveEventTime(eventTime, receiveTime));
return new ResolvedInfluxTime(
parsed.getEpochMillis(), parsed.getMeasurementDate(), parsed.getLocalDateTime());
}
/** InfluxDB 点的时间戳和 measurement 日期必须由同一个接收时间计算。 */
static String effectiveEventTime(String eventTime, String receiveTime) {
return eventTime == null || eventTime.trim().isEmpty() ? receiveTime : eventTime;
}
/** InfluxDB 点的时间戳和 measurement 日期必须由同一个事件时间计算。 */
static final class ResolvedInfluxTime {
private final long timestampMs;
private final String measurementDate;
private final LocalDateTime receivedAt;
private final LocalDateTime eventAt;
private ResolvedInfluxTime(long timestampMs, String measurementDate, LocalDateTime receivedAt) {
private ResolvedInfluxTime(long timestampMs, String measurementDate, LocalDateTime eventAt) {
this.timestampMs = timestampMs;
this.measurementDate = measurementDate;
this.receivedAt = receivedAt;
this.eventAt = eventAt;
}
long getTimestampMs() {
@@ -794,8 +795,8 @@ public class SysLogProcessor {
return measurementDate;
}
LocalDateTime getReceivedAt() {
return receivedAt;
LocalDateTime getEventAt() {
return eventAt;
}
}
@@ -28,13 +28,16 @@ public class CompiledRuleCache {
private static final ObjectMapper DIAGNOSTIC_JSON = new ObjectMapper();
private static final List<String> EXECUTION_RULE_FIELDS = Arrays.asList(
"cropper_params", "kv_params", "sep", "regexp", "mappers",
"filters_params", "complete_columns");
"match_params", "filters_params", "complete_columns");
private final CompiledRuleCompiler compiler;
private final Cache<String, CompiledRule> compiled;
private final Cache<String, RuleMatchProfile> matchProfiles;
private final Cache<String, FailureRecord> failures;
private final Cache<String, FailureRecord> matchFailures;
private final Cache<String, Boolean> loggedFailures;
private final Cache<Long, Set<String>> keysByRule;
private final Cache<Long, Set<String>> matchKeysByRule;
private final NormalizationRuleMetrics metrics;
private final boolean ruleDetailLogEnabled;
private final LongAdder compileSuccess = new LongAdder();
@@ -58,6 +61,10 @@ public class CompiledRuleCache {
.maximumSize(config.getLocalLimit())
.expireAfterAccess(config.getExpireAfterAccessMinutes(), TimeUnit.MINUTES)
.build();
this.matchKeysByRule = Caffeine.newBuilder()
.maximumSize(config.getLocalLimit())
.expireAfterAccess(config.getExpireAfterAccessMinutes(), TimeUnit.MINUTES)
.build();
this.compiled = Caffeine.newBuilder()
.maximumSize(config.getLocalLimit())
.expireAfterAccess(config.getExpireAfterAccessMinutes(), TimeUnit.MINUTES)
@@ -66,10 +73,23 @@ public class CompiledRuleCache {
if (key != null && value != null) unlink(((CompiledRule) value).getRuleId(), key.toString());
})
.build();
this.matchProfiles = Caffeine.newBuilder()
.maximumSize(config.getLocalLimit())
.expireAfterAccess(config.getExpireAfterAccessMinutes(), TimeUnit.MINUTES)
.removalListener((key, value, cause) -> {
if (key != null && value != null) {
unlink(matchKeysByRule, sourceRuleId(key.toString()), key.toString());
}
})
.build();
this.failures = Caffeine.newBuilder()
.maximumSize(Math.min(config.getLocalLimit(), 1_000))
.expireAfterWrite(config.getFailureCacheSeconds(), TimeUnit.SECONDS)
.build();
this.matchFailures = Caffeine.newBuilder()
.maximumSize(Math.min(config.getLocalLimit(), 1_000))
.expireAfterWrite(config.getFailureCacheSeconds(), TimeUnit.SECONDS)
.build();
this.loggedFailures = Caffeine.newBuilder()
.maximumSize(Math.min(config.getLocalLimit(), 1_000))
.expireAfterAccess(config.getExpireAfterAccessMinutes(), TimeUnit.MINUTES)
@@ -133,15 +153,42 @@ public class CompiledRuleCache {
});
}
/** 获取只包含显式条件和自动字段指纹的缓存画像。 */
public RuleMatchProfile getMatchProfile(RuleSource source) {
String key = compiler.fingerprint(source);
RuleMatchProfile cached = matchProfiles.getIfPresent(key);
if (cached != null) return cached;
FailureRecord failed = matchFailures.getIfPresent(key);
if (failed != null) throw failed.toException(source.getId());
return matchProfiles.get(key, ignored -> {
FailureRecord recentFailure = matchFailures.getIfPresent(key);
if (recentFailure != null) throw recentFailure.toException(source.getId());
try {
RuleMatchProfile profile = compiler.compileMatchProfile(source);
matchKeysByRule.asMap().computeIfAbsent(source.getId(), ruleId ->
Collections.newSetFromMap(new ConcurrentHashMap<>())).add(key);
return profile;
} catch (RuntimeException failure) {
matchFailures.put(key, new FailureRecord(failure));
throw failure;
}
});
}
public void invalidateRule(long ruleId) {
Set<String> keys = keysByRule.asMap().remove(ruleId);
if (keys != null) compiled.invalidateAll(keys);
Set<String> matchKeys = matchKeysByRule.asMap().remove(ruleId);
if (matchKeys != null) matchProfiles.invalidateAll(matchKeys);
for (String key : failures.asMap().keySet()) {
if (key.startsWith(ruleId + ":")) failures.invalidate(key);
}
for (String key : loggedFailures.asMap().keySet()) {
if (key.startsWith(ruleId + ":")) loggedFailures.invalidate(key);
}
for (String key : matchFailures.asMap().keySet()) {
if (key.startsWith(ruleId + ":")) matchFailures.invalidate(key);
}
metrics.compiledCacheGauge(compiled.estimatedSize(), evictions.sum());
}
@@ -185,10 +232,24 @@ public class CompiledRuleCache {
}
private void unlink(long ruleId, String key) {
Set<String> keys = keysByRule.getIfPresent(ruleId);
unlink(keysByRule, ruleId, key);
}
private static void unlink(Cache<Long, Set<String>> index, long ruleId, String key) {
Set<String> keys = index.getIfPresent(ruleId);
if (keys == null) return;
keys.remove(key);
if (keys.isEmpty()) keysByRule.invalidate(ruleId);
if (keys.isEmpty()) index.invalidate(ruleId);
}
private static long sourceRuleId(String key) {
int colon = key.indexOf(':');
if (colon <= 0) return -1L;
try {
return Long.parseLong(key.substring(0, colon));
} catch (NumberFormatException ignored) {
return -1L;
}
}
private static final class FailureRecord {
@@ -47,6 +47,9 @@ public class CompiledRuleCompiler {
Pattern.compile(separator);
}
// match_params 和自动 origin_field 指纹都是规则执行内容的一部分。
// 即使当前开关未启用唯一选择,也必须在规则保存/编译时完成结构校验。
compileMatchProfile(root, source);
Map<String, List<CompiledMapper>> mappers = compileMappers(root.get("mappers"), source);
List<CompiledFilterGroup> filters = compileFilters(root.get("filters_params"), source);
List<CompiledCompletion> completions = compileCompletions(root.get("complete_columns"), source);
@@ -69,6 +72,29 @@ public class CompiledRuleCompiler {
return source.getId() + ":" + source.getFingerprint();
}
/**
* 只编译归属判定画像,不要求 mapper action、过滤和补全能够进入编译执行器。
* 历史规则回退旧执行器时仍可因此参与同一套唯一选择算法。
*/
public RuleMatchProfile compileMatchProfile(RuleSource source) {
if (source == null) throw new IllegalArgumentException("规则源不能为空");
if (blank(source.getRuleContent())) throw invalid(source, "rule_content 不能为空");
try {
JsonNode root = objectMapper.readTree(source.getRuleContent());
if (root == null || !root.isObject()) throw invalid(source, "rule_content 必须是 JSON 对象");
return compileMatchProfile(root, source);
} catch (RuleCompilationException known) {
throw known;
} catch (Exception failure) {
throw new RuleCompilationException(source.getId(),
"规则归属画像编译失败: " + failure.getClass().getSimpleName(), failure);
}
}
private RuleMatchProfile compileMatchProfile(JsonNode root, RuleSource source) {
return RuleMatchProfile.compile(root, root.get("mappers"), source, objectMapper);
}
private CompiledCropper compileCropper(JsonNode node, RuleSource source) {
if (node == null || node.isNull()) return null;
if (!node.isObject()) throw invalid(source, "cropper_params 必须是对象");
@@ -10,6 +10,7 @@ public final class PreparedNormalizationInput {
private final String eventId;
private final String topic;
private final LocalDateTime receivedAt;
private final LocalDateTime eventAt;
private final Long deviceId;
private final Long deviceCollectId;
private final String dataType;
@@ -19,12 +20,14 @@ public final class PreparedNormalizationInput {
private final Map<String, String> deviceInfo;
public PreparedNormalizationInput(String eventId, String topic, LocalDateTime receivedAt,
LocalDateTime eventAt,
Long deviceId, Long deviceCollectId, String dataType,
String syslogBody, String fullMessage, String deviceHeader,
Map<String, String> deviceInfo) {
this.eventId = eventId;
this.topic = topic;
this.receivedAt = receivedAt;
this.eventAt = eventAt;
this.deviceId = deviceId;
this.deviceCollectId = deviceCollectId;
this.dataType = dataType;
@@ -37,6 +40,7 @@ public final class PreparedNormalizationInput {
public String getEventId() { return eventId; }
public String getTopic() { return topic; }
public LocalDateTime getReceivedAt() { return receivedAt; }
public LocalDateTime getEventAt() { return eventAt; }
public Long getDeviceId() { return deviceId; }
public Long getDeviceCollectId() { return deviceCollectId; }
public String getDataType() { return dataType; }
@@ -0,0 +1,292 @@
package com.Modules.NormalData.compiled;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 一条规则用于“归属判定”的不可变画像。
*
* <p>画像只包含显式匹配计划和由 {@code mappers.origin_field} 生成的自动字段指纹,
* 不包含消息状态,也不会执行映射、过滤、补全或任何外部调用。</p>
*/
public final class RuleMatchProfile {
private final boolean explicitEnabled;
private final int priority;
private final List<MatchCondition> all;
private final List<MatchCondition> any;
private final List<MatchCondition> none;
private final Set<String> originFields;
private RuleMatchProfile(boolean explicitEnabled, int priority,
List<MatchCondition> all, List<MatchCondition> any,
List<MatchCondition> none, Set<String> originFields) {
this.explicitEnabled = explicitEnabled;
this.priority = priority;
this.all = immutable(all);
this.any = immutable(any);
this.none = immutable(none);
this.originFields = Collections.unmodifiableSet(new LinkedHashSet<>(originFields));
}
static RuleMatchProfile compile(JsonNode root, JsonNode mappers, RuleSource source,
ObjectMapper objectMapper) {
Set<String> origins = compileOrigins(mappers, source, objectMapper);
JsonNode params = root.get("match_params");
if (params == null || params.isNull()) {
return new RuleMatchProfile(false, 0, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(), origins);
}
if (!params.isObject()) throw invalid(source, "match_params 必须是对象");
JsonNode enabledNode = params.get("enabled");
if (enabledNode != null && !enabledNode.isBoolean()) {
throw invalid(source, "match_params.enabled 必须是布尔值");
}
boolean enabled = enabledNode != null && enabledNode.booleanValue();
JsonNode priorityNode = params.get("priority");
if (priorityNode != null && (!priorityNode.isIntegralNumber()
|| !priorityNode.canConvertToInt())) {
throw invalid(source, "match_params.priority 必须是整数");
}
int priority = priorityNode == null ? 0 : priorityNode.intValue();
List<MatchCondition> all = compileConditions(params.get("all"), "all", source, objectMapper);
List<MatchCondition> any = compileConditions(params.get("any"), "any", source, objectMapper);
List<MatchCondition> none = compileConditions(params.get("none"), "none", source, objectMapper);
if (enabled && all.isEmpty() && any.isEmpty() && none.isEmpty()) {
throw invalid(source, "match_params.enabled=true 时必须至少配置一个条件");
}
return new RuleMatchProfile(enabled, priority, all, any, none, origins);
}
public ExplicitEvaluation evaluate(Map<String, Object> parsedFields, String rawMessage) {
if (!explicitEnabled) return ExplicitEvaluation.notMatched();
int specificity = 0;
for (MatchCondition condition : all) {
if (!condition.matches(parsedFields, rawMessage)) return ExplicitEvaluation.notMatched();
specificity += condition.specificity();
}
if (!any.isEmpty()) {
boolean matched = false;
int anySpecificity = 0;
for (MatchCondition condition : any) {
if (condition.matches(parsedFields, rawMessage)) {
matched = true;
anySpecificity = Math.max(anySpecificity, condition.specificity());
}
}
if (!matched) return ExplicitEvaluation.notMatched();
specificity += anySpecificity;
}
for (MatchCondition condition : none) {
if (condition.matches(parsedFields, rawMessage)) return ExplicitEvaluation.notMatched();
// 成功排除一种格式同样提高规则的具体度。
specificity += condition.specificity();
}
return new ExplicitEvaluation(true, specificity);
}
public boolean isExplicitEnabled() { return explicitEnabled; }
public int getPriority() { return priority; }
public Set<String> getOriginFields() { return originFields; }
private static Set<String> compileOrigins(JsonNode node, RuleSource source,
ObjectMapper objectMapper) {
if (node != null && node.isTextual()) {
try {
node = objectMapper.readTree(node.textValue());
} catch (Exception invalidJson) {
throw invalid(source, "mappers 内嵌 JSON 无效");
}
}
if (node == null || !node.isArray()) throw invalid(source, "mappers 必须是数组");
Set<String> result = new LinkedHashSet<>();
for (JsonNode mapper : node) {
JsonNode origin = mapper.get("origin_field");
if (origin == null || !origin.isTextual() || blank(origin.textValue())) {
throw invalid(source, "mappers.origin_field 不能为空");
}
result.add(origin.textValue());
}
return result;
}
private static List<MatchCondition> compileConditions(JsonNode node, String group,
RuleSource source,
ObjectMapper objectMapper) {
if (node == null || node.isNull()) return Collections.emptyList();
if (!node.isArray()) throw invalid(source, "match_params." + group + " 必须是数组");
List<MatchCondition> conditions = new ArrayList<>();
int index = 0;
for (JsonNode condition : node) {
String path = "match_params." + group + "[" + index + "]";
conditions.add(MatchCondition.compile(condition, path, source, objectMapper));
index++;
}
return conditions;
}
private static <T> List<T> immutable(List<T> source) {
return Collections.unmodifiableList(new ArrayList<>(source));
}
private static boolean blank(String value) {
return value == null || value.trim().isEmpty();
}
private static CompiledRuleCompiler.RuleCompilationException invalid(RuleSource source,
String message) {
return new CompiledRuleCompiler.RuleCompilationException(source.getId(), message);
}
public static final class ExplicitEvaluation {
private static final ExplicitEvaluation NOT_MATCHED = new ExplicitEvaluation(false, 0);
private final boolean matched;
private final int specificity;
private ExplicitEvaluation(boolean matched, int specificity) {
this.matched = matched;
this.specificity = specificity;
}
static ExplicitEvaluation notMatched() { return NOT_MATCHED; }
public boolean isMatched() { return matched; }
public int getSpecificity() { return specificity; }
}
private enum Source { PARSED, RAW }
private enum Operation {
EXISTS, NOT_EXISTS, NOT_BLANK, EQ, NE, IN, CONTAINS, REGEXP
}
private static final class MatchCondition {
private final Source source;
private final String field;
private final Operation operation;
private final String value;
private final Set<String> values;
private final Pattern pattern;
private MatchCondition(Source source, String field, Operation operation, String value,
Set<String> values, Pattern pattern) {
this.source = source;
this.field = field;
this.operation = operation;
this.value = value;
this.values = values;
this.pattern = pattern;
}
static MatchCondition compile(JsonNode node, String path, RuleSource rule,
ObjectMapper objectMapper) {
if (node == null || !node.isObject()) throw invalid(rule, path + " 必须是对象");
String sourceText = text(node.get("source"));
Source source;
try {
source = sourceText == null ? Source.PARSED
: Source.valueOf(sourceText.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException unsupported) {
throw invalid(rule, path + ".source 不支持: " + sourceText);
}
String opText = text(node.get("op"));
if (blank(opText)) throw invalid(rule, path + ".op 不能为空");
Operation operation;
try {
operation = Operation.valueOf(opText.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException unsupported) {
throw invalid(rule, path + ".op 不支持: " + opText);
}
String field = text(node.get("field"));
if (source == Source.PARSED && blank(field)) {
throw invalid(rule, path + ".field 不能为空");
}
if (source == Source.RAW && !(operation == Operation.EQ || operation == Operation.NE
|| operation == Operation.NOT_BLANK || operation == Operation.CONTAINS
|| operation == Operation.REGEXP)) {
throw invalid(rule, path + ".op 不适用于 raw: " + opText);
}
JsonNode valueNode = node.get("value");
boolean needsSingleValue = operation == Operation.EQ || operation == Operation.NE
|| operation == Operation.CONTAINS || operation == Operation.REGEXP;
if (needsSingleValue && (valueNode == null || valueNode.isNull()
|| !valueNode.isValueNode())) {
throw invalid(rule, path + ".value 必须是单值");
}
String value = needsSingleValue ? valueNode.asText() : null;
Set<String> values = Collections.emptySet();
if (operation == Operation.IN) {
if (valueNode == null || !valueNode.isArray()) {
throw invalid(rule, path + ".value 必须是数组");
}
Set<String> compiledValues = new LinkedHashSet<>();
for (JsonNode item : valueNode) {
if (item == null || item.isContainerNode()) {
throw invalid(rule, path + ".value 只能包含标量");
}
compiledValues.add(item.isNull() ? null : item.asText());
}
values = Collections.unmodifiableSet(compiledValues);
}
Pattern pattern = null;
if (operation == Operation.REGEXP) {
try {
pattern = Pattern.compile(value);
} catch (RuntimeException invalidRegex) {
throw invalid(rule, path + ".value 正则无效");
}
}
return new MatchCondition(source, field, operation, value, values, pattern);
}
boolean matches(Map<String, Object> parsedFields, String rawMessage) {
boolean present;
Object actual;
if (source == Source.RAW) {
present = rawMessage != null;
actual = rawMessage;
} else {
present = parsedFields != null && parsedFields.containsKey(field);
actual = present ? parsedFields.get(field) : null;
}
switch (operation) {
case EXISTS: return present;
case NOT_EXISTS: return !present;
case NOT_BLANK: return present && actual != null && !actual.toString().trim().isEmpty();
case EQ: return present && string(actual).equals(value);
case NE: return present && !string(actual).equals(value);
case IN: return present && values.contains(actual == null ? null : actual.toString());
case CONTAINS: return present && actual != null && actual.toString().contains(value);
case REGEXP: return present && actual != null && pattern.matcher(actual.toString()).find();
default: throw new IllegalStateException("未知显式匹配操作符: " + operation);
}
}
int specificity() {
switch (operation) {
case EQ:
case IN:
case REGEXP: return 3;
case CONTAINS: return 2;
default: return 1;
}
}
private static String string(Object value) {
return value == null ? "" : value.toString();
}
private static String text(JsonNode node) {
return node == null || node.isNull() ? null : node.asText();
}
}
}
@@ -0,0 +1,263 @@
package com.Modules.NormalData.compiled;
import com.Modules.NormalData.metrics.NormalizationRuleMetrics;
import com.config.NormalizationOptimizationProperties;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 同设备多规则的纯内存归属选择器。 */
public final class RuleSelectionEngine {
private static final double EPSILON = 1.0e-12d;
private final CompiledRuleCache ruleCache;
private final NormalizationOptimizationProperties properties;
private final NormalizationRuleMetrics metrics;
public RuleSelectionEngine(CompiledRuleCache ruleCache,
NormalizationOptimizationProperties properties,
NormalizationRuleMetrics metrics) {
this.ruleCache = ruleCache;
this.properties = properties;
this.metrics = metrics;
}
public <T> Decision<T> select(List<Candidate<T>> parsedCandidates, String rawMessage,
boolean shadowOnly) {
NormalizationOptimizationProperties.RuleSelection config = properties.getRuleSelection();
List<Ranked<T>> usable = new ArrayList<>();
for (Candidate<T> candidate : parsedCandidates) {
try {
usable.add(new Ranked<>(candidate, ruleCache.getMatchProfile(candidate.getSource())));
} catch (RuntimeException invalidProfile) {
metrics.ruleSelection("invalid_match_config");
}
}
Decision<T> result = null;
if (config.isExplicitEnabled()) {
List<Ranked<T>> explicit = new ArrayList<>();
for (Ranked<T> ranked : usable) {
if (!ranked.profile.isExplicitEnabled()) continue;
RuleMatchProfile.ExplicitEvaluation evaluation = ranked.profile.evaluate(
ranked.candidate.getParsedFields(), rawMessage);
if (evaluation.isMatched()) {
ranked.explicitSpecificity = evaluation.getSpecificity();
explicit.add(ranked);
}
}
if (!explicit.isEmpty()) result = choose(explicit, SelectionMode.EXPLICIT);
}
if (result == null && config.isAutomaticFallbackEnabled()) {
List<Ranked<T>> automatic = new ArrayList<>();
for (Ranked<T> ranked : usable) {
// 启用了显式条件但未命中的规则,严禁绕回字段评分。
if (!ranked.profile.isExplicitEnabled()) automatic.add(ranked);
}
if (!automatic.isEmpty()) result = choose(automatic, SelectionMode.AUTOMATIC);
}
if (result == null || !result.isSelected()) {
metrics.ruleSelection(shadowOnly ? "shadow_no_match" : "no_match");
metrics.ruleSelectionObservation(parsedCandidates.size(), 0.0d, 0.0d);
return Decision.none();
}
if (shadowOnly) {
metrics.ruleSelection("shadow_selected");
} else {
metrics.ruleSelection("selected");
metrics.ruleSelection(result.getMode() == SelectionMode.EXPLICIT
? "explicit_selected" : "automatic_selected");
if (result.isAmbiguous()) metrics.ruleSelection("ambiguous");
}
metrics.ruleSelectionObservation(parsedCandidates.size(),
parsedCandidates.isEmpty() ? 0.0d
: (double) result.getRankedCandidateCount() / parsedCandidates.size(),
result.getScoreMargin());
return result;
}
private <T> Decision<T> choose(List<Ranked<T>> candidates, SelectionMode mode) {
applyAutomaticScores(candidates);
List<Ranked<T>> eligible = new ArrayList<>();
for (Ranked<T> ranked : candidates) {
// 显式条件已经证明格式归属,允许没有 mapper 指纹的完成型规则参与。
if (mode == SelectionMode.EXPLICIT || ranked.score.matchedCount > 0
|| (candidates.size() == 1 && ranked.profile.getOriginFields().isEmpty())) {
eligible.add(ranked);
}
}
if (eligible.isEmpty()) return Decision.none();
Comparator<Ranked<T>> comparator = mode == SelectionMode.EXPLICIT
? RuleSelectionEngine::compareExplicit : RuleSelectionEngine::compareAutomatic;
eligible.sort(comparator);
Ranked<T> first = eligible.get(0);
List<Candidate<T>> tied = new ArrayList<>();
for (Ranked<T> ranked : eligible) {
if (!sameRank(first, ranked, mode)) break;
tied.add(ranked.candidate);
}
tied.sort(Comparator.comparingLong(candidate -> candidate.getSource().getId()));
Candidate<T> winner = tied.get(0);
double margin = eligible.size() < 2 ? first.score.ownershipScore
: Math.max(0.0d, first.score.ownershipScore
- eligible.get(1).score.ownershipScore);
return new Decision<>(winner, mode, tied.size() > 1, tied,
eligible.size(), margin);
}
private static <T> void applyAutomaticScores(List<Ranked<T>> candidates) {
Map<String, Integer> frequency = new HashMap<>();
for (Ranked<T> candidate : candidates) {
for (String field : candidate.profile.getOriginFields()) {
frequency.put(field, frequency.getOrDefault(field, 0) + 1);
}
}
for (Ranked<T> candidate : candidates) {
double totalWeight = 0.0d;
double matchedWeight = 0.0d;
int matchedCount = 0;
Set<String> parsed = candidate.candidate.getParsedFields().keySet();
for (String field : candidate.profile.getOriginFields()) {
double weight = 1.0d / frequency.get(field);
totalWeight += weight;
if (parsed.contains(field)) {
matchedWeight += weight;
matchedCount++;
}
}
double coverage = totalWeight == 0.0d ? 0.0d : matchedWeight / totalWeight;
candidate.score = new AutomaticScore(matchedWeight * coverage,
matchedWeight, coverage, matchedCount);
}
}
private static int compareExplicit(Ranked<?> left, Ranked<?> right) {
int compared = Integer.compare(right.profile.getPriority(), left.profile.getPriority());
if (compared != 0) return compared;
compared = Integer.compare(right.explicitSpecificity, left.explicitSpecificity);
if (compared != 0) return compared;
compared = compareScore(left.score, right.score);
if (compared != 0) return compared;
return Long.compare(left.candidate.getSource().getId(), right.candidate.getSource().getId());
}
private static int compareAutomatic(Ranked<?> left, Ranked<?> right) {
int compared = compareScore(left.score, right.score);
if (compared != 0) return compared;
return Long.compare(left.candidate.getSource().getId(), right.candidate.getSource().getId());
}
private static int compareScore(AutomaticScore left, AutomaticScore right) {
int compared = Double.compare(right.ownershipScore, left.ownershipScore);
if (compared != 0) return compared;
compared = Double.compare(right.matchedWeight, left.matchedWeight);
if (compared != 0) return compared;
compared = Double.compare(right.coverage, left.coverage);
if (compared != 0) return compared;
return Integer.compare(right.matchedCount, left.matchedCount);
}
private static boolean sameRank(Ranked<?> left, Ranked<?> right, SelectionMode mode) {
if (mode == SelectionMode.EXPLICIT
&& (left.profile.getPriority() != right.profile.getPriority()
|| left.explicitSpecificity != right.explicitSpecificity)) {
return false;
}
return close(left.score.ownershipScore, right.score.ownershipScore)
&& close(left.score.matchedWeight, right.score.matchedWeight)
&& close(left.score.coverage, right.score.coverage)
&& left.score.matchedCount == right.score.matchedCount;
}
private static boolean close(double left, double right) {
return Math.abs(left - right) <= EPSILON;
}
public enum SelectionMode { EXPLICIT, AUTOMATIC }
public static final class Candidate<T> {
private final RuleSource source;
private final Map<String, Object> parsedFields;
private final T payload;
public Candidate(RuleSource source, Map<String, Object> parsedFields, T payload) {
this.source = source;
this.parsedFields = Collections.unmodifiableMap(new LinkedHashMap<>(parsedFields));
this.payload = payload;
}
public RuleSource getSource() { return source; }
public Map<String, Object> getParsedFields() { return parsedFields; }
public T getPayload() { return payload; }
}
public static final class Decision<T> {
private static final Decision<?> NONE = new Decision<>(null, null, false,
Collections.emptyList(), 0, 0.0d);
private final Candidate<T> winner;
private final SelectionMode mode;
private final boolean ambiguous;
private final List<Candidate<T>> tiedCandidates;
private final int rankedCandidateCount;
private final double scoreMargin;
private Decision(Candidate<T> winner, SelectionMode mode, boolean ambiguous,
List<Candidate<T>> tiedCandidates, int rankedCandidateCount,
double scoreMargin) {
this.winner = winner;
this.mode = mode;
this.ambiguous = ambiguous;
this.tiedCandidates = Collections.unmodifiableList(new ArrayList<>(tiedCandidates));
this.rankedCandidateCount = rankedCandidateCount;
this.scoreMargin = scoreMargin;
}
@SuppressWarnings("unchecked")
static <T> Decision<T> none() { return (Decision<T>) NONE; }
public boolean isSelected() { return winner != null; }
public Candidate<T> getWinner() { return winner; }
public SelectionMode getMode() { return mode; }
public boolean isAmbiguous() { return ambiguous; }
public List<Candidate<T>> getTiedCandidates() { return tiedCandidates; }
public int getRankedCandidateCount() { return rankedCandidateCount; }
public double getScoreMargin() { return scoreMargin; }
}
private static final class Ranked<T> {
private final Candidate<T> candidate;
private final RuleMatchProfile profile;
private int explicitSpecificity;
private AutomaticScore score = AutomaticScore.ZERO;
private Ranked(Candidate<T> candidate, RuleMatchProfile profile) {
this.candidate = candidate;
this.profile = profile;
}
}
private static final class AutomaticScore {
private static final AutomaticScore ZERO = new AutomaticScore(0.0d, 0.0d, 0.0d, 0);
private final double ownershipScore;
private final double matchedWeight;
private final double coverage;
private final int matchedCount;
private AutomaticScore(double ownershipScore, double matchedWeight,
double coverage, int matchedCount) {
this.ownershipScore = ownershipScore;
this.matchedWeight = matchedWeight;
this.coverage = coverage;
this.matchedCount = matchedCount;
}
}
}
@@ -5,6 +5,7 @@ import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.DoubleAdder;
import java.util.concurrent.atomic.LongAdder;
/** 固定维度的进程内规则指标;不以 rule/device/event 作为长期标签。 */
@@ -18,7 +19,15 @@ public class NormalizationRuleMetrics {
"collect_task_hit", "collect_task_load", "collect_task_failure");
private final Counters invalidation = new Counters("local", "remote", "after_commit", "failure");
private final Counters messageParse = new Counters("hit", "miss");
private final Counters ruleSelection = new Counters("selected", "explicit_selected",
"automatic_selected", "ambiguous", "equivalent_tie", "conflicting_tie",
"no_match", "invalid_match_config", "shadow_selected", "shadow_no_match");
private final LongAdder ruleJsonParse = new LongAdder();
private final LongAdder selectionObservationCount = new LongAdder();
private final LongAdder selectionCandidateTotal = new LongAdder();
private final AtomicLong selectionCandidateMax = new AtomicLong();
private final DoubleAdder selectionMatchRateTotal = new DoubleAdder();
private final DoubleAdder selectionScoreMarginTotal = new DoubleAdder();
private final LongAdder compileDurationTotalNanos = new LongAdder();
private final LongAdder compileDurationCount = new LongAdder();
private final AtomicLong compileDurationMaxNanos = new AtomicLong();
@@ -39,6 +48,15 @@ public class NormalizationRuleMetrics {
}
public void invalidation(String outcome) { invalidation.increment(outcome); }
public void messageParse(String outcome) { messageParse.increment(outcome); }
public void ruleSelection(String outcome) { ruleSelection.increment(outcome); }
public void ruleSelectionObservation(int candidateCount, double matchRate, double scoreMargin) {
int candidates = Math.max(0, candidateCount);
selectionObservationCount.increment();
selectionCandidateTotal.add(candidates);
updateMax(selectionCandidateMax, candidates);
selectionMatchRateTotal.add(Math.max(0.0d, Math.min(1.0d, matchRate)));
selectionScoreMarginTotal.add(Math.max(0.0d, scoreMargin));
}
public void ruleJsonParse() { ruleJsonParse.increment(); }
public void compiledCacheGauge(long size, long evictions) {
compiledCacheSize.set(Math.max(0L, size));
@@ -61,6 +79,15 @@ public class NormalizationRuleMetrics {
result.put("enrichment_cache_get_total", enrichment.snapshot());
result.put("rule_invalidation_total", invalidation.snapshot());
result.put("message_parse_cache_get_total", messageParse.snapshot());
result.put("rule_selection_total", ruleSelection.snapshot());
result.put("rule_selection_observation_count", selectionObservationCount.sum());
result.put("rule_selection_candidate_count_total", selectionCandidateTotal.sum());
result.put("rule_selection_candidate_count_max", selectionCandidateMax.get());
long observations = selectionObservationCount.sum();
result.put("rule_selection_match_rate_avg", observations == 0L ? 0.0d
: selectionMatchRateTotal.sum() / observations);
result.put("rule_selection_score_margin_avg", observations == 0L ? 0.0d
: selectionScoreMarginTotal.sum() / observations);
result.put("rule_json_parse_total", ruleJsonParse.sum());
return result;
}
@@ -32,6 +32,7 @@ public class DmStandardizedBatchExecutor {
private final StatsChangeCaptureService statsChangeCaptureService;
private final DirectStatsProjectionWriter directStatsProjectionWriter;
private final DmDirectTransactionMetrics transactionMetrics;
private final LateAlarmBucketWriter lateAlarmBucketWriter;
@org.springframework.beans.factory.annotation.Value("${stats.direct.transaction-log-enabled:false}")
private boolean transactionLogEnabled;
@@ -46,7 +47,8 @@ public class DmStandardizedBatchExecutor {
PlatformTransactionManager transactionManager,
StatsChangeCaptureService statsChangeCaptureService,
DirectStatsProjectionWriter directStatsProjectionWriter,
DmDirectTransactionMetrics transactionMetrics) {
DmDirectTransactionMetrics transactionMetrics,
LateAlarmBucketWriter lateAlarmBucketWriter) {
// SqlSessionTemplate 本身线程安全。ExecutorType.BATCH 会把相同 SQL 的参数暂存在当前事务会话中,
// 直到 flushStatements()/commit 时才真正交给 JDBC 驱动批量执行。
this.batchSqlSession = new SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH);
@@ -54,6 +56,7 @@ public class DmStandardizedBatchExecutor {
this.statsChangeCaptureService = statsChangeCaptureService;
this.directStatsProjectionWriter = directStatsProjectionWriter;
this.transactionMetrics = transactionMetrics;
this.lateAlarmBucketWriter = lateAlarmBucketWriter;
}
/**
@@ -126,6 +129,12 @@ public class DmStandardizedBatchExecutor {
batchSqlSession.flushStatements();
timing.factFlushMs = elapsedMs(flushStarted);
// 与标准化告警事实共用同一事务写入事件时间桶。事实成功而通知失败时必须整体回滚,
// 否则迟到数据可能永久失去重放机会。
if (alarmTable) {
lateAlarmBucketWriter.record(rows);
}
// DIRECT/SHADOW 模式在当前事务内按设备、规则聚合写统计;LEGACY 模式会直接返回。
// 任一统计 SQL 失败都会回滚本事务的事实行,禁止产生无法定位的统计缺口。
timing.statsResult = directStatsProjectionWriter.writeStandardizedRows(rows);
@@ -0,0 +1,162 @@
package com.common.service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.dao.DeadlockLoserDataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 在标准化告警事实事务内登记事件时间桶。
*
* <p>该通知不是“告警一定迟到”的结论。通知与事实在同一事务提交;下游能领取通知时,事实必然
* 已经可见,再根据领取时已经完成的规则窗口决定是否重放。这里刻意不使用事实的
* {@code created_at} 判断提交先后,因为 Kafka 积压时接收时间可能早于实际入库时间。
*/
@Component
public class LateAlarmBucketWriter {
private static final String UPSERT_SQL =
"MERGE INTO analysis_late_alarm_bucket t "
+ "USING (SELECT ? device_id, ? bucket_start FROM dual) s "
+ "ON (t.device_id=s.device_id AND t.bucket_start=s.bucket_start) "
+ "WHEN MATCHED THEN UPDATE SET "
+ "t.requested_generation=t.requested_generation+1, "
+ "t.last_observed_at=SYSDATE, "
+ "t.status=CASE WHEN t.status='RUNNING' THEN 'RUNNING' ELSE 'PENDING' END, "
+ "t.updated_at=SYSDATE "
+ "WHEN NOT MATCHED THEN INSERT "
+ "(id,device_id,bucket_start,bucket_end,last_observed_at,requested_generation,"
+ "claimed_generation,processed_generation,status,attempt_count,fencing_token,created_at,updated_at) "
+ "VALUES (?,?,?,?,SYSDATE,1,0,0,'PENDING',0,0,SYSDATE,SYSDATE)";
private final JdbcTemplate jdbcTemplate;
@Value("${analysis.late-replay.outbox-enabled:true}")
private boolean enabled;
public LateAlarmBucketWriter(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* 每个设备每分钟只写一条通知。调用方必须已经开启标准化事实事务。
*
* @param rows 已确认写入 {@code syslog_normal_alarm} 的同构小批次
*/
public void record(List<Map<String, Object>> rows) {
if (!enabled || rows == null || rows.isEmpty()) {
return;
}
Set<BucketKey> buckets = new LinkedHashSet<>();
for (Map<String, Object> row : rows) {
Integer deviceId = integerValue(row.get("device_id"));
LocalDateTime eventTime = timeValue(row.get("log_time"));
if (deviceId == null || eventTime == null) {
throw new IllegalArgumentException("迟到告警通知缺少 device_id 或 log_time");
}
buckets.add(new BucketKey(deviceId, eventTime.truncatedTo(ChronoUnit.MINUTES)));
}
for (BucketKey key : buckets) {
LocalDateTime bucketEnd = key.bucketStart.plusMinutes(1);
try {
jdbcTemplate.update(
UPSERT_SQL,
key.deviceId,
Timestamp.valueOf(key.bucketStart),
UUID.randomUUID().toString(),
key.deviceId,
Timestamp.valueOf(key.bucketStart),
Timestamp.valueOf(bucketEnd));
} catch (RuntimeException failure) {
// 多个 Kafka 分区可能同时首次写同一设备分钟,MERGE 的并发插入会竞争唯一键。
// 交给外层事实事务整体回滚并有限重试,不能只吞掉 outbox 失败。
if (isConcurrentWrite(failure)) {
throw new DirectStatsProjectionWriter.ConcurrentStatsWriteException(
"迟到告警分钟桶并发写入失败,可安全回滚后有限重试", failure);
}
throw failure;
}
}
}
private Integer integerValue(Object value) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value == null) {
return null;
}
return Integer.valueOf(value.toString());
}
private LocalDateTime timeValue(Object value) {
if (value instanceof LocalDateTime) {
return (LocalDateTime) value;
}
if (value instanceof Timestamp) {
return ((Timestamp) value).toLocalDateTime();
}
if (value instanceof java.util.Date) {
return new Timestamp(((java.util.Date) value).getTime()).toLocalDateTime();
}
return null;
}
private boolean isConcurrentWrite(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof DuplicateKeyException
|| current instanceof CannotAcquireLockException
|| current instanceof DeadlockLoserDataAccessException) {
return true;
}
String message = String.valueOf(current.getMessage()).toLowerCase(java.util.Locale.ROOT);
if (message.contains("unique constraint") || message.contains("duplicate key")) {
return true;
}
current = current.getCause();
}
return false;
}
private static final class BucketKey {
private final Integer deviceId;
private final LocalDateTime bucketStart;
private BucketKey(Integer deviceId, LocalDateTime bucketStart) {
this.deviceId = deviceId;
this.bucketStart = bucketStart;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof BucketKey)) {
return false;
}
BucketKey that = (BucketKey) other;
return Objects.equals(deviceId, that.deviceId)
&& Objects.equals(bucketStart, that.bucketStart);
}
@Override
public int hashCode() {
return Objects.hash(deviceId, bucketStart);
}
}
}
@@ -30,6 +30,8 @@ public class SyslogNormalDataService {
private StatsChangeCaptureService statsChangeCaptureService;
@Autowired
private DirectStatsProjectionWriter directStatsProjectionWriter;
@Autowired
private LateAlarmBucketWriter lateAlarmBucketWriter;
/**
@@ -49,6 +51,9 @@ public class SyslogNormalDataService {
? syslogNormalAlarmMapper.insertDynamic(dataMap)
: syslogNormalDataMapper.insertDynamic(dataMap);
if (inserted > 0) {
if (alarmData && lateAlarmBucketWriter != null) {
lateAlarmBucketWriter.record(java.util.Collections.singletonList(dataMap));
}
statsChangeCaptureService.captureStandardizedInsert(dataMap, alarmData);
directStatsProjectionWriter.writeStandardizedRow(dataMap);
}
@@ -5,17 +5,37 @@ import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
/**
* 灰度控制和本地缓存边界。默认值必须与 application*.properties 保持一致。
* 标准化热路径的执行策略、灰度开关和缓存边界。
*
* <p>配置前缀为 {@code normalization}。本类中的初始值是配置缺失时的最后保护值,必须与
* {@code application*.properties} 保持一致。修改开关时需要同时考虑下面三类影响:</p>
*
* <ul>
* <li>结果语义:是否启用多规则唯一归属、是否仍可能产生多条标准化结果;</li>
* <li>执行方式:使用编译规则还是旧解释执行器、是否进行影子计算;</li>
* <li>缓存一致性:规则源、消息解析和设备补全数据是否允许复用。</li>
* </ul>
*
* <p>{@link #afterPropertiesSet()} 在 Spring 完成属性绑定后校验组合配置,防止应用带着
* 无效缓存依赖或未知歧义策略启动。</p>
*/
@Component
@ConfigurationProperties(prefix = "normalization")
public class NormalizationOptimizationProperties implements InitializingBean {
/** JetCache 总开关;远端规则/补全缓存依赖该开关。 */
private final Toggle jetcache = new Toggle();
/** 规则 JSON 预编译和编译/旧执行器影子对比配置。 */
private final CompiledRule compiledRule = new CompiledRule();
/** 单个 Kafka 批次内复用设备和采集任务补全结果。 */
private final Toggle batchEnrichmentCache = new Toggle();
/** 同一条消息在解析签名相同时复用 crop + parse 结果。 */
private final Toggle messageParseCache = new Toggle();
/** 同设备多条泛化规则的唯一归属策略;默认启用。 */
private final RuleSelection ruleSelection = new RuleSelection();
/** 设备关联规则列表的本地/远端缓存参数。 */
private final RuleSourceCache ruleSourceCache = new RuleSourceCache();
/** 设备和采集任务基础信息的本地/远端缓存参数。 */
private final EnrichmentCache enrichmentCache = new EnrichmentCache();
public boolean isJetcacheEnabled() {
@@ -31,22 +51,32 @@ public class NormalizationOptimizationProperties implements InitializingBean {
return jetcache;
}
/** 对应 {@code normalization.compiled-rule.*}。 */
public CompiledRule getCompiledRule() {
return compiledRule;
}
/** 对应 {@code normalization.batch-enrichment-cache.enabled}。 */
public Toggle getBatchEnrichmentCache() {
return batchEnrichmentCache;
}
/** 对应 {@code normalization.message-parse-cache.enabled}。 */
public Toggle getMessageParseCache() {
return messageParseCache;
}
/** 对应 {@code normalization.rule-selection.*}。 */
public RuleSelection getRuleSelection() {
return ruleSelection;
}
/** 对应 {@code normalization.rule-source-cache.*}。 */
public RuleSourceCache getRuleSourceCache() {
return ruleSourceCache;
}
/** 对应 {@code normalization.enrichment-cache.*}。 */
public EnrichmentCache getEnrichmentCache() {
return enrichmentCache;
}
@@ -62,10 +92,12 @@ public class NormalizationOptimizationProperties implements InitializingBean {
"normalization.batch-enrichment-cache.enabled=true 要求 normalization.jetcache.enabled=true");
}
compiledRule.validate();
ruleSelection.validate();
ruleSourceCache.validate();
enrichmentCache.validate();
}
/** 只包含 {@code enabled} 的通用配置段。未显式赋值时默认关闭。 */
public static class Toggle {
private boolean enabled;
@@ -78,14 +110,28 @@ public class NormalizationOptimizationProperties implements InitializingBean {
}
}
/**
* 编译规则执行器配置。
*
* <p>{@code enabled=false} 时使用旧解释执行器;编译失败的单条规则仍会自动回退旧执行器,
* 不会因为缓存中存在不兼容历史规则而阻塞整条 Kafka 消息。</p>
*/
public static class CompiledRule {
/** 是否让编译规则执行器成为主路径。 */
private boolean enabled;
/** 旧执行器为主路径时,是否抽样运行编译执行器并比较业务结果。 */
private boolean shadowCompareEnabled = true;
/** 编译执行器影子对比抽样率,范围 0~1。 */
private double shadowSampleRate = 0.001d;
/** JVM 内编译规则缓存最大条目数。 */
private int localLimit = 10_000;
/** 编译规则缓存访问后过期时间,单位分钟。 */
private int expireAfterAccessMinutes = 30;
/** 编译失败短缓存时间,避免每条日志反复编译同一条无效规则。 */
private int failureCacheSeconds = 10;
/** 单次影子对比最大等待时间,单位毫秒。 */
private int shadowTimeoutMs = 1_000;
/** 是否记录脱敏后的规则编译详情;不包含原始日志。 */
private boolean ruleDetailLogEnabled = true;
public boolean isEnabled() {
@@ -173,6 +219,77 @@ public class NormalizationOptimizationProperties implements InitializingBean {
}
}
/**
* 同设备多条泛化规则的唯一归属配置。
*
* <p>主流程是:对所有规则做无副作用试解析;先检查规则正文中的显式
* {@code match_params};没有显式命中时,再对未启用显式配置的历史规则执行自动字段
* 指纹评分;最后只允许唯一胜出规则执行映射、过滤、补全和入库。</p>
*
* <p>{@code enabled} 继承自 {@link Toggle},本配置段默认设为 {@code true}。关闭时恢复
* 历史“逐条完整执行所有设备规则”的行为,因此同一日志可能重新产生多条结果。</p>
*/
public static class RuleSelection extends Toggle {
/**
* 主开关关闭时是否仍在后台计算新选择结果。影子计算只记录指标,不改变旧路径输出;
* 主开关开启时直接执行唯一选择,本值不再决定业务输出。
*/
private boolean shadowEnabled = true;
/**
* 候选排名完全相同时的策略:{@code LOWEST_RULE_ID} 稳定选择最小规则 ID;
* {@code STRICT} 会复核并列规则的业务结果,冲突时只保存一条非标记录。
*/
private String ambiguousFallback = "LOWEST_RULE_ID";
/**
* 是否输出低频歧义详情日志。日志只包含 event hash、候选规则 ID 和分数,不输出原文。
*/
private boolean detailLogEnabled;
/** 是否启用 {@code rule_content.match_params} 显式格式指纹。 */
private boolean explicitEnabled = true;
/** 没有显式命中时,是否允许未配置显式条件的历史规则使用自动字段指纹评分。 */
private boolean automaticFallbackEnabled = true;
/** 默认启用唯一归属,确保配置文件缺失时仍不会退回多规则重复产出。 */
public RuleSelection() {
setEnabled(true);
}
/** 返回是否启用关闭主开关后的影子计算。 */
public boolean isShadowEnabled() { return shadowEnabled; }
public void setShadowEnabled(boolean shadowEnabled) { this.shadowEnabled = shadowEnabled; }
/** 返回并列候选兜底策略名称。 */
public String getAmbiguousFallback() { return ambiguousFallback; }
public void setAmbiguousFallback(String ambiguousFallback) {
this.ambiguousFallback = ambiguousFallback;
}
/** 返回是否记录脱敏后的规则选择详情。 */
public boolean isDetailLogEnabled() { return detailLogEnabled; }
public void setDetailLogEnabled(boolean detailLogEnabled) {
this.detailLogEnabled = detailLogEnabled;
}
/** 返回是否启用规则正文中的显式 {@code match_params}。 */
public boolean isExplicitEnabled() { return explicitEnabled; }
public void setExplicitEnabled(boolean explicitEnabled) { this.explicitEnabled = explicitEnabled; }
/** 返回是否启用历史规则自动字段指纹兜底。 */
public boolean isAutomaticFallbackEnabled() { return automaticFallbackEnabled; }
public void setAutomaticFallbackEnabled(boolean automaticFallbackEnabled) {
this.automaticFallbackEnabled = automaticFallbackEnabled;
}
/** 是否使用“业务结果冲突即转非标”的严格歧义策略。 */
public boolean isStrictAmbiguity() {
return "STRICT".equalsIgnoreCase(ambiguousFallback);
}
private void validate() {
if (!("LOWEST_RULE_ID".equalsIgnoreCase(ambiguousFallback)
|| "STRICT".equalsIgnoreCase(ambiguousFallback))) {
throw new IllegalArgumentException(
"ruleSelection.ambiguousFallback 仅支持 LOWEST_RULE_ID 或 STRICT");
}
}
}
/** 设备关联规则快照的两级缓存 TTL 配置,单位均为秒。 */
public static class RuleSourceCache extends Toggle {
private int localTtlSeconds = 30;
private int remoteTtlSeconds = 300;
@@ -191,6 +308,7 @@ public class NormalizationOptimizationProperties implements InitializingBean {
}
}
/** 设备和采集任务补全事实的两级缓存 TTL 配置,单位均为秒。 */
public static class EnrichmentCache {
private int localTtlSeconds = 60;
private int remoteTtlSeconds = 3600;
@@ -14,7 +14,7 @@ public class PointTimeResolver {
this.metrics = metrics;
}
public Long resolve(boolean present, String rawValue, long receiveTimeMillis, boolean required) {
public Long resolve(boolean present, String rawValue, long eventTimeMillis, boolean required) {
if (!present) {
if (!required) return null;
metrics.legacyMissing();
@@ -23,7 +23,7 @@ public class PointTimeResolver {
throw new IllegalArgumentException("旧 Kafka 消息缺少 point_time_ns,且 legacy fallback 已关闭");
}
try {
long assigned = allocator.allocate(receiveTimeMillis);
long assigned = allocator.allocate(eventTimeMillis);
metrics.legacyAssigned();
return assigned;
} catch (RuntimeException failure) {
@@ -37,14 +37,14 @@ public class PointTimeResolver {
final long value;
try { value = Long.parseLong(rawValue); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("point_time_ns 非数字或溢出", invalid); }
validate(value, receiveTimeMillis);
validate(value, eventTimeMillis);
return value;
}
public static void validate(long pointTimeNs, long receiveTimeMillis) {
public static void validate(long pointTimeNs, long eventTimeMillis) {
if (pointTimeNs <= 0L) throw new IllegalArgumentException("point_time_ns 必须为正整数");
if (Math.floorDiv(pointTimeNs, PointTimeAllocator.NANOS_PER_MILLI) != receiveTimeMillis) {
throw new IllegalArgumentException("point_time_ns 毫秒部分与 receive_time 不一致");
if (Math.floorDiv(pointTimeNs, PointTimeAllocator.NANOS_PER_MILLI) != eventTimeMillis) {
throw new IllegalArgumentException("point_time_ns 毫秒部分与 event_time 不一致");
}
}
}
@@ -12,9 +12,9 @@ public class RawStorageProperties {
public static final int UUID_BUCKET_COUNT = 4096;
@Value("${influxdb.raw.write-schema:V1}") private String writeSchemaValue;
@Value("${influxdb.raw.read-mode:V2_ONLY}") private String readModeValue;
@Value("${influxdb.raw.read-mode:V1_ONLY}") private String readModeValue;
@Value("${influxdb.raw.v1-bucket:${influxdb.bucket:yelangbucket}}") private String v1Bucket;
@Value("${influxdb.raw.v2-bucket:yelangbucket}") private String v2Bucket;
@Value("${influxdb.raw.v2-bucket:yelangbucket_v2}") private String v2Bucket;
@Value("${influxdb.raw.uuid-bucket-count:4096}") private int uuidBucketCount;
@Value("${influxdb.raw.legacy-point-time-fallback-enabled:true}") private boolean legacyFallbackEnabled;
@Value("${influxdb.raw.query-max-rows:10000}") private int queryMaxRows;
@@ -139,6 +139,12 @@ normalization.compiled-rule.shadow-timeout-ms=1000
normalization.compiled-rule.rule-detail-log-enabled=true
normalization.batch-enrichment-cache.enabled=true
normalization.message-parse-cache.enabled=true
normalization.rule-selection.enabled=true
normalization.rule-selection.shadow-enabled=true
normalization.rule-selection.ambiguous-fallback=LOWEST_RULE_ID
normalization.rule-selection.detail-log-enabled=false
normalization.rule-selection.explicit-enabled=true
normalization.rule-selection.automatic-fallback-enabled=true
normalization.rule-source-cache.local-ttl-seconds=30
normalization.rule-source-cache.remote-ttl-seconds=300
normalization.rule-source-cache.empty-ttl-seconds=10
@@ -200,6 +200,12 @@ normalization.compiled-rule.shadow-timeout-ms=1000
normalization.compiled-rule.rule-detail-log-enabled=true
normalization.batch-enrichment-cache.enabled=true
normalization.message-parse-cache.enabled=true
normalization.rule-selection.enabled=true
normalization.rule-selection.shadow-enabled=true
normalization.rule-selection.ambiguous-fallback=LOWEST_RULE_ID
normalization.rule-selection.detail-log-enabled=false
normalization.rule-selection.explicit-enabled=true
normalization.rule-selection.automatic-fallback-enabled=true
normalization.rule-source-cache.local-ttl-seconds=30
normalization.rule-source-cache.remote-ttl-seconds=300
normalization.rule-source-cache.empty-ttl-seconds=10
@@ -163,6 +163,12 @@ normalization.compiled-rule.shadow-timeout-ms=1000
normalization.compiled-rule.rule-detail-log-enabled=true
normalization.batch-enrichment-cache.enabled=true
normalization.message-parse-cache.enabled=true
normalization.rule-selection.enabled=true
normalization.rule-selection.shadow-enabled=true
normalization.rule-selection.ambiguous-fallback=LOWEST_RULE_ID
normalization.rule-selection.detail-log-enabled=false
normalization.rule-selection.explicit-enabled=true
normalization.rule-selection.automatic-fallback-enabled=true
normalization.rule-source-cache.local-ttl-seconds=30
normalization.rule-source-cache.remote-ttl-seconds=300
normalization.rule-source-cache.empty-ttl-seconds=10
@@ -142,6 +142,12 @@ normalization.compiled-rule.shadow-timeout-ms=1000
normalization.compiled-rule.rule-detail-log-enabled=true
normalization.batch-enrichment-cache.enabled=true
normalization.message-parse-cache.enabled=true
normalization.rule-selection.enabled=true
normalization.rule-selection.shadow-enabled=true
normalization.rule-selection.ambiguous-fallback=LOWEST_RULE_ID
normalization.rule-selection.detail-log-enabled=false
normalization.rule-selection.explicit-enabled=true
normalization.rule-selection.automatic-fallback-enabled=true
normalization.rule-source-cache.local-ttl-seconds=30
normalization.rule-source-cache.remote-ttl-seconds=300
normalization.rule-source-cache.empty-ttl-seconds=10
@@ -101,6 +101,12 @@ normalization.compiled-rule.shadow-timeout-ms=1000
normalization.compiled-rule.rule-detail-log-enabled=true
normalization.batch-enrichment-cache.enabled=true
normalization.message-parse-cache.enabled=true
normalization.rule-selection.enabled=true
normalization.rule-selection.shadow-enabled=true
normalization.rule-selection.ambiguous-fallback=LOWEST_RULE_ID
normalization.rule-selection.detail-log-enabled=false
normalization.rule-selection.explicit-enabled=true
normalization.rule-selection.automatic-fallback-enabled=true
normalization.rule-source-cache.local-ttl-seconds=30
normalization.rule-source-cache.remote-ttl-seconds=300
normalization.rule-source-cache.empty-ttl-seconds=10
@@ -27,9 +27,9 @@ influxdb.retry.attempts=3
influxdb.retry.delay=1000
# 原始 Syslog V2 分阶段开关。兼容部署先保持 V1/V1_ONLY,切换时统一写 V2。
influxdb.raw.write-schema=${INFLUXDB_RAW_WRITE_SCHEMA:V2}
influxdb.raw.read-mode=${INFLUXDB_RAW_READ_MODE:V2_ONLY}
influxdb.raw.read-mode=${INFLUXDB_RAW_READ_MODE:V1_V2}
influxdb.raw.v1-bucket=${INFLUXDB_RAW_V1_BUCKET:${influxdb.bucket}}
influxdb.raw.v2-bucket=${INFLUXDB_RAW_V2_BUCKET:yelangbucket}
influxdb.raw.v2-bucket=${INFLUXDB_RAW_V2_BUCKET:yelangbucket_v2}
influxdb.raw.uuid-bucket-count=${INFLUXDB_RAW_UUID_BUCKET_COUNT:4096}
influxdb.raw.legacy-point-time-fallback-enabled=${INFLUXDB_RAW_LEGACY_POINT_TIME_FALLBACK_ENABLED:true}
influxdb.raw.query-max-rows=${INFLUXDB_RAW_QUERY_MAX_ROWS:10000}
@@ -103,6 +103,9 @@ spring.kafka.listener.type=batch
# 定时任务配置
spring.task.scheduling.pool.size=10
# 标准化告警事实与迟到重放通知使用同一个达梦事务。
analysis.late-replay.outbox-enabled=${ANALYSIS_LATE_REPLAY_OUTBOX_ENABLED:true}
# 日志配置
logging.level.com.common.schedule=INFO
logging.level.com.common.service=INFO
@@ -204,6 +207,18 @@ normalization.compiled-rule.shadow-timeout-ms=1000
normalization.compiled-rule.rule-detail-log-enabled=true
normalization.batch-enrichment-cache.enabled=true
normalization.message-parse-cache.enabled=true
# 同设备多规则唯一归属:默认开启,最终只允许一条胜出规则进入映射和入库。
normalization.rule-selection.enabled=true
# 仅在 enabled=false 时用于后台观察新选择结果,不改变旧路径的实际输出。
normalization.rule-selection.shadow-enabled=true
# 并列策略:LOWEST_RULE_ID=稳定选择最小规则ID;STRICT=业务结果冲突时转为一条非标记录。
normalization.rule-selection.ambiguous-fallback=LOWEST_RULE_ID
# 只记录 event hash、候选规则ID和分数等脱敏详情,不记录原始日志。
normalization.rule-selection.detail-log-enabled=false
# 优先使用 rule_content.match_params 中的显式格式条件。
normalization.rule-selection.explicit-enabled=true
# 没有显式命中时,允许未配置 match_params 的历史规则按自动字段指纹选优。
normalization.rule-selection.automatic-fallback-enabled=true
normalization.rule-source-cache.local-ttl-seconds=30
normalization.rule-source-cache.remote-ttl-seconds=300
normalization.rule-source-cache.empty-ttl-seconds=10