从发现到定位的最后一公里:机器学习时序异常检测与根因分析结合实践

信息图

一、为什么要结合?

1.1 孤立的痛点

先看两个常见的失败场景:

场景一:异常洪水

03:15 Prometheus告警:CPU > 90%
03:16 Prometheus告警:内存 > 85%
03:17 Prometheus告警:磁盘IO > 95%
03:18 Prometheus告警:延迟P99 > 2s
... 接下来10分钟,又触发了20条告警

值班工程师面对一堆告警,无从下手。这些指标到底哪个是因、哪个是果?

场景二:有异常无告警

服务响应变慢,用户投诉
但所有指标都在阈值范围内
——因为没有达到告警阈值

这就是传统阈值告警的盲区。而机器学习时序检测可以捕捉到"指标虽然没超阈值,但趋势已经明显异常"的情况。

二、系统设计:从检测到关联再到定位

2.1 总体架构

flowchart TD
    A["[时序指标]"] --> B["异常检测(多算法)"] --> C["异常事件"]
    D["[错误日志]"] --> E["错误聚合(聚类)"] --> F["错误模式"]
    C --> G["异常关联引擎"]
    F --> G
    G --> H["LLM根因分析器"] --> I["根因分析报告"]

2.2 多算法时序异常检测

单一算法容易误判,我们采用多算法投票机制:

# multi_detector.py — 多算法异常检测
import numpy as np
from scipy import stats
from sklearn.ensemble import IsolationForest
import pandas as pd

class MultiAlgorithmDetector:
    """多算法异常检测引擎"""
    
    def __init__(self):
        self.detectors = {
            '3sigma': self._detect_3sigma,
            'mad': self._detect_mad,
            'iqr': self._detect_iqr,
            'isolation_forest': self._detect_iforest
        }
    
    def _detect_3sigma(self, series: pd.Series):
        """3-sigma检测"""
        mean = series.mean()
        std = series.std()
        anomalies = (series > mean + 3*std) | (series < mean - 3*std)
        return anomalies, mean, std
    
    def _detect_mad(self, series: pd.Series):
        """MAD(中位数绝对偏差)检测,对异常值更鲁棒"""
        median = series.median()
        mad = np.median(np.abs(series - median))
        # 阈值:median ± 3 * 1.4826 * MAD
        threshold = 3 * 1.4826 * mad
        anomalies = (series > median + threshold) | (series < median - threshold)
        return anomalies, median, threshold
    
    def _detect_iqr(self, series: pd.Series):
        """IQR四分位距检测"""
        q1 = series.quantile(0.25)
        q3 = series.quantile(0.75)
        iqr = q3 - q1
        lower = q1 - 1.5 * iqr
        upper = q3 + 1.5 * iqr
        anomalies = (series < lower) | (series > upper)
        return anomalies, lower, upper
    
    def _detect_iforest(self, series: pd.Series):
        """Isolation Forest检测"""
        values = series.values.reshape(-1, 1)
        model = IsolationForest(contamination=0.05, random_state=42)
        preds = model.fit_predict(values)
        anomalies = pd.Series(preds == -1, index=series.index)
        return anomalies, None, None
    
    def ensemble_detect(self, series: pd.Series, 
                        min_votes: int = 2):
        """集成投票:至少2个算法判定为异常才算异常"""
        votes = np.zeros(len(series))
        
        for name, detector in self.detectors.items():
            anomalies, _, _ = detector(series)
            votes += anomalies.astype(int)
        
        # 投票决定
        final_anomaly = votes >= min_votes
        anomaly_score = votes / len(self.detectors)
        
        return {
            'is_anomaly': final_anomaly,
            'confidence': anomaly_score,
            'votes': votes,
            'details': {name: detector(series)[0].tolist() 
                       for name, detector in self.detectors.items()}
        }

2.3 日志错误模式聚类

# log_cluster.py — 日志错误模式聚类
import re
from collections import Counter
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import DBSCAN

class ErrorLogCluster:
    """错误日志模式聚类"""
    
    def __init__(self):
        self.patterns = {}
        self.vectorizer = TfidfVectorizer(
            analyzer='char_wb',
            ngram_range=(3, 5),
            max_features=1000
        )
    
    def normalize_log(self, message: str) -> str:
        """将变量部分替换为占位符"""
        # 替换IP地址
        message = re.sub(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', '{ip}', message)
        # 替换数字
        message = re.sub(r'\d+', '{n}', message)
        # 替换UUID
        message = re.sub(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', 
                        '{uuid}', message)
        return message
    
    def cluster(self, log_entries: list) -> dict:
        """聚类错误日志"""
        messages = [entry['message'] for entry in log_entries]
        normalized = [self.normalize_log(m) for m in messages]
        
        # TF-IDF向量化 + DBSCAN聚类
        X = self.vectorizer.fit_transform(normalized)
        clustering = DBSCAN(eps=0.3, min_samples=2, metric='cosine')
        labels = clustering.fit_predict(X)
        
        # 统计每个聚类的数量和样本
        clusters = {}
        for i, label in enumerate(labels):
            if label == -1:
                continue  # 噪点忽略
            if label not in clusters:
                clusters[label] = {
                    'count': 0,
                    'pattern': normalized[i],
                    'sample_message': messages[i]
                }
            clusters[label]['count'] += 1
        
        # 按数量排序
        sorted_clusters = sorted(
            clusters.values(),
            key=lambda x: x['count'],
            reverse=True
        )
        
        return {
            'total_errors': len(log_entries),
            'clusters': sorted_clusters[:10],
            'unique_patterns': len(clusters)
        }

2.4 异常关联引擎

这是最关键的一步——把指标异常和日志错误关联起来:

# anomaly_correlator.py — 异常关联引擎
from datetime import datetime, timedelta
import numpy as np

class AnomalyCorrelator:
    """异常关联引擎:找到指标异常和日志错误的因果关系"""
    
    def __init__(self, time_window: int = 5):
        self.time_window = time_window  # 关联时间窗口(分钟)
    
    def correlate(self, metric_anomalies: dict, 
                  log_clusters: dict,
                  time_range: tuple) -> dict:
        """核心关联逻辑"""
        correlations = []
        
        # 对每个异常指标,检查同一时间窗口内的日志模式
        for metric_name, anomaly_info in metric_anomalies.items():
            anomaly_times = anomaly_info['anomaly_times']
            
            for atime in anomaly_times:
                window_start = atime - timedelta(minutes=self.time_window)
                window_end = atime + timedelta(minutes=self.time_window)
                
                # 找这个时间窗口内的错误日志
                matching_errors = [
                    c for c in log_clusters['clusters']
                    if any(window_start <= e['timestamp'] <= window_end 
                          for e in c.get('entries', []))
                ]
                
                if matching_errors:
                    correlations.append({
                        'metric': metric_name,
                        'anomaly_time': atime.isoformat(),
                        'anomaly_value': anomaly_info['value'],
                        'baseline': anomaly_info['baseline'],
                        'deviation': f"{((anomaly_info['value'] / anomaly_info['baseline']) - 1) * 100:.1f}%",
                        'related_errors': [
                            {
                                'pattern': c['pattern'],
                                'count': c['count'],
                                'sample': c['sample_message']
                            }
                            for c in matching_errors
                        ],
                        'correlation_strength': len(matching_errors) / len(log_clusters['clusters'])
                    })
        
        # 按关联强度排序
        correlations.sort(key=lambda x: x['correlation_strength'], reverse=True)
        
        return {
            'total_correlations': len(correlations),
            'top_correlations': correlations[:5],
            'uncorrelated_anomalies': self._find_uncorrelated(
                metric_anomalies, correlations
            )
        }
    
    def _find_uncorrelated(self, anomalies, correlations):
        """找出来关联不上的异常(可能是未知问题)"""
        correlated_metrics = {c['metric'] for c in correlations}
        return [
            name for name in anomalies 
            if name not in correlated_metrics
        ]

2.5 LLM根因分析

# llm_analyzer.py — LLM根因分析器
class LLMRootCauseAnalyzer:
    """基于关联结果,用LLM做根因分析"""
    
    def analyze(self, correlation_result: dict, 
                service_name: str) -> str:
        """生成根因分析"""
        correlations = correlation_result['top_correlations']
        
        # 按时间线排序
        timeline = sorted(correlations, 
                         key=lambda x: x['anomaly_time'])
        
        # 找到最早出现的异常(极有可能是根因)
        earliest = timeline[0] if timeline else None
        
        prompt = f"""根据以下关联分析结果,判断{service_name}的故障根因:

## 执行时间线(按时间排序)
{self._format_timeline(timeline)}

## 最早出现的异常
{json.dumps(earliest, indent=2, ensure_ascii=False) if earliest else '无'}

## 未关联的异常指标
{json.dumps(correlation_result.get('uncorrelated_anomalies', []), ensure_ascii=False)}

请分析:
1. 最早异常的指标是什么?它是否可能是根因?
2. 其他异常是指标级联效应还是独立事件?
3. 给出最可能的根因(最多3个,按概率排序)
4. 下一步排查建议
"""
        # 调用LLM...
        return self._call_llm(prompt)

三、生产案例

在一次生产故障中,我们的系统检测到了以下关联:

flowchart TD
    Start["检测到5个指标异常,2个错误日志模式"] --> Analysis["关联分析结果"]

    subgraph AnalysisDetails [关联分析详情]
        direction TB
        A1["1. [最早] order-db 活跃连接数 50→200 (16:30:05)"] --> A1_Err["关联错误:conn pool exhausted (count: 847)"]
        A1 --> A1_Str["关联强度:0.83"]

        A2["2. order-service CPU 40%→85% (16:30:30)"] --> A2_Err["关联错误:connection timeout (count: 423)"]
        A2 --> A2_Str["关联强度:0.67"]

        A3["3. api-gateway 延迟P99 80ms→3.2s (16:31:00)"] --> A3_Err["关联错误:upstream timeout (count: 215)"]
        A3 --> A3_Str["关联强度:0.54"]
    end

    Analysis --> RootCause["根因概率"]
    
    subgraph RootCauseDetails [根因分析]
        direction TB
        RC1["1. 数据库连接池耗尽 — 80%<br/>(最早异常,关联最强)"]
        RC2["2. 数据库慢查询 — 15%"]
        RC3["3. 网络问题 — 5%"]
    end

    RootCause --> RC1
    RootCause --> RC2
    RootCause --> RC3

    RC1 --> Action["排查建议"]
    
    subgraph ActionDetails [排查步骤]
        direction TB
        S1["1. 检查MySQL max_connections和连接池配置"]
        S2["2. 查看慢查询日志"]
        S3["3. 检查是否有未关闭的事务"]
    end

    Action --> S1
    Action --> S2
    Action --> S3

实际根因:数据库连接池max_connections配置为200,但应用实例从3个扩容到10个时,每个实例的连接池扩大,总和超过了200。

四、总结

从"多个维度独立检测"到"跨维度关联分析",这是运维可观测性从L2向L3演进的关键一步。机器学习做时序检测发现异常,日志聚类聚合错误模式,关联引擎连接指标和日志,LLM给出最终分析——这四层组合起来,让"发现到定位"的平均时间从40分钟降到了8分钟。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐