fix(syslog-consumer-rule): 解耦统计物化并解决多实例达梦死锁
- 将设备、规则和采集任务业务统计改为专用 Kafka Topic 异步物化 - 使用维度 ID 作为 Kafka key,保证同一统计维度串行更新 - 物化过程读取小型统计表精确值,确保重复消费不会重复计数 - 增加提交后发布、失败重试及定时小表对账补偿 - 固定日统计聚合键写入顺序,完善达梦死锁识别和事务重试 - JSON 解析失败直接转为非标日志,避免进入 DLT 重试链路 - 非标日志使用独立异步队列,不再阻塞正常消息 Kafka ACK - Kafka 升级脚本新增 STATS.MATERIALIZE Topic - 增加统计物化状态指标、配置示例及单元测试
This commit is contained in:
+17
@@ -122,6 +122,23 @@ class LogNormalProcessorRuleSelectionTest {
|
||||
verify(fixture.nonNormalService, never()).saveMessage(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonWithoutObjectBoundaryAlsoGoesDirectlyToDeferredNonNormal() {
|
||||
RuleSource invalidJson = new RuleSource(10L, "json-no-cropper", "json-no-cropper",
|
||||
"json", "{\"mappers\":[" + equalMapping()
|
||||
+ "],\"complete_columns\":[]}", true, "0");
|
||||
RuleSource regexWouldMatch = regexRule(20L, "regex-second", "value=(\\S+)");
|
||||
Fixture fixture = fixture(Arrays.asList(invalidJson, regexWouldMatch), config(true));
|
||||
|
||||
LogNormalProcessor.NormalizationResult result = fixture.prepareActive("value=ok");
|
||||
|
||||
assertEquals(LogNormalProcessor.ProcessingOutcome.FILTERED, result.getOutcome());
|
||||
assertTrue(result.getRows().isEmpty());
|
||||
assertEquals(1, result.getNonNormalMessages().size());
|
||||
assertEquals("JSON解析异常", result.getNonNormalMessages().get(0).getReason());
|
||||
verify(fixture.nonNormalService, never()).saveMessage(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyInvalidJsonIsNonNormalTerminalInsteadOfException() {
|
||||
Fixture fixture = fixture(Arrays.asList(
|
||||
|
||||
+28
@@ -13,6 +13,7 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -63,6 +64,8 @@ class SysLogProcessorParallelTest {
|
||||
ReflectionTestUtils.setField(processor, "standardizedBatchWriter", batchWriter);
|
||||
ReflectionTestUtils.setField(processor, "nonNormalMessageService", nonNormalMessageService);
|
||||
ReflectionTestUtils.setField(processor, "nonNormalWriterExecutor", nonNormalExecutor);
|
||||
ReflectionTestUtils.setField(processor, "nonNormalMaxConcurrencyRetries", 3);
|
||||
ReflectionTestUtils.setField(processor, "nonNormalConcurrencyRetryBackoffMs", 0L);
|
||||
when(batchWriter.write(any())).thenReturn(new DmStandardizedBatchWriter.WriteReport<>());
|
||||
when(normalProcessor.captureRuleSnapshot(any())).thenReturn(
|
||||
new BatchRuleSnapshot(Collections.emptyMap(), System.currentTimeMillis()));
|
||||
@@ -182,6 +185,31 @@ class SysLogProcessorParallelTest {
|
||||
assertEquals(2, metrics.filteredCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asynchronousNonNormalDeadlockRetriesInANewServiceTransaction() {
|
||||
when(normalProcessor.prepare(any(PreparedNormalizationInput.class),
|
||||
any(), any(), any())).thenAnswer(invocation -> {
|
||||
SyslogNonNormalMessage message = new SyslogNonNormalMessage();
|
||||
message.setId(java.util.UUID.randomUUID().toString());
|
||||
return LogNormalProcessor.NormalizationResult.filtered(
|
||||
Collections.singletonList(message));
|
||||
});
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(nonNormalMessageService.saveBatchMessages(anyList())).thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 1) {
|
||||
throw new RuntimeException(new DataIntegrityViolationException("Deadlock"));
|
||||
}
|
||||
return ((List<?>) invocation.getArgument(0)).size();
|
||||
});
|
||||
|
||||
SysLogProcessor.BatchMetrics metrics = new SysLogProcessor.BatchMetrics();
|
||||
processor.normalizePreparedMessages(messages(2), metrics);
|
||||
|
||||
verify(nonNormalMessageService, timeout(1000).times(2)).saveBatchMessages(anyList());
|
||||
assertEquals(2, attempts.get());
|
||||
assertEquals(2, metrics.nonNormalQueued());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedNonNormalTaskDoesNotFailKafkaBatch() {
|
||||
when(normalProcessor.prepare(any(PreparedNormalizationInput.class),
|
||||
|
||||
+17
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import com.common.service.StatsMaterializationConsumer;
|
||||
|
||||
/** 防止多节点 consumer-rule 的监听器 ID 或显式消费组在后续合并中回退。 */
|
||||
class SyslogKafkaTopologyTest {
|
||||
@@ -48,4 +49,20 @@ class SyslogKafkaTopologyTest {
|
||||
"${syslog.reliability.replay-topic:${spring.kafka.consumer.topic}.REPLAY}"
|
||||
}, listener.topics());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsMaterializerUsesDedicatedTopicAndGroup() throws Exception {
|
||||
Method method = StatsMaterializationConsumer.class.getMethod(
|
||||
"consume", List.class, org.springframework.kafka.support.Acknowledgment.class);
|
||||
KafkaListener listener = method.getAnnotation(KafkaListener.class);
|
||||
|
||||
assertNotNull(listener);
|
||||
assertEquals("syslog-stats-materializer", listener.id());
|
||||
assertEquals("${stats.materialization.group-id:${spring.kafka.consumer.group-id}-stats-materialize}",
|
||||
listener.groupId());
|
||||
assertEquals("statsMaterializationKafkaListenerContainerFactory", listener.containerFactory());
|
||||
assertArrayEquals(new String[]{
|
||||
"${stats.materialization.topic:${spring.kafka.consumer.topic}.STATS.MATERIALIZE}"
|
||||
}, listener.topics());
|
||||
}
|
||||
}
|
||||
|
||||
+46
-2
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Answers;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.sql.BatchUpdateException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -19,7 +21,9 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -105,10 +109,51 @@ class DirectStatsProjectionWriterTest {
|
||||
assertTrue(sql.getAllValues().get(1).contains("UPDATE device_device"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void damengBatchDeadlockWrappedAsIntegrityViolationIsRetryable() {
|
||||
BatchUpdateException deadlock = new BatchUpdateException("Deadlock", null, 0, new int[0]);
|
||||
DataIntegrityViolationException wrapped =
|
||||
new DataIntegrityViolationException("PreparedStatementCallback", deadlock);
|
||||
|
||||
assertTrue(DirectStatsProjectionWriter.isConcurrentFailure(wrapped));
|
||||
}
|
||||
|
||||
@Test
|
||||
void kafkaMaterializationRemovesBusinessTableUpdatesFromFactTransaction() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
StatsMaterializationPublisher publisher = mock(StatsMaterializationPublisher.class);
|
||||
when(publisher.isEnabled()).thenReturn(true);
|
||||
DirectStatsProjectionWriter writer = new DirectStatsProjectionWriter(
|
||||
jdbc, new StatsWriteModeProvider("DIRECT"), new DirectStatsWriteMetrics(), publisher);
|
||||
configure(writer, true);
|
||||
TransactionSynchronizationManager.setActualTransactionActive(true);
|
||||
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("created_at", LocalDateTime.now());
|
||||
row.put("log_time", LocalDateTime.now());
|
||||
row.put("device_id", 7L);
|
||||
row.put("normalize_rule_id", 11L);
|
||||
writer.writeStandardizedRow(row);
|
||||
|
||||
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, times(2)).batchUpdate(sql.capture(), anyList());
|
||||
assertTrue(sql.getAllValues().get(0).contains("MERGE INTO device_daily_stats"));
|
||||
assertTrue(sql.getAllValues().get(1).contains("MERGE INTO normalize_rule_daily_stats"));
|
||||
verify(publisher).enqueueAfterCommit(anyCollection());
|
||||
assertTrue("KAFKA_DIRTY_KEY".equals(writer.status().get("business_materialization_mode")));
|
||||
}
|
||||
|
||||
private DirectStatsProjectionWriter writer(JdbcTemplate jdbc, String mode, boolean readEnabled) {
|
||||
StatsWriteModeProvider modeProvider = new StatsWriteModeProvider(mode);
|
||||
StatsMaterializationPublisher publisher = mock(StatsMaterializationPublisher.class);
|
||||
when(publisher.isEnabled()).thenReturn(false);
|
||||
DirectStatsProjectionWriter writer = new DirectStatsProjectionWriter(
|
||||
jdbc, modeProvider, new DirectStatsWriteMetrics());
|
||||
jdbc, modeProvider, new DirectStatsWriteMetrics(), publisher);
|
||||
configure(writer, readEnabled);
|
||||
return writer;
|
||||
}
|
||||
|
||||
private void configure(DirectStatsProjectionWriter writer, boolean readEnabled) {
|
||||
ReflectionTestUtils.setField(writer, "readEnabled", readEnabled);
|
||||
ReflectionTestUtils.setField(writer, "directDeviceTable", "device_daily_stats");
|
||||
ReflectionTestUtils.setField(writer, "directRuleTable", "normalize_rule_daily_stats");
|
||||
@@ -117,6 +162,5 @@ class DirectStatsProjectionWriterTest {
|
||||
ReflectionTestUtils.setField(writer, "controlTable", "stats_direct_control");
|
||||
ReflectionTestUtils.setField(writer, "todayStatsDateEnabled", true);
|
||||
ReflectionTestUtils.setField(writer, "businessZone", "Asia/Shanghai");
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.common.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class StatsMaterializationCommandTest {
|
||||
@Test
|
||||
void usesStableDimensionKeyAndValidatesDate() {
|
||||
StatsMaterializationCommand command = StatsMaterializationCommand.device(
|
||||
LocalDate.of(2026, 9, 11), 7L);
|
||||
|
||||
assertEquals("DEVICE:7", command.key());
|
||||
assertEquals(LocalDate.of(2026, 9, 11), command.date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingDateForDailyDimension() {
|
||||
StatsMaterializationCommand command = new StatsMaterializationCommand();
|
||||
command.setType(StatsMaterializationCommand.Type.RULE);
|
||||
command.setDimensionId(8L);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, command::validate);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.common.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class StatsMaterializationConsumerTest {
|
||||
@Test
|
||||
void materializesExactValuesInOneTransaction() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
PlatformTransactionManager manager = mock(PlatformTransactionManager.class);
|
||||
when(manager.getTransaction(any(TransactionDefinition.class)))
|
||||
.thenReturn(new SimpleTransactionStatus());
|
||||
StatsMaterializationConsumer consumer = new StatsMaterializationConsumer(
|
||||
jdbc, manager, new ObjectMapper(), new StatsWriteModeProvider("DIRECT"));
|
||||
ReflectionTestUtils.setField(consumer, "readEnabled", true);
|
||||
ReflectionTestUtils.setField(consumer, "deviceTable", "device_daily_stats");
|
||||
ReflectionTestUtils.setField(consumer, "ruleTable", "normalize_rule_daily_stats");
|
||||
ReflectionTestUtils.setField(consumer, "todayStatsDateEnabled", true);
|
||||
ReflectionTestUtils.setField(consumer, "businessZone", "Asia/Shanghai");
|
||||
ReflectionTestUtils.setField(consumer, "transactionTimeoutSeconds", 45);
|
||||
ReflectionTestUtils.setField(consumer, "maxConcurrencyRetries", 3);
|
||||
ReflectionTestUtils.setField(consumer, "retryBackoffMs", 0L);
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
int count = consumer.materializeWithRetry(Arrays.asList(
|
||||
StatsMaterializationCommand.device(today, 7L),
|
||||
StatsMaterializationCommand.rule(today, 8L),
|
||||
StatsMaterializationCommand.collect(2L)));
|
||||
|
||||
assertEquals(3, count);
|
||||
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, times(3)).batchUpdate(sql.capture(), anyList());
|
||||
List<String> statements = sql.getAllValues();
|
||||
assertTrue(statements.get(0).contains("MERGE INTO device_device"));
|
||||
assertTrue(statements.get(1).contains("MAX(max_log_time)"));
|
||||
assertTrue(statements.get(2).contains("MERGE INTO device_collect_task"));
|
||||
verify(manager).commit(any());
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.common.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class StatsMaterializationPublisherTest {
|
||||
@AfterEach
|
||||
void clearTransactionState() {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void dirtyKeyBecomesPublishableOnlyAfterDatabaseCommit() {
|
||||
@SuppressWarnings("unchecked")
|
||||
KafkaTemplate<String, String> kafka = mock(KafkaTemplate.class);
|
||||
StatsMaterializationPublisher publisher = new StatsMaterializationPublisher(kafka, new ObjectMapper());
|
||||
ReflectionTestUtils.setField(publisher, "enabled", true);
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
|
||||
publisher.enqueueAfterCommit(Collections.singletonList(
|
||||
StatsMaterializationCommand.device(LocalDate.of(2026, 9, 11), 7L)));
|
||||
assertEquals(0, publisher.status().get("pending_keys"));
|
||||
|
||||
for (TransactionSynchronization synchronization
|
||||
: TransactionSynchronizationManager.getSynchronizations()) {
|
||||
synchronization.afterCommit();
|
||||
}
|
||||
assertEquals(1, publisher.status().get("pending_keys"));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.common.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class StatsMaterializationReconcilerTest {
|
||||
@Test
|
||||
void scansOnlyAggregateTablesAndBuildsExactDirtyKeys() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> {
|
||||
if (!"queryForList".equals(invocation.getMethod().getName())) {
|
||||
return org.mockito.Answers.RETURNS_DEFAULTS.answer(invocation);
|
||||
}
|
||||
String sql = (String) invocation.getArguments()[0];
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
if (sql.contains("device_daily_stats")) row.put("DIMENSION_ID", 7L);
|
||||
else if (sql.contains("normalize_rule_daily_stats")) row.put("dimension_id", 8L);
|
||||
else if (sql.contains("device_collect_stats")) row.put("DIMENSION_ID", 2L);
|
||||
else return Collections.emptyList();
|
||||
return Collections.singletonList(row);
|
||||
});
|
||||
StatsMaterializationReconciler reconciler = new StatsMaterializationReconciler(
|
||||
jdbc, new StatsWriteModeProvider("DIRECT"), mock(StatsMaterializationPublisher.class));
|
||||
ReflectionTestUtils.setField(reconciler, "deviceTable", "device_daily_stats");
|
||||
ReflectionTestUtils.setField(reconciler, "ruleTable", "normalize_rule_daily_stats");
|
||||
ReflectionTestUtils.setField(reconciler, "businessZone", "Asia/Shanghai");
|
||||
|
||||
List<StatsMaterializationCommand> commands = reconciler.findDifferences();
|
||||
|
||||
assertEquals(3, commands.size());
|
||||
assertEquals("DEVICE:7", commands.get(0).key());
|
||||
assertEquals("RULE:8", commands.get(1).key());
|
||||
assertEquals("COLLECT:2", commands.get(2).key());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user