8173c2ff3f
- 入口统计改用独立 Kafka 消费组,移除主消费同步统计写库 - DIRECT 模式入库计数改为事务提交后异步投递,取消事实事务内统计 Outbox 写入 - 采样审计、告警分钟桶通知改为后台处理,异常不阻断主消费或回滚告警 - RAW 基础业务批量默认 500,按保留率动态扩大,上限 3000 - 限制业务块字节数及规则任务数量,保留告警全量处理和整次 poll 完成后提交 - 细化 SLOW_SYSLOG_BATCH,补充批次关联、线程排队、数据库执行及提交耗时 - 更新 v4 升级说明,明确异步统计损失风险及现场验证方法 验证:JDK 8 构建成功,232 项测试通过。 升级:复用现有表和 Kafka Topic,无新增建表或建 Topic 脚本要求。
141 lines
6.8 KiB
Java
141 lines
6.8 KiB
Java
package com.common.service;
|
|
|
|
import com.Modules.NormalData.SyslogFailureRouter;
|
|
import com.common.entity.IngressStatsEvent;
|
|
import com.common.util.Sm4Util;
|
|
import com.common.util.SyslogParser;
|
|
import com.config.AppProperties;
|
|
import com.influx.raw.ReceiveTimeParser;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.apache.kafka.clients.consumer.ConsumerRecord;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.kafka.annotation.KafkaListener;
|
|
import org.springframework.kafka.support.Acknowledgment;
|
|
import org.springframework.stereotype.Component;
|
|
|
|
import javax.annotation.PostConstruct;
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
|
|
/** 独立消费原始Topic,只维护接收时间;不执行规则、采样或事实表写入。 */
|
|
@Component
|
|
@Slf4j
|
|
public class IngressStatsConsumer {
|
|
private final IngressStatsProjectionWriter writer;
|
|
private final SyslogFailureRouter failureRouter;
|
|
private final AppProperties appProperties;
|
|
private final AtomicLong batches = new AtomicLong();
|
|
private final AtomicLong invalidAttempts = new AtomicLong();
|
|
private final AtomicLong writeFailures = new AtomicLong();
|
|
|
|
@Value("${spring.kafka.consumer.group-id}") private String rawGroup;
|
|
@Value("${syslog.large-listener.group-id:${spring.kafka.consumer.group-id}-large}")
|
|
private String largeRawGroup;
|
|
@Value("${stats.ingress-metadata.group-id:${spring.kafka.consumer.group-id}-ingress-v1}")
|
|
private String group;
|
|
@Value("${stats.ingress-metadata.large-group-id:${stats.ingress-metadata.group-id}-large}")
|
|
private String largeGroup;
|
|
|
|
public IngressStatsConsumer(IngressStatsProjectionWriter writer,
|
|
SyslogFailureRouter failureRouter, AppProperties appProperties) {
|
|
this.writer = writer;
|
|
this.failureRouter = failureRouter;
|
|
this.appProperties = appProperties;
|
|
}
|
|
|
|
@PostConstruct
|
|
public void validateGroups() {
|
|
if (group == null || group.trim().isEmpty() || largeGroup == null || largeGroup.trim().isEmpty()
|
|
|| group.equals(largeGroup) || group.equals(rawGroup) || group.equals(largeRawGroup)
|
|
|| largeGroup.equals(rawGroup) || largeGroup.equals(largeRawGroup)) {
|
|
throw new IllegalStateException("入口统计必须使用与主消费隔离的非空 Kafka group-id");
|
|
}
|
|
log.info("Ingress stats consumer ready: group={}, largeGroup={}, mode=INDEPENDENT_KAFKA_DIRECT_MERGE",
|
|
group, largeGroup);
|
|
}
|
|
|
|
@KafkaListener(id = "syslog-ingress-stats", groupId = "${stats.ingress-metadata.group-id}",
|
|
topics = "${spring.kafka.consumer.topic}",
|
|
autoStartup = "${stats.ingress-metadata.enabled:true}",
|
|
containerFactory = "ingressStatsKafkaListenerContainerFactory")
|
|
public void consume(List<ConsumerRecord<String, String>> records, Acknowledgment ack) {
|
|
consumeBatch(records, ack, "RAW");
|
|
}
|
|
|
|
@KafkaListener(id = "syslog-ingress-stats-large", groupId = "${stats.ingress-metadata.large-group-id}",
|
|
topics = "${spring.kafka.consumer.large-topic}",
|
|
autoStartup = "#{${stats.ingress-metadata.enabled:true} && ${syslog.large-listener.enabled:true}}",
|
|
containerFactory = "largeIngressStatsKafkaListenerContainerFactory")
|
|
public void consumeLarge(List<ConsumerRecord<String, String>> records, Acknowledgment ack) {
|
|
consumeBatch(records, ack, "RAW_LARGE");
|
|
}
|
|
|
|
private void consumeBatch(List<ConsumerRecord<String, String>> records, Acknowledgment ack, String lane) {
|
|
long started = System.nanoTime();
|
|
List<IngressStatsEvent> events = new ArrayList<>(records.size());
|
|
int invalid = 0;
|
|
String firstInvalid = null;
|
|
for (ConsumerRecord<String, String> record : records) {
|
|
try {
|
|
events.add(decode(record));
|
|
} catch (RuntimeException malformed) {
|
|
invalid++;
|
|
if (firstInvalid == null) firstInvalid = record.topic() + "-" + record.partition()
|
|
+ "@" + record.offset() + ":" + malformed.getClass().getSimpleName();
|
|
}
|
|
}
|
|
if (invalid > 0) {
|
|
invalidAttempts.addAndGet(invalid);
|
|
// 原始消息仍留在Kafka;坏头不会永久挡住其它设备接收状态,也不向业务Retry重复投递。
|
|
log.error("[INGRESS_STATS_INVALID] lane={}, invalidRecords={}, firstInvalid={}, "
|
|
+ "action=SKIP_INVALID_METADATA", lane, invalid, firstInvalid);
|
|
}
|
|
int groups;
|
|
try {
|
|
groups = writer.writeBatch(events);
|
|
} catch (RuntimeException failure) {
|
|
writeFailures.incrementAndGet();
|
|
log.error("[INGRESS_STATS_WRITE_FAILED] lane={}, records={}, action=RETRY_STATS_GROUP",
|
|
lane, records.size(), failure);
|
|
throw failure;
|
|
}
|
|
// MIN/MAX更新和达梦提交均已完成,才推进独立统计组;重复消费不重复增加事实数量。
|
|
ack.acknowledge();
|
|
batches.incrementAndGet();
|
|
log.info("[INGRESS_STATS_BATCH] lane={}, records={}, valid={}, invalid={}, groups={}, totalMs={}",
|
|
lane, records.size(), events.size(), invalid, groups, (System.nanoTime() - started) / 1_000_000L);
|
|
}
|
|
|
|
IngressStatsEvent decode(ConsumerRecord<String, String> record) {
|
|
SyslogFailureRouter.MessageContext context = failureRouter.resolve(record);
|
|
String decrypted = Sm4Util.decryptCbc(context.getPayload(), appProperties.getSm4Key());
|
|
if (decrypted.indexOf(']') < 0) throw new IllegalArgumentException("缺少设备元数据头");
|
|
Map<String, String> header = SyslogParser.parseKeyValuePairs(
|
|
SyslogParser.substringBeforeFirstChar(decrypted, ']'));
|
|
Long device = positiveId(header.get("device_id"));
|
|
Long collector = positiveId(header.get("device_collect_id"));
|
|
if (device == null && collector == null) throw new IllegalArgumentException("缺少有效设备/采集任务ID");
|
|
return new IngressStatsEvent(context.getEventId(), device, collector,
|
|
ReceiveTimeParser.parse(header.get("receive_time")).getLocalDateTime());
|
|
}
|
|
|
|
private static Long positiveId(String value) {
|
|
if (value == null || value.trim().isEmpty()) return null;
|
|
long parsed = Long.parseLong(value);
|
|
return parsed > 0L ? parsed : null;
|
|
}
|
|
|
|
public Map<String, Object> status() {
|
|
Map<String, Object> result = new LinkedHashMap<>();
|
|
result.put("group_id", group);
|
|
result.put("large_group_id", largeGroup);
|
|
result.put("successful_batches", batches.get());
|
|
result.put("invalid_record_attempts", invalidAttempts.get());
|
|
result.put("write_failures", writeFailures.get());
|
|
return result;
|
|
}
|
|
}
|