Drain3项目解读

Drain3 项目解读与学习指南

1. 项目概述

Drain3 是基于 Drain 算法的改进版在线日志解析工具,主要功能是从日志流中实时提取日志模板。核心创新点是通过固定深度的前缀树结构实现高效的日志聚类。

1.1 核心功能

  • 实时日志模板挖掘(
  • 参数提取(如 IP、数字等动态内容)
  • 支持多种持久化方式(Kafka/Redis/文件)
  • 可配置的掩码规则(

1.2 算法流程

  • 预处理 :通过分隔符将日志消息拆分为token序列
  • 树遍历 :根据token长度和特定位置token进行层级匹配
  • 相似度计算 :使用最长公共子序列(LCS)比对日志模板
  • 聚类更新 :相似度超过阈值则合并到同一集群

2. 技术架构

2.1 核心组件

drain3/
├── drain.py        # Drain 算法基础实现
├── jaccard_drain.py # Jaccard相似度变种
├── template_miner.py # 核心业务逻辑封装
└── template_miner_config.py # 配置管理

2.2 关键技术

  1. Drain 算法 :通过固定深度前缀树实现快速聚类
  2. 正则掩码 : LogMasker 预处理日志中的动态参数
  3. LRU 缓存 :优化内存使用( drain.py
  4. 配置驱动:通过 INI 文件实现运行时参数调整

3. 核心实现解析

3.1 日志处理流程

# template_miner.py 关键处理流程
def add_log_message(self, log_message: str):
    masked_content = self.masker.mask(log_message)  # 1. 掩码处理
    cluster, change_type = self.drain.add_log_message(masked_content)  # 2. 聚类分析
    self.save_state()  # 3. 状态持久化

3.2 配置系统实现

# template_miner_config.py 配置加载逻辑
def load(self, config_filename: str):
    parser = configparser.ConfigParser()
    # 加载各模块配置(DRAIN、MASKING等)
    self.drain_sim_th = parser.getfloat(section_drain, 'sim_th')
    self.masking_instructions = json.loads(masking_instructions_str)

4. 学习路径建议

4.1 前置知识

  • Python 3.7+ 语法
  • 正则表达式
  • 树形数据结构
  • LRU 缓存机制

4.2 学习步骤

  1. 运行示例 :执行 drain_bigfile_demo.py 观察输出
  2. 配置实验 :修改 drain3.ini 调整聚类阈值等参数
  3. 源码调试 :断点跟踪 add_log_message 执行过程
  4. 扩展开发 :尝试添加新的掩码规则

5. 技术栈总结 类别 技术选型 开发语言

Python 3.7+ 核心算法

Drain 改进算法 数据存储

Redis/Kafka(可选) 配置管理

ConfigParser 依赖管理

Poetry 性能优化

LRU Cache

6. 项目经验转化建议

在简历/面试中可突出:

  1. 实现实时日志分析系统的架构设计能力

  2. 算法优化经验(前缀树深度控制、相似度计算)

  3. 大规模日志处理中的内存优化实践

  4. 可配置系统的设计实现经验

  5. 开源项目贡献经验(该项目已移交 logpai 组织)
    建议从以下角度准备项目演示:

  6. 展示不同配置参数对聚类结果的影响

  7. 演示持久化功能在故障恢复中的应用

  8. 对比原始 Drain 算法的改进点

读代码

入口文件drain_bigfile_demo.py

# 设置项目路径
current_file = os.path.abspath(__file__)
project_root = os.path.dirname(os.path.dirname(current_file))
sys.path.append(project_root)
from drain3 import TemplateMiner
from drain3.template_miner_config import TemplateMinerConfig
# 入口文件与drain3文件夹不是同级目录
# 配置日志
logger = logging.getLogger(__name__)
# logger是子记录器,默认情况下会继承根记录器的配置
# 当该文件作为主程序运行时
# __name__ == "__main__"
# 记录器名称即为 "__main__"
# 当该模块被导入时
# 假设文件名为 drain_bigfile_demo.py
# __name__ == "examples.drain_bigfile_demo" 

logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(message)s')
# logging.basicConfig()是用来配置根日志记录器的
# 即使是在子记录器中调用了basicConfig(),子记录器也会继承根记录器的配置(顺序不一致也是同样的效果)
# 应该先配置根记录器属性,在定义子记录器logger
# 下载日志数据集
# 添加异常抛出,由于下载连接是外网,需要挂载魔法可能会报错
if not os.path.isfile(in_log_file):
    
    # 检查压缩包是否不存在
    if not os.path.isfile(in_gz_file):
        # 记录下载开始
        logger.info(f"Downloading {download_url}")
        try:
            # 使用urllib下载文件(适用于简单下载)
            urlretrieve(download_url, in_gz_file)
            # 记录下载成功
            logger.info(f"Download completed: {in_gz_file}")
        except Exception as e:
            # 捕获所有异常,记录错误信息
            logger.error(f"Download failed: {str(e)}")
            # 退出程序(1表示异常退出)
            sys.exit(1)
    
    # 记录解压开始
    logger.info(f"Extracting {in_gz_file}")
    try:
        # 使用tarfile模块打开gz压缩包
        with tarfile.open(in_gz_file) as tar:
            # 解压全部文件到当前目录
            tar.extractall()
        # 记录解压成功
        logger.info("Extraction completed")
    except Exception as e:
        # 捕获解压异常(例如文件损坏)
        logger.error(f"Extraction failed: {str(e)}")
        # 退出程序
        sys.exit(1)

urllib.request.urlretrieve()下载文件

# 基础用法(直接下载到本地文件)
from urllib.request import urlretrieve
url = "http://example.com/file.zip"
local_path, headers = urlretrieve(url, filename="local_file.zip")

关键特性

  1. 同步下载 :阻塞执行直到下载完成
  2. 自动续传 :支持断点续传(通过 reporthook 参数)
  3. 头部控制 :可自定义请求头(通过 headers 字典)
  4. 代理支持 :通过环境变量配置代理

参数详解

def urlretrieve(
    url, 
    filename=None,  # 本地保存路径(默认生成临时文件)
    reporthook=None,  # 进度回调函数
    data=None,  # POST数据(字典或字节流)
    timeout=socket._GLOBAL_DEFAULT_TIMEOUT,  # 超时设置
    # ...其他参数
)

优化建议

# 增加进度显示
def progress_hook(count, block_size, total_size):
    percent = count * block_size / total_size * 100
    print(f"下载进度: {percent:.1f}%")

# 带进度条的下载
try:
    urlretrieve(
        url=download_url,
        filename=in_gz_file,
        reporthook=progress_hook,
        timeout=30  # 设置超时
    )
except URLError as e:
    logger.error(f"URL错误: {str(e)}")

解压使用tarfilem模块
核心功能实现:

with tarfile.open(in_gz_file) as tar:  # 打开.tar.gz文件
    tar.extractall()                   # 解压所有文件到当前目录

关键参数说明:
1、打开模式

tarfile.open(name=None, mode='r', fileobj=None, **kwargs)
  • mode 参数常见值:
    • r :默认读取模式(自动检测压缩格式)
    • r:gz :明确使用gzip压缩
    • w 或 w:gz :创建/写入压缩包
  1. 解压方法 :
extractall(path=None, members=None, numeric_owner=False)
  • path :指定解压目录(默认当前目录)
  • members :指定要解压的文件列表(TarInfo对象列表)
    3、实际场景
    查看压缩包内容
with tarfile.open('file.tar.gz', 'r:gz') as tar:
    print(tar.getnames())  # 打印所有文件名

提取单个文件

with tarfile.open('file.tar.gz') as tar:
    tar.extract('specific_file.log')

4、Windows 特别注意事项
路径分隔符处理

# 建议使用原始字符串处理Windows路径
tar.extractall(r'E:\\log_archive\\extracted')
# 若没写路径,则解压时,tarfile模块会读取压缩包内的每个成员的名称,并按照这个名称解压到当前目录。

文件权限问题

# 解压时忽略权限信息(避免Windows权限错误)
tar.extractall( numeric_owner=False)

初始化Drain3配置

# 初始化Drain3配置
config = TemplateMinerConfig()
config.load(f"{dirname(__file__)}/drain3.ini")
# 配置中loaddrain3.ini文件
config.profiling_enabled = True
# TemplateMiner是Drain3的核心类,用于:
# 1. 对日志消息进行模板挖掘和聚类
# 2. 维护日志模板的前缀树结构
# 3. 提供性能分析功能
template_miner = TemplateMiner(config=config)

配置文件drain3.ini

尽量不要添加中文注释,默认不是utf-8编码,是gbk,添加中文解释会报错

; 快照配置部分
[SNAPSHOT]
; 每10分钟保存一次状态快照
snapshot_interval_minutes = 10
; 启用状态压缩
compress_state = True

; 日志掩码配置部分
[MASKING]
; 定义各种正则表达式模式来匹配和替换特定格式的文本
masking = [
          ; 匹配MAC地址等ID格式
          ; MAC地址匹配规则
          ; regex_pattern: 匹配以非字母数字字符开头或字符串开始((?<=[^A-Za-z0-9])|^)
          ; 后跟2个以上十六进制数和冒号的重复3次以上([0-9a-f]{2,}:){3,}
          ; 最后跟一组十六进制数([0-9a-f]{2,})
          ; 以非字母数字字符或字符串结束((?=[^A-Za-z0-9])|$)
          ; mask_with: 将匹配到的内容替换为"ID"
          ; 匹配的字符串可以出现在文本的任何位置,但必须满足前后不是字母或数字的条件
          {"regex_pattern":"((?<=[^A-Za-z0-9])|^)(([0-9a-f]{2,}:){3,}([0-9a-f]{2,}))((?=[^A-Za-z0-9])|$)", "mask_with": "ID"},

          ; IP地址匹配规则
          ; regex_pattern: 匹配以非字母数字字符开头或字符串开始
          ; 后跟四组1-3位数字,由点分隔(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})
          ; 以非字母数字字符或字符串结束
          ; mask_with: 将匹配到的内容替换为"IP"
          {"regex_pattern":"((?<=[^A-Za-z0-9])|^)(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})((?=[^A-Za-z0-9])|$)", "mask_with": "IP"},

          ; 连续16进制序列匹配规则
          ; regex_pattern: 匹配以非字母数字字符开头或字符串开始
          ; 后跟至少6位十六进制数和可选空格的组合,重复3次以上([0-9a-f]{6,} ?){3,}
          ; 以非字母数字字符或字符串结束
          ; mask_with: 将匹配到的内容替换为"SEQ"
          {"regex_pattern":"((?<=[^A-Za-z0-9])|^)([0-9a-f]{6,} ?){3,}((?=[^A-Za-z0-9])|$)", "mask_with": "SEQ"},

          ; 4位分组的16进制序列匹配规则
          ; regex_pattern: 匹配以非字母数字字符开头或字符串开始
          ; 后跟4位十六进制数和可选空格的组合,重复4次以上([0-9A-F]{4} ?){4,}
          ; 以非字母数字字符或字符串结束
          ; mask_with: 将匹配到的内容替换为"SEQ"
          {"regex_pattern":"((?<=[^A-Za-z0-9])|^)([0-9A-F]{4} ?){4,}((?=[^A-Za-z0-9])|$)", "mask_with": "SEQ"},

          ; 16进制数值匹配规则
          ; regex_pattern: 匹配以非字母数字字符开头或字符串开始
          ; 后跟0x开头的十六进制数(0x[a-f0-9A-F]+)
          ; 以非字母数字字符或字符串结束
          ; mask_with: 将匹配到的内容替换为"HEX"
          {"regex_pattern":"((?<=[^A-Za-z0-9])|^)(0x[a-f0-9A-F]+)((?=[^A-Za-z0-9])|$)", "mask_with": "HEX"},

          ; 数字匹配规则
          ; regex_pattern: 匹配以非字母数字字符开头或字符串开始
          ; 后跟可选的正负号和数字([\\-\\+]?\\d+)
          ; 以非字母数字字符或字符串结束
          ; mask_with: 将匹配到的内容替换为"NUM"
          {"regex_pattern":"((?<=[^A-Za-z0-9])|^)([\\-\\+]?\\d+)((?=[^A-Za-z0-9])|$)", "mask_with": "NUM"},

          ; 命令匹配规则
          ; regex_pattern: 匹配executed cmd后的引号内容
          ; (?<=executed cmd )表示前向查找executed cmd
          ; (\".+?\")匹配引号内的所有内容
          ; mask_with: 将匹配到的内容替换为"CMD"
          {"regex_pattern":"(?<=executed cmd )(\".+?\")", "mask_with": "CMD"}
          ]
; 掩码前缀和后缀
mask_prefix = <:
mask_suffix = :>

; Drain日志解析引擎配置
[DRAIN]
# engine is Optional parameter. Engine will be "Drain" if the engine argument is not specified.
# engine has two options: 'Drain' and 'JaccardDrain'.
# engine = Drain
; 相似度阈值
sim_th = 0.4
; 解析树深度
depth = 4
; 每个节点最大子节点数
max_children = 100
; 最大聚类数
max_clusters = 1024
; 额外的分隔符
extra_delimiters = ["_"]

; 性能分析配置
[PROFILING]
; 启用性能分析
enabled = True
; 每30秒生成一次报告
report_sec = 30

[DRAIN]参数说明

  1. sim_th(相似度阈值)
  • 作用 :控制日志合并的敏感度
  • 值域 :0.3(宽松)到0.6(严格)
  • 项目应用 :在 drain_bigfile_demo.py 中,0.4的设置平衡了聚类数量和准确性 2. depth(解析树深度)
  • 算法作用 :决定树的层级结构
  • 项目配置 :depth=4表示树结构为:
    root
    ├── token数量层
    ├── 第1个token层
    ├── 第2个token层
    └── 叶子节点层(存储实际模板)
    
  • 示例 :日志 Failed password for user admin from 192.168.1.1 会被拆分为4层处理 3. max_children(最大子节点数)
  • 作用 :防止内存爆炸式增长
  • 项目实现 :在 Drain 类中,当子节点超过100时会触发LRU淘汰 4. max_clusters(最大聚类数)
  • 作用 :控制内存使用上限
  • 项目应用 :设置为1024时,当聚类数达到限制后会淘汰最近最少使用的模板 5. extra_delimiters(额外分隔符)
  • 算法作用 :增强token分割能力
  • 项目配置 :默认使用 _ 作为补充分隔符,与空格共同作用
  • 示例 : user_login_failed 会被拆分为["user", "login", "failed"]
    参数调整建议
参数 调大效果 调小效果 使用场景
sim 聚类变少 模板更通用 聚类增多,模板更具体 0.4适合混合日志,0.5适合结构化日志
depth 匹配更精确 匹配更宽松 4层适合中等复杂度日志
max_children 内存占用增加 可能丢失低频模板 根据日志多样性调整

算法可视化示例
假设有以下日志:

  1. User admin logged in from 192.168.1.1
  2. User guest logged in from 10.0.0.1
  3. Failed login attempt from 192.168.1.1
    经过Drain处理后的模板:
User <*> logged in from <IP>
Failed login attempt from <IP>

这个模板生成过程受到 sim_th=0.4 和 depth=4 的直接影响,而IP地址的识别则由 drain3.ini 中的掩码规则实现。

树形结构分层详解

root(第1层)
└── token_count=7(第2层:日志分割后token数量)
    └── token[0]="Failed"(第3层:首个token内容)
        └── token[1]="password"(第4层:第二个token内容)
            └── 叶子节点(模板存储层)
                └── "Failed password for user <*> from <IP>"

详细分层解析 :

  1. 第1层(根节点) 所有日志的入口节点,无实际处理逻辑

  2. 第2层(token数量层) 日志被分割为: ["Failed", "password", "for", "user", "admin", "from", "192.168.1.1"] → 7个token → 进入 token_count=7 分支

  3. 第3层(首token层) 取第一个token: "Failed" → 进入 token[0]=Failed 分支

  4. 第4层(次token层) 取第二个token: "password" → 进入 token[1]=password 分支
    模板生成阶段 (在叶子节点层完成):

  5. 对比现有模板,发现无相似模板(初次出现)

  6. 应用drain3.ini 中的掩码规则:

    • admin → <:NUM:> (根据 ([-+]?\d+) 规则)
    • 192.168.1.1 → <:IP:> (根据IP正则规则)
  7. 生成新模板: "Failed password for user <:NUM:> from <:IP:>"
    关键算法交互点 :

  8. 分隔符处理 ( extra_delimiters=["_"] ) 若日志中出现 user_admin 会被分割为 ["user", "admin"]

  9. 相似度计算 ( sim_th=0.4 ) 当出现新日志 Failed password for user root from 10.0.0.1 时:

    相似度 = 相同token数 / 总token数 = 5/7 ≈ 0.71 > 0.4
    → 合并到已有模板
    
  10. 参数提取 最终模板中的 <:NUM:> 和 <:IP:> 来自配置文件中[MASKING]节的定义

template_miner.py

class TemplateMiner:
    """
    模板挖掘器类,用于日志模板挖掘
    主要功能:
    1. 日志模板挖掘
    2. 参数提取
    3. 状态持久化
    4. 性能分析
    """

    def __init__(self,
                 persistence_handler: Optional[PersistenceHandler] = None, 
                 config: Optional[TemplateMinerConfig] = None):

        """
        persistence_handler是构造函数的一个参数名。
        Optional[PersistenceHandler]是类型注解,表示这个参数可以是PersistenceHandler类型的实例,也可以是None。
        Optional是Python标准库typing模块中的一个类型构造器,用于表示一个类型可以是另一个类型或者None。
        """
        
        """初始化模板挖掘器"""
        logger.info("Starting Drain3 template miner")

        # 如果没有传入配置,则从配置文件加载
        if config is None:
            logger.info(f"Loading configuration from {config_filename}")
            config = TemplateMinerConfig()
            config.load(config_filename)

        self.config = config

        # 初始化性能分析器
        self.profiler: Profiler = NullProfiler()
        # 设置空对象模式,Profiler是一个基类
        # NullProfiler 和 SimpleProfiler 都是 Profiler 的子类
        #默认使用空实现的 `NullProfiler` (不进行性能分析)
        #当配置开启性能分析时( profiling_enabled=True ),才替换为具体实现的 `SimpleProfiler`
        if self.config.profiling_enabled:
            self.profiler = SimpleProfiler()

        # 用途:负责模板挖掘器状态的持久化存储和加载,不传参时不会进行持久化操作
        self.persistence_handler = persistence_handler # 
        
        # 构造参数字符串
        param_str = f"{self.config.mask_prefix}*{self.config.mask_suffix}"

        # 根据配置实例化Drain引擎
        target_obj = self.config.engine
        if target_obj not in ["Drain", "JaccardDrain"]:
            raise ValueError(f"Invalid matched_pattern: {target_obj}, must be either 'Drain' or 'JaccardDrain'")
        
        # 初始化Drain实例
        """
            globals()["Drain"] → 获取当前模块中名为"Drain"的类对象
            `globals()`返回当前模块的全局符号表,其中包含了所有导入的类和函数。`target_obj`是从配置中读取的字符串
            ,`DrainBase`是一个抽象基类,不能被直接实例化。
            它定义了一些抽象方法,要求子类必须实现这些方法。
            这样做的好处是允许不同的子类(如Drain和JaccardDrain)提供不同的具体实现,而模板挖掘器可以根据配置动态选择使用哪个子类。
        """
        self.drain: DrainBase = globals()[target_obj](
            sim_th=self.config.drain_sim_th,
            depth=self.config.drain_depth,
            max_children=self.config.drain_max_children,
            max_clusters=self.config.drain_max_clusters,
            extra_delimiters=self.config.drain_extra_delimiters,
            profiler=self.profiler,
            param_str=param_str,
            parametrize_numeric_tokens=self.config.parametrize_numeric_tokens
        )

        # 初始化掩码器和参数提取缓存
        self.masker = LogMasker(self.config.masking_instructions, self.config.mask_prefix, self.config.mask_suffix)
        self.parameter_extraction_cache: MutableMapping[Tuple[str, bool], str] = \
            LRUCache(self.config.parameter_extraction_cache_capacity)
        # 创建了一个 LRU (Least Recently Used) 缓存,容量由配置参数 parameter_extraction_cache_capacity 决定(默认为3000)
        """
        
        2. 缓存的用途 :
   
           - 这个缓存与 @cachedmethod(lambda self: self.parameter_extraction_cache) 装饰器配合使用
           - 装饰器应用在 _get_template_parameter_extraction_regex 方法上,该方法用于生成参数提取的正则表达式
        """
        self.last_save_time = time.time()

        # 如果有持久化处理器,则加载状态
        if persistence_handler is not None:
            self.load_state()

    def load_state(self) -> None:
        """加载持久化状态"""
        logger.info("Checking for saved state")

        assert self.persistence_handler is not None

        # 从持久化处理器加载状态
        state = self.persistence_handler.load_state()
        if state is None:
            logger.info("Saved state not found")
            return

        # 如果状态被压缩,则解压
        if self.config.snapshot_compress_state:
            state = zlib.decompress(base64.b64decode(state))

        # 反序列化状态
        loaded_drain: Drain = jsonpickle.loads(state, keys=True)

        # 处理向后兼容性问题
        if len(loaded_drain.id_to_cluster) > 0 and isinstance(next(iter(loaded_drain.id_to_cluster.keys())), str):
            loaded_drain.id_to_cluster = {int(k): v for k, v in list(loaded_drain.id_to_cluster.items())}
            if self.config.drain_max_clusters:
                cache: MutableMapping[int, Optional[LogCluster]] = LRUCache(maxsize=self.config.drain_max_clusters)
                cache.update(loaded_drain.id_to_cluster)
                loaded_drain.id_to_cluster = cache

        # 恢复Drain状态
        self.drain.id_to_cluster = loaded_drain.id_to_cluster
        self.drain.clusters_counter = loaded_drain.clusters_counter
        self.drain.root_node = loaded_drain.root_node

        logger.info(f"Restored {len(loaded_drain.clusters)} clusters "
                    f"built from {loaded_drain.get_total_cluster_size()} messages")

    def save_state(self, snapshot_reason: str) -> None:
        """保存当前状态"""
        assert self.persistence_handler is not None

        # 序列化状态
        state = jsonpickle.dumps(self.drain, keys=True).encode('utf-8')
        if self.config.snapshot_compress_state:
            state = base64.b64encode(zlib.compress(state))

        logger.info(f"Saving state of {len(self.drain.clusters)} clusters "
                    f"with {self.drain.get_total_cluster_size()} messages, {len(state)} bytes, "
                    f"reason: {snapshot_reason}")
        self.persistence_handler.save_state(state)

    def get_snapshot_reason(self, change_type: str, cluster_id: int) -> Optional[str]:
        """获取快照原因"""
        if change_type != "none":
            return f"{change_type} ({cluster_id})"

        diff_time_sec = time.time() - self.last_save_time
        if diff_time_sec >= self.config.snapshot_interval_minutes * 60:
            return "periodic"

        return None

    def add_log_message(self, log_message: str) -> Mapping[str, Union[str, int]]:
        """添加日志消息并进行模板挖掘"""
        self.profiler.start_section("total")

        # 对日志进行掩码处理
        self.profiler.start_section("mask")
        masked_content = self.masker.mask(log_message)
        self.profiler.end_section()

        # 添加到Drain进行处理
        self.profiler.start_section("drain")
        cluster, change_type = self.drain.add_log_message(masked_content)
        self.profiler.end_section("drain")
        # 构造结果字典,包含以下字段:
        # - change_type: 变更类型
        # - cluster_id: 簇ID 
        # - cluster_size: 簇大小
        # - template_mined: 挖掘出的模板
        # - cluster_count: 簇总数
        result: Mapping[str, Union[str, int]] = {
            "change_type": change_type,  # 记录模板变更类型(新建/更新/无变化)
            "cluster_id": cluster.cluster_id,  # 记录当前簇的唯一标识符
            "cluster_size": cluster.size,  # 记录当前簇包含的日志条数
            "template_mined": cluster.get_template(),  # 获取挖掘出的日志模板
            "cluster_count": len(self.drain.clusters)  # 获取当前簇的总数
        }

        # 如果配置了持久化处理器,则根据需要保存状态
        if self.persistence_handler is not None:
            self.profiler.start_section("save_state")  # 开始记录保存状态的性能指标
            # 获取需要保存快照的原因(新建簇/更新簇/定期保存)
            snapshot_reason = self.get_snapshot_reason(change_type, cluster.cluster_id)
            if snapshot_reason:  # 如果需要保存快照
                self.save_state(snapshot_reason)  # 保存当前状态
                self.last_save_time = time.time()  # 更新最后保存时间
            self.profiler.end_section()  # 结束记录保存状态的性能指标

        self.profiler.end_section("total")  # 结束记录总体性能指标
        self.profiler.report(self.config.profiling_report_sec)  # 输出性能报告
        return result  # 返回结果字典

    def match(self, log_message: str, full_search_strategy: str = "never") -> Optional[LogCluster]:
        """
        对日志消息进行模板匹配
        """
        masked_content = self.masker.mask(log_message)
        matched_cluster = self.drain.match(masked_content, full_search_strategy)
        return matched_cluster

    def get_parameter_list(self, log_template: str, log_message: str) -> Sequence[str]:
        """
        从日志消息中提取参数(已弃用)
        """
        extracted_parameters = self.extract_parameters(log_template, log_message, exact_matching=False)
        if not extracted_parameters:
            return []
        return [parameter.value for parameter in extracted_parameters]

    def extract_parameters(self,
                           log_template: str,
                           log_message: str,
                           exact_matching: bool = True) -> Optional[Sequence[ExtractedParameter]]:
        """
        从日志消息中提取参数
        """
        # 替换分隔符
        for delimiter in self.config.drain_extra_delimiters:
            log_message = re.sub(delimiter, " ", log_message)

        # 获取模板正则表达式
        template_regex, param_group_name_to_mask_name = self._get_template_parameter_extraction_regex(
            log_template, exact_matching)

        # 进行正则匹配
        parameter_match = re.match(template_regex, log_message)

        if not parameter_match:
            return None

        # 提取参数
        extracted_parameters = []
        for group_name, parameter in parameter_match.groupdict().items():
            if group_name in param_group_name_to_mask_name:
                mask_name = param_group_name_to_mask_name[group_name]
                extracted_parameter = ExtractedParameter(parameter, mask_name)
                extracted_parameters.append(extracted_parameter)

        return extracted_parameters

    @cachedmethod(lambda self: self.parameter_extraction_cache)
    def _get_template_parameter_extraction_regex(self,
                                                 log_template: str,
                                                 exact_matching: bool) -> Tuple[str, Mapping[str, str]]:
        """
        获取用于参数提取的正则表达式
        """
        param_group_name_to_mask_name = {}
        param_name_counter = [0]

        def get_next_param_name() -> str:
            """获取下一个参数名"""
            param_group_name = f"p_{str(param_name_counter[0])}"
            param_name_counter[0] += 1
            return param_group_name

        def create_capture_regex(_mask_name: str) -> str:
            """创建捕获正则表达式"""
            allowed_patterns = []
            if exact_matching:
                masking_instructions = self.masker.instructions_by_mask_name(_mask_name)
                for mi in masking_instructions:
                    if hasattr(mi, 'regex') and hasattr(mi, 'pattern'):
                        mi_groups = mi.regex.groupindex.keys()
                        pattern: str = mi.pattern
                    else:
                        mi_groups = []
                        pattern = ".+?"

                    for group_name in mi_groups:
                        param_group_name = get_next_param_name()

                        def replace_captured_param_name(param_pattern: str) -> str:
                            _search_str = param_pattern.format(group_name)
                            _replace_str = param_pattern.format(param_group_name)
                            return pattern.replace(_search_str, _replace_str)

                        pattern = replace_captured_param_name("(?P={}")
                        pattern = replace_captured_param_name("(?P<{}>")

                    pattern = re.sub(r"\\(?!0)\d{1,2}", r"(?:.+?)", pattern)
                    allowed_patterns.append(pattern)

            if not exact_matching or _mask_name == "*":
                allowed_patterns.append(r".+?")

            param_group_name = get_next_param_name()
            param_group_name_to_mask_name[param_group_name] = _mask_name
            joined_patterns = "|".join(allowed_patterns)
            capture_regex = f"(?P<{param_group_name}>{joined_patterns})"
            return capture_regex

        # 获取所有掩码名称
        mask_names = set(self.masker.mask_names)
        mask_names.add("*")

        escaped_prefix = re.escape(self.masker.mask_prefix)
        escaped_suffix = re.escape(self.masker.mask_suffix)
        template_regex = re.escape(log_template)

        # 替换掩码名称为正则表达式
        for mask_name in mask_names:
            search_str = escaped_prefix + re.escape(mask_name) + escaped_suffix
            while True:
                rep_str = create_capture_regex(mask_name)
                template_regex_new = template_regex.replace(search_str, rep_str, 1)
                if template_regex_new == template_regex:
                    break
                template_regex = template_regex_new

        # 处理空白字符
        template_regex = re.sub(r"\\ ", r"\\s+", template_regex)
        template_regex = f"^{template_regex}$"
        return template_regex, param_group_name_to_mask_name

LRUCache 的作用

LRUCache(最近最少使用缓存)是一种缓存淘汰策略,它的特点是:

  1. 有限容量 :只保存固定数量的项目

  2. 淘汰机制 :当缓存满时,会删除最长时间未被访问的项目

  3. 快速访问 :已缓存的项目可以被快速访问,无需重新计算
    在 Drain3 中,LRUCache 主要用于优化性能:

  4. 避免重复计算 :

    • 参数提取正则表达式的生成是计算密集型操作
    • 对于相同的日志模板和匹配策略,可以重用之前生成的正则表达式
  5. 内存管理 :

    • 限制缓存大小,防止内存无限增长
    • 自动淘汰不常用的模板正则表达式

实际调用流程

  1. 当调用 extract_parameters 方法时,会间接调用 _get_template_parameter_extraction_regex

  2. _get_template_parameter_extraction_regex 方法会:

    • 首先检查缓存中是否已有对应的正则表达式
    • 如果有,直接返回缓存结果
    • 如果没有,计算新的正则表达式,并将结果存入缓存
  3. 缓存的键是 (log_template, exact_matching) 元组:

    • log_template :日志模板字符串
    • exact_matching :是否进行精确匹配的布尔标志
  4. 缓存的值是一个元组 (template_regex, param_group_name_to_mask_name) :

    • template_regex :用于参数提取的正则表达式
    • param_group_name_to_mask_name :参数组名到掩码名称的映射

masking.py

class MaskingInstruction(AbstractMaskingInstruction):

    def __init__(self, pattern: str, mask_with: str):
        super().__init__(mask_with)
        self.regex = re.compile(pattern)
        # 构造函数将正则表达式字符串编译为可执行的正则表达式对象 self.regex
    def mask(self, content: str, mask_prefix: str, mask_suffix: str) -> str:
        mask = mask_prefix + self.mask_with + mask_suffix
        # 使用正则表达式的sub方法将匹配到的内容替换为mask
        # self.regex.sub(repl, string) 方法会将string中所有匹配pattern的部分替换为repl
        # 这里将content中匹配正则表达式的内容替换为mask字符串
        return self.regex.sub(mask, content)
class LogMasker:

    def __init__(self, masking_instructions: Collection[AbstractMaskingInstruction],
                 mask_prefix: str, mask_suffix: str):
        self.mask_prefix = mask_prefix
        self.mask_suffix = mask_suffix
        self.masking_instructions = masking_instructions
        mask_name_to_instructions: Dict[str, List[AbstractMaskingInstruction]] = {}
        for mi in self.masking_instructions:
            # 每个mi都是一个MaskingInstruction类的实例,mask_with就是类实例化后的属性"ip","num"等
            mask_name_to_instructions.setdefault(mi.mask_with, [])
            mask_name_to_instructions[mi.mask_with].append(mi)
        self.mask_name_to_instructions = mask_name_to_instructions

    def mask(self, content: str) -> str:
        for mi in self.masking_instructions:
            # 对content进行多次替换,每次替换都使用mi.mask_with替换匹配到的内容
            # self.masking_instructions是定义的掩码规则,遍历每个规则,将匹配到的内容替换为mi.mask_with
            content = mi.mask(content, self.mask_prefix, self.mask_suffix)
        return content

SimpleProfiler.py

SimpleProfiler 类是 Drain3 项目中的性能分析工具,主要用于测量和报告代码中不同部分(称为"section")的执行时间和性能指标。在日志模板挖掘过程中,它能帮助开发者识别性能瓶颈,优化算法效率。

class SimpleProfiler(Profiler):
    def __init__(self,
                 # 重置采样计数的阈值,达到该值时重置统计数据
                 reset_after_sample_count: int = 0,
                 # 封闭section的名称,用于计算百分比
                 enclosing_section_name: str = "total",
                 # 打印函数,默认使用print
                 printer: Callable[[str], Any] = print,
                 # 报告间隔时间(秒)
                 report_sec: int = 30):
        # 初始化打印函数
        self.printer = printer
        # 初始化封闭section名称
        self.enclosing_section_name = enclosing_section_name
        # 初始化重置采样计数阈值
        self.reset_after_sample_count = reset_after_sample_count
        # 初始化报告间隔时间
        self.report_sec = report_sec

        # 用于存储各个section的统计信息的字典
        # MutableMapping 是一个类型提示,表示这是一个可变映射(类似字典),键为字符串,值为 ProfiledSectionStats 对象。
        self.section_to_stats: MutableMapping[str, ProfiledSectionStats] = {}
        # 记录上次生成报告的时间戳
        self.last_report_timestamp_sec = time.time()
        # 记录最近启动的section名称
        self.last_started_section_name = ""
    def start_section(self, section_name: str) -> None:
        """Start measuring a section"""
        
        # 检查section_name是否为空,为空则抛出异常
        if not section_name:
            raise ValueError("Section name is empty") 
            
        # 记录最近启动的section名称
        self.last_started_section_name = section_name

        # 从section_to_stats字典中获取section统计信息,如果不存在返回None
        section = self.section_to_stats.get(section_name, None)
        
        # 如果section不存在,创建新的ProfiledSectionStats对象并存入字典
        if section is None:
            # 创建一个新的ProfiledSectionStats对象用于统计该section的性能指标
            # ProfiledSectionStats包含:section名称、开始时间、样本计数、总耗时等信息
            section = ProfiledSectionStats(section_name)
            self.section_to_stats[section_name] = section

        # 检查section是否已经启动(start_time_sec不为0表示已启动)
        if section.start_time_sec != 0:
            raise ValueError(f"Section {section_name} is already started")

        # 记录section启动时间戳
        section.start_time_sec = time.time()

白话解读SimpleProfiler

SimpleProfiler 就像是一个计时器,用来测量代码中不同部分执行需要多少时间。这就像你在做菜时,想知道切菜、炒菜、煮饭各需要多少时间,以便找出哪个步骤最耗时,然后优化它。

"section" 就是代码中的一个部分或一个步骤。在 Drain3 项目中,主要的 section 包括:

  • "mask":对日志进行掩码处理的步骤
  • "drain":将日志添加到 Drain 算法进行模板匹配的步骤
  • "save_state":保存当前状态的步骤
  • "total":整个处理过程的总时间

作用

  1. 找出瓶颈 :通过报告可以看出哪部分代码最耗时(例如上面的Drain算法)
  2. 优化重点 :知道瓶颈后,可以集中精力优化那部分代码
  3. 监控性能 :长期运行时,可以监控性能是否稳定或随着数据量增加而变化
  4. 评估优化效果 :优化代码后,可以通过报告直观地看到改进效果
    简单来说,性能分析器就像是给代码装上了一个"计时器",帮助开发者了解代码中各个部分的执行效率,从而有针对性地进行优化。

ProfiledSectionStats 类的使用方法

ProfiledSectionStats 是 SimpleProfiler 中用于存储和计算每个代码段(section)性能统计数据的辅助类。这个类主要在 SimpleProfiler 内部使用,通常不需要直接操作。

ProfiledSectionStats 的作用

它就像一个给每个section定义的一个解释器,每个section的信息,包括name,执行时间等都会包含进去
这个类负责:

  1. 存储每个代码段的性能数据(如执行次数、总执行时间等)
  2. 计算性能指标(如平均执行时间、每秒处理样本数等)
  3. 格式化输出性能报告

如何在 SimpleProfiler 中使用

在 SimpleProfiler 中, ProfiledSectionStats 的使用流程是:

  1. 创建实例 :当首次监控某个代码段时,创建一个新的 ProfiledSectionStats 实例

    section = ProfiledSectionStats(section_name)
    self.section_to_stats[section_name] = section
    
  2. 记录开始时间 :当代码段开始执行时,记录开始时间

    section.start_time_sec = time.time()
    
  3. 计算执行时间并更新统计 :当代码段结束时,计算执行时间并更新统计数据

    took_sec = now - section.start_time_sec
    section.sample_count += 1
    section.total_time_sec += took_sec
    
  4. 生成报告 :调用 to_string() 方法生成格式化的性能报告

    section.to_string(enclosing_time_sec, include_batch_rates)
    

drain.py

class DrainBase(ABC):
    def __init__(self,
                 depth: int = 4,
                 sim_th: float = 0.4,
                 max_children: int = 100,
                 max_clusters: Optional[int] = None,
                 extra_delimiters: Sequence[str] = (),
                 profiler: Profiler = NullProfiler(),
                 param_str: str = "<*>",
                 parametrize_numeric_tokens: bool = True) -> None:
        """
        Create a new Drain instance.

        :param depth: max depth levels of log clusters. Minimum is 3.
            For example, for depth==4, Root is considered depth level 1.
            Token count is considered depth level 2.
            First log token is considered depth level 3.
            Log clusters below first token node are considered depth level 4.
        :param sim_th: similarity threshold - if percentage of similar tokens for a log message is below this
            number, a new log cluster will be created.
        :param max_children: max number of children of an internal node
        :param max_clusters: max number of tracked clusters (unlimited by default).
            When this number is reached, model starts replacing old clusters
            with a new ones according to the LRU policy.
        :param extra_delimiters: delimiters to apply when splitting log message into words (in addition to whitespace).
        :param parametrize_numeric_tokens: whether to treat tokens that contains at least one digit
            as template parameters.
        """
        if depth < 3:
            raise ValueError("depth argument must be at least 3")

        self.log_cluster_depth = depth
        self.max_node_depth = depth - 2  # max depth of a prefix tree node, starting from zero
        self.sim_th = sim_th
        self.max_children = max_children
        self.root_node = Node()
        self.profiler = profiler
        self.extra_delimiters = extra_delimiters
        self.max_clusters = max_clusters
        self.param_str = param_str
        self.parametrize_numeric_tokens = parametrize_numeric_tokens

        self.id_to_cluster: MutableMapping[int, Optional[LogCluster]] = \
            {} if max_clusters is None else LogClusterCache(maxsize=max_clusters)
        self.clusters_counter = 0



    def get_content_as_tokens(self, content: str) -> Sequence[str]:
        content = content.strip()
        for delimiter in self.extra_delimiters:
            content = content.replace(delimiter, " ")
        content_tokens = content.split()
        return content_tokens

    def add_log_message(self, content: str) -> Tuple[LogCluster, str]:
        content_tokens = self.get_content_as_tokens(content)

        if self.profiler:
            self.profiler.start_section("tree_search")
        match_cluster = self.tree_search(self.root_node, content_tokens, self.sim_th, False)
        if self.profiler:
            self.profiler.end_section()

        # Match no existing log cluster
        # 如果没有找到匹配的日志簇
        if match_cluster is None:
            # 如果启用了性能分析器,开始记录创建簇的时间
            if self.profiler:
                self.profiler.start_section("create_cluster")
            
            # 簇计数器加1
            self.clusters_counter += 1
            # 使用计数器值作为新簇的ID
            cluster_id = self.clusters_counter
            
            # 使用输入的日志内容tokens和簇ID创建新的LogCluster对象
            # LogCluster类用于存储日志模板和相关信息
            match_cluster = LogCluster(content_tokens, cluster_id)
            
            # 将新创建的簇添加到簇字典中,以簇ID为key
            self.id_to_cluster[cluster_id] = match_cluster
            
            # 将新簇添加到前缀树中
            # add_seq_to_prefix_tree方法用于构建和维护前缀树结构
            self.add_seq_to_prefix_tree(self.root_node, match_cluster)
            
            # 设置更新类型为"创建新簇"
            update_type = "cluster_created"

        # Add the new log message to the existing cluster
        else:
            # 如果启用了性能分析器,开始记录簇存在时的处理时间
            if self.profiler:
                self.profiler.start_section("cluster_exist")
      
            # 根据当前日志内容和已有模板创建新的模板tokens
            new_template_tokens = self.create_template(content_tokens, match_cluster.log_template_tokens)
      
            # 如果新模板与原模板完全相同,表示不需要更新
            if tuple(new_template_tokens) == match_cluster.log_template_tokens:
                update_type = "none"
            else:
                # 如果模板不同,更新簇的模板tokens
                match_cluster.log_template_tokens = tuple(new_template_tokens)
                # 标记更新类型为模板变更
                update_type = "cluster_template_changed"
     
            # 增加该簇的日志数量
            match_cluster.size += 1
    
            # 访问簇以更新LRU缓存
            # noinspection PyStatementEffect 
            self.id_to_cluster[match_cluster.cluster_id]

        if self.profiler:
            self.profiler.end_section()

        # 返回两个值:
        # 1. match_cluster: 匹配到的或新创建的日志簇对象,包含了日志模板和相关信息
        # 2. update_type: 更新类型,可能的值:
        #    - "cluster_created": 创建了新的日志簇
        #    - "cluster_template_changed": 更新了已有簇的模板
        #    - "none": 完全匹配已有模板,无需更新
        return match_cluster, update_type



    @abstractmethod
    def tree_search(self,
                    root_node: Node,      # 前缀树的根节点
                    tokens: Sequence[str], # 需要匹配的日志内容分词序列  
                    sim_th: float,        # 相似度阈值
                    include_params: bool   # 是否在相似度计算中包含通配符参数
                    ) -> Optional[LogCluster]:  # 返回匹配到的日志簇或None
        """
        在前缀树中搜索最匹配的日志簇
        这是一个抽象方法,需要子类实现具体的搜索逻辑
        主要用于在已有的日志模板树中查找与输入日志最相似的簇
        如果找到相似度超过阈值的簇则返回,否则返回None
        """
        ...


class Drain(DrainBase):

def tree_search(self,
                    root_node: Node,          # 根节点参数,类型为Node
                    tokens: Sequence[str],     # 输入的token序列,使用typing.Sequence类型标注
                    sim_th: float,            # 相似度阈值,浮点数类型
                    include_params: bool       # 是否包含参数标记,布尔类型
                    ) -> Optional[LogCluster]: # 返回类型标注,可能返回LogCluster或None
        """在前缀树中搜索最匹配的日志簇"""

        # 获取tokens长度作为第一层分组依据
        # len()为内置函数,用于获取序列长度
        token_count = len(tokens)
        # 使用dict.get()方法获取key对应的值,不存在则返回None
        # str()将数字转换为字符串
        # key_to_child_node是一个字典,存储了节点的子节点映射关系
        # 结构示例: {'3': Node对象1, '4': Node对象2} - key是token数量的字符串,value是对应的Node对象
        # get方法在key不存在时返回None,存在时返回对应的Node对象
        # str(token_count)将数字转为字符串作为key去查找
        cur_node = root_node.key_to_child_node.get(str(token_count))

        # 如果找不到对应token数量的节点,返回None
        if cur_node is None:
            return None

        # 处理空日志的特殊情况
        # 直接返回该分组下的第一个簇
        if token_count == 0:
            return self.id_to_cluster.get(cur_node.cluster_ids[0])

        # 遍历tokens,在树中查找匹配路径
        cur_node_depth = 1
        for token in tokens:
            # 超过最大深度限制,退出循环
            if cur_node_depth >= self.max_node_depth:
                break

            # 到达最后一个token,退出循环
            if cur_node_depth == token_count:
                break

            # 获取当前节点的子节点字典
            key_to_child_node = cur_node.key_to_child_node
            # 尝试精确匹配当前token
            cur_node = key_to_child_node.get(token)
            # 精确匹配失败,尝试通配符匹配
            if cur_node is None:  
                cur_node = key_to_child_node.get(self.param_str)
            # 通配符也匹配失败,返回None
            if cur_node is None:
                return None

            cur_node_depth += 1

        # 在找到的节点下获取最佳匹配的簇
        # 使用fast_match方法计算相似度并返回最匹配的簇
        cluster = self.fast_match(cur_node.cluster_ids, tokens, sim_th, include_params)
        return cluster

def add_seq_to_prefix_tree(self, root_node: Node, cluster: LogCluster) -> None:
        # 获取日志模板的token数量
        token_count = len(cluster.log_template_tokens)
        
        # 将token数量转换为字符串作为key
        token_count_str = str(token_count)
        # 如果根节点下没有该token数量对应的子节点,创建一个新的节点
        # 检查根节点的子节点字典中是否已存在该token数量对应的节点
        # 所有的模板树都共享同一个根节点,通过token数量进行第一层分组
        if token_count_str not in root_node.key_to_child_node:
            first_layer_node = Node()
            root_node.key_to_child_node[token_count_str] = first_layer_node
            # key_to_child_node是一个字典,用于存储子节点
            # key是token,value是对应的子节点
        else:
            # 如果已存在,直接获取该节点
            first_layer_node = root_node.key_to_child_node[token_count_str]

        # 当前处理的节点设为第一层节点
        cur_node = first_layer_node

        # 处理空日志字符串的特殊情况
        if token_count == 0:
            cur_node.cluster_ids = [cluster.cluster_id]
            return

        # 从第一层开始遍历
        current_depth = 1
        # 遍历日志模板中的每个token
        for token in cluster.log_template_tokens:
            # 如果达到最大深度或已处理完所有token,将当前日志簇添加到叶子节点
            if current_depth >= self.max_node_depth or current_depth >= token_count:
                # 清理失效的簇ID
                new_cluster_ids = []
                for cluster_id in cur_node.cluster_ids:
                    # cur_node.cluster_ids是一个列表,存储了与当前节点相关联的簇ID(如果前面是新创建的就是初始化空)
                    if cluster_id in self.id_to_cluster: # self.id_to_cluster :全局簇字典,在 `DrainBase` 初始化时创建
                        new_cluster_ids.append(cluster_id)
                new_cluster_ids.append(cluster.cluster_id)
                cur_node.cluster_ids = new_cluster_ids
                break
                """
                以上代码:
                - 对新建节点来说,清理操作实际上就是初始化cluster_ids
                - 对已存在节点,清理操作是必要的,因为可能存在失效的cluster_ids
                - 这个设计保证了无论是新节点还是旧节点,最终的cluster_ids都是有效的
                """
            # 检查当前token是否已存在于子节点字典中

            # 如果当前token在现有树的这一层中不存在匹配
            if token not in cur_node.key_to_child_node:
                # 如果启用了数字参数化且token包含数字
                if self.parametrize_numeric_tokens and self.has_numbers(token):
                    if self.param_str not in cur_node.key_to_child_node:
                        # 创建新的参数节点
                        new_node = Node()
                        cur_node.key_to_child_node[self.param_str] = new_node
                        cur_node = new_node
                    else:
                        # 使用已有的参数节点
                        cur_node = cur_node.key_to_child_node[self.param_str]

                else:
                    # 处理非数字token的情况
                    if self.param_str in cur_node.key_to_child_node:
                        # 如果存在参数节点且未达到子节点数量上限
                        if len(cur_node.key_to_child_node) < self.max_children:
                            new_node = Node()
                            cur_node.key_to_child_node[token] = new_node
                            cur_node = new_node
                        else:
                            # 达到上限则使用参数节点
                            cur_node = cur_node.key_to_child_node[self.param_str]
                    else:
                        # 根据子节点数量决定是创建具体token节点还是参数节点
                        if len(cur_node.key_to_child_node) + 1 < self.max_children:
                            new_node = Node()
                            cur_node.key_to_child_node[token] = new_node
                            cur_node = new_node
                        elif len(cur_node.key_to_child_node) + 1 == self.max_children:
                            new_node = Node()
                            cur_node.key_to_child_node[self.param_str] = new_node
                            cur_node = new_node
                        else:
                            cur_node = cur_node.key_to_child_node[self.param_str]

            # 如果token匹配到现有节点
            else:
                cur_node = cur_node.key_to_child_node[token]

            # 深度加1
            current_depth += 1

tree_search先调用DrainBase基类方法,但是基类中的该方法时一个抽象类。

Python抽象方法的语法解析

抽象方法的语法结构

@abstractmethod
def tree_search(self,
                root_node: Node,      
                tokens: Sequence[str],  
                sim_th: float,        
                include_params: bool   
                ) -> Optional[LogCluster]:
    ...

这段代码使用了以下Python语法特性:

  1. 装饰器 @abstractmethod

    • 来自 abc 模块(Abstract Base Classes)
    • 标记该方法为抽象方法
    • 要求所有子类必须实现这个方法
  2. 类型注解

    • 参数类型注解:如 root_node: Node
    • 返回值类型注解: -> Optional[LogCluster]
    • 帮助IDE提供代码提示和类型检查

与子类的关系

在代码中, DrainBase 是抽象基类,而 Drain 是其子类。它们的关系如下:

  1. 强制实现

    # 抽象基类中的定义
    @abstractmethod
    def tree_search(self, ...) -> Optional[LogCluster]:
        ...
    
    # Drain子类中的具体实现
    def tree_search(self,
                    root_node: Node,
                    tokens: Sequence[str],
                    sim_th: float,
                    include_params: bool) -> Optional[LogCluster]:
        # 具体实现代码
        ...
    
    • 子类必须实现这个方法,否则会报错
    • 子类实现时必须保持相同的方法签名
  2. 方法重写规则

    • 参数名称必须匹配
    • 参数类型必须兼容
    • 返回值类型必须兼容
    • 可以添加额外的实现细节

tree_search 方法的执行过程解析

这段代码实现了日志模板匹配的核心搜索逻辑,让我用一个实际的例子来解释:

假设我们有一条日志:

执行步骤

1. 第一层匹配:按token数量分组
  • 首先将日志分词,得到6个token
  • 在前缀树第一层查找key为"6"的节点
  • 如果找不到包含6个token的模板组,直接返回None
2. 特殊情况处理
  • 处理空日志的情况
  • 比如日志内容为""时,直接返回该分组的第一个簇
3. 逐层匹配过程

假设树的最大深度(max_node_depth)为4,那么:

  1. 第一轮:匹配"User"
    • 精确匹配:找到了"User"节点
  2. 第二轮:匹配"alice"
    • 精确匹配失败
    • 尝试通配符匹配:找到"<*>"节点
  3. 第三轮:匹配"logged"
    • 精确匹配:找到"logged"节点
      到达最大深度后停止匹配。
4. 匹配策略

就像模板: "User <> logged in from <>"

  • 对于固定词("User", "logged", "in", "from"):进行精确匹配
  • 对于变化词("alice", "192.168.1.1"):使用通配符"<*>"匹配
5. 最终相似度计算
cluster = self.fast_match(cur_node.cluster_ids, tokens, sim_th, include_params)
  • 在找到的节点下,计算日志与该节点下所有模板的相似度
  • 返回相似度最高且超过阈值的簇

如果没有对应模板则返回None回到基类去创建模板,第一条日志必创建模板

add_seq_to_prefix_tree解读

设计目的 :

  1. 参数化处理:将数字token统一处理为 <*> ,减少模板数量
  2. 节点复用:通过 max_children 控制树的宽度,避免过度分支
  3. 渐进式构建:根据实际需要创建节点,节省内存
    示例树结构 :
    对于日志 "user 123 login failed" :
root
└── 4 (token数量)
    └── user
        └── <*> (参数化的"123")
            └── login
                └── failed

如果后续来一条 "user 456 login success" :

root
└── 4
    └── user
        └── <*> (参数化后"123"和"456"共用此节点)
            └── login
                ├── failed
                └── success

这种设计的优点:

  1. 自动参数化:数字等变化部分自动转为通配符
  2. 空间优化:相似模式共享节点路径
  3. 灵活匹配:支持精确匹配和模糊匹配
  4. 可控生长:通过max_children限制树的复杂度

DrainBase类中create_template方法的目的

传入参数content_tokens和match_cluster.log_template_tokens不一样吗,只是一个事list一个元组,生成的结果还是一个,会出现不一样的情况吗,前面不都将日志使用掩码了吗,为啥会不一样

  1. 为什么需要创建新模板?
  • 虽然前面通过 tree_search 找到了匹配的簇,但这个匹配是基于相似度阈值的( sim_th ),不一定是完全相同
  • 两个日志序列可能在某些位置有差异,需要进一步参数化
  1. 举个例子 :
# 已有模板 (match_cluster.log_template_tokens)
["user", "login", "failed", "attempt", "123"]

# 新日志 (content_tokens)
["user", "login", "failed", "attempt", "456"]

# 创建新模板后 (new_template_tokens)
["user", "login", "failed", "attempt", "<*>"]
  1. 不同的情况 :
# 情况1:完全匹配
已有模板: ["user", "login", "<*>"]
新日志:   ["user", "login", "123"]
结果:     ["user", "login", "<*>"]  # 保持不变

# 情况2:需要更新
已有模板: ["user", "login", "failed"]
新日志:   ["user", "login", "success"]
结果:     ["user", "login", "<*>"]  # 参数化不同部分

# 情况3:部分参数化
已有模板: ["user", "id", "123", "login"]
新日志:   ["user", "id", "456", "logout"]
结果:     ["user", "id", "<*>", "<*>"]  # 多处参数化

4. 为什么前面的掩码不够 :
- tree_search 中的掩码主要用于树的构建和快速匹配
- 这里的模板创建是为了优化和统一同一簇内的日志模式
- 随着新日志的加入,模板会逐渐泛化,捕获更多变化
5. 这样设计的好处 :
- 动态适应:模板会随着新日志的加入而优化
- 增量学习:不需要预先知道所有可能的变化
- 模式提炼:自动识别哪些部分是固定的,哪些是变化的

### 使用 drain_bigfile_demo.py 时配置持久化(默认不使用持久化器)

1. 修改入口文件 :

```python
# 导入所需的持久化处理器
from drain3.file_persistence import FilePersistence

# 创建持久化处理器实例
persistence = FilePersistence("drain3_state.bin")  # 将状态保存到文件

# 创建模板挖掘器时传入持久化处理器
template_miner = TemplateMiner(persistence_handler=persistence)
  1. 持久化选项 :
    你可以选择以下几种持久化方式:
# 1. 文件持久化
from drain3.file_persistence import FilePersistence
persistence = FilePersistence("drain3_state.bin")

# 2. Redis持久化
from drain3.redis_persistence import RedisPersistence
persistence = RedisPersistence(
    redis_host="localhost",
    redis_port=6379,
    redis_db=0
)

# 3. Kafka持久化
from drain3.kafka_persistence import KafkaPersistence
persistence = KafkaPersistence("drain3_state")
  1. 持久化触发时机 :
    根据 template_miner.py 中的代码,持久化会在以下情况触发:
  • 创建新的簇时: change_type = "cluster_created"
  • 簇模板发生变化时: change_type = "cluster_template_changed"
  • 达到定期保存时间时: diff_time_sec >= self.config.snapshot_interval_minutes * 60
  • 当快照返回不为控制,自动触发对应持久器的save_state

drain_stdin_demo.py

# 这是一个训练模式的主循环,用于从标准输入读取日志并进行模板挖掘
while True:
    # 从标准输入读取一行日志,显示提示符 ">"
    log_line = input("> ")
    
    # 如果输入是'q',则退出循环
    if log_line == 'q':
        break
    
    # 将日志消息添加到模板挖掘器中,并获取结果
    result = template_miner.add_log_message(log_line)
    
    # 将结果转换为JSON字符串并打印
    result_json = json.dumps(result)
    print(result_json)
    
    # 从结果中获取挖掘出的模板
    template = result["template_mined"]
    
    # 从日志行中提取与模板匹配的参数
    params = template_miner.extract_parameters(template, log_line)
    # 若为匹配不到,params则为空
    # 打印提取出的参数
    print(f"Parameters: {str(params)}")

print("Training done. Mined clusters:")
for cluster in template_miner.drain.clusters:
    print(cluster)

print(f"Starting inference mode, matching to pre-trained clusters. Input log lines or 'q' to finish")
# 这是一个推理模式的主循环,用于将输入的日志与已训练好的模板进行匹配
while True:
    # 从标准输入读取日志行
    log_line = input("> ")
    
    # 如果输入'q'则退出循环
    if log_line == 'q':
        break
    
    # 尝试将输入的日志与已有的模板进行匹配
    cluster = template_miner.match(log_line)
    
    # 如果没有找到匹配的模板,打印未匹配信息
    if cluster is None:
        print(f"No match found")
    else:
        # 如果找到匹配的模板,获取模板内容
        template = cluster.get_template()
        # 打印匹配到的模板ID和内容
        print(f"Matched template #{cluster.cluster_id}: {template}")
        # 提取并打印从日志中匹配到的参数列表
        print(f"Parameters: {template_miner.get_parameter_list(template, log_line)}")

posted @ 2025-04-22 17:56  UPLY-AI  阅读(675)  评论(0)    收藏  举报