feat(syslog): 完成高吞吐可靠链路与统计存储优化

- 新增接入 WAL、检查点、背压恢复及可靠分发机制,完善 Kafka 幂等发布、分区、重试、DLT 和补偿流程
- 引入规则预编译、批内快照及 JetCache/Caffeine 两级缓存,支持缓存失效广播、影子对账和执行指标
- 升级 InfluxDB V2 原始日志模型,统一接收时间与 Point 时间,支持按日 measurement、UUID 分桶及 V1/V2 双读
- 重构达梦标准化批量写入与统计链路,支持事务内直写、增量投影、幂等控制、窗口水位和日期切换
- 完善告警处理、配置校验、运行监控及测试覆盖
This commit is contained in:
王绍宏
2026-08-20 00:18:33 +08:00
parent a3852fc0f9
commit 29445c6825
303 changed files with 20419 additions and 4481 deletions
@@ -1,147 +0,0 @@
package com.haobang.config;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import java.io.File;
import com.typesafe.config.ConfigValueFactory;
import java.nio.charset.Charset;
import java.util.Map;
public class AppConfig {
private static final Config config;
static {
// 加载配置文件
File configFile = new File("application.properties");
Config loadedConfig;
if (configFile.exists()) {
loadedConfig = ConfigFactory.parseFile(configFile);
} else {
loadedConfig = ConfigFactory.load("application.properties");
}
// 解析环境变量占位符
config = resolveEnvironmentVariables(loadedConfig);
}
/**
* 解析环境变量占位符
*/
private static Config resolveEnvironmentVariables(Config originalConfig) {
Config resolvedConfig = originalConfig;
// 遍历所有配置项,查找需要解析的占位符
for (Map.Entry<String, com.typesafe.config.ConfigValue> entry : originalConfig.entrySet()) {
String key = entry.getKey();
String value = originalConfig.getString(key);
if (value.contains("${")) {
String resolvedValue = resolvePlaceholder(value);
resolvedConfig = resolvedConfig.withValue(
key,
ConfigValueFactory.fromAnyRef(resolvedValue)
);
}
}
return resolvedConfig;
}
/**
* 解析单个占位符值
* 格式: ${ENV_VAR:default_value}
*/
private static String resolvePlaceholder(String value) {
if (!value.startsWith("${") || !value.endsWith("}")) {
return value;
}
String placeholder = value.substring(2, value.length() - 1);
String[] parts = placeholder.split(":");
if (parts.length == 0) {
return value; // 无效格式,返回原值
}
String envVarName = parts[0].trim();
String defaultValue = parts.length > 1 ? parts[1].trim() : "";
// 1. 从系统环境变量获取
String envValue = System.getenv(envVarName);
if (envValue != null && !envValue.trim().isEmpty()) {
return envValue.trim();
}
// 2. 从系统属性获取 (java -D参数)
String sysValue = System.getProperty(envVarName);
if (sysValue != null && !sysValue.trim().isEmpty()) {
return sysValue.trim();
}
// 3. 返回默认值
return defaultValue;
}
// Syslog 配置
public static int getSyslogTcpPort() {
return config.getInt("syslog.tcp.port");
}
public static int getSyslogUdpPort() {
return config.getInt("syslog.udp.port");
}
public static int getSyslogMaxFrameLength() {
return config.getInt("syslog.max.frame.length");
}
public static int getSyslogBufferSize() {
return config.getInt("syslog.buffer.size");
}
/**
* syslog 消息字符编码,默认 GBK(国内安全设备普遍使用 GBK)
* 配置示例: syslog.charset=GBK
*/
public static Charset getSyslogCharset() {
if (config.hasPath("syslog.charset")) {
return Charset.forName(config.getString("syslog.charset"));
}
return Charset.forName("GBK");
}
// app service 配置
public static String getAppServieDeviceId() {
return config.getString("app.service.device_id");
}
public static String getAppServieDeviceName() { return config.getString("app.service.device_name");
}
public static String getAppServieVendor() {
return config.getString("app.service.vendor");
}
public static String getAppServieProductName() {
return config.getString("app.service.product_name");
}
public static String getAppServieDataType() {
return config.getString("app.service.data_type");
}
// kafka 配置
public static String getKafkaProducerBootstrap() {
return config.getString("spring.kafka.producer.bootstrap-servers");
}
public static String getKafkaProducerTopic() {
return config.getString("spring.kafka.producer.topic");
}
public static int getDeviceCollectId() {
return config.getInt("app.service.device_collect_id");
}
public static String getSM4Key() {
return config.getString("syslog.sm4.generateKey");
}
}
@@ -5,7 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 应用配置属性 - 替代静态 AppConfig(syslog-serve 模块)
* syslog-serve 统一应用配置属性
* 使用 Spring Boot 标准 @Value 机制,支持 docker-compose 环境变量覆盖
*/
@Data
@@ -25,9 +25,110 @@ public class AppProperties {
@Value("${syslog.buffer.size:1024}")
private int syslogBufferSize;
@Value("${syslog.tcp.backlog:4096}")
private int syslogTcpBacklog;
@Value("${syslog.tcp.receive-buffer-bytes:4194304}")
private int syslogTcpReceiveBufferBytes;
@Value("${syslog.udp.receive-buffer-bytes:16777216}")
private int syslogUdpReceiveBufferBytes;
@Value("${syslog.charset:GBK}")
private String syslogCharset;
// ========== 接入 WAL 配置 ==========
/** WAL 是 Kafka 接管前的可靠性边界,正式链路不允许关闭。 */
@Value("${syslog.wal.enabled:true}")
private boolean syslogWalEnabled;
/** 必须挂载到宿主机持久化磁盘;兼容原归档目录环境变量。 */
@Value("${syslog.wal.directory:${SYSLOG_WAL_DIRECTORY:${SYSLOG_RAW_ARCHIVE_DIRECTORY:/data/syslog-wal}}}")
private String syslogWalDirectory;
/** Netty 到 WAL 单写线程之间的有界队列。 */
@Value("${syslog.wal.queue-capacity:10000}")
private int syslogWalQueueCapacity;
@Value("${syslog.wal.high-watermark-percent:70}")
private int syslogWalHighWatermarkPercent;
@Value("${syslog.wal.low-watermark-percent:40}")
private int syslogWalLowWatermarkPercent;
@Value("${syslog.wal.retry-interval-ms:10}")
private long syslogWalRetryIntervalMs;
@Value("${syslog.wal.disk-retry-interval-ms:1000}")
private long syslogWalDiskRetryIntervalMs;
/** 累计记录数或时间达到任一条件即 force,Future 只在 force 后视为持久化。 */
@Value("${syslog.wal.flush-records:200}")
private int syslogWalFlushRecords;
@Value("${syslog.wal.flush-interval-ms:200}")
private long syslogWalFlushIntervalMs;
@Value("${syslog.wal.segment-max-bytes:268435456}")
private long syslogWalSegmentMaxBytes;
/** 已完成段的保留天数;0 表示不自动删除。 */
@Value("${syslog.wal.retention-days:0}")
private int syslogWalRetentionDays;
@Value("${syslog.wal.shutdown-timeout-ms:30000}")
private long syslogWalShutdownTimeoutMs;
/** ACK 游标允许批量刷盘;崩溃时最多重复投递该批,event_id 保证下游幂等。 */
@Value("${syslog.wal.checkpoint-flush-records:200}")
private int syslogWalCheckpointFlushRecords;
@Value("${syslog.wal.checkpoint-flush-interval-ms:200}")
private long syslogWalCheckpointFlushIntervalMs;
@Value("${syslog.wal.disk-high-watermark-percent:80}")
private int syslogWalDiskHighWatermarkPercent;
@Value("${syslog.wal.disk-critical-watermark-percent:90}")
private int syslogWalDiskCriticalWatermarkPercent;
@Value("${syslog.wal.min-free-bytes:10737418240}")
private long syslogWalMinFreeBytes;
/** 兼容部署阶段关闭;开启后新接入事件必须以 WAL schema v3 固化 point_time_ns。 */
@Value("${syslog.point-time.write-enabled:false}")
private boolean syslogPointTimeWriteEnabled;
// ========== WAL Dispatcher 配置 ==========
@Value("${syslog.dispatch.worker-threads:4}")
private int syslogDispatchWorkerThreads;
@Value("${syslog.dispatch.max-inflight:5000}")
private int syslogDispatchMaxInflight;
@Value("${syslog.dispatch.retry-backoff-ms:1000}")
private long syslogDispatchRetryBackoffMs;
@Value("${syslog.dispatch.shutdown-timeout-ms:30000}")
private long syslogDispatchShutdownTimeoutMs;
@Value("${syslog.device-context.cache-ttl-ms:300000}")
private long syslogDeviceContextCacheTtlMs;
@Value("${syslog.status-log-interval-ms:60000}")
private long syslogStatusLogIntervalMs;
// ========== 旧 v1 原文归档精确导入(默认关闭) ==========
@Value("${syslog.wal.legacy-import.enabled:false}")
private boolean syslogWalLegacyImportEnabled;
@Value("${syslog.wal.legacy-import.archive-directory:}")
private String syslogWalLegacyImportArchiveDirectory;
/** 一行可写一个 event_id,也可直接粘贴包含 event_id 的拒绝日志。 */
@Value("${syslog.wal.legacy-import.event-id-file:}")
private String syslogWalLegacyImportEventIdFile;
// ========== App Service 配置(探针设备信息)==========
@Value("${app.service.device_id:}")
private String appServiceDeviceId;
@@ -47,6 +148,9 @@ public class AppProperties {
@Value("${app.service.device_collect_id:1}")
private int appServiceDeviceCollectId;
@Value("${app.service.device_collect_name:DevCollect-01}")
private String appServiceDeviceCollectName;
// ========== Kafka Producer 配置 ==========
@Value("${spring.kafka.producer.bootstrap-servers:localhost:9092}")
private String kafkaProducerBootstrap;
@@ -54,6 +158,9 @@ public class AppProperties {
@Value("${spring.kafka.producer.topic:agent-syslog-topic}")
private String kafkaProducerTopic;
@Value("${spring.kafka.producer.ack-timeout-ms:130000}")
private long kafkaProducerAckTimeoutMs;
// ========== SM4 加密配置 ==========
@Value("${syslog.sm4.generateKey:}")
private String sm4Key;
@@ -0,0 +1,148 @@
package com.haobang.dispatch;
import com.common.entity.DeviceDevice;
import com.common.entity.DeviceUnknown;
import com.common.service.DeviceDeviceService;
import com.common.service.DeviceUnknownService;
import com.haobang.config.AppProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.List;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
/**
* 来源 IP 到设备接入上下文的进程内缓存。
*
* <p>原实现每条日志至少查询两次设备表;该缓存使正常热路径不访问达梦。缓存失效时同一
* source_ip 使用条带锁合并并发查询,避免恢复洪峰击穿数据库。</p>
*/
@Component
public class DeviceContextResolver {
private static final Logger log = LoggerFactory.getLogger(DeviceContextResolver.class);
private final DeviceDeviceService deviceService;
private final DeviceUnknownService unknownService;
private final AppProperties properties;
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
private final Object[] locks = new Object[64];
private final LongAdder hits = new LongAdder();
private final LongAdder misses = new LongAdder();
public DeviceContextResolver(DeviceDeviceService deviceService, DeviceUnknownService unknownService,
AppProperties properties) {
this.deviceService = deviceService;
this.unknownService = unknownService;
this.properties = properties;
for (int i = 0; i < locks.length; i++) locks[i] = new Object();
}
public Resolution resolve(String sourceIp, String protocol) {
long now = System.currentTimeMillis();
CacheEntry cached = cache.get(sourceIp);
if (cached != null && cached.expiresAt > now) {
hits.increment();
return cached.resolution;
}
Object lock = locks[(sourceIp.hashCode() & Integer.MAX_VALUE) % locks.length];
synchronized (lock) {
cached = cache.get(sourceIp);
if (cached != null && cached.expiresAt > now) {
hits.increment();
return cached.resolution;
}
misses.increment();
List<DeviceDevice> devices = deviceService.getByIpSafely(sourceIp);
Resolution resolution;
if (devices == null || devices.isEmpty()) {
recordUnknown(sourceIp, protocol);
resolution = Resolution.terminal(Type.UNKNOWN_DEVICE, null,
"来源 IP 未注册: " + sourceIp);
} else if (devices.size() != 1) {
resolution = Resolution.terminal(Type.DUPLICATE_DEVICE, null,
"来源 IP 存在多条设备记录: " + sourceIp);
} else {
DeviceDevice device = devices.get(0);
Integer assignedCollector = device.getDeviceCollectId();
if (assignedCollector == null
|| assignedCollector != properties.getAppServiceDeviceCollectId()) {
resolution = Resolution.terminal(Type.NOT_ASSIGNED, device,
"设备不属于当前采集探针: deviceId=" + device.getId());
} else {
resolution = Resolution.registered(device);
}
}
cache.put(sourceIp, new CacheEntry(resolution,
now + properties.getSyslogDeviceContextCacheTtlMs()));
if (resolution.type != Type.REGISTERED) {
log.warn("Syslog 设备接入终态: type={}, sourceIp={}, reason={}",
resolution.type, sourceIp, resolution.reason);
}
return resolution;
}
}
public void invalidate(String sourceIp) {
if (sourceIp != null) cache.remove(sourceIp);
}
public void invalidateAll() { cache.clear(); }
public long getHitCount() { return hits.sum(); }
public long getMissCount() { return misses.sum(); }
public int getCacheSize() { return cache.size(); }
/** 未知设备仍保留原业务登记能力,但最多每个本地缓存 TTL 执行一次,不占用网络线程。 */
private void recordUnknown(String sourceIp, String protocol) {
List<DeviceUnknown> existing = unknownService.getDevicesByIp(sourceIp);
LocalDateTime now = LocalDateTime.now();
if (existing != null && !existing.isEmpty()) {
unknownService.updateLastTime(existing.get(0).getId(), now);
return;
}
DeviceUnknown unknown = new DeviceUnknown();
unknown.setDeviceCollectId(properties.getAppServiceDeviceCollectId());
unknown.setDeviceCollectName(properties.getAppServiceDeviceCollectName());
unknown.setDeviceIp(sourceIp);
unknown.setFirstTime(now);
unknown.setLastTime(now);
unknown.setOrganizationId(0);
unknown.setNetworkProtocol(protocol);
unknown.setSourceMethod("SYSTEM");
unknownService.createDevice(unknown);
}
private static final class CacheEntry {
private final Resolution resolution;
private final long expiresAt;
private CacheEntry(Resolution resolution, long expiresAt) {
this.resolution = resolution;
this.expiresAt = expiresAt;
}
}
public enum Type { REGISTERED, UNKNOWN_DEVICE, DUPLICATE_DEVICE, NOT_ASSIGNED }
public static final class Resolution {
private final Type type;
private final DeviceDevice device;
private final String reason;
private Resolution(Type type, DeviceDevice device, String reason) {
this.type = type;
this.device = device;
this.reason = reason;
}
static Resolution registered(DeviceDevice device) {
return new Resolution(Type.REGISTERED, device, null);
}
static Resolution terminal(Type type, DeviceDevice device, String reason) {
return new Resolution(type, device, reason);
}
public boolean isRegistered() { return type == Type.REGISTERED; }
public Type getType() { return type; }
public DeviceDevice getDevice() { return device; }
public String getReason() { return reason; }
}
}
@@ -0,0 +1,265 @@
package com.haobang.dispatch;
import com.haobang.config.AppProperties;
import com.haobang.wal.SyslogWalRecord;
import com.haobang.wal.SyslogWalService;
import com.kafka.kafkaProducer;
import com.netty.SyslogMessageHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
/** 从 WAL 有界并发异步投递 Kafka,并只按连续 ACK 推进 WAL 游标。 */
@Component
public class SyslogWalDispatcher {
private static final Logger log = LoggerFactory.getLogger(SyslogWalDispatcher.class);
private static final DateTimeFormatter WAL_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
private static final DateTimeFormatter HEADER_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
private final SyslogWalService walService;
private final DeviceContextResolver deviceResolver;
private final kafkaProducer producer;
private final AppProperties properties;
private final AtomicBoolean running = new AtomicBoolean();
private final Object completionMonitor = new Object();
private final Map<Long, SyslogWalRecord> completedOutOfOrder = new HashMap<>();
private final LongAdder kafkaAcked = new LongAdder();
private final LongAdder terminalFiltered = new LongAdder();
private final LongAdder retryCount = new LongAdder();
private final AtomicLong inflight = new AtomicLong();
private Semaphore permits;
private ExecutorService workers;
private ScheduledExecutorService scheduler;
private Thread readerThread;
private SyslogWalService.WalCursor cursor;
private long nextCommitSequence;
private long nextReadSequence;
public SyslogWalDispatcher(SyslogWalService walService,
DeviceContextResolver deviceResolver,
kafkaProducer producer,
AppProperties properties) {
this.walService = walService;
this.deviceResolver = deviceResolver;
this.producer = producer;
this.properties = properties;
}
@PostConstruct
public void start() throws Exception {
validateConfiguration();
permits = new Semaphore(properties.getSyslogDispatchMaxInflight());
workers = Executors.newFixedThreadPool(properties.getSyslogDispatchWorkerThreads(),
namedFactory("syslog-dispatch-worker"));
scheduler = Executors.newScheduledThreadPool(2, namedFactory("syslog-dispatch-scheduler"));
cursor = walService.openCursor();
nextCommitSequence = walService.getCheckpointSequence() + 1L;
nextReadSequence = nextCommitSequence;
running.set(true);
readerThread = new Thread(this::readLoop, "syslog-wal-dispatch-reader");
readerThread.setDaemon(true);
readerThread.start();
scheduler.scheduleWithFixedDelay(this::logStatus,
properties.getSyslogStatusLogIntervalMs(), properties.getSyslogStatusLogIntervalMs(),
TimeUnit.MILLISECONDS);
log.info("Syslog WAL Dispatcher 已启动: workerThreads={}, maxInflight={}, checkpointSequence={}",
properties.getSyslogDispatchWorkerThreads(), properties.getSyslogDispatchMaxInflight(),
walService.getCheckpointSequence());
}
private void readLoop() {
try {
while (running.get()) {
permits.acquire();
SyslogWalRecord record = cursor.next(1000L);
if (record == null) {
permits.release();
continue;
}
if (record.getSequence() != nextReadSequence) {
permits.release();
throw new IllegalStateException("WAL sequence 不连续,拒绝越过: expected="
+ nextReadSequence + ", actual=" + record.getSequence());
}
nextReadSequence++;
inflight.incrementAndGet();
workers.execute(() -> dispatch(record, 1));
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (Throwable failure) {
running.set(false);
log.error("WAL Dispatcher 读取失败,停止推进游标以保护数据", failure);
}
}
private void dispatch(SyslogWalRecord record, int attempt) {
if (!running.get()) return;
if (record.isQuarantinedInvalid()) {
log.error("损坏 WAL 已隔离并推进游标: sequence={}, eventId={}, reason={}",
record.getSequence(), record.getEventId(), record.getValidationError());
markComplete(record);
return;
}
final DeviceContextResolver.Resolution resolution;
try {
resolution = deviceResolver.resolve(record.getSourceIp(), record.getProtocol());
} catch (Throwable failure) {
scheduleRetry(record, attempt, "DEVICE_LOOKUP", failure);
return;
}
if (!resolution.isRegistered()) {
try {
walService.recordTerminal(record, resolution.getType().name(), resolution.getReason());
terminalFiltered.increment();
markComplete(record);
} catch (Throwable failure) {
scheduleRetry(record, attempt, "TERMINAL_LEDGER", failure);
}
return;
}
final String receiveTime;
try {
receiveTime = LocalDateTime.parse(record.getReceiveTime(), WAL_TIME).format(HEADER_TIME);
producer.messagePush(resolution.getDevice(), record.getRawMessage(), receiveTime,
record.getEventId(), record.getPointTimeNs())
.whenComplete((delivery, failure) -> {
if (failure == null) {
kafkaAcked.increment();
markComplete(record);
} else {
scheduleRetry(record, attempt, "KAFKA_ACK", unwrap(failure));
}
});
} catch (Throwable failure) {
// 加密、配置或 Producer 本地错误都不能越过 WAL;修复依赖后沿用同一 event_id 重试。
scheduleRetry(record, attempt, "KAFKA_PREPARE", failure);
}
}
private void scheduleRetry(SyslogWalRecord record, int attempt, String stage, Throwable failure) {
retryCount.increment();
long retries = retryCount.sum();
if (attempt == 1 || retries % 100 == 0) {
log.warn("WAL 事件投递失败,将保留原 event_id 重试: eventId={}, sequence={}, stage={}, "
+ "attempt={}, retryTotal={}, error={}",
record.getEventId(), record.getSequence(), stage, attempt, retries,
failure == null ? "unknown" : failure.toString());
}
if (!running.get()) return;
scheduler.schedule(() -> {
if (running.get()) workers.execute(() -> dispatch(record, attempt + 1));
}, properties.getSyslogDispatchRetryBackoffMs(), TimeUnit.MILLISECONDS);
}
private void markComplete(SyslogWalRecord record) {
synchronized (completionMonitor) {
if (record.getSequence() < nextCommitSequence) return;
completedOutOfOrder.put(record.getSequence(), record);
SyslogWalRecord latest = null;
int released = 0;
while (true) {
SyslogWalRecord contiguous = completedOutOfOrder.remove(nextCommitSequence);
if (contiguous == null) break;
latest = contiguous;
nextCommitSequence++;
released++;
}
if (latest != null) walService.markCheckpoint(latest);
if (released > 0) {
inflight.addAndGet(-released);
permits.release(released);
}
}
}
private void logStatus() {
if (!running.get()) return;
log.info("Syslog WAL状态: networkReceived={}, appended={}, walPendingApprox={}, walQueue={}/{}, durableSequence={}, "
+ "checkpointSequence={}, kafkaInflight={}, kafkaAcked={}, terminalFiltered={}, "
+ "retry={}, tcpPauseTotal={}, udpDropped={}, deviceCacheSize={}, deviceCacheHits={}, "
+ "deviceCacheMisses={}",
SyslogMessageHandler.getNetworkReceivedCount(), walService.getAppendedCount(),
walService.getPendingRecordsApprox(),
walService.getQueueSize(), walService.getQueueCapacity(), walService.getDurableSequence(),
walService.getCheckpointSequence(), inflight.get(), kafkaAcked.sum(), terminalFiltered.sum(),
retryCount.sum(), SyslogMessageHandler.getTcpPauseCount(),
SyslogMessageHandler.getUdpDroppedCount(), deviceResolver.getCacheSize(),
deviceResolver.getHitCount(), deviceResolver.getMissCount());
}
private Throwable unwrap(Throwable failure) {
if (failure instanceof java.util.concurrent.CompletionException && failure.getCause() != null) {
return failure.getCause();
}
return failure;
}
private ThreadFactory namedFactory(String prefix) {
AtomicLong sequence = new AtomicLong();
return runnable -> {
Thread thread = new Thread(runnable, prefix + "-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
};
}
private void validateConfiguration() {
if (properties.getSyslogDispatchWorkerThreads() <= 0
|| properties.getSyslogDispatchMaxInflight() <= 0
|| properties.getSyslogDispatchRetryBackoffMs() <= 0
|| properties.getSyslogDispatchShutdownTimeoutMs() <= 0
|| properties.getSyslogDeviceContextCacheTtlMs() <= 0
|| properties.getSyslogStatusLogIntervalMs() <= 0) {
throw new IllegalArgumentException("Dispatcher 线程、在途、重试、缓存和状态日志参数必须大于 0");
}
}
public long getInflight() { return inflight.get(); }
public long getKafkaAcked() { return kafkaAcked.sum(); }
public long getTerminalFiltered() { return terminalFiltered.sum(); }
public long getRetryCount() { return retryCount.sum(); }
@PreDestroy
public void shutdown() {
if (!running.compareAndSet(true, false)) return;
if (readerThread != null) readerThread.interrupt();
if (scheduler != null) scheduler.shutdownNow();
if (workers != null) workers.shutdown();
long deadline = System.nanoTime()
+ TimeUnit.MILLISECONDS.toNanos(properties.getSyslogDispatchShutdownTimeoutMs());
try {
while (inflight.get() > 0 && System.nanoTime() < deadline) Thread.sleep(20L);
if (workers != null) {
long remaining = Math.max(1L, deadline - System.nanoTime());
workers.awaitTermination(remaining, TimeUnit.NANOSECONDS);
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
if (workers != null && !workers.isTerminated()) workers.shutdownNow();
try { if (cursor != null) cursor.close(); }
catch (Exception failure) { log.warn("关闭 WAL cursor 失败", failure); }
log.info("Syslog WAL Dispatcher 已停止: inflight={}, checkpointSequence={}",
inflight.get(), walService.getCheckpointSequence());
}
}
@@ -78,25 +78,45 @@ public class DeviceInfoUtil {
public static String getFullLogString(DeviceDevice deviceDevice,String strSyslog,String receive_time)
{
AppProperties appProps = getProps();
ObjectMapper objectMapper = new ObjectMapper();
// 创建设备配置信息MAP
// 获取当前时间(精度到毫秒)
LocalDateTime now = LocalDateTime.now();
// 定义格式化器,包含毫秒
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
// 转换为字符串
String timeString = now.format(formatter);
return getFullLogString(deviceDevice, strSyslog, receive_time, null);
}
/**
* [流程节点3.1] 将接入边界生成的 event_id 固化到消息头;后续 Kafka 重试和人工回放沿用该值。
*/
public static String getFullLogString(DeviceDevice deviceDevice, String strSyslog,
String receive_time, String eventId)
{
return getFullLogString(deviceDevice, strSyslog, receive_time, eventId, getProps());
}
/** Dispatcher 使用显式配置对象,避免 WAL 恢复线程依赖静态 SpringContext 初始化时序。 */
public static String getFullLogString(DeviceDevice deviceDevice, String strSyslog,
String receive_time, String eventId,
AppProperties appProps)
{
return getFullLogString(deviceDevice, strSyslog, receive_time, eventId, null, appProps);
}
/** Kafka 业务头只新增 point_time_ns;WAL schema_version 绝不进入业务载荷。 */
public static String getFullLogString(DeviceDevice deviceDevice, String strSyslog,
String receive_time, String eventId, Long pointTimeNs,
AppProperties appProps)
{
Map<String, String> map = new LinkedHashMap<>();
map.put("receive_time",receive_time);
if (eventId != null && !eventId.isEmpty()) {
map.put("event_id", eventId);
}
if (pointTimeNs != null) {
map.put("point_time_ns", Long.toString(pointTimeNs));
}
map.put("device_id",deviceDevice.getId().toString());
map.put("device_name", deviceDevice.getName());
map.put("vendor", deviceDevice.getVendor());
map.put("data_type", appProps.getAppServiceDataType());
map.put("device_collect_id", Long.toString(appProps.getAppServiceDeviceCollectId()));
String formattedString = formatDeviceInfo(map);
//return formattedString + "" + strSyslog.substring(34);
//测试环境截取34位之后字符串
return formattedString + strSyslog;
}
@@ -0,0 +1,32 @@
package com.haobang.wal;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
/** 在 receive_time 所属毫秒内分配 InfluxDB Point 的纳秒尾数。 */
public final class PointTimeAllocator {
public static final long NANOS_PER_MILLI = 1_000_000L;
private final AtomicLong sequence;
private final LongAdder assigned = new LongAdder();
public PointTimeAllocator() {
this(0L);
}
PointTimeAllocator(long initialSequence) {
this.sequence = new AtomicLong(initialSequence);
}
public long allocate(long receiveTimeMillis) {
long suffix = Math.floorMod(sequence.getAndIncrement(), NANOS_PER_MILLI);
long pointTimeNs = Math.addExact(
Math.multiplyExact(receiveTimeMillis, NANOS_PER_MILLI), suffix);
assigned.increment();
return pointTimeNs;
}
public long getAssignedCount() {
return assigned.sum();
}
}
@@ -0,0 +1,42 @@
package com.haobang.wal;
import java.time.LocalDateTime;
/** 网络入口已经解帧、但尚未写入 WAL 的不可变事件。 */
public final class SyslogIngressEvent {
private final String eventId;
private final LocalDateTime receivedAt;
private final String sourceIp;
private final int sourcePort;
private final String protocol;
private final String rawMessage;
private final Long pointTimeNs;
public SyslogIngressEvent(String eventId, LocalDateTime receivedAt, String sourceIp,
int sourcePort, String protocol, String rawMessage) {
this(eventId, receivedAt, sourceIp, sourcePort, protocol, rawMessage, null);
}
public SyslogIngressEvent(String eventId, LocalDateTime receivedAt, String sourceIp,
int sourcePort, String protocol, String rawMessage, Long pointTimeNs) {
this.eventId = eventId;
this.receivedAt = receivedAt;
this.sourceIp = sourceIp;
this.sourcePort = sourcePort;
this.protocol = protocol;
this.rawMessage = rawMessage;
this.pointTimeNs = pointTimeNs;
}
public String getEventId() { return eventId; }
public LocalDateTime getReceivedAt() { return receivedAt; }
public String getSourceIp() { return sourceIp; }
public int getSourcePort() { return sourcePort; }
public String getProtocol() { return protocol; }
public String getRawMessage() { return rawMessage; }
public Long getPointTimeNs() { return pointTimeNs; }
public SyslogIngressEvent withPointTimeNs(long value) {
return new SyslogIngressEvent(eventId, receivedAt, sourceIp, sourcePort, protocol, rawMessage, value);
}
}
@@ -0,0 +1,152 @@
package com.haobang.wal;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.haobang.config.AppProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 把旧 schema_version=1 归档中明确列出的拒绝事件导入 WAL v2。
* 默认关闭,且必须提供 event_id 清单;绝不自动重放整个历史归档。
*/
@Component
public class SyslogLegacyArchiveImporter {
private static final Logger log = LoggerFactory.getLogger(SyslogLegacyArchiveImporter.class);
private static final Pattern EVENT_ID = Pattern.compile("(20\\d{6}-[0-9a-fA-F-]{36})");
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final SyslogWalService walService;
public SyslogLegacyArchiveImporter(AppProperties properties, ObjectMapper objectMapper,
SyslogWalService walService) {
this.properties = properties;
this.objectMapper = objectMapper;
this.walService = walService;
}
@PostConstruct
public void importIfRequested() throws Exception {
if (!properties.isSyslogWalLegacyImportEnabled()) return;
Path archiveDirectory = requiredPath(properties.getSyslogWalLegacyImportArchiveDirectory(),
"syslog.wal.legacy-import.archive-directory");
Path eventIdFile = requiredPath(properties.getSyslogWalLegacyImportEventIdFile(),
"syslog.wal.legacy-import.event-id-file");
if (!Files.isDirectory(archiveDirectory) || !Files.isRegularFile(eventIdFile)) {
throw new IllegalArgumentException("旧归档导入目录或 event_id 文件不存在");
}
byte[] requestBytes = Files.readAllBytes(eventIdFile);
String requestHash = sha256(requestBytes);
Path marker = Paths.get(properties.getSyslogWalDirectory()).toAbsolutePath().normalize()
.resolve("checkpoint").resolve("legacy-import-" + requestHash + ".completed");
if (Files.exists(marker)) {
log.info("旧归档导入请求已完成,跳过重复扫描: marker={}", marker);
return;
}
Set<String> requested = extractEventIds(new String(requestBytes, StandardCharsets.UTF_8));
if (requested.isEmpty()) throw new IllegalArgumentException("旧归档导入清单中没有合法 event_id");
Set<String> remaining = new HashSet<>(requested);
long appendedBefore = walService.getAppendedCount();
int accepted = 0;
for (Path segment : listLegacySegments(archiveDirectory)) {
try (BufferedReader reader = Files.newBufferedReader(segment, StandardCharsets.UTF_8)) {
String line;
while (!remaining.isEmpty() && (line = reader.readLine()) != null) {
JsonNode node = objectMapper.readTree(line);
String eventId = node.path("event_id").asText();
if (!remaining.remove(eventId)) continue;
if (node.path("schema_version").asInt() != 1) {
throw new IllegalStateException("旧归档导入仅支持 schema_version=1: " + segment);
}
SyslogIngressEvent event = new SyslogIngressEvent(
eventId,
LocalDateTime.parse(node.path("receive_time").asText()),
node.path("source_ip").asText(),
node.path("source_port").asInt(),
node.path("protocol").asText(),
node.path("raw_message").asText());
appendWithoutDropping(event);
accepted++;
}
}
if (remaining.isEmpty()) break;
}
if (!remaining.isEmpty()) {
throw new IllegalStateException("旧归档中未找到 " + remaining.size() + " 个 event_id,示例: "
+ remaining.iterator().next());
}
final long expected = appendedBefore + accepted;
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
while (walService.getAppendedCount() < expected && System.nanoTime() < deadline) Thread.sleep(20L);
if (walService.getAppendedCount() < expected) {
throw new IllegalStateException("旧归档事件未在超时内完成 WAL force,拒绝写完成标记");
}
Files.write(marker, ("request_hash=" + requestHash + "\ncount=" + accepted + "\n")
.getBytes(StandardCharsets.UTF_8));
log.info("旧 v1 归档事件已精确导入 WAL: requested={}, imported={}, marker={}",
requested.size(), accepted, marker);
}
private void appendWithoutDropping(SyslogIngressEvent event) throws Exception {
event = walService.prepareForWal(event);
while (true) {
SyslogWalService.AdmissionResult result = walService.tryAppend(event);
if (result == SyslogWalService.AdmissionResult.ACCEPTED) return;
if (result == SyslogWalService.AdmissionResult.UNAVAILABLE) {
throw new IllegalStateException("WAL 不可用,无法导入旧归档 eventId=" + event.getEventId(),
walService.getFatalFailure());
}
Thread.sleep(walService.getRetryIntervalMs());
}
}
private Set<String> extractEventIds(String text) {
Set<String> ids = new HashSet<>();
Matcher matcher = EVENT_ID.matcher(text);
while (matcher.find()) ids.add(matcher.group(1));
return ids;
}
private List<Path> listLegacySegments(Path directory) throws Exception {
List<Path> result = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, "syslog-raw-*.jsonl")) {
for (Path path : stream) result.add(path);
}
result.sort((left, right) -> left.getFileName().toString().compareTo(right.getFileName().toString()));
return result;
}
private Path requiredPath(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " 不能为空");
}
return Paths.get(value).toAbsolutePath().normalize();
}
private String sha256(byte[] value) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value);
StringBuilder text = new StringBuilder();
for (byte item : digest) text.append(String.format("%02x", item & 0xff));
return text.toString();
}
}
@@ -0,0 +1,85 @@
package com.haobang.wal;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* WAL v2/v3 磁盘记录;segment/offset 仅用于运行时确认游标,不写入 JSON 正文。
*
* <p>早期 v2 记录错误复用了 Redis 多态 ObjectMapper,因而额外包含 "@class"。这里仅忽略这个
* 已知历史字段,使修复后的专用 WAL ObjectMapper 能无损恢复既有数据;其他未知字段仍保持
* Jackson 默认的严格失败语义,避免悄悄接受不可识别的 WAL schema。</p>
*/
@JsonIgnoreProperties(value = "@class")
public class SyslogWalRecord {
@JsonProperty("schema_version")
private int schemaVersion = 2;
private long sequence;
@JsonProperty("event_id")
private String eventId;
@JsonProperty("receive_time")
private String receiveTime;
@JsonProperty("point_time_ns")
@JsonInclude(JsonInclude.Include.NON_NULL)
private Long pointTimeNs;
@JsonProperty("source_ip")
private String sourceIp;
@JsonProperty("source_port")
private int sourcePort;
private String protocol;
private String charset;
@JsonProperty("raw_length")
private int rawLength;
@JsonProperty("raw_sha256")
private String rawSha256;
@JsonProperty("raw_message")
private String rawMessage;
@JsonIgnore
private String segment;
@JsonIgnore
private long startOffset;
@JsonIgnore
private long endOffset;
@JsonIgnore
private boolean quarantinedInvalid;
@JsonIgnore
private String validationError;
public int getSchemaVersion() { return schemaVersion; }
public void setSchemaVersion(int schemaVersion) { this.schemaVersion = schemaVersion; }
public long getSequence() { return sequence; }
public void setSequence(long sequence) { this.sequence = sequence; }
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getReceiveTime() { return receiveTime; }
public void setReceiveTime(String receiveTime) { this.receiveTime = receiveTime; }
public Long getPointTimeNs() { return pointTimeNs; }
public void setPointTimeNs(Long pointTimeNs) { this.pointTimeNs = pointTimeNs; }
public String getSourceIp() { return sourceIp; }
public void setSourceIp(String sourceIp) { this.sourceIp = sourceIp; }
public int getSourcePort() { return sourcePort; }
public void setSourcePort(int sourcePort) { this.sourcePort = sourcePort; }
public String getProtocol() { return protocol; }
public void setProtocol(String protocol) { this.protocol = protocol; }
public String getCharset() { return charset; }
public void setCharset(String charset) { this.charset = charset; }
public int getRawLength() { return rawLength; }
public void setRawLength(int rawLength) { this.rawLength = rawLength; }
public String getRawSha256() { return rawSha256; }
public void setRawSha256(String rawSha256) { this.rawSha256 = rawSha256; }
public String getRawMessage() { return rawMessage; }
public void setRawMessage(String rawMessage) { this.rawMessage = rawMessage; }
public String getSegment() { return segment; }
public void setSegment(String segment) { this.segment = segment; }
public long getStartOffset() { return startOffset; }
public void setStartOffset(long startOffset) { this.startOffset = startOffset; }
public long getEndOffset() { return endOffset; }
public void setEndOffset(long endOffset) { this.endOffset = endOffset; }
public boolean isQuarantinedInvalid() { return quarantinedInvalid; }
public void setQuarantinedInvalid(boolean quarantinedInvalid) { this.quarantinedInvalid = quarantinedInvalid; }
public String getValidationError() { return validationError; }
public void setValidationError(String validationError) { this.validationError = validationError; }
}
@@ -0,0 +1,81 @@
package com.haobang.wal;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
/** WAL 所有扫描、恢复和派发路径共用的 v2/v3 严格校验。 */
final class SyslogWalRecordValidator {
private static final ZoneId RECEIVE_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter RECEIVE_FORMAT = DateTimeFormatter
.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS").withResolverStyle(ResolverStyle.STRICT);
private SyslogWalRecordValidator() { }
static void validateNode(JsonNode node) {
JsonNode versionNode = node.get("schema_version");
if (versionNode == null || !versionNode.isIntegralNumber() || !versionNode.canConvertToInt()) {
throw new IllegalArgumentException("schema_version 缺失或不是整数");
}
int version = versionNode.intValue();
JsonNode pointTimeNode = node.get("point_time_ns");
if (version == 2) {
if (pointTimeNode != null && !pointTimeNode.isNull()) {
throw new IllegalArgumentException("WAL v2 禁止携带 point_time_ns");
}
return;
}
if (version != 3) {
throw new IllegalArgumentException("不支持的 WAL 版本: " + version);
}
if (pointTimeNode == null || !pointTimeNode.isIntegralNumber() || !pointTimeNode.canConvertToLong()) {
throw new IllegalArgumentException("WAL v3 point_time_ns 缺失、非数字或溢出");
}
validatePointTime(pointTimeNode.longValue(), text(node, "receive_time"));
}
static void validateRecord(SyslogWalRecord record) {
if (record.getSchemaVersion() == 2) {
if (record.getPointTimeNs() != null) {
throw new IllegalArgumentException("WAL v2 禁止携带 point_time_ns");
}
return;
}
if (record.getSchemaVersion() != 3) {
throw new IllegalArgumentException("不支持的 WAL 版本: " + record.getSchemaVersion());
}
if (record.getPointTimeNs() == null) {
throw new IllegalArgumentException("WAL v3 缺少 point_time_ns");
}
validatePointTime(record.getPointTimeNs(), record.getReceiveTime());
}
static long receiveTimeMillis(String receiveTime) {
if (receiveTime == null || receiveTime.isEmpty()) {
throw new IllegalArgumentException("receive_time 不能为空");
}
return LocalDateTime.parse(receiveTime, RECEIVE_FORMAT)
.atZone(RECEIVE_ZONE).toInstant().toEpochMilli();
}
private static void validatePointTime(long pointTimeNs, String receiveTime) {
if (pointTimeNs <= 0L) {
throw new IllegalArgumentException("point_time_ns 必须为正整数");
}
long receiveTimeMillis = receiveTimeMillis(receiveTime);
if (Math.floorDiv(pointTimeNs, PointTimeAllocator.NANOS_PER_MILLI) != receiveTimeMillis) {
throw new IllegalArgumentException("point_time_ns 毫秒部分与 receive_time 不一致");
}
}
private static String text(JsonNode node, String name) {
JsonNode value = node.get(name);
if (value == null || !value.isTextual()) {
throw new IllegalArgumentException(name + " 缺失或不是字符串");
}
return value.textValue();
}
}
@@ -0,0 +1,834 @@
package com.haobang.wal;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.haobang.config.AppProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileStore;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
/**
* Kafka 接管前的本地持久化 WAL。
*
* <p>网络线程只向有界队列提交事件;单写线程顺序追加并批量 force。Dispatcher 只读取已经
* force 的记录,Kafka ACK 后按连续 sequence 推进持久化游标。崩溃恢复可能重复投递游标后
* 的少量记录,但 event_id 不变,由下游幂等约束消除业务重复。</p>
*/
@Component
public class SyslogWalService {
private static final Logger log = LoggerFactory.getLogger(SyslogWalService.class);
private static final DateTimeFormatter RECORD_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
private static final DateTimeFormatter FILE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmssSSS");
private static final String SEGMENT_GLOB = "syslog-wal-*.jsonl";
public enum AdmissionResult { ACCEPTED, BACKPRESSURE, UNAVAILABLE }
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final Charset syslogCharset;
private final PointTimeAllocator pointTimeAllocator = new PointTimeAllocator();
private final AtomicBoolean initialized = new AtomicBoolean();
private final AtomicLong nextSequence = new AtomicLong();
private final AtomicLong durableSequence = new AtomicLong();
private final LongAdder appended = new LongAdder();
private final LongAdder admissionBackpressure = new LongAdder();
private final LongAdder quarantined = new LongAdder();
private final LongAdder terminalRecorded = new LongAdder();
private final Object durableMonitor = new Object();
private final Object checkpointMonitor = new Object();
private volatile boolean running;
private volatile Throwable fatalFailure;
private volatile Checkpoint persistedCheckpoint = new Checkpoint();
private volatile Checkpoint pendingCheckpoint = new Checkpoint();
private volatile long checkpointDirtyRecords;
private volatile long lastCheckpointFlushNanos;
private volatile DiskState diskState = DiskState.NORMAL;
private volatile long lastDiskCheckNanos;
private ArrayBlockingQueue<SyslogIngressEvent> appendQueue;
private Thread writerThread;
private Path rootDirectory;
private Path activeDirectory;
private Path completedDirectory;
private Path quarantineDirectory;
private Path terminalDirectory;
private Path checkpointDirectory;
private Path checkpointFile;
private FileChannel writerChannel;
private Path writerPath;
private LocalDate writerDate;
private long writerBytes;
private int recordsSinceForce;
private long lastWrittenSequence;
private long lastForceNanos;
public SyslogWalService(AppProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
/*
* WAL 是内部、固定 schema 的持久化协议,不能直接复用 Redis 缓存的多态 ObjectMapper。
*
* CacheConfig 为 Redis 的 LinkedHashMap/实体反序列化开启了 default typing。直接复用它会在
* WAL JSON 中写入 "@class",但它的安全白名单又不允许 com.haobang.wal.SyslogWalRecord,
* 最终造成“自己写出的第一条 WAL 自己无法读取”,Dispatcher 永久停在 offset=0。
*
* copy() 保留 JavaTimeModule 等通用模块,但 deactivateDefaultTyping() 明确移除 Redis 专用的
* 多态协议。这样新 WAL 不再依赖 Java 类名,也不会通过磁盘 JSON 触发任意类型解析。
* SyslogWalRecord 对历史 "@class" 字段做了精确忽略,因此现场已经落盘的旧记录仍可恢复。
*/
this.objectMapper = objectMapper.copy().deactivateDefaultTyping();
this.syslogCharset = resolveSyslogCharset(properties.getSyslogCharset());
}
@PostConstruct
public void initialize() {
if (!initialized.compareAndSet(false, true)) return;
validateConfiguration();
try {
rootDirectory = Paths.get(properties.getSyslogWalDirectory()).toAbsolutePath().normalize();
activeDirectory = rootDirectory.resolve("active");
completedDirectory = rootDirectory.resolve("completed");
quarantineDirectory = rootDirectory.resolve("quarantine");
terminalDirectory = rootDirectory.resolve("terminal");
checkpointDirectory = rootDirectory.resolve("checkpoint");
checkpointFile = checkpointDirectory.resolve("dispatch-checkpoint.json");
Files.createDirectories(activeDirectory);
Files.createDirectories(completedDirectory);
Files.createDirectories(quarantineDirectory);
Files.createDirectories(terminalDirectory);
Files.createDirectories(checkpointDirectory);
applyDirectoryPermissions(rootDirectory);
applyDirectoryPermissions(activeDirectory);
applyDirectoryPermissions(completedDirectory);
applyDirectoryPermissions(quarantineDirectory);
applyDirectoryPermissions(terminalDirectory);
applyDirectoryPermissions(checkpointDirectory);
repairIncompleteTails();
persistedCheckpoint = loadCheckpoint();
pendingCheckpoint = persistedCheckpoint.copy();
long maxSequence = Math.max(persistedCheckpoint.sequence, scanMaxSequence());
nextSequence.set(maxSequence);
durableSequence.set(maxSequence);
cleanupCompletedSegments();
refreshDiskState(true);
} catch (Exception failure) {
fatalFailure = failure;
throw new IllegalStateException("Syslog WAL 初始化失败,拒绝开放接入端口: "
+ properties.getSyslogWalDirectory(), failure);
}
appendQueue = new ArrayBlockingQueue<>(properties.getSyslogWalQueueCapacity());
running = true;
lastForceNanos = System.nanoTime();
lastCheckpointFlushNanos = System.nanoTime();
writerThread = new Thread(this::writerLoop, "syslog-wal-writer");
writerThread.setDaemon(true);
writerThread.start();
log.info("Syslog WAL 已启用: directory={}, charset={}, queueCapacity={}, highWatermark={}%, lowWatermark={}%, "
+ "flushRecords={}, flushIntervalMs={}, checkpointSequence={}",
rootDirectory, syslogCharset.name(), properties.getSyslogWalQueueCapacity(),
properties.getSyslogWalHighWatermarkPercent(), properties.getSyslogWalLowWatermarkPercent(),
properties.getSyslogWalFlushRecords(), properties.getSyslogWalFlushIntervalMs(),
persistedCheckpoint.sequence);
}
public AdmissionResult tryAppend(SyslogIngressEvent event) {
if (properties.isSyslogPointTimeWriteEnabled() && event.getPointTimeNs() == null) {
throw new IllegalArgumentException("point_time 写入已开启,SyslogIngressEvent 必须先固化 point_time_ns");
}
refreshDiskState(false);
if (!running || fatalFailure != null || diskState == DiskState.CRITICAL) {
return AdmissionResult.UNAVAILABLE;
}
if (!appendQueue.offer(event)) {
admissionBackpressure.increment();
return AdmissionResult.BACKPRESSURE;
}
return AdmissionResult.ACCEPTED;
}
public boolean shouldPauseTcp() {
refreshDiskState(false);
if (fatalFailure != null || diskState != DiskState.NORMAL) return true;
int high = percentageCount(properties.getSyslogWalHighWatermarkPercent());
return appendQueue != null && appendQueue.size() >= high;
}
public boolean canResumeTcp() {
refreshDiskState(false);
if (fatalFailure != null || diskState != DiskState.NORMAL) return false;
int low = percentageCount(properties.getSyslogWalLowWatermarkPercent());
return appendQueue != null && appendQueue.size() <= low;
}
public long getRetryIntervalMs() {
refreshDiskState(false);
return diskState == DiskState.NORMAL && fatalFailure == null
? properties.getSyslogWalRetryIntervalMs()
: properties.getSyslogWalDiskRetryIntervalMs();
}
public int getQueueSize() { return appendQueue == null ? 0 : appendQueue.size(); }
public int getQueueCapacity() { return properties.getSyslogWalQueueCapacity(); }
public long getAppendedCount() { return appended.sum(); }
public long getBackpressureCount() { return admissionBackpressure.sum(); }
public long getQuarantinedCount() { return quarantined.sum(); }
public long getTerminalRecordedCount() { return terminalRecorded.sum(); }
public long getIngressPointTimeAssignedCount() { return pointTimeAllocator.getAssignedCount(); }
public long getDurableSequence() { return durableSequence.get(); }
public long getCheckpointSequence() { return persistedCheckpoint.sequence; }
public long getPendingRecordsApprox() {
return Math.max(0L, nextSequence.get() - persistedCheckpoint.sequence);
}
public Throwable getFatalFailure() { return fatalFailure; }
public boolean isDiskCritical() { refreshDiskState(false); return diskState == DiskState.CRITICAL; }
/** 每个事件只调用一次;TCP 背压重试必须复用返回对象,不能重新分配 Point 身份。 */
public SyslogIngressEvent prepareForWal(SyslogIngressEvent event) {
if (!properties.isSyslogPointTimeWriteEnabled() || event.getPointTimeNs() != null) {
return event;
}
long receiveTimeMillis = event.getReceivedAt().atZone(ZoneId.of("Asia/Shanghai"))
.toInstant().toEpochMilli();
return event.withPointTimeNs(pointTimeAllocator.allocate(receiveTimeMillis));
}
public WalCursor openCursor() throws IOException {
return new WalCursor(persistedCheckpoint.copy());
}
/** 仅由 Dispatcher 在连续 ACK 区间推进后调用。 */
public void markCheckpoint(SyslogWalRecord record) {
synchronized (checkpointMonitor) {
if (record.getSequence() <= pendingCheckpoint.sequence) return;
pendingCheckpoint = new Checkpoint(record.getSequence(), record.getSegment(), record.getEndOffset());
checkpointDirtyRecords++;
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastCheckpointFlushNanos);
if (checkpointDirtyRecords >= properties.getSyslogWalCheckpointFlushRecords()
|| elapsedMs >= properties.getSyslogWalCheckpointFlushIntervalMs()) {
flushCheckpointLocked();
}
}
}
/** 永久异常必须先持久化隔离记录,调用方才能把对应 sequence 视为完成。 */
public void quarantine(SyslogWalRecord record, String reason, Throwable failure) throws IOException {
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("quarantined_at", Instant.now().toString());
envelope.put("reason", reason);
envelope.put("error_type", failure == null ? null : failure.getClass().getName());
envelope.put("error_message", failure == null ? null : failure.getMessage());
envelope.put("wal_segment", record.getSegment());
envelope.put("wal_offset", record.getStartOffset());
envelope.put("record", record);
byte[] json = objectMapper.writeValueAsBytes(envelope);
Path target = quarantineDirectory.resolve(String.format("quarantine-%020d-%s.json",
record.getSequence(), record.getEventId()));
try {
try (FileChannel channel = FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
writeFully(channel, json);
channel.force(true);
}
applyFilePermissions(target);
} catch (FileAlreadyExistsException alreadyPersisted) {
// 进程可能在隔离文件 force 后、游标推进前退出;重复恢复视为已持久化。
return;
}
quarantined.increment();
}
/** 业务终态先持久化原因,随后才允许 Dispatcher 推进连续 ACK 游标。 */
public void recordTerminal(SyslogWalRecord record, String type, String reason) throws IOException {
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("completed_at", Instant.now().toString());
envelope.put("type", type);
envelope.put("reason", reason);
envelope.put("event_id", record.getEventId());
envelope.put("sequence", record.getSequence());
envelope.put("source_ip", record.getSourceIp());
envelope.put("wal_segment", record.getSegment());
envelope.put("wal_offset", record.getStartOffset());
byte[] json = objectMapper.writeValueAsBytes(envelope);
Path target = terminalDirectory.resolve(String.format("terminal-%020d-%s.json",
record.getSequence(), record.getEventId()));
try {
try (FileChannel channel = FileChannel.open(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
writeFully(channel, json);
channel.force(true);
}
applyFilePermissions(target);
} catch (FileAlreadyExistsException alreadyPersisted) {
return;
}
terminalRecorded.increment();
}
private void writerLoop() {
try {
while (running || !appendQueue.isEmpty()) {
SyslogIngressEvent event = appendQueue.poll(properties.getSyslogWalFlushIntervalMs(), TimeUnit.MILLISECONDS);
if (event != null) appendEvent(event);
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastForceNanos);
if (recordsSinceForce > 0 && (recordsSinceForce >= properties.getSyslogWalFlushRecords()
|| elapsedMs >= properties.getSyslogWalFlushIntervalMs())) {
forceWriter();
}
flushCheckpointIfDue();
}
forceWriter();
synchronized (checkpointMonitor) { flushCheckpointLocked(); }
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
failWal(interrupted);
} catch (Throwable failure) {
failWal(failure);
} finally {
closeWriter();
}
}
private void appendEvent(SyslogIngressEvent event) throws Exception {
long sequence = nextSequence.incrementAndGet();
SyslogWalRecord record = toRecord(sequence, event);
byte[] json = objectMapper.writeValueAsBytes(record);
byte[] line = new byte[json.length + 1];
System.arraycopy(json, 0, line, 0, json.length);
line[line.length - 1] = (byte) '\n';
LocalDate receiveDate = event.getReceivedAt().toLocalDate();
if (writerChannel == null) openWriter(sequence, receiveDate);
if (writerBytes > 0 && (writerBytes + line.length > properties.getSyslogWalSegmentMaxBytes()
|| !receiveDate.equals(writerDate))) {
forceWriter();
closeWriter();
openWriter(sequence, receiveDate);
}
writeFully(writerChannel, line);
writerBytes += line.length;
recordsSinceForce++;
lastWrittenSequence = sequence;
}
private SyslogWalRecord toRecord(long sequence, SyslogIngressEvent event) throws Exception {
byte[] raw = event.getRawMessage().getBytes(syslogCharset);
SyslogWalRecord record = new SyslogWalRecord();
if (properties.isSyslogPointTimeWriteEnabled()) {
record.setSchemaVersion(3);
record.setPointTimeNs(event.getPointTimeNs());
} else {
record.setSchemaVersion(2);
}
record.setSequence(sequence);
record.setEventId(event.getEventId());
record.setReceiveTime(event.getReceivedAt().format(RECORD_TIME));
record.setSourceIp(event.getSourceIp());
record.setSourcePort(event.getSourcePort());
record.setProtocol(event.getProtocol());
record.setCharset(syslogCharset.name());
record.setRawLength(raw.length);
record.setRawSha256(sha256(raw));
record.setRawMessage(event.getRawMessage());
SyslogWalRecordValidator.validateRecord(record);
return record;
}
private void forceWriter() throws IOException {
if (writerChannel == null || recordsSinceForce == 0) return;
writerChannel.force(false);
durableSequence.set(lastWrittenSequence);
appended.add(recordsSinceForce);
recordsSinceForce = 0;
lastForceNanos = System.nanoTime();
synchronized (durableMonitor) { durableMonitor.notifyAll(); }
}
private void openWriter(long startSequence, LocalDate date) throws IOException {
String name = String.format("syslog-wal-%020d-%s.jsonl", startSequence,
LocalDateTime.now().format(FILE_TIME));
writerPath = activeDirectory.resolve(name);
writerChannel = FileChannel.open(writerPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
applyFilePermissions(writerPath);
writerDate = date;
writerBytes = 0L;
}
private void closeWriter() {
if (writerChannel == null) return;
try { writerChannel.close(); }
catch (IOException failure) { log.warn("关闭 WAL 分段失败: file={}", writerPath, failure); }
finally { writerChannel = null; }
}
private void failWal(Throwable failure) {
fatalFailure = failure;
running = false;
synchronized (durableMonitor) { durableMonitor.notifyAll(); }
log.error("Syslog WAL 发生致命故障,接入已进入 fail-closed 状态", failure);
}
private int percentageCount(int percent) {
return Math.max(1, (int) Math.floor(properties.getSyslogWalQueueCapacity() * percent / 100.0));
}
private void validateConfiguration() {
if (!properties.isSyslogWalEnabled()) {
throw new IllegalArgumentException("syslog.wal.enabled=false 会破坏可靠接入边界,正式链路禁止关闭 WAL");
}
if (properties.getSyslogWalQueueCapacity() <= 0 || properties.getSyslogWalFlushRecords() <= 0
|| properties.getSyslogWalFlushIntervalMs() <= 0 || properties.getSyslogWalSegmentMaxBytes() <= 0
|| properties.getSyslogWalShutdownTimeoutMs() <= 0
|| properties.getSyslogWalCheckpointFlushRecords() <= 0
|| properties.getSyslogWalCheckpointFlushIntervalMs() <= 0) {
throw new IllegalArgumentException("WAL 队列、刷盘、分段、游标和停机参数必须大于 0");
}
if (properties.getSyslogWalRetryIntervalMs() <= 0
|| properties.getSyslogWalDiskRetryIntervalMs() <= 0) {
throw new IllegalArgumentException("WAL 队列和磁盘重试间隔必须大于 0");
}
if (properties.getSyslogWalLowWatermarkPercent() < 0
|| properties.getSyslogWalHighWatermarkPercent() <= properties.getSyslogWalLowWatermarkPercent()
|| properties.getSyslogWalHighWatermarkPercent() >= 100) {
throw new IllegalArgumentException("WAL 水位必须满足 0 <= low < high < 100");
}
if (properties.getSyslogWalDiskHighWatermarkPercent() <= 0
|| properties.getSyslogWalDiskCriticalWatermarkPercent()
<= properties.getSyslogWalDiskHighWatermarkPercent()
|| properties.getSyslogWalDiskCriticalWatermarkPercent() >= 100) {
throw new IllegalArgumentException("WAL 磁盘水位必须满足 0 < high < critical < 100");
}
if (properties.getSyslogWalRetentionDays() < 0 || properties.getSyslogWalMinFreeBytes() < 0) {
throw new IllegalArgumentException("WAL 保留天数和最小剩余字节不能小于 0");
}
}
public Charset getSyslogCharset() {
return syslogCharset;
}
private Charset resolveSyslogCharset(String configuredCharset) {
if (configuredCharset == null || configuredCharset.trim().isEmpty()) {
throw new IllegalArgumentException("syslog.charset 不能为空");
}
try {
return Charset.forName(configuredCharset.trim());
} catch (Exception invalidCharset) {
throw new IllegalArgumentException("不支持的 syslog.charset: " + configuredCharset, invalidCharset);
}
}
private void repairIncompleteTails() throws IOException {
for (Path path : listSegments(activeDirectory)) {
long size = Files.size(path);
if (size == 0) continue;
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
ByteBuffer one = ByteBuffer.allocate(1);
channel.position(size - 1);
channel.read(one);
one.flip();
if (one.get() == (byte) '\n') continue;
long position = size - 1;
while (position >= 0) {
one.clear();
channel.position(position);
channel.read(one);
one.flip();
if (one.get() == (byte) '\n') break;
position--;
}
long truncateAt = position < 0 ? 0 : position + 1;
channel.truncate(truncateAt);
channel.force(true);
log.warn("WAL 启动恢复已截断未完成尾记录: file={}, oldBytes={}, newBytes={}",
path.getFileName(), size, truncateAt);
}
}
}
private long scanMaxSequence() throws IOException {
long max = 0L;
for (Path path : listSegments(activeDirectory)) {
try (java.io.BufferedReader reader = Files.newBufferedReader(path, java.nio.charset.StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.trim().isEmpty()) continue;
JsonNode node = objectMapper.readTree(line);
JsonNode sequenceNode = node.get("sequence");
if (sequenceNode == null || !sequenceNode.isIntegralNumber()
|| !sequenceNode.canConvertToLong() || sequenceNode.longValue() <= 0L) {
throw new IOException("WAL sequence 缺失或非法: " + path);
}
try {
SyslogWalRecordValidator.validateNode(node);
} catch (IllegalArgumentException invalidRecord) {
log.error("WAL 扫描发现损坏记录,启动后将由游标隔离: file={}, sequence={}, reason={}",
path, sequenceNode.longValue(), invalidRecord.getMessage());
}
max = Math.max(max, sequenceNode.longValue());
}
}
}
return max;
}
private Checkpoint loadCheckpoint() throws IOException {
if (!Files.exists(checkpointFile)) return new Checkpoint();
return objectMapper.readValue(Files.readAllBytes(checkpointFile), Checkpoint.class);
}
private void flushCheckpointIfDue() {
synchronized (checkpointMonitor) {
if (checkpointDirtyRecords == 0) return;
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastCheckpointFlushNanos);
if (elapsedMs >= properties.getSyslogWalCheckpointFlushIntervalMs()) flushCheckpointLocked();
}
}
private void flushCheckpointLocked() {
if (checkpointDirtyRecords == 0 || pendingCheckpoint.sequence <= persistedCheckpoint.sequence) return;
Path temporary = checkpointDirectory.resolve("dispatch-checkpoint.tmp");
try {
byte[] json = objectMapper.writeValueAsBytes(pendingCheckpoint);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
writeFully(channel, json);
channel.force(true);
}
try {
Files.move(temporary, checkpointFile, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(temporary, checkpointFile, StandardCopyOption.REPLACE_EXISTING);
}
applyFilePermissions(checkpointFile);
forceDirectoryBestEffort(checkpointDirectory);
persistedCheckpoint = pendingCheckpoint.copy();
checkpointDirtyRecords = 0L;
lastCheckpointFlushNanos = System.nanoTime();
moveCompletedSegments();
} catch (IOException failure) {
fatalFailure = failure;
running = false;
log.error("持久化 WAL ACK 游标失败,接入已停止", failure);
}
}
private void moveCompletedSegments() throws IOException {
String checkpointSegment = persistedCheckpoint.segment;
if (checkpointSegment == null) return;
for (Path segment : listSegments(activeDirectory)) {
String name = segment.getFileName().toString();
if (name.compareTo(checkpointSegment) >= 0 || segment.equals(writerPath)) continue;
Files.move(segment, completedDirectory.resolve(name), StandardCopyOption.REPLACE_EXISTING);
}
cleanupCompletedSegments();
}
private void cleanupCompletedSegments() throws IOException {
int retentionDays = properties.getSyslogWalRetentionDays();
if (retentionDays <= 0) return;
Instant cutoff = LocalDate.now().minusDays(retentionDays).atStartOfDay(ZoneId.systemDefault()).toInstant();
for (Path path : listSegments(completedDirectory)) {
if (Files.getLastModifiedTime(path).toInstant().isBefore(cutoff)) Files.deleteIfExists(path);
}
}
private List<Path> listSegments(Path directory) throws IOException {
if (directory == null || !Files.exists(directory)) return Collections.emptyList();
List<Path> paths = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, SEGMENT_GLOB)) {
for (Path path : stream) paths.add(path);
}
paths.sort(Comparator.comparing(path -> path.getFileName().toString()));
return paths;
}
private void refreshDiskState(boolean force) {
if (rootDirectory == null) return;
long now = System.nanoTime();
if (!force && TimeUnit.NANOSECONDS.toMillis(now - lastDiskCheckNanos) < 1000) return;
lastDiskCheckNanos = now;
try {
FileStore store = Files.getFileStore(rootDirectory);
long total = store.getTotalSpace();
long usable = store.getUsableSpace();
long usedPercent = total <= 0 ? 0 : ((total - usable) * 100L / total);
DiskState next = usedPercent >= properties.getSyslogWalDiskCriticalWatermarkPercent()
|| usable < properties.getSyslogWalMinFreeBytes() ? DiskState.CRITICAL
: usedPercent >= properties.getSyslogWalDiskHighWatermarkPercent()
? DiskState.HIGH : DiskState.NORMAL;
if (next != diskState) {
log.warn("WAL 磁盘状态变化: previous={}, current={}, usedPercent={}%, usableBytes={}",
diskState, next, usedPercent, usable);
}
diskState = next;
} catch (IOException failure) {
diskState = DiskState.CRITICAL;
fatalFailure = failure;
log.error("无法检查 WAL 磁盘容量,按 fail-closed 处理", failure);
}
}
private String sha256(byte[] value) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value);
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte item : digest) hex.append(String.format("%02x", item & 0xff));
return hex.toString();
}
private void applyFilePermissions(Path path) {
try {
Files.setPosixFilePermissions(path, EnumSet.of(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
} catch (UnsupportedOperationException | IOException failure) {
log.warn("当前文件系统不支持 WAL 文件 POSIX 0600 权限,请检查目录 ACL: file={}", path);
}
}
private void applyDirectoryPermissions(Path path) {
try {
Set<PosixFilePermission> permissions = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(path, permissions);
} catch (UnsupportedOperationException | IOException failure) {
log.warn("当前文件系统不支持 WAL 目录 POSIX 0700 权限,请检查目录 ACL: directory={}", path);
}
}
private void forceDirectoryBestEffort(Path directory) {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException | IOException failure) {
// ATOMIC_MOVE 仍保证不会读取半个 checkpoint;Linux 生产环境应支持目录 force。
log.warn("当前文件系统不支持 checkpoint 目录 force,请确认文件系统掉电语义: directory={}",
directory);
}
}
private static void writeFully(FileChannel channel, byte[] bytes) throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
}
@PreDestroy
public void shutdown() {
running = false;
synchronized (durableMonitor) { durableMonitor.notifyAll(); }
if (writerThread != null) {
try {
writerThread.join(properties.getSyslogWalShutdownTimeoutMs());
if (writerThread.isAlive()) {
log.error("WAL 写线程未在超时内退出: remaining={}", appendQueue.size());
writerThread.interrupt();
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
synchronized (checkpointMonitor) { flushCheckpointLocked(); }
}
public final class WalCursor implements AutoCloseable {
private String currentSegment;
private long position;
private FileChannel channel;
private WalCursor(Checkpoint checkpoint) throws IOException {
this.currentSegment = checkpoint.segment;
this.position = checkpoint.offset;
if (currentSegment != null) {
Path path = activeDirectory.resolve(currentSegment);
if (!Files.exists(path)) {
throw new IOException("WAL checkpoint 指向的活动分段不存在,拒绝跳过: " + path);
}
channel = FileChannel.open(path, StandardOpenOption.READ);
}
}
public SyslogWalRecord next(long waitMs) throws IOException, InterruptedException {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs);
while (running || getPendingRecordsApprox() > 0) {
ensureChannel();
if (channel == null) {
awaitDurable(deadline);
if (System.nanoTime() >= deadline) return null;
continue;
}
long start = position;
byte[] line = readLine();
if (line == null) {
if (openNextSegment()) continue;
awaitDurable(deadline);
if (System.nanoTime() >= deadline) return null;
continue;
}
SyslogWalRecord record;
try {
JsonNode node = objectMapper.readTree(line);
try {
SyslogWalRecordValidator.validateNode(node);
record = objectMapper.treeToValue(node, SyslogWalRecord.class);
SyslogWalRecordValidator.validateRecord(record);
} catch (Exception invalidRecord) {
record = invalidRecordTombstone(node, invalidRecord);
}
} catch (Exception failure) {
throw new IOException("WAL 中间记录损坏,拒绝越过: segment=" + currentSegment
+ ", offset=" + start, failure);
}
if (record.getSequence() > durableSequence.get()) {
position = start;
awaitDurable(deadline);
if (System.nanoTime() >= deadline) return null;
continue;
}
record.setSegment(currentSegment);
record.setStartOffset(start);
record.setEndOffset(position);
if (record.isQuarantinedInvalid()) {
quarantine(record, "INVALID_WAL_RECORD", new IllegalArgumentException(record.getValidationError()));
}
return record;
}
return null;
}
private SyslogWalRecord invalidRecordTombstone(JsonNode node, Exception failure) throws IOException {
JsonNode sequenceNode = node == null ? null : node.get("sequence");
if (sequenceNode == null || !sequenceNode.isIntegralNumber()
|| !sequenceNode.canConvertToLong() || sequenceNode.longValue() <= 0L) {
throw new IOException("损坏 WAL 无法取得合法 sequence,拒绝越过", failure);
}
SyslogWalRecord record = new SyslogWalRecord();
record.setSequence(sequenceNode.longValue());
record.setSchemaVersion(node.path("schema_version").asInt(-1));
record.setEventId(node.path("event_id").asText("unknown-" + record.getSequence()));
record.setReceiveTime(node.path("receive_time").asText(null));
record.setQuarantinedInvalid(true);
record.setValidationError(failure.getMessage() == null
? failure.getClass().getName() : failure.getMessage());
return record;
}
private void ensureChannel() throws IOException {
if (channel != null) return;
List<Path> segments = listSegments(activeDirectory);
if (segments.isEmpty()) return;
Path first = segments.get(0);
currentSegment = first.getFileName().toString();
position = 0L;
channel = FileChannel.open(first, StandardOpenOption.READ);
}
private boolean openNextSegment() throws IOException {
List<Path> segments = listSegments(activeDirectory);
for (Path path : segments) {
String name = path.getFileName().toString();
if (currentSegment == null || name.compareTo(currentSegment) > 0) {
closeChannel();
currentSegment = name;
position = 0L;
channel = FileChannel.open(path, StandardOpenOption.READ);
return true;
}
}
return false;
}
private byte[] readLine() throws IOException {
long start = position;
ByteArrayOutputStream output = new ByteArrayOutputStream(2048);
ByteBuffer buffer = ByteBuffer.allocate(8192);
while (true) {
buffer.clear();
channel.position(position);
int count = channel.read(buffer);
if (count < 0 || count == 0) {
position = start;
return null;
}
buffer.flip();
int consumed = 0;
while (buffer.hasRemaining()) {
byte value = buffer.get();
consumed++;
if (value == (byte) '\n') {
position += consumed;
return output.toByteArray();
}
output.write(value);
}
position += consumed;
}
}
private void awaitDurable(long deadline) throws InterruptedException {
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) return;
synchronized (durableMonitor) {
TimeUnit.NANOSECONDS.timedWait(durableMonitor, remainingNanos);
}
}
private void closeChannel() throws IOException {
if (channel != null) channel.close();
channel = null;
}
@Override public void close() throws IOException { closeChannel(); }
}
public static class Checkpoint {
public long sequence;
public String segment;
public long offset;
public Checkpoint() { this(0L, null, 0L); }
public Checkpoint(long sequence, String segment, long offset) {
this.sequence = sequence;
this.segment = segment;
this.offset = offset;
}
private Checkpoint copy() { return new Checkpoint(sequence, segment, offset); }
}
private enum DiskState { NORMAL, HIGH, CRITICAL }
}
@@ -0,0 +1,58 @@
package com.haobang.wal;
import com.haobang.dispatch.DeviceContextResolver;
import com.haobang.dispatch.SyslogWalDispatcher;
import com.netty.SyslogMessageHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/** 运维只读状态接口;不提供跳过、清空或人工推进游标等危险操作。 */
@RestController
@RequestMapping("/api/syslog/wal")
public class SyslogWalStatusController {
private final SyslogWalService wal;
private final SyslogWalDispatcher dispatcher;
private final DeviceContextResolver resolver;
public SyslogWalStatusController(SyslogWalService wal, SyslogWalDispatcher dispatcher,
DeviceContextResolver resolver) {
this.wal = wal;
this.dispatcher = dispatcher;
this.resolver = resolver;
}
@GetMapping("/status")
public Map<String, Object> status() {
Map<String, Object> value = new LinkedHashMap<>();
value.put("network_received", SyslogMessageHandler.getNetworkReceivedCount());
value.put("wal_appended", wal.getAppendedCount());
value.put("wal_queue_size", wal.getQueueSize());
value.put("wal_queue_capacity", wal.getQueueCapacity());
value.put("wal_durable_sequence", wal.getDurableSequence());
value.put("wal_checkpoint_sequence", wal.getCheckpointSequence());
value.put("wal_pending_approx", wal.getPendingRecordsApprox());
value.put("wal_admission_backpressure", wal.getBackpressureCount());
value.put("wal_terminal_recorded", wal.getTerminalRecordedCount());
value.put("wal_quarantined", wal.getQuarantinedCount());
Map<String, Long> pointTimeAssigned = new LinkedHashMap<>();
pointTimeAssigned.put("ingress", wal.getIngressPointTimeAssignedCount());
value.put("point_time_assigned_total", pointTimeAssigned);
value.put("wal_disk_critical", wal.isDiskCritical());
value.put("wal_fatal_error", wal.getFatalFailure() == null
? null : wal.getFatalFailure().toString());
value.put("dispatch_inflight", dispatcher.getInflight());
value.put("kafka_acked", dispatcher.getKafkaAcked());
value.put("dispatch_terminal_filtered", dispatcher.getTerminalFiltered());
value.put("dispatch_retry", dispatcher.getRetryCount());
value.put("tcp_pause_total", SyslogMessageHandler.getTcpPauseCount());
value.put("udp_dropped_total", SyslogMessageHandler.getUdpDroppedCount());
value.put("device_cache_size", resolver.getCacheSize());
value.put("device_cache_hits", resolver.getHitCount());
value.put("device_cache_misses", resolver.getMissCount());
return value;
}
}