简介:基于Python的日志审计系统是一套面向网络安全初学者与毕业设计学生的实践型项目源码,围绕日志采集、解析、存储、分析与告警等环节展开,帮助读者理解如何用Python搭建可运行的审计流程。压缩包共51个文件,约33KB,以25个JavaScript与10个Python文件为主体,辅以XML配置、Vue页面及HTML、JSON等前端资源,呈现Django后端与Vue前端的分离结构,便于对照阅读接口、模型与页面实现。项目已有184人学习,说明其在同类课程设计与安全入门场景中具有一定参考价值。源码中包含后端应用、路由配置、数据库模型与测试文件,也保留了前端构建配置和依赖清单,读者可据此梳理日志审计系统的模块划分、数据流转与前后端协作方式,并在此基础上做功能扩展或课程答辩准备。
1. 从一个凌晨两点的排查现场说起
凌晨两点被叫起来处理一台机器的异常登录,登上去先ls /var/log,auth.log、secure、nginx 的 access.log 散在好几个目录,grep加tail翻了二十分钟,时间线还是拼不起来。基于 Python 的日志审计系统要解决的就是这种场面:把多来源日志统一采集、按解析规则转成结构化事件,再用可配置的规则做模式匹配和频次判定,命中就落一条告警。它适合手里管着几台到几十台服务器、又不想为日志专门上一套重型平台的团队,也适合想读一份改得动的 Python 代码、把审计逻辑塞进自己运维脚本的开发者。整套东西不依赖额外中间件,单机就能跑起来。
2. 项目骨架:Python 技术栈、目录结构与模块职责
2.1 这套系统到底审计什么
先把边界划清楚,不然后面代码会越写越散。日志审计在实际落地里其实承担两件事:一是留痕,任何一条登录、一次配置变更、一个敏感接口调用,事后都能按人、按 IP、按时间捞出来;二是发现,对持续发生的事件做统计和模式匹配,把爆破、异常时段登录、扫目录这类行为在发生时就抛出来。前者靠存储和检索,后者靠规则引擎,两条线共用同一份结构化事件,这是整个项目最核心的设计取舍。
按这个思路,系统抽象出来的核心对象只有三个。原始日志行是采集层进来的字符串,系统不假设它的格式;事件(event)是解析后的结构化记录,包含时间、来源、主机、类别、源 IP、账号和原始内容;告警(alert)是规则命中后生成的记录,指回触发它的那批事件。三个对象串成一条单向链路,采集、解析、入库、判定、通知,每一段都可以单独替换,二次开发时不用动整条链。
审计范围我一般按来源分四类:认证类日志(SSH 登录成功与失败、sudo 提权)、Web 访问日志(状态码、UA、请求路径)、应用日志(业务侧自己打的审计点)、系统日志(服务启停、内核消息)。类别这个字段后面会直接参与规则匹配,所以解析阶段就要定死枚举值,不要留自由文本,否则规则写起来会很痛苦。这一步看起来是小事,实际上决定了后面规则配置能不能做成纯 JSON。
2.2 技术栈选型:为什么是 Python 而不是 Go 或 Java
这个项目的技术栈组合是 Web 框架 + ORM + 关系库 + 定时任务 + 模板渲染,全是 Python 生态里最常规的几样。Web 层用 Flask 起 API 和页面,ORM 用 SQLAlchemy 屏蔽数据库差异,默认落 SQLite,需要并发写入时切 MySQL 只改一行连接串;采集端用标准库的open加自实现的 tail 逻辑,配上watchdog监听目录变化;定时任务用 APScheduler 跑归档和清理;前端就是 Jinja2 模板加一点静态资源,不引入前端构建链。告警外发用requests打 webhook,够用且没有额外依赖。
为什么不用 Go 或 Java?说句实在话,这个系统的瓶颈根本不在语言性能上。真正耗 CPU 的是正则解析,而正则引擎在 Python 里是 C 实现的;真正麻烦的是规则怎么表达、解析规则怎么快速迭代,Python 在这件事上的开发效率优势很明显。一条新规则从想法到上线,改一个 JSON 文件或者写十几行 Python 就行,不用重新编译打包。代价是单进程吞吐有限,常见做法是采集与判定放在同一个进程里用队列解耦,批量写库,把每秒事件量控制在几千条以内,这个量级对几十台服务器完全够。
存储这块值得单独说一下,因为它是后期最容易翻车的地方。
| 方案 | 适用规模 | 优点 | 需要注意 |
|---|---|---|---|
| SQLite | 单机、日事件百万以内 | 零运维、单文件、备份就是拷贝 | 写操作串行,必须开 WAL 并批量提交 |
| MySQL | 多实例、日事件千万级 | 并发写入稳、生态成熟 | 需要单独维护实例和连接池 |
| Elasticsearch | 需要全文检索、跨月查询 | 检索能力强、聚合方便 | 资源占用高,不适合小规模部署 |
默认走 SQLite 是有意的:绝大多数人的第一需求是"先跑起来看看效果",而不是一上来就搭集群。代码里通过 SQLAlchemy 抽象之后,切换成本主要落在建表语句的方言差异上,业务代码基本不动。
2.3 目录结构逐层拆解
项目按职责分层,目录名直接对应链路里的环节,找一个功能不用翻半天:
log-audit/ ├── run.py # 入口,带子命令:init-db / run / collect ├── config.yaml # 唯一配置文件 ├── requirements.txt ├── app/ │ ├── __init__.py # 应用工厂,装配 Flask、DB、调度器 │ ├── models.py # Event / Alert / Rule 三张表的 ORM 定义 │ ├── collector/ # 采集层:日志怎么进来 │ │ ├── file_tail.py # 文件增量读取,处理轮转 │ │ ├── syslog_server.py # UDP 514 接收 syslog │ │ └── http_ingest.py # POST /api/ingest 主动上报 │ ├── parser/ # 解析层:字符串怎么变成事件 │ │ ├── patterns.py # 各类日志的正则定义 │ │ └── normalizer.py # 字段补齐、时间归一、类别映射 │ ├── engine/ # 判定层 │ │ ├── rules.py # 规则加载与匹配 │ │ ├── window.py # 滑动窗口计数 │ │ └── notifier.py # 告警外发与抑制 │ ├── api/ # 对外接口 │ │ ├── events.py # 事件查询、分页、过滤 │ │ ├── alerts.py # 告警列表、标记已处理 │ │ └── rules.py # 规则增删改查 │ ├── web/ # 页面模板与静态资源 │ └── tasks/ │ └── scheduler.py # 归档、清理、规则热加载 └── data/ # SQLite 文件与归档日志这个结构里最值得留意的是collector与parser的分离。采集层只负责"拿到一行原始文本",它不知道这行是 SSH 还是 nginx;解析层只负责"把一行文本按某套规则变成字典",它不知道数据从哪来。之所以这么切,是因为日志格式的变化频率远高于采集方式的变化频率,改正则的时候不应该碰到 IO 代码。engine和api同理,一个只管判定,一个只管读写,互不越界。
run.py做成子命令形式也是刻意的。init-db建表,run起 Web 加全部采集器,collect只跑采集不启 Web,方便在生产机器上只部署采集侧、把数据推给中心节点。这种拆分在单机部署时看不出价值,等到日志源机器不想开 Web 端口的时候就知道有多顺手了。
3. 核心链路:日志从原始行到告警事件的完整处理
3.1 采集层:文件 tail 与轮转检测
文件采集最容易踩的坑不是读不到,而是日志轮转之后还在读旧文件句柄,看起来进程活着,实际一条新日志都没进来。所以我一般不用现成的tail -F子进程,而是在 Python 里自己维护文件句柄,靠 inode 变化判断轮转:
import os class FileTailer: def __init__(self, path, from_end=True): self.path = path # errors='replace' 防止二进制脏数据把整个采集线程搞崩 self.fh = open(path, "r", encoding="utf-8", errors="replace") if from_end: self.fh.seek(0, os.SEEK_END) # 首次启动不重放历史 self._ino = os.fstat(self.fh.fileno()).st_ino def read_lines(self): # 轮转检测:inode 变了说明原文件被 rename,新文件已经建好 try: if os.stat(self.path).st_ino != self._ino: self.fh.close() self.fh = open(self.path, "r", encoding="utf-8", errors="replace") self._ino = os.fstat(self.fh.fileno()).st_ino except FileNotFoundError: return [] # 文件暂时不存在,下轮再试 return [ln.rstrip("\n") for ln in self.fh.readlines() if ln.strip()]from_end控制首次启动时是否从头读。生产环境一般设True,避免重启时把几个 G 的历史日志全部重放一遍,导致事件表瞬间膨胀;调试阶段设False,方便用现成的日志文件验证解析规则。errors="replace"是小细节但很关键,应用日志里混进半截二进制内容的情况并不罕见,编码异常会直接掀翻采集线程,而采集线程一挂,后面的链路全哑。
轮转检测这段逻辑值得多说一句。Linux 下日志轮转通常是mv auth.log auth.log.1再创建新的auth.log,原来的文件句柄依然有效但指向的是改名后的旧文件,读它永远读不到新内容。用os.stat(self.path).st_ino和缓存的 inode 比对,一旦不等就重建句柄,这是最省事的判断方式。这个检查放在每次读取之前,代价是一次 stat 系统调用,可以忽略。
采集层另外两个入口相对简单。Syslog 服务用socketserver.UDPServer监听 514 端口,收到报文直接塞进解析队列;HTTP 入口提供一个POST /api/ingest接口,允许业务系统主动上报审计事件,请求体里带上source、category和原始行,方便那些没有本地日志文件的场景。
3.2 解析层:正则规则与字段标准化
解析层的输入是一行字符串加一个 parser 名字,输出是统一结构的字典。做法是把每种日志格式写成一个带命名分组的正则,放在patterns.py里集中管理:
import re # SSH 登录失败:Failed password for [invalid user] xxx from 1.2.3.4 SSH_FAILED = re.compile( r"^(?P<ts>[A-Z][a-z]{2}\s+\d{1,2}\s\d{2}:\d{2}:\d{2}).*?" r"Failed password for (?:invalid user )?(?P<user>\S+) " r"from (?P<src_ip>\d{1,3}(?:\.\d{1,3}){3})" ) # nginx combined 格式,只取审计关心的几个字段 NGINX_ACCESS = re.compile( r'^(?P<src_ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] ' r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3})' ) PARSERS = { "sshd": SSH_FAILED, "nginx_combined": NGINX_ACCESS, }正则里两个细节容易被忽略。第一,.*?用了非贪婪,因为 SSH 日志前缀里可能带主机名和进程号,贪婪匹配会一路吃到最后一个Failed,字段就错位了。第二,(?:invalid user )?用了非捕获分组,这样"用户不存在"和"密码错误"两种失败能共用同一条规则,审计上它们都属于登录失败,没必要拆成两个 parser。
时间字段的归一化是解析层真正的麻烦点。syslog 风格的时间戳没有年份,只有Jan 15 03:22:11,直接strptime解析出来会默认补 1900 年;跨年的时候还会出现"12 月的日志排在 1 月后面"这种排序错乱。常见做法是先按当前年份补全,如果算出来的时间比当前时间超前超过一天,就把年份减一:
from datetime import datetime, timedelta def parse_syslog_ts(raw, now=None): now = now or datetime.now() dt = datetime.strptime(f"{now.year} {raw}", "%Y %b %d %H:%M:%S") if dt - now > timedelta(days=1): # 明显是去年的日志 dt = dt.replace(year=now.year - 1) return dt这个判断用"超前一天"作为阈值,是因为日志本身可能有几秒到几分钟的时钟偏差,阈值太小会把正常日志误判成去年。入库统一存 UTC,展示时再按本地时区转换,这样跨时区的多台机器日志才能正确排序。字段标准化还包括空值补齐,user_name取不到就写空串而不是None,否则后面按账号分组的 SQL 会漏掉这批记录。
3.3 存储层:事件表与索引设计
事件表是整条链路的落点,字段设计直接决定后面查询好不好写:
CREATE TABLE audit_event ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts DATETIME NOT NULL, -- 事件发生时间,统一 UTC source VARCHAR(32) NOT NULL, -- sshd / nginx / app host VARCHAR(64), -- 来源主机 category VARCHAR(32) NOT NULL, -- auth / web / system action VARCHAR(32), -- login_failed / access ... src_ip VARCHAR(45), -- 兼容 IPv6 长度 user_name VARCHAR(64), raw TEXT, -- 原始行,追查时全靠它 extra JSON -- 解析出的其余字段 ); CREATE INDEX idx_event_ts ON audit_event(ts); CREATE INDEX idx_event_ip_ts ON audit_event(src_ip, ts); CREATE INDEX idx_event_cat_ts ON audit_event(category, ts);三个索引对应三类典型查询:按时间范围翻页、按源 IP 追一个攻击者的全部行为、按类别统计某段时间的登录失败量。src_ip留 45 位是为了兼容 IPv6,这个坑不提前避,等真的出现 IPv6 来源就得改表。raw字段冗余存一份原始行,看着浪费空间,但出事的时候能立刻看到上下文,比重新去服务器上翻日志快得多,我一般都会保留。
写入用批量提交,每 200 条或者每 1 秒 flush 一次,取决于哪个先到。SQLite 默认的日志模式是 delete,并发写入时会直接报database is locked,建库的时候顺手开 WAL:
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;WAL 允许读写并发,代价是多出-wal和-shm两个文件,备份时要一起拷。synchronous从 FULL 降到 NORMAL,在断电场景下可能丢最后几条事务,但对日志审计来说这个取舍可以接受,毕竟丢几条日志远好过写入被卡死导致采集队列堆爆内存。
3.4 规则引擎:滑动窗口与频次判定
规则用 JSON 表达,让运维能改配置而不用碰代码:
{ "name": "SSH 暴力破解", "match": {"category": "auth", "action": "login_failed"}, "group_by": ["src_ip"], "threshold": 10, "window": 300, "severity": "high", "cooldown": 600 }含义是:在 300 秒窗口内,同一个src_ip的登录失败事件达到 10 条就触发高危告警,触发后同一分组 600 秒内不再重复告警。group_by做成数组而不是单字段,是为了支持"同一账号 + 同一 IP"这种组合维度,实际用起来会发现很需要。
窗口计数不用数据库做,内存里维护一个按分组 key 索引的双端队列就行:
from collections import deque from time import time class SlidingWindow: def __init__(self, seconds): self.seconds = seconds self.buckets = {} # key -> deque[float] def hit(self, key, now=None): now = now or time() dq = self.buckets.setdefault(key, deque()) cutoff = now - self.seconds while dq and dq[0] < cutoff: # 弹出窗口外的旧记录 dq.popleft() dq.append(now) return len(dq) # 当前窗口内的命中次数 def reset(self, key): self.buckets.pop(key, None) # 冷却期结束后清空用内存窗口而不是每次查库统计,是因为判定发生在事件写入之前,走数据库会引入一次额外的写读往返,高频日志下直接拖垮吞吐。代价是进程重启后窗口清零,短时间内可能漏一次告警,对审计场景来说可以接受,真要做到精确就得把窗口状态落到 Redis。popleft的循环保证了队列里只保留窗口内的数据,内存占用随事件速率变化而不是无限增长,长期跑下来不会涨到几个 G。
规则匹配的执行顺序是先按match做字段过滤,命中才进窗口计数,最后比对阈值。这个顺序不能反,否则所有事件都要进一次哈希表,白白增加开销。
3.5 告警抑制与通知
没有抑制的告警系统活不过三天,因为爆破会持续几个小时,每 300 秒就报一次,微信群里很快就没人看了。抑制逻辑记录每个(规则 ID, 分组 key)的最后触发时间,冷却期内直接跳过:
class Notifier: def __init__(self, webhook=None): self.webhook = webhook self.last_fired = {} # (rule_id, group_key) -> ts def fire(self, rule, group_key, count, now): key = (rule["name"], group_key) last = self.last_fired.get(key, 0) if now - last < rule.get("cooldown", 0): return False # 冷却中,丢弃 self.last_fired[key] = now payload = { "rule": rule["name"], "severity": rule["severity"], "key": group_key, "count": count, } if self.webhook: import requests requests.post(self.webhook, json=payload, timeout=3) return Truetimeout=3是必须的。外发 webhook 最怕目标端不响应,一个卡住的 HTTP 请求会把整个判定线程堵死,后面的事件全部积压。设了超时之后还要在调用处包一层try/except,通知失败只记本地日志,绝不能让告警外发的问题反过来影响采集和入库。冷却期结束不清空last_fired,是因为这个字典只增不删也不会太大,规则数和攻击源数量的乘积通常在两三位数级别。
4. 跑起来:环境准备、配置与端到端验证
4.1 依赖安装与初始化
环境要求不高,Python 3.10 以上就行。虚拟环境加依赖安装是标准流程:
python -m venv venv source venv/bin/activate pip install -r requirements.txtrequirements.txt里主要是 Flask、SQLAlchemy、APScheduler、watchdog、PyYAML、requests 这几样,没有需要编译的 C 扩展,装起来不会卡在编译阶段。装完之后先初始化数据库:
python run.py init-db这条命令做三件事:按配置里的连接串建库、执行建表和索引语句、写入一份默认规则集。默认规则集包含 SSH 爆破、异常时段登录、Web 目录扫描三条,够用来验证链路是否通畅,后续按需增删。重复执行是幂等的,不会把已有数据冲掉。
4.2 配置文件逐项说明
配置只有一个config.yaml,方便和代码一起纳管:
server: host: 0.0.0.0 port: 8000 database: url: sqlite:///data/audit.db collector: file_sources: - path: /var/log/auth.log category: auth parser: sshd - path: /var/log/nginx/access.log category: web parser: nginx_combined syslog: enabled: true port: 514 batch_size: 200 flush_interval: 1 retention: days: 90 archive_dir: data/archive alert: webhook: ""file_sources是数组,加一个新日志源就是复制一段改路径和 parser 名,不用改代码。batch_size和flush_interval控制写库节奏,机器日志量大就调大批量、调小间隔的优先级反过来;事件量小的时候保持默认即可。retention.days决定保留天数,超过的由定时任务归档到archive_dir再从主表删除,归档文件按天切分,方便需要时回灌。webhook留空表示只落库不外发,先观察几天告警质量再填地址是比较稳妥的做法。
4.3 启动与造数据验证
配置就绪后启动主进程:
python run.py run它会同时拉起 Web 服务、文件采集线程、syslog 监听和调度器。验证链路最直接的办法是造几条假日志,往采集路径里追加:
for i in $(seq 1 12); do echo "$(date '+%b %e %H:%M:%S') testhost sshd[1234]: \ Failed password for invalid user demo from 10.0.0.9 port 5000$i ssh2" >> /tmp/auth.log done前提是配置里的file_sources指向了/tmp/auth.log,调试阶段建议先这么配,别直接拿生产日志做实验。追加完之后等一两秒,访问GET /api/events?category=auth应该能看到 12 条结构化事件,字段里的src_ip是10.0.0.9、action是login_failed。再看GET /api/alerts,因为默认阈值是 10、窗口 300 秒,这 12 条足够触发一条高危告警。
如果事件有、告警没有,先查规则的match字段和解析出来的category、action是否对得上,这是最高频的原因;如果告警有但 webhook 没收到,检查alert.webhook是否为空以及外发地址是否可达。整条链路验证通过之后,再把配置切到真实的日志路径。
5. 避坑排查:五个真实卡住过我的地方
5.1 日志轮转后采集静默停止
现象:进程还在,CPU 和内存都正常,但事件表里从某个整点开始再没有新数据,恰好是日志轮转的时间。
原因:采集器持有的是轮转前的文件句柄,mv之后句柄仍然有效但指向旧文件,读取永远返回空,没有任何报错。
解决:按 3.1 里的做法比对 inode,发现变化就重建句柄。另外加一个兜底监控,如果某个file_source连续 10 分钟没有产生任何事件,就往系统日志里打一条 warn,别指望靠人肉发现采集停了。
5.2 跨年之后时间排序全乱
现象:一月份的告警列表里混进了去年十二月的记录,按时间倒序看着像是"未来的日志"。
原因:syslog 风格的时间戳没有年份,解析时补了当前年份,去年 12 月的日志就被补成了今年 12 月,比当前时间还晚。
解决:解析时做超前判断,超过一天就把年份减一,统一转 UTC 入库。另外建议在事件表里额外存一个采集时间ingested_at,查询时用事件时间排序、用采集时间做兜底排查,两者分开能省很多口水。
5.3 SQLite 报 database is locked
现象:跑一段时间后采集线程抛异常,写入失败,队列开始堆积,内存持续上涨。
原因:默认日志模式下写操作是排他的,Web 查询和采集写入撞在一起就锁冲突,加上每条事件单独提交,冲突概率被放大。
解决:开启 WAL 模式,写入改批量提交,把batch_size调到 200 左右。还有个容易忽略的点,SQLAlchemy 的连接池对 SQLite 要设check_same_thread=False并控制池大小,多线程共享连接的配置不对一样会锁。
5.4 正则把字段吃错了位置
现象:事件能入库,但user_name里是空的,src_ip里混进了半截主机名,规则死活不触发。
原因:日志前缀长度不固定,正则用了贪婪匹配,或者没加行首锚点,匹配从中间某处开始,命名分组抓到了错误的片段。
解决:所有解析正则加^锚点,中间的不确定部分用非贪婪.*?,写完之后拿真实日志样本跑一遍单测。我一般会在parser/下留一个samples/目录,每种格式存几行脱敏样本,改正则之后跑一遍确认字段没串位。
5.5 告警刷屏导致没人看
现象:上线第一天群里几百条告警,第二天开始没人回复,真出事的时候反而被淹了。
原因:规则只有阈值没有冷却,攻击持续时每满一个窗口就报一次;同时内网扫描器、监控探活产生的失败请求也被算了进去。
解决:给每条规则配cooldown,并把已知的扫描器、负载均衡健康检查地址加进白名单,在进窗口之前就过滤掉。规则上线前先跑一天只落库不外发,看看命中量级,量太大就说明阈值或者过滤条件有问题,别急着开通知。
6. 二次开发:加日志源、加规则与换存储
这套代码被我改得最多的三处,按改动频率从高到低排:加日志源、调规则、换存储。加日志源的标准动作是在patterns.py里补一个带命名分组的正则,注册进PARSERS,然后在normalizer.py里把它映射到已有的category和action枚举上,最后在config.yaml的file_sources里加一段。整个过程不碰采集和判定代码,这也是当初分层的目的。
# 新增一类应用审计日志:2025-01-15 03:22:11 [audit] uid=1001 action=delete obj=/data/x APP_AUDIT = re.compile( r"^(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*?" r"uid=(?P<uid>\d+)\s+action=(?P<action>\w+)\s+obj=(?P<obj>\S+)" ) PARSERS["app_audit"] = APP_AUDIT这个 parser 的时间格式自带年份,走strptime直接解析即可,不用走 syslog 那套补年份的逻辑,所以在normalizer.py里要按 parser 名分派不同的时间解析函数。字段映射上action=delete可以归到category: app、action: delete,再配一条"单账号 1 分钟内删除超过 20 个对象"的规则,就能覆盖批量误删的场景。
加规则完全不用写代码,往规则表或者规则 JSON 文件里加一条就行:
{ "name": "批量删除对象", "match": {"category": "app", "action": "delete"}, "group_by": ["user_name"], "threshold": 20, "window": 60, "severity": "medium", "cooldown": 1800 }group_by这里用的是user_name,前提是解析阶段把uid映射到了user_name字段。如果要做账号维度之外的分析,还可以再加一层extra里的对象路径做分组,group_by支持数组正是为了这种组合场景。
换存储的改造点集中在两处:config.yaml的database.url,以及建表语句里的方言差异。SQLAlchemy 的 ORM 层基本不用动,但PRAGMA journal_mode这种 SQLite 专有语句要挪到条件分支里,MySQL 下换成对应的引擎和字符集配置。数据量涨到 SQLite 扛不住的时候,还有一个折中方案:主表继续放关系库存最近 7 天,历史数据按天落到归档文件,查询接口在时间范围超出 7 天时自动切到归档读取,改动量比直接上 Elasticsearch 小得多。
验证改造是否成功,我习惯用同一个方法:拿一份脱敏的真实日志,把时间轴从头到尾喂一遍,比对事件总数、各类别计数、告警条数三个数字。任何一个对不上,说明解析或者判定环节出了偏差,比看页面点来点去靠谱。这套流程我踩过一次坑之后就一直坚持——有回改了一条正则,页面看着正常,直到一周后要查一条记录才发现那个时间段的日志少了三分之一,因为非贪婪写成了贪婪,部分行压根没匹配上,静默丢了。
从那以后我每次动解析规则,都强制走一遍"样本回放 + 三个计数比对",哪怕只改一个字符。希望这套东西能帮你少走点弯路,把精力花在规则设计上而不是排查链路上。
本文还有配套的精品资源,点击获取