feat(syslog): 增加普通通道字段截断并完善慢写诊断

- 新增普通通道字段上限配置,默认2048个UTF-8字节,截断尾部保留标记
- 规则计算后执行截断,告警不应用新增2KB限制,原有256KB策略不变
- 非标原文、头部及详细原因应用新增限制,大日志通道和InfluxDB策略不变
- Retry/Replay按原始Topic识别通道,启动日志输出生效上限
- 补充达梦实际写入内容大小、最大行及最大字段诊断
- 慢日志默认阈值统一调整为1000毫秒,异常和失败仍记录
- 更新v4配置说明及回归测试

验证:JDK 8构建成功,250项测试通过
升级:替换JAR并重启,无需数据库或Kafka脚本
This commit is contained in:
王绍宏
2026-09-13 08:00:08 +08:00
parent 8173c2ff3f
commit e393350c8b
16 changed files with 684 additions and 19 deletions
@@ -8,6 +8,7 @@ import com.common.util.SyslogParser;
import com.config.AppProperties;
import com.config.KafkaConsumerProperties;
import com.common.service.DmStandardizedBatchWriter;
import com.common.service.RawLaneFieldLimiter;
import com.common.service.DirectStatsProjectionWriter;
import com.common.service.SyslogNonNormalMessageService;
import com.common.service.SyslogNormalDataService;
@@ -54,6 +55,9 @@ import com.common.util.Sm4Util;
@Component
public class SysLogProcessor {
@Autowired
private RawLaneFieldLimiter rawLaneFieldLimiter;
@Autowired
@Qualifier("logNormalProcessorExecutor")
private Executor taskExecutor;
@@ -85,7 +89,7 @@ public class SysLogProcessor {
@Value("${syslog.failure-continuity.enabled:true}")
private boolean failureContinuityEnabled;
@Value("${syslog.slow-batch.threshold-ms:500}")
@Value("${syslog.slow-batch.threshold-ms:1000}")
private long slowBatchThresholdMs;
/** listener、业务处理和 Kafka commit 均在同一个 consumer 线程,ThreadLocal 用于拼接端到端慢批次。 */
@@ -1120,15 +1124,25 @@ public class SysLogProcessor {
continue;
}
boolean limitRawFields = rawLaneFieldLimiter.appliesTo(prepared.message.record.topic(),
prepared.message.context == null ? null : prepared.message.context.getOriginalTopic());
if (retained.keepNonNormal) {
if (limitRawFields) {
prepared.result.getNonNormalMessages().forEach(rawLaneFieldLimiter::truncateNonNormal);
}
nonNormalMessages.addAll(prepared.result.getNonNormalMessages());
}
int retainedStandardRows = 0;
for (Map<String, Object> row : prepared.result.getRows()) {
boolean alarm = syslogNormalDataService.isAlarmData(row);
if (alarm || retained.keepNormal) {
Map<String, Object> storedRow = row;
if (limitRawFields && !alarm) {
storedRow = new LinkedHashMap<>(row);
rawLaneFieldLimiter.truncateRow(storedRow);
}
DmStandardizedBatchWriter.WriteRequest<PreparedMessage> request =
new DmStandardizedBatchWriter.WriteRequest<>(prepared.message, row);
new DmStandardizedBatchWriter.WriteRequest<>(prepared.message, storedRow);
if (alarm) alarmWriteRequests.add(request);
else normalWriteRequests.add(request);
retainedStandardRows++;
@@ -46,20 +46,33 @@ public class DmPreparedBatchInserter {
public BatchExecution insert(boolean alarmTable, List<Map<String, Object>> rows,
int statementTimeoutSeconds) {
return insert(alarmTable, rows, statementTimeoutSeconds, new DmWriteSizeEstimate());
}
BatchExecution insert(boolean alarmTable, List<Map<String, Object>> rows,
int statementTimeoutSeconds, DmWriteSizeEstimate writeSize) {
if (rows == null || rows.isEmpty()) {
return BatchExecution.EMPTY;
}
final String table = alarmTable ? ALARM_TABLE : NORMAL_TABLE;
return jdbcTemplate.execute((ConnectionCallback<BatchExecution>) connection ->
execute(connection, table, rows, statementTimeoutSeconds));
execute(connection, table, rows, statementTimeoutSeconds, writeSize));
}
BatchExecution execute(Connection connection, String table, List<Map<String, Object>> rows,
int statementTimeoutSeconds) throws SQLException {
return execute(connection, table, rows, statementTimeoutSeconds, new DmWriteSizeEstimate());
}
BatchExecution execute(Connection connection, String table, List<Map<String, Object>> rows,
int statementTimeoutSeconds, DmWriteSizeEstimate writeSize) throws SQLException {
long planStarted = System.nanoTime();
TableMetadata metadata = metadata(connection, table);
BatchPlan plan = buildPlan(table, rows, metadata);
long planMs = elapsedMillis(planStarted);
// 使用元数据白名单选中的列,排除规则携带但不会实际绑定的字段。
// 执行前完成测量,使JDBC绑定/执行失败时仍可输出这次尝试的数据量。
writeSize.measure(rows, plan.columns);
long bindMs;
long executeMs;
@@ -36,7 +36,7 @@ public class DmStandardizedBatchExecutor {
@org.springframework.beans.factory.annotation.Value("${stats.direct.transaction-log-enabled:false}")
private boolean transactionLogEnabled;
@org.springframework.beans.factory.annotation.Value("${syslog.slow-batch.threshold-ms:500}")
@org.springframework.beans.factory.annotation.Value("${syslog.slow-batch.threshold-ms:1000}")
private long slowTransactionThresholdMs;
@org.springframework.beans.factory.annotation.Value("${stats.direct.max-concurrency-retries:1}")
@@ -118,7 +118,7 @@ public class DmStandardizedBatchExecutor {
// 事实表使用一个固定 PreparedStatement 直接 addBatch,彻底绕开逐行 OGNL 动态 SQL。
timing.phase("JDBC_FACT_WRITE");
timing.batchExecution = preparedBatchInserter.insert(
alarmTable, rows, transactionTimeoutSeconds);
alarmTable, rows, transactionTimeoutSeconds, timing.writeSize);
// 兼容期变更日志仍与事实行共用当前事务;默认关闭时完全不创建 MyBatis BatchSession。
if (statsChangeCaptureService.isEnabled()) {
@@ -145,11 +145,11 @@ public class DmStandardizedBatchExecutor {
} catch (RuntimeException failure) {
log.error("[SLOW_DM_TRANSACTION] batchId={}, table={}, factRows={}, status=FAILED, "
+ "failureStage={}, phaseElapsedMs={}, transactionMs={}, connectionBeginMs={}, "
+ "jdbcExecuteMs={}, lateAlarmBucketMs={}, retry={}, errorType={}",
+ "jdbcExecuteMs={}, lateAlarmBucketMs={}, retry={}, errorType={}, {}",
org.slf4j.MDC.get("syslogBatchId"), alarmTable ? "syslog_normal_alarm" : "syslog_normal_data",
rows.size(), timing.phase, elapsedMs(timing.phaseStarted), elapsedMs(transactionStarted),
timing.beginMs, timing.batchExecution.getExecuteMs(), timing.lateBucketMs, retries,
failure.getClass().getName());
failure.getClass().getName(), timing.writeSize);
throw failure;
}
long transactionMs = elapsedMs(transactionStarted);
@@ -159,22 +159,22 @@ public class DmStandardizedBatchExecutor {
if (slowTransactionThresholdMs > 0 && transactionMs >= slowTransactionThresholdMs) {
log.warn("[SLOW_DM_TRANSACTION] batchId={}, table={}, factRows={}, status=SUCCESS, connectionBeginMs={}, jdbcPlanMs={}, "
+ "jdbcBindMs={}, jdbcExecuteMs={}, lateAlarmBucketMs={}, statsProjectionStageMs={}, "
+ "commitMs={}, transactionMs={}, retry={}",
+ "commitMs={}, transactionMs={}, retry={}, {}",
org.slf4j.MDC.get("syslogBatchId"), alarmTable ? "syslog_normal_alarm" : "syslog_normal_data", rows.size(), timing.beginMs,
timing.batchExecution.getPlanMs(), timing.batchExecution.getBindMs(),
timing.batchExecution.getExecuteMs(), timing.lateBucketMs,
timing.statsResult == null ? 0L : timing.statsResult.getMergeMs(),
timing.commitMs, transactionMs, retries);
timing.commitMs, transactionMs, retries, timing.writeSize);
}
if (transactionLogEnabled && timing.statsResult != null && timing.statsResult.isDirect()) {
log.info("DIRECT 达梦事务完成: factRows={}, deviceGroups={}, ruleGroups={}, aggregateMs={}, " +
"jdbcPlanMs={}, jdbcBindMs={}, jdbcExecuteMs={}, factFlushMs={}, "
+ "statsProjectionStageMs={}, materializeMs={}, commitMs={}, transactionMs={}, retry={}",
+ "statsProjectionStageMs={}, materializeMs={}, commitMs={}, transactionMs={}, retry={}, {}",
rows.size(), timing.statsResult.getDeviceGroups(), timing.statsResult.getRuleGroups(),
timing.statsResult.getAggregateMs(), timing.batchExecution.getPlanMs(),
timing.batchExecution.getBindMs(), timing.batchExecution.getExecuteMs(), timing.factFlushMs,
timing.statsResult.getMergeMs(), timing.statsResult.getMaterializeMs(),
timing.commitMs, transactionMs, retries);
timing.commitMs, transactionMs, retries, timing.writeSize);
}
}
@@ -189,6 +189,7 @@ public class DmStandardizedBatchExecutor {
}
private static final class TransactionTiming {
private final DmWriteSizeEstimate writeSize = new DmWriteSizeEstimate();
private String phase = "CONNECTION_BEGIN";
private long phaseStarted = System.nanoTime();
private void phase(String value) { phase = value; phaseStarted = System.nanoTime(); }
@@ -69,7 +69,7 @@ public class DmStandardizedBatchWriter {
@Value("${dm.standardized-write.batch-size:500}")
private int batchSize;
@Value("${syslog.slow-batch.threshold-ms:500}")
@Value("${syslog.slow-batch.threshold-ms:1000}")
private long slowQueueThresholdMs;
@Value("${dm.standardized-write.transaction-timeout-seconds:45}")
@@ -0,0 +1,107 @@
package com.common.service;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* JDBC参数内容大小估算,不是网络包、数据库页或REDO大小。
* 只统计实际INSERT字段,调用位置在截断/字段准备之后、绑定/执行之前。
* 不读取LOB或流,不序列化任意对象,不保留字段值,不创建大字符串的UTF-8副本。
*/
final class DmWriteSizeEstimate {
private String status = "NOT_MEASURED";
private long payloadBytes;
private long maxRowBytes;
private long maxFieldBytes;
private String maxFieldName = "none";
private long unmeasuredValues;
private long elapsedMs;
void measure(List<Map<String, Object>> rows, List<DmPreparedBatchInserter.ColumnMetadata> columns) {
long started = System.nanoTime();
try {
for (Map<String, Object> row : rows) {
long rowBytes = 0L;
for (DmPreparedBatchInserter.ColumnMetadata column : columns) {
long bytes = valueBytes(row.get(column.name));
if (bytes < 0L) {
unmeasuredValues++;
continue;
}
rowBytes += bytes;
if (bytes > maxFieldBytes) {
maxFieldBytes = bytes;
maxFieldName = column.name;
}
}
payloadBytes += rowBytes;
maxRowBytes = Math.max(maxRowBytes, rowBytes);
}
status = unmeasuredValues == 0L ? "COMPLETE" : "PARTIAL";
} catch (RuntimeException diagnosticFailure) {
// 观测失败不能改变事实写入行为;不记录异常消息,避免携带字段内容。
status = "FAILED";
} finally {
elapsedMs = (System.nanoTime() - started) / 1_000_000L;
}
}
static long valueBytes(Object value) {
if (value == null) return 0L;
if (value instanceof String) return utf8Length((String) value);
if (value instanceof byte[]) return ((byte[]) value).length;
// 只对已知标量计其文本表示,避免任意对象toString触发IO或泄漏内容。
Class<?> type = value.getClass();
if (type == Boolean.class || type == Character.class || type == Byte.class
|| type == Short.class || type == Integer.class || type == Long.class
|| type == Float.class || type == Double.class || type == BigDecimal.class
|| type == BigInteger.class || type == LocalDate.class || type == LocalDateTime.class
|| type == Timestamp.class || type == java.sql.Date.class || type == java.sql.Time.class) {
return utf8Length(value.toString());
}
if (type == java.util.Date.class) {
return utf8Length(new Timestamp(((java.util.Date) value).getTime()).toString());
}
return -1L;
}
/** 与JDK8 String.getBytes(UTF_8)一致:有效代理对4字节,孤立代理字符替换为一个问号字节。 */
static long utf8Length(String text) {
long bytes = 0L;
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
if (ch < 0x80) bytes++;
else if (ch < 0x800) bytes += 2L;
else if (Character.isHighSurrogate(ch) && i + 1 < text.length()
&& Character.isLowSurrogate(text.charAt(i + 1))) {
bytes += 4L;
i++;
} else if (Character.isSurrogate(ch)) bytes++;
else bytes += 3L;
}
return bytes;
}
long getPayloadBytes() { return payloadBytes; }
long getMaxRowBytes() { return maxRowBytes; }
long getMaxFieldBytes() { return maxFieldBytes; }
String getMaxFieldName() { return maxFieldName; }
long getUnmeasuredValues() { return unmeasuredValues; }
String getStatus() { return status; }
@Override
public String toString() {
return "sizeEstimateStatus=" + status
+ ", estimatedPayloadBytes=" + payloadBytes
+ ", maxRowEstimatedBytes=" + maxRowBytes
+ ", maxFieldEstimatedBytes=" + maxFieldBytes
+ ", maxFieldName=" + maxFieldName
+ ", unmeasuredValues=" + unmeasuredValues
+ ", sizeEstimateMs=" + elapsedMs;
}
}
@@ -0,0 +1,62 @@
package com.common.service;
import com.common.entity.SyslogNonNormalMessage;
import com.common.util.Utf8TextTruncator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** 普通来源消息完成规则和采样后,限制非告警达梦输出文本;调用方排除告警,不修改规则输入和InfluxDB原文。 */
@Component
public class RawLaneFieldLimiter {
// 这些字段参与幂等、路由、分区或关联,不允许截断改变其语义。
private static final Set<String> IDENTITY_FIELDS = new HashSet<>(Arrays.asList(
"id", "syslog_uuid", "syslog_topic", "normalize_rule_id", "rule_version",
"device_id", "tenant_id", "agent_id", "origin_agent_id", "collect_task_id",
"log_time", "created_at", "receive_time", "event_level"));
private final long maxBytes;
private final String rawTopic;
public RawLaneFieldLimiter(
@Value("${syslog.raw-listener.field-max-utf8-bytes:2048}") long maxBytes,
@Value("${spring.kafka.consumer.topic}") String rawTopic) {
if (maxBytes < 0 || (maxBytes > 0 && maxBytes < 64)) {
throw new IllegalArgumentException("syslog.raw-listener.field-max-utf8-bytes 必须为0或至少64");
}
this.maxBytes = maxBytes;
this.rawTopic = rawTopic;
}
public boolean appliesTo(String currentTopic, String originalTopic) {
String sourceTopic = originalTopic == null || originalTopic.isEmpty() ? currentTopic : originalTopic;
return maxBytes > 0 && rawTopic != null && rawTopic.equals(sourceTopic);
}
/** 包括自定义映射文本,不限于旧的大字段白名单;数值、日期、二进制值保持原类型。 */
public void truncateRow(Map<String, Object> row) {
if (row == null || maxBytes == 0) return;
for (Map.Entry<String, Object> entry : row.entrySet()) {
if (entry.getValue() instanceof String && !IDENTITY_FIELDS.contains(entry.getKey())) {
entry.setValue(truncate((String) entry.getValue()));
}
}
}
public void truncateNonNormal(SyslogNonNormalMessage message) {
if (message == null || maxBytes == 0) return;
message.setSyslogMessage(truncate(message.getSyslogMessage()));
message.setHeaderMessage(truncate(message.getHeaderMessage()));
message.setReasonDetail(truncate(message.getReasonDetail()));
}
private String truncate(String value) {
// 每个UTF-16代码单元最多占3个UTF-8字节,短字段无需分配UTF-8数组。
if (value == null || (long) value.length() * 3L <= maxBytes) return value;
return Utf8TextTruncator.truncate(value, maxBytes).getValue();
}
}
@@ -12,7 +12,7 @@ import org.springframework.stereotype.Component;
public class SyslogBuildStartupLogger {
// 确认包含此功能修订,不代表唯一构建;后续功能修订时同步更新此标识。
static final String CODE_REVISION = "ASYNC_STATS_ADAPTIVE_RAW_V20260913_56";
static final String CODE_REVISION = "ASYNC_STATS_ADAPTIVE_RAW_V20260913_57";
private final Environment environment;
public SyslogBuildStartupLogger(Environment environment) {
@@ -32,12 +32,13 @@ public class SyslogBuildStartupLogger {
+ ", rawConcurrency=" + value("syslog.raw-listener.concurrency", "3")
+ ", baseProcessingRecords=" + value("syslog.raw-listener.max-poll-records", "500")
+ ", adaptiveMaxRecords=" + value("syslog.raw-listener.adaptive-max-records", "3000")
+ ", rawFieldMaxUtf8Bytes=" + value("syslog.raw-listener.field-max-utf8-bytes", "2048")
+ ", samplingEnabled=" + value("syslog.backlog-sampling.enabled", "true")
+ ", statsWriteMode=" + value("stats.write-mode", "DIRECT")
+ ", ingressStatsEnabled=" + value("stats.ingress-metadata.enabled", "true")
+ ", statsPublisherEnabled=" + value("stats.projection-async.enabled", "true")
+ ", lateBucketEnabled=" + value("analysis.late-replay.outbox-enabled", "true")
+ ", slowBatchThresholdMs=" + value("syslog.slow-batch.threshold-ms", "500");
+ ", slowBatchThresholdMs=" + value("syslog.slow-batch.threshold-ms", "1000");
}
private String value(String key, String fallback) {
@@ -82,8 +82,9 @@ spring.kafka.consumer.large-topic=${KAFKA_CONSUMER_LARGE_TOPIC:${spring.kafka.co
syslog.raw-consumer.enabled=true
# Raw Topic 当前有 3 个分区,使用独立工厂显式创建 3 个 consumer;可通过环境变量灰度调整。
syslog.raw-listener.concurrency=${SYSLOG_RAW_LISTENER_CONCURRENCY:3}
# 端到端批次耗时达到该阈值时,打印业务各阶段及 Kafka offset 同步提交耗时;0 表示关闭。
syslog.slow-batch.threshold-ms=${SYSLOG_SLOW_BATCH_THRESHOLD_MS:500}
# 慢日志统一阈值,默认1000毫秒(1秒):端到端批次、达梦事务、写入排队及非标异步写入。
# 达到阈值时打印诊断;0关闭耗时触发的慢日志,但异常/失败仍记录。已有同名环境变量优先。
syslog.slow-batch.threshold-ms=${SYSLOG_SLOW_BATCH_THRESHOLD_MS:1000}
# 普通业务基础批量500(保留旧配置名);不再表示底层poll上限。已有同名环境变量仍覆盖基础值。
# 目标业务批量=ceil(基础条数*10000/max(普通保留万分比,非标保留万分比)),并受下面3000上限限制。
syslog.raw-listener.max-poll-records=${SYSLOG_RAW_LISTENER_MAX_POLL_RECORDS:500}
@@ -220,6 +221,12 @@ dm.standardized-write.batch-size=${DM_STANDARDIZED_WRITE_BATCH_SIZE:500}
dm.standardized-write.writer-concurrency=2
dm.standardized-write.writer-queue-capacity=100
# 标准化大字段和非标原文均在达梦持久化边界截断;0 表示不限制。
# 普通Kafka来源字段上限:默认2KB=2048个UTF-8字节(含截断标记),0关闭本层限制,启用时至少64。
# 在规则计算/采样之后限制普通表的文本字段(幂等ID/路由/分区等技术字段除外),告警不应用本层2KB限制,
# 以及非标表syslog_message/header_message/reason_detail;不影响InfluxDB原文,大日志不应用2KB限制。
# 重试/重放按originalTopic识别;改为4KB填4096。已有同名环境变量会覆盖JAR内默认值。
syslog.raw-listener.field-max-utf8-bytes=${SYSLOG_RAW_LISTENER_FIELD_MAX_UTF8_BYTES:2048}
# 以下旧限制继续生效(告警也保留原有大字段限制);同时命中时遵循较小上限,关闭上面的限制不会关闭以下限制。
dm.normal.large-field-max-utf8-bytes=${DM_NORMAL_LARGE_FIELD_MAX_UTF8_BYTES:262144}
dm.normal.large-fields=${DM_NORMAL_LARGE_FIELDS:http_req_body,http_resp_body,http_req_header,http_resp_header,detail,description,cmdline,shell_cmdline,parent_cmdline,mail_message}
dm.non-normal.large-field-max-utf8-bytes=${DM_NON_NORMAL_LARGE_FIELD_MAX_UTF8_BYTES:262144}
@@ -65,6 +65,8 @@ class SysLogProcessorParallelTest {
executor = Executors.newFixedThreadPool(4);
nonNormalExecutor = Executors.newSingleThreadExecutor();
processor = new SysLogProcessor();
ReflectionTestUtils.setField(processor, "rawLaneFieldLimiter",
new com.common.service.RawLaneFieldLimiter(2048, "raw"));
normalProcessor = mock(LogNormalProcessor.class);
failureRouter = mock(SyslogFailureRouter.class);
batchWriter = mock(DmStandardizedBatchWriter.class);
@@ -459,6 +461,53 @@ class SysLogProcessorParallelTest {
return messages;
}
@Test
@SuppressWarnings("unchecked")
void rawOutputIsLimitedAfterRulesAndLargeOutputIsUnchangedIncludingRetry() {
for (String originalTopic : java.util.Arrays.asList("raw", "raw.large")) {
ConsumerRecord<String, String> record = new ConsumerRecord<>("retry", 0, 0L, "key", "ciphertext");
SyslogFailureRouter.MessageContext context = new SyslogFailureRouter.MessageContext(
"ciphertext", "stable-id", 1, null, Instant.EPOCH, originalTopic, null);
String content = String.join("", Collections.nCopies(3000, "中"));
SysLogProcessor.PreparedMessage source = new SysLogProcessor.PreparedMessage(record, context,
content, "stable-id", null, LocalDateTime.now(), 7L, 3L, System.currentTimeMillis());
java.util.Map<String, Object> normal = normalizedRow();
normal.put("custom_field", content);
java.util.Map<String, Object> alarm = normalizedRow();
alarm.put("event_level", "HIGH");
alarm.put("description", content);
SyslogNonNormalMessage nonNormal = new SyslogNonNormalMessage();
nonNormal.setSyslogMessage(content);
List<SysLogProcessor.PreparedNormalization> computed = java.util.Arrays.asList(
new SysLogProcessor.PreparedNormalization(source,
LogNormalProcessor.NormalizationResult.success(java.util.Arrays.asList(normal, alarm)), null),
new SysLogProcessor.PreparedNormalization(source,
LogNormalProcessor.NormalizationResult.filtered(Collections.singletonList(nonNormal)), null));
Object retainedPlan = ReflectionTestUtils.invokeMethod(processor, "allRetainedPlan", computed);
Object persistencePlan = ReflectionTestUtils.invokeMethod(processor, "prepareStandardizedPersistence",
ReflectionTestUtils.getField(retainedPlan, "normalizations"), new SysLogProcessor.BatchMetrics());
for (String requestsName : java.util.Arrays.asList("normalWriteRequests", "alarmWriteRequests")) {
List<DmStandardizedBatchWriter.WriteRequest<SysLogProcessor.PreparedMessage>> requests =
(List<DmStandardizedBatchWriter.WriteRequest<SysLogProcessor.PreparedMessage>>)
ReflectionTestUtils.getField(persistencePlan, requestsName);
assertEquals(1, requests.size(), "告警和普通记录数量不变");
String stored = (String) requests.get(0).getData().get(
"alarmWriteRequests".equals(requestsName) ? "description" : "custom_field");
if ("raw".equals(originalTopic) && "normalWriteRequests".equals(requestsName)) {
assertTrue(stored.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 2048);
assertTrue(stored.contains("HB_XDR_TRUNCATED"));
} else {
assertEquals(content, stored);
}
}
assertEquals(content, normal.get("custom_field"), "规则计算结果不被原地覆盖");
assertEquals(content, alarm.get("description"));
assertEquals(content, ReflectionTestUtils.getField(source, "decryptedMessage"), "Influx原文不改变");
if ("raw".equals(originalTopic)) assertTrue(nonNormal.getSyslogMessage().contains("HB_XDR_TRUNCATED"));
else assertEquals(content, nonNormal.getSyslogMessage());
}
}
private List<SysLogProcessor.PreparedMessage> oldMessages(int count) {
List<SysLogProcessor.PreparedMessage> old = new ArrayList<>();
for (int i = 0; i < count; i++) {
@@ -113,13 +113,24 @@ class DmPreparedBatchInserterTest {
DmPreparedBatchInserter inserter = new DmPreparedBatchInserter(mock(
org.springframework.jdbc.core.JdbcTemplate.class));
DmWriteSizeEstimate size = new DmWriteSizeEstimate();
DmPreparedBatchInserter.BatchExecution result = inserter.execute(connection,
DmPreparedBatchInserter.NORMAL_TABLE, Arrays.asList(first, second), 45);
DmPreparedBatchInserter.NORMAL_TABLE, Arrays.asList(first, second), 45, size);
assertEquals(2, result.getRows());
assertEquals(2, result.getColumns());
assertEquals(18L, size.getPayloadBytes());
assertEquals(13L, size.getMaxRowBytes());
assertEquals("src_ip", size.getMaxFieldName());
verify(insertStatement, times(2)).addBatch();
verify(insertStatement).executeBatch();
verify(insertStatement).setNull(2, Types.VARCHAR);
when(insertStatement.executeBatch()).thenThrow(new java.sql.SQLException("test failure"));
DmWriteSizeEstimate failedSize = new DmWriteSizeEstimate();
assertThrows(java.sql.SQLException.class, () -> inserter.execute(connection,
DmPreparedBatchInserter.NORMAL_TABLE, Arrays.asList(first, second), 45, failedSize));
assertEquals("COMPLETE", failedSize.getStatus());
assertEquals(18L, failedSize.getPayloadBytes());
}
}
@@ -54,6 +54,34 @@ class DmStandardizedBatchWriterTest {
when(normalDataService.isAlarmData(any())).thenReturn(false);
}
@Test
void alarmsKeepLegacyTruncationInBothBatchAndSingleMode() {
DmLargeTextLimiter limiter = new DmLargeTextLimiter();
ReflectionTestUtils.setField(limiter, "normalMaxBytes", 64L);
ReflectionTestUtils.setField(limiter, "normalFieldsValue", "description");
limiter.validate();
ReflectionTestUtils.setField(writer, "largeTextLimiter", limiter);
when(normalDataService.isAlarmData(any())).thenAnswer(invocation ->
((Map<?, ?>) invocation.getArgument(0)).get("event_level") != null);
when(normalDataService.insertDynamic(any())).thenReturn(1);
String content = String.join("", java.util.Collections.nCopies(300000, "x"));
for (String mode : java.util.Arrays.asList("BATCH", "SINGLE")) {
ReflectionTestUtils.setField(writer, "mode", mode);
List<DmStandardizedBatchWriter.WriteRequest<String>> requests = requests(2, -1);
requests.get(0).getData().put("event_level", "HIGH");
requests.get(0).getData().put("description", content);
requests.get(1).getData().put("description", content);
DmStandardizedBatchWriter.WriteReport<String> report = writer.write(requests);
assertEquals(2, report.getSuccessfulRows());
String alarm = (String) requests.get(0).getData().get("description");
assertTrue(alarm.contains("HB_XDR_TRUNCATED"), "告警仍遵守旧的大字段上限");
assertTrue(alarm.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 64);
String ordinary = (String) requests.get(1).getData().get("description");
assertTrue(ordinary.contains("HB_XDR_TRUNCATED"));
assertTrue(ordinary.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 64);
}
}
@Test
void oneBadRowIsIsolatedWithoutDowngradingEveryGoodRowToSingleInsert() {
List<DmStandardizedBatchWriter.WriteRequest<String>> requests = requests(100, 73);
@@ -0,0 +1,87 @@
package com.common.service;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DmTransactionWriteSizeLogTest {
@Test
void successfulSlowNormalAndAlarmTransactionsIncludeOnlySizeMetadata() {
check(false, false);
check(true, false);
}
@Test
void failedWriteIncludesAttemptedPayloadAndStillThrows() {
check(false, true);
}
private void check(boolean alarm, boolean fail) {
SqlSessionFactory sessions = mock(SqlSessionFactory.class);
Configuration configuration = new Configuration();
configuration.setEnvironment(new org.apache.ibatis.mapping.Environment("test",
new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(), mock(javax.sql.DataSource.class)));
when(sessions.getConfiguration()).thenReturn(configuration);
DmPreparedBatchInserter inserter = mock(DmPreparedBatchInserter.class);
when(inserter.insert(eq(alarm), anyList(), anyInt(), any(DmWriteSizeEstimate.class)))
.thenAnswer(invocation -> {
DmWriteSizeEstimate size = invocation.getArgument(3);
size.measure(invocation.getArgument(1), DmWriteSizeEstimateTest.columns("detail"));
if (fail) throw new IllegalStateException("write failed");
return new DmPreparedBatchInserter.BatchExecution(1, 1, 0, 0, 0);
});
AbstractPlatformTransactionManager transactions = new AbstractPlatformTransactionManager() {
protected Object doGetTransaction() { return new Object(); }
protected void doBegin(Object transaction, TransactionDefinition definition) { }
protected void doCommit(DefaultTransactionStatus status) {
// 使成功事务确定超过1ms阈值,不依赖真实数据库。
java.util.concurrent.locks.LockSupport.parkNanos(5_000_000L);
}
protected void doRollback(DefaultTransactionStatus status) { }
};
DmStandardizedBatchExecutor executor = new DmStandardizedBatchExecutor(sessions, transactions,
inserter, mock(StatsChangeCaptureService.class), mock(DirectStatsProjectionWriter.class),
new DmDirectTransactionMetrics(), mock(LateAlarmBucketWriter.class));
ReflectionTestUtils.setField(executor, "slowTransactionThresholdMs", 1L);
Logger logger = (Logger) LoggerFactory.getLogger(DmStandardizedBatchExecutor.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
List<Map<String, Object>> rows = Collections.singletonList(
Collections.<String, Object>singletonMap("detail", "秘密😀"));
try {
if (fail) assertThrows(IllegalStateException.class, () -> executor.execute(alarm, rows, 45));
else executor.execute(alarm, rows, 45);
String message = appender.list.stream().map(ILoggingEvent::getFormattedMessage)
.filter(text -> text.contains("[SLOW_DM_TRANSACTION]")).findFirst().orElse("");
assertTrue(message.contains("table=" + (alarm ? "syslog_normal_alarm" : "syslog_normal_data")));
assertTrue(message.contains("status=" + (fail ? "FAILED" : "SUCCESS")));
assertTrue(message.contains("sizeEstimateStatus=COMPLETE"));
assertTrue(message.contains("estimatedPayloadBytes=10"));
assertTrue(message.contains("maxRowEstimatedBytes=10"));
assertTrue(message.contains("maxFieldEstimatedBytes=10"));
assertTrue(message.contains("maxFieldName=detail"));
assertTrue(message.contains("sizeEstimateMs="));
assertFalse(message.contains("秘密"));
} finally {
logger.detachAppender(appender);
appender.stop();
}
}
}
@@ -0,0 +1,109 @@
package com.common.service;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import java.sql.Types;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class DmWriteSizeEstimateTest {
@Test
void utf8MatchesJdkForAsciiChineseEmojiMalformedSurrogatesAndRandomText() {
for (String text : Arrays.asList("", "ascii", "中文😀", "\uD800", "\uDC00", "\uD800x\uDC00")) {
assertEquals(text.getBytes(StandardCharsets.UTF_8).length, DmWriteSizeEstimate.utf8Length(text));
}
Random random = new Random(42);
for (int i = 0; i < 200; i++) {
char[] chars = new char[1024];
for (int j = 0; j < chars.length; j++) chars[j] = (char) random.nextInt(65536);
String text = new String(chars);
assertEquals(text.getBytes(StandardCharsets.UTF_8).length, DmWriteSizeEstimate.utf8Length(text));
}
}
@Test
void measuresOnlyBoundColumnsAndDoesNotMutateRowsOrLogValues() {
Map<String, Object> first = new LinkedHashMap<>();
first.put("id", "a");
first.put("detail", "敏感内容😀"); // 16 UTF-8 bytes
first.put("ignored", "not-in-sql");
Map<String, Object> second = new LinkedHashMap<>();
second.put("id", "bc");
second.put("detail", new byte[3]);
Map<String, Object> snapshot = new LinkedHashMap<>(first);
DmWriteSizeEstimate size = new DmWriteSizeEstimate();
size.measure(Arrays.asList(first, second), columns("id", "detail"));
assertEquals(22L, size.getPayloadBytes());
assertEquals(17L, size.getMaxRowBytes());
assertEquals(16L, size.getMaxFieldBytes());
assertEquals("detail", size.getMaxFieldName());
assertEquals("COMPLETE", size.getStatus());
assertEquals(snapshot, first);
assertFalse(size.toString().contains("敏感内容"));
assertFalse(size.toString().contains("not-in-sql"));
}
@Test
void measuresTruncatedValuesNotOriginalAndKeepsTruncationMarker() {
DmLargeTextLimiter limiter = new DmLargeTextLimiter();
ReflectionTestUtils.setField(limiter, "normalMaxBytes", 64L);
ReflectionTestUtils.setField(limiter, "normalFieldsValue", "detail");
limiter.validate();
Map<String, Object> row = new LinkedHashMap<>();
row.put("detail", String.join("", Collections.nCopies(100, "中")));
limiter.truncateNormalRow(row);
String truncated = (String) row.get("detail");
assertTrue(truncated.getBytes(StandardCharsets.UTF_8).length <= 64);
DmWriteSizeEstimate size = new DmWriteSizeEstimate();
size.measure(Collections.singletonList(row), columns("detail"));
assertEquals(truncated.getBytes(StandardCharsets.UTF_8).length, size.getPayloadBytes());
assertSame(truncated, row.get("detail"));
}
@Test
void handlesNullScalarsAndUnknownWithoutCallingArbitraryToString() {
Object unknown = new Object() {
@Override public String toString() { throw new AssertionError("must not stringify"); }
};
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", 123);
row.put("flag", true);
row.put("unknown", unknown);
row.put("missing", null);
DmWriteSizeEstimate size = new DmWriteSizeEstimate();
size.measure(Collections.singletonList(row), columns("id", "flag", "unknown", "missing"));
assertEquals(7L, size.getPayloadBytes());
assertEquals(1L, size.getUnmeasuredValues());
assertEquals("PARTIAL", size.getStatus());
}
@Test
void diagnosticFailureIsMarkedWithoutThrowingAndUnmeasuredIsExplicit() {
DmWriteSizeEstimate size = new DmWriteSizeEstimate();
assertEquals("NOT_MEASURED", size.getStatus());
Map<String, Object> bad = new HashMap<String, Object>() {
@Override public Object get(Object key) { throw new IllegalStateException("sensitive value"); }
};
assertDoesNotThrow(() -> size.measure(Collections.singletonList(bad), columns("id")));
assertEquals("FAILED", size.getStatus());
assertFalse(size.toString().contains("sensitive value"));
}
@Test
void totalUsesLongBeyondTwoGiBWithoutAllocatingAnEncodedCopy() {
String text = String.join("", Collections.nCopies(1024 * 1024, "a"));
DmWriteSizeEstimate size = new DmWriteSizeEstimate();
size.measure(Collections.nCopies(2050, Collections.<String, Object>singletonMap("detail", text)),
columns("detail"));
assertEquals(2050L * 1024 * 1024, size.getPayloadBytes());
}
static List<DmPreparedBatchInserter.ColumnMetadata> columns(String... names) {
List<DmPreparedBatchInserter.ColumnMetadata> columns = new ArrayList<>();
for (String name : names) columns.add(new DmPreparedBatchInserter.ColumnMetadata(name, Types.VARCHAR));
return columns;
}
}
@@ -0,0 +1,112 @@
package com.common.service;
import com.common.entity.SyslogNonNormalMessage;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.env.MapPropertySource;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class RawLaneFieldLimiterTest {
@Test
void springDefaultIsTwoKiBAndExplicitOverrideWorks() {
checkSpringLimit(null, 2048);
checkSpringLimit("4096", 4096);
checkSpringLimit("0", 0);
}
private void checkSpringLimit(String override, int expected) {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
Map<String, Object> properties = new HashMap<>();
properties.put("spring.kafka.consumer.topic", "raw");
if (override != null) properties.put("syslog.raw-listener.field-max-utf8-bytes", override);
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test", properties));
context.register(RawLaneFieldLimiter.class);
context.refresh();
RawLaneFieldLimiter limiter = context.getBean(RawLaneFieldLimiter.class);
Map<String, Object> row = new HashMap<>();
String content = repeat("a", 5000);
row.put("custom_field", content);
limiter.truncateRow(row);
assertEquals(expected == 0 ? content : expected,
expected == 0 ? row.get("custom_field") : bytes(row.get("custom_field")));
assertEquals(expected != 0, limiter.appliesTo("raw", null));
}
}
@Test
void truncatesCustomTextByUtf8WithMarkerAndPreservesTypesAndIdentity() {
RawLaneFieldLimiter limiter = new RawLaneFieldLimiter(2048, "raw");
Map<String, Object> row = new HashMap<>();
String content = repeat("中文🙂", 1000);
row.put("custom_field", content);
row.put("id", content);
row.put("event_level", "HIGH");
row.put("number", 123L);
row.put("nothing", null);
byte[] binary = new byte[3000];
row.put("binary", binary);
row.put("exact", repeat("a", 2048));
row.put("short", "短字段");
limiter.truncateRow(row);
String stored = (String) row.get("custom_field");
assertTrue(bytes(stored) <= 2048);
assertTrue(stored.endsWith("...[HB_XDR_TRUNCATED original_utf8_bytes=10000]"));
assertEquals(stored, new String(stored.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8));
assertEquals(content, row.get("id"));
assertEquals("HIGH", row.get("event_level"));
assertEquals(123L, row.get("number"));
assertNull(row.get("nothing"));
assertSame(binary, row.get("binary"));
assertEquals(repeat("a", 2048), row.get("exact"));
assertEquals("短字段", row.get("short"));
limiter.truncateRow(row);
assertEquals(stored, row.get("custom_field"), "重复处理不能重复追加标记");
}
@Test
void onlyOriginalRawTopicAppliesIncludingRetryAndReplay() {
RawLaneFieldLimiter limiter = new RawLaneFieldLimiter(2048, "custom-raw");
assertTrue(limiter.appliesTo("custom-raw", null));
assertTrue(limiter.appliesTo("retry", "custom-raw"));
assertTrue(limiter.appliesTo("replay", "custom-raw"));
assertFalse(limiter.appliesTo("custom-large", null));
assertFalse(limiter.appliesTo("retry", "custom-large"));
assertFalse(limiter.appliesTo("custom-raw", "custom-large"));
assertFalse(limiter.appliesTo("retry", null));
}
@Test
void nonNormalContentLimitedButIdentityUnchanged() {
RawLaneFieldLimiter limiter = new RawLaneFieldLimiter(2048, "raw");
SyslogNonNormalMessage message = new SyslogNonNormalMessage();
String content = repeat("x", 4096);
message.setSyslogMessage(content);
message.setHeaderMessage(content);
message.setReasonDetail(content);
message.setSyslogUuid("stable-id");
limiter.truncateNonNormal(message);
assertEquals(2048, bytes(message.getSyslogMessage()));
assertEquals(2048, bytes(message.getHeaderMessage()));
assertEquals(2048, bytes(message.getReasonDetail()));
assertEquals("stable-id", message.getSyslogUuid());
}
@Test
void validatesMarkerBudget() {
assertThrows(IllegalArgumentException.class, () -> new RawLaneFieldLimiter(-1, "raw"));
assertThrows(IllegalArgumentException.class, () -> new RawLaneFieldLimiter(63, "raw"));
assertDoesNotThrow(() -> new RawLaneFieldLimiter(64, "raw"));
}
private static int bytes(Object text) { return ((String) text).getBytes(StandardCharsets.UTF_8).length; }
private static String repeat(String text, int count) {
StringBuilder value = new StringBuilder();
for (int i = 0; i < count; i++) value.append(text);
return value.toString();
}
}
@@ -15,6 +15,7 @@ class SyslogBuildStartupLoggerTest {
MockEnvironment environment = new MockEnvironment()
.withProperty("syslog.raw-listener.max-poll-records", "700")
.withProperty("syslog.raw-listener.adaptive-max-records", "2500")
.withProperty("syslog.slow-batch.threshold-ms", "1500")
.withProperty("stats.projection-async.enabled", "false");
environment.setActiveProfiles("prod");
SyslogBuildStartupLogger reporter = new SyslogBuildStartupLogger(environment);
@@ -32,6 +33,7 @@ class SyslogBuildStartupLoggerTest {
assertTrue(message.contains("baseProcessingRecords=700"));
assertTrue(message.contains("adaptiveMaxRecords=2500"));
assertTrue(message.contains("statsPublisherEnabled=false"));
assertTrue(message.contains("slowBatchThresholdMs=1500"));
} finally {
logger.detachAppender(appender);
appender.stop();
@@ -46,6 +48,8 @@ class SyslogBuildStartupLoggerTest {
assertFalse(summary.contains("buildTime="));
assertTrue(summary.contains("baseProcessingRecords=500"));
assertTrue(summary.contains("adaptiveMaxRecords=3000"));
assertTrue(summary.contains("slowBatchThresholdMs=1000"));
assertTrue(summary.contains("rawFieldMaxUtf8Bytes=2048"));
}
}