正则表达式基础:元字符、量词、分组与捕获、贪婪与非贪婪模式

说实话,正则表达式这东西,刚接触时觉得像天书。但做日志分析绕不开它。我当年第一次写日志解析脚本,就是被一堆乱七八糟的时间戳格式逼着去啃正则的。啃完之后发现,嗯,真香。

这一章咱们就把正则的底子打牢。你不需要背下所有语法,但核心的几个概念必须吃透:元字符、量词、分组与捕获、还有那个坑过无数人的贪婪与非贪婪。

2.1 元字符:正则世界的字母表

元字符就是有特殊含义的字符。说白了,它们不是匹配自己,而是匹配某一类东西。

元字符 含义 举个栗子
. 匹配任意单个字符(换行符除外) a.c 能匹配 abc、a1c,但不匹配 ac
\d 匹配一个数字,等价于 [0-9] \d\d\d 匹配三位数字
\w 匹配一个字母、数字或下划线 \w+ 匹配一个单词
\s 匹配一个空白字符(空格、制表符、换行) name\s+age 匹配中间有空白
^ 匹配字符串开头 ^ERROR 只匹配开头的 ERROR
$ 匹配字符串结尾 done$ 只匹配结尾的 done
[] 字符集,匹配括号内任意一个字符 [aeiou] 匹配任意元音字母
[^] 否定字符集,匹配不在括号内的字符 [^0-9] 匹配非数字

我的小习惯:写正则时,我总爱先用 . 占位,调试通了再换成精确的元字符。这样能快速验证整体结构对不对。

2.2 量词:控制重复次数

量词解决的是「这个字符出现几次」的问题。没有量词,你写 \d\d\d\d\d 匹配五位数,那匹配二十位数得写到手抽筋。

量词 含义 示例
* 0 次或多次 ab*c 匹配 ac、abc、abbc
+ 1 次或多次 ab+c 匹配 abc、abbc,不匹配 ac
? 0 次或 1 次 colou?r 匹配 color 和 colour
{n} 恰好 n 次 \d{4} 匹配四位数字
{n,} 至少 n 次 \d{2,} 匹配至少两位数字
{n,m} n 到 m 次 \d{2,4} 匹配 2 到 4 位数字

避坑指南:我曾经在解析 Nginx 日志时,用 .* 去匹配请求路径,结果把整行都吞掉了。后来才发现,* 和 + 默认是贪婪的——它们会尽可能多匹配。这个问题我们马上讲。

2.3 分组与捕获:把匹配结果「拎」出来

分组用圆括号 () 表示。它有两个作用:一是把多个字符当成一个整体来用量词修饰,二是把匹配到的内容「捕获」下来供后续使用。

举个例子,你要从日志里提取 IP 和端口:

192.168.1.1:8080

正则可以写成:

(\d+\.\d+\.\d+\.\d+):(\d+)

这里两个括号就是两个捕获组。第一个组拿到 IP,第二个组拿到端口。在 Python 里用 re.match() 或 re.search() 之后,通过 .group(1) 和 .group(2) 就能取出来。

我个人的经验:写日志解析规则时,我习惯给每个要提取的字段都加上命名分组,比如 (?P<ip>\d+\.\d+\.\d+\.\d+)。这样代码可读性高很多,三个月后回来看还能秒懂。

命名分组的语法是 (?P<name>...)。Python 里用 .group('name') 获取,比数序号靠谱多了。

2.4 贪婪与非贪婪:一个字符引发的血案

这是正则里最容易踩的坑,没有之一。

贪婪模式是默认行为:量词会尽可能多地匹配字符。非贪婪模式则在量词后面加个 ?,让它尽可能少地匹配。

看个例子。假设有字符串:

<div>内容1</div><div>内容2</div>

用贪婪模式 <div>.*</div> 匹配,结果是什么?

它会匹配整个字符串,从第一个 <div> 一直吃到最后一个 </div>。因为 .* 太贪心了,它把中间的 </div><div> 也当成了自己的「菜」。

用非贪婪模式 <div>.*?</div> 呢?

它会匹配到 <div>内容1</div> 就停下来。因为 .*? 说「够吃就行」,碰到第一个 </div> 就收手了。

我曾经踩过的坑:有一次写日志清洗脚本,要提取中括号里的内容。日志长这样:[INFO] [2024-01-01] [模块A] 启动成功。我用 \[(.*)\] 去匹配,结果只拿到一个结果——从第一个 [ 到最后一个 ] 的全部内容。改成 \[(.*?)\] 才正确提取出三个独立字段。那一次排查花了我半小时,从此我对非贪婪模式刻骨铭心。

2.5 知识体系总览

下面这张图把本章的核心知识点串起来了。你可以把它当成正则基础的地图,以后写正则时对照着看。

正则表达式基础核心知识体系 正则表达式基础 元字符 量词 分组与捕获 贪婪与非贪婪 . \d \w \s ^ $ [ ] * + ? {n,m} 捕获组 命名分组 贪婪模式 非贪婪模式 核心要点 元字符定义「匹配什么」,量词定义「匹配多少次」 分组用于提取和复用,非贪婪模式用 ? 解决「吃太多」的问题

2.6 实战:用正则提取日志中的关键字段

光说不练假把式。咱们拿一条典型的应用日志来练手:

2024-06-15 14:30:22,123 [http-nio-8080-exec-3] ERROR com.example.service.OrderService - 订单处理失败: orderId=ORD20240615001, userId=U10086, 原因: 库存不足

我们要提取:时间、日志级别、线程名、类名、订单ID、用户ID、错误原因。

正则可以这样写:

(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+
\[(?P<thread>[^\]]+)\]\s+
(?P<level>ERROR|INFO|WARN|DEBUG)\s+
(?P<class>\S+)\s*-\s*
订单处理失败:\s*
orderId=(?P<order_id>\w+),\s*
userId=(?P<user_id>\w+),\s*
原因:\s*(?P<reason>.+)$

拆解一下:

  • \d{4}-\d{2}-\d{2} 匹配日期,\d{2}:\d{2}:\d{2},\d{3} 匹配时间+毫秒
  • [^\]]+ 匹配中括号内的线程名,用否定字符集避免跨出括号
  • ERROR|INFO|WARN|DEBUG 用 | 做多选匹配
  • \S+ 匹配非空白字符的类名
  • 最后的 .+$ 匹配到行尾,把错误原因全部抓下来

在 Python 里用起来就是:

import re

log_line = "2024-06-15 14:30:22,123 [http-nio-8080-exec-3] ERROR com.example.service.OrderService - 订单处理失败: orderId=ORD20240615001, userId=U10086, 原因: 库存不足"

pattern = r'(?P<timestamp>\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+\[(?P<thread>[^\]]+)\]\s+(?P<level>ERROR|INFO|WARN|DEBUG)\s+(?P<class>\S+)\s*-\s*订单处理失败:\s*orderId=(?P<order_id>\w+),\s*userId=(?P<user_id>\w+),\s*原因:\s*(?P<reason>.+)$'

match = re.match(pattern, log_line)
if match:
    print(f"时间: {match.group('timestamp')}")
    print(f"线程: {match.group('thread')}")
    print(f"级别: {match.group('level')}")
    print(f"类名: {match.group('class')}")
    print(f"订单ID: {match.group('order_id')}")
    print(f"用户ID: {match.group('user_id')}")
    print(f"原因: {match.group('reason')}")

输出结果:

时间: 2024-06-15 14:30:22,123
线程: http-nio-8080-exec-3
级别: ERROR
类名: com.example.service.OrderService
订单ID: ORD20240615001
用户ID: U10086
原因: 库存不足

注意:实际生产环境中的日志格式可能更复杂。比如时间戳可能有时区,类名可能包含内部类(带 $ 符号),错误原因可能跨行。我建议你在写正则之前,先拿 100 条真实日志样本跑一遍,看看有没有边界情况。我曾经就因为没考虑到错误信息里包含换行符,导致线上少采集了 30% 的异常日志——那场面,相当尴尬。

好了,正则基础就讲到这里。元字符让你知道「匹配什么」,量词告诉你「匹配多少次」,分组帮你「提取出来」,非贪婪模式帮你「别吃太多」。这四个概念吃透了,日志解析的正则部分你就掌握了 80%。剩下的就是多练、多踩坑、多总结。