StreamSwitchLogAnalyzer.java 13.5 KB
package com.genersoft.iot.vmp.jtt1078.subscriber;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 码流切换日志分析器
 *
 * 用于分析码流切换过程中可能出现的问题:
 * 1. FFmpeg启动/关闭日志
 * 2. 视频参数变化检测
 * 3. 花屏/断流问题定位
 * 4. 性能问题分析
 *
 * 使用方法:
 * 1. 在出现问题是,查看控制台日志
 * 2. 或者将日志保存到文件后分析
 *
 * 常见问题排查:
 * - 如果看到"FFmpeg进程未能正常退出":可能是FFmpeg被阻塞
 * - 如果看到"SPS变化但未重启":可能是冷却期内
 * - 如果持续花屏:可能是SPS解析失败或FLV编码器问题
 */
public class StreamSwitchLogAnalyzer {

    static Logger logger = LoggerFactory.getLogger(StreamSwitchLogAnalyzer.class);

    /** 日志事件类型 */
    public enum EventType {
        FFmpeg_START("FFmpeg推流任务启动"),
        FFmpeg_STOP("FFmpeg推流任务结束"),
        FFmpeg_CLOSE("开始关闭FFmpeg推流"),
        FFmpeg_RESTART("开始重启FFmpeg推流"),
        SPS_CHANGE("检测到视频参数变化"),
        SPS_HASH("SPS哈希变化"),
        IFRAME_RECEIVE("收到新的I帧"),
        STREAM_SWITCH("收到码流切换通知"),
        COOLDOWN_SKIP("FFmpeg重启过于频繁,跳过本次重启"),
        ERROR("错误"),
        WARNING("警告");

        private final String keyword;

        EventType(String keyword) {
            this.keyword = keyword;
        }

        public String getKeyword() {
            return keyword;
        }
    }

    /** 日志事件 */
    public static class LogEvent {
        public long timestamp;
        public EventType type;
        public String tag;
        public String message;
        public Map<String, Object> data;

        public LogEvent(long timestamp, EventType type, String tag, String message) {
            this.timestamp = timestamp;
            this.type = type;
            this.tag = tag;
            this.message = message;
            this.data = new HashMap<>();
        }

        @Override
        public String toString() {
            return String.format("[%s] [%s] [%s] %s",
                    new Date(timestamp), type.name(), tag, message);
        }
    }

    /** 问题诊断结果 */
    public static class DiagnosisResult {
        public boolean hasProblem = false;
        public String problemType;      // 问题类型
        public String description;       // 问题描述
        public String suggestion;         // 解决建议
        public List<LogEvent> relatedEvents = new ArrayList<>();

        @Override
        public String toString() {
            if (!hasProblem) {
                return "诊断结果:未发现问题";
            }
            return String.format(
                "【问题诊断】\n" +
                "问题类型: %s\n" +
                "问题描述: %s\n" +
                "解决建议: %s\n" +
                "相关日志: %d条",
                problemType, description, suggestion, relatedEvents.size()
            );
        }
    }

    /**
     * 分析日志列表,诊断问题
     */
    public static DiagnosisResult diagnose(List<String> rawLogs) {
        DiagnosisResult result = new DiagnosisResult();
        List<LogEvent> events = parseLogs(rawLogs);

        if (events.isEmpty()) {
            result.hasProblem = false;
            result.description = "没有找到相关的日志记录";
            return result;
        }

        // 检查问题1:FFmpeg重启过于频繁
        List<LogEvent> cooldownSkips = filterEvents(events, EventType.COOLDOWN_SKIP);
        if (cooldownSkips.size() >= 3) {
            result.hasProblem = true;
            result.problemType = "FFmpeg重启过于频繁";
            result.description = String.format(
                "在%d次码流切换中,有%d次因为冷却期被跳过,可能导致切换延迟",
                events.size(), cooldownSkips.size()
            );
            result.suggestion = "建议将冷却时间从3000ms减少到1000ms,或者检查码流切换逻辑是否有问题";
            result.relatedEvents.addAll(cooldownSkips);
            return result;
        }

        // 检查问题2:FFmpeg启动后立即关闭
        Map<String, List<LogEvent>> tagGroups = groupByTag(events);
        for (Map.Entry<String, List<LogEvent>> entry : tagGroups.entrySet()) {
            List<LogEvent> tagEvents = entry.getValue();
            LogEvent firstStart = findFirst(tagEvents, EventType.FFmpeg_START);
            LogEvent firstStop = findFirst(tagEvents, EventType.FFmpeg_STOP);

            if (firstStart != null && firstStop != null) {
                long duration = firstStop.timestamp - firstStart.timestamp;
                if (duration < 1000) {
                    result.hasProblem = true;
                    result.problemType = "FFmpeg启动后立即关闭";
                    result.description = String.format(
                        "Tag[%s]的FFmpeg启动后%dms就关闭了,可能是源流有问题或FFmpeg配置错误",
                        entry.getKey(), duration
                    );
                    result.suggestion = "检查源流地址是否正确,FFmpeg是否有足够时间接收数据";
                    result.relatedEvents.add(firstStart);
                    result.relatedEvents.add(firstStop);
                    return result;
                }
            }
        }

        // 检查问题3:SPS变化但未检测到重启
        List<LogEvent> spsChanges = filterEvents(events, EventType.SPS_CHANGE);
        List<LogEvent> restarts = filterEvents(events, EventType.FFmpeg_RESTART);
        if (spsChanges.size() > restarts.size() * 2) {
            result.hasProblem = true;
            result.problemType = "视频参数变化未触发FFmpeg重启";
            result.description = String.format(
                "检测到%d次SPS变化,但只触发了%d次FFmpeg重启",
                spsChanges.size(), restarts.size()
            );
            result.suggestion = "检查日志中的冷却跳过记录,可能需要调整冷却时间";
            result.relatedEvents.addAll(spsChanges);
            return result;
        }

        // 检查问题4:持续的花屏迹象(ERROR日志)
        List<LogEvent> errors = filterEvents(events, EventType.ERROR);
        if (errors.size() >= 5) {
            result.hasProblem = true;
            result.problemType = "存在大量错误日志";
            result.description = String.format(
                "在%d条日志中发现%d个错误,可能存在持续性问题",
                events.size(), errors.size()
            );
            result.suggestion = "查看错误日志详情,分析具体错误原因";
            result.relatedEvents.addAll(errors);
            return result;
        }

        // 问题5:没有找到SPS变化但也没有重启
        if (!spsChanges.isEmpty() && restarts.isEmpty()) {
            result.hasProblem = true;
            result.problemType = "未检测到FFmpeg重启";
            result.description = "检测到视频参数变化,但没有FFmpeg重启记录";
            result.suggestion = "检查Channel.java中的restartRtmpPublisher是否被正确调用";
            return result;
        }

        // 如果没有问题,返回成功
        result.hasProblem = false;
        result.description = "码流切换日志分析完成,未发现明显问题";

        // 添加统计信息
        StringBuilder stats = new StringBuilder();
        stats.append(String.format("日志统计:\n"));
        stats.append(String.format("- FFmpeg启动次数: %d\n", filterEvents(events, EventType.FFmpeg_START).size()));
        stats.append(String.format("- FFmpeg关闭次数: %d\n", filterEvents(events, EventType.FFmpeg_STOP).size()));
        stats.append(String.format("- 码流切换次数: %d\n", filterEvents(events, EventType.STREAM_SWITCH).size()));
        stats.append(String.format("- SPS变化次数: %d\n", filterEvents(events, EventType.SPS_CHANGE).size()));
        stats.append(String.format("- FFmpeg重启次数: %d\n", restarts.size()));

        result.description = stats.toString();

        return result;
    }

    /**
     * 解析日志列表
     */
    private static List<LogEvent> parseLogs(List<String> rawLogs) {
        List<LogEvent> events = new ArrayList<>();

        for (String line : rawLogs) {
            LogEvent event = parseLogLine(line);
            if (event != null) {
                events.add(event);
            }
        }

        // 按时间排序
        events.sort(Comparator.comparingLong(e -> e.timestamp));
        return events;
    }

    /**
     * 解析单行日志
     */
    private static LogEvent parseLogLine(String line) {
        if (line == null || line.isEmpty()) {
            return null;
        }

        // 提取时间戳(简化处理,假设格式为 [yyyy-MM-dd HH:mm:ss] 或类似)
        long timestamp = System.currentTimeMillis(); // 默认当前时间
        Pattern timePattern = Pattern.compile("(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})");
        Matcher timeMatcher = timePattern.matcher(line);
        if (timeMatcher.find()) {
            try {
                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                Date date = sdf.parse(timeMatcher.group(1));
                if (date != null) {
                    timestamp = date.getTime();
                }
            } catch (Exception e) {
                // 使用默认时间
            }
        }

        // 提取tag
        String tag = "unknown";
        Pattern tagPattern = Pattern.compile("\\[([^\\]]+)\\]\\s*(?:RTMPPublisher|Channel|\\[)");
        Matcher tagMatcher = tagPattern.matcher(line);
        if (tagMatcher.find()) {
            tag = tagMatcher.group(1);
        }

        // 判断事件类型
        EventType type = null;
        String message = line;

        for (EventType t : EventType.values()) {
            if (line.contains(t.getKeyword())) {
                type = t;
                break;
            }
        }

        if (type == null) {
            return null; // 不关心的日志行
        }

        return new LogEvent(timestamp, type, tag, message);
    }

    /**
     * 过滤特定类型的事件
     */
    private static List<LogEvent> filterEvents(List<LogEvent> events, EventType type) {
        List<LogEvent> result = new ArrayList<>();
        for (LogEvent event : events) {
            if (event.type == type) {
                result.add(event);
            }
        }
        return result;
    }

    /**
     * 按tag分组
     */
    private static Map<String, List<LogEvent>> groupByTag(List<LogEvent> events) {
        Map<String, List<LogEvent>> groups = new HashMap<>();
        for (LogEvent event : events) {
            groups.computeIfAbsent(event.tag, k -> new ArrayList<>()).add(event);
        }
        return groups;
    }

    /**
     * 查找第一个指定类型的事件
     */
    private static LogEvent findFirst(List<LogEvent> events, EventType type) {
        for (LogEvent event : events) {
            if (event.type == type) {
                return event;
            }
        }
        return null;
    }

    /**
     * 生成诊断报告
     */
    public static String generateReport(List<String> rawLogs) {
        DiagnosisResult result = diagnose(rawLogs);

        StringBuilder report = new StringBuilder();
        report.append(repeatChar('=', 60)).append("\n");
        report.append("码流切换问题诊断报告\n");
        report.append("生成时间: ").append(new Date()).append("\n");
        report.append(repeatChar('=', 60)).append("\n\n");

        if (result.hasProblem) {
            report.append("【发现问题】\n");
            report.append(result.toString()).append("\n\n");

            report.append("【相关日志详情】\n");
            for (LogEvent event : result.relatedEvents) {
                report.append(event.toString()).append("\n");
            }
        } else {
            report.append("【诊断结果】\n");
            report.append(result.description).append("\n");
        }

        report.append("\n").append(repeatChar('=', 60)).append("\n");
        report.append("报告结束\n");
        report.append(repeatChar('=', 60)).append("\n");

        return report.toString();
    }

    /**
     * Java 8兼容的字符重复方法
     */
    private static String repeatChar(char c, int count) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < count; i++) {
            sb.append(c);
        }
        return sb.toString();
    }

    /**
     * 打印诊断报告到日志
     */
    public static void logDiagnosis(List<String> rawLogs) {
        DiagnosisResult result = diagnose(rawLogs);

        if (result.hasProblem) {
            logger.error("========== 码流切换问题诊断 ==========");
            logger.error(result.toString());

            if (!result.relatedEvents.isEmpty()) {
                logger.error("---------- 相关日志 ----------");
                for (LogEvent event : result.relatedEvents) {
                    logger.error(event.toString());
                }
            }
            logger.error("====================================");
        } else {
            logger.info("========== 码流切换诊断 ==========");
            logger.info(result.description);
            logger.info("==================================");
        }
    }
}