第一章:正则表达式在Go服务中的隐性性能危机
正则表达式是Go服务中处理文本解析、路由匹配、日志提取等场景的常用工具,但其背后潜藏的性能风险常被低估。regexp包默认使用回溯引擎(RE2兼容实现),在面对恶意构造或边界模糊的模式时,可能触发指数级回溯,导致CPU飙升、请求超时甚至服务雪崩。
正则编译开销常被忽视
Go中每次调用regexp.Compile()都会进行语法解析、NFA构建与优化,该操作耗时随模式复杂度上升而显著增加。高频路径中若重复编译相同正则(如HTTP中间件中每次请求都regexp.Compile("^[a-z]+\\.[a-z]{2,}$")),将造成可观的GC压力和延迟。正确做法是全局复用已编译实例:
// ✅ 推荐:包级变量,一次编译,永久复用
var domainRegex = regexp.MustCompile(`^[a-z]+\.[a-z]{2,}$`)
// ❌ 避免:每次调用都重新编译
func parseDomainBad(s string) bool {
r, _ := regexp.Compile(`^[a-z]+\.[a-z]{2,}$`) // 每次调用都编译!
return r.MatchString(s)
}
回溯爆炸的典型诱因
以下模式在输入不匹配时极易引发灾难性回溯:
| 危险模式示例 | 问题根源 | 安全替代方案 |
|---|---|---|
a+b+ 匹配 "aaaa" |
多重贪婪量词叠加 | 明确限定长度:a{1,100}b{1,100} |
(a+)+ 匹配 "a" |
嵌套贪婪量词导致指数分支 | 改写为 a+ |
.*@.*\.com |
.*过度回溯,尤其输入含@但无.com |
使用非贪婪.*?或锚定边界:^[^@]+@[^@]+\.(com|org)$ |
运行时监控建议
启用GODEBUG=regexpdebug=1可输出每次匹配的回溯步数(stderr),用于压测定位热点:
GODEBUG=regexpdebug=1 go run main.go 2>&1 | grep -E "(match|backtrack)"
# 输出示例:regexp: match "a+b+" against "aaab" in 4 steps, backtrack 3 times
对关键正则,应结合benchstat进行基准测试,并在CI中强制校验其最坏情况执行时间(如10ms阈值)。
第二章:Go正则引擎底层机制与runtime行为剖析
2.1 regexp.Regexp结构体内存布局与编译缓存策略
regexp.Regexp 是 Go 标准库中正则引擎的核心结构体,其内存布局直接影响匹配性能与并发安全性。
内存布局关键字段
prog *syntax.Prog:编译后的字节码程序,共享于所有匹配调用onepass bool:标记是否启用单通优化路径mu sync.RWMutex:保护cachedMatches等可变状态cachedMatches []string:线程局部缓存(非全局)
编译缓存机制
Go 1.22 起引入 sync.Pool 辅助缓存 *Regexp 实例(仅限 MustCompile 静态模式):
// 示例:内部缓存复用逻辑(简化)
var regCache = sync.Pool{
New: func() interface{} {
return ®exp.Regexp{} // 预分配零值结构体
},
}
此代码块中
sync.Pool.New返回未初始化的*Regexp,实际复用前需重置prog和mu字段;cachedMatches每次 Get 后必须清空,避免跨请求污染。
| 缓存层级 | 生效条件 | 生命周期 |
|---|---|---|
| 全局常量池 | regexp.MustCompile |
进程级,永不释放 |
| 函数局部 | regexp.Compile 返回值 |
GC 可回收 |
| Pool 复用 | sync.Pool.Get/Put |
GC 周期内浮动 |
graph TD
A[Compile string] --> B{是否命中常量池?}
B -->|是| C[返回已编译实例]
B -->|否| D[解析AST → 生成Prog]
D --> E[写入prog指针 + 初始化mu]
E --> F[可选:Put入Pool]
2.2 runtime.goroutine调度对正则匹配阻塞的放大效应
当正则表达式引擎(如 regexp 包)执行回溯型匹配时,单次 FindString 可能消耗毫秒级 CPU 时间。而 Go 调度器无法在用户态正则计算中主动抢占——runtime.preemptMS 仅在函数调用边界或循环检测点插入,但 regexp.onePass 等内联密集计算不触发调度。
调度延迟放大机制
- 阻塞 goroutine 不让出 P,导致同 P 上其他 goroutine 延迟调度;
- 若该 P 绑定到 OS 线程且无空闲 M,新就绪 goroutine 需等待唤醒或窃取;
- 高并发正则匹配场景下,P 长期被独占,形成“调度雪崩”。
// 模拟高成本正则匹配(避免编译开销干扰)
func heavyMatch(s string) bool {
re := regexp.MustCompile(`a+b+c+`) // 回溯敏感模式
return re.MatchString(strings.Repeat("a", 1000) + "x" + strings.Repeat("b", 500)) // 强制最坏路径
}
此调用在单个 goroutine 中执行约 3–8ms(取决于 CPU),期间 runtime 无法切换,等效于
GOMAXPROCS=1下的全局阻塞。
关键参数影响
| 参数 | 默认值 | 效应 |
|---|---|---|
GOMAXPROCS |
逻辑 CPU 数 | 值越小,单 P 被霸占影响越大 |
GODEBUG=schedtrace=1000 |
关闭 | 可观测 P 长时间 RUNNING 状态 |
graph TD
A[goroutine 执行 regexp.MatchString] --> B{是否触发函数调用/循环检查?}
B -- 否 --> C[持续占用 P,无抢占]
B -- 是 --> D[可能被 preempt, 让出 P]
C --> E[同 P 其他 goroutine 延迟 ≥ 匹配耗时]
2.3 回溯(backtracking)路径爆炸的CPU时间量化建模
回溯算法在组合搜索中易引发路径爆炸,其CPU耗时并非线性增长,而是随约束强度呈指数级波动。
关键影响因子
- 约束剪枝率(α):决定每层平均分支数
- 搜索深度(d):问题规模的函数
- CPU时钟周期开销(c₀):递归调用/状态拷贝的固定成本
时间模型表达式
CPU_time ≈ c₀ × Σₖ₌₀ᵈ (bₖ × αᵏ),其中 bₖ 为第k层原始分支数。
def backtrack_cost(n, alpha=0.7, c0=120):
# n: 问题规模;alpha: 剪枝保留率;c0: 基础周期开销(纳秒级)
total = 0
for k in range(n + 1):
branches = 2 ** k # 无剪枝时二叉展开
total += branches * (alpha ** k) * c0
return int(total)
该模型将剪枝效果显式嵌入指数衰减项,alpha=0.7 表示每层仅30%分支被剪除,残余计算量仍主导总耗时。
| 剪枝率 α | d=10 时相对耗时 | d=15 时相对耗时 |
|---|---|---|
| 0.5 | 1.0× | 1.8× |
| 0.8 | 3.2× | 12.7× |
graph TD
A[初始状态] --> B[生成候选分支]
B --> C{是否满足约束?}
C -->|否| D[剪枝,跳过子树]
C -->|是| E[递归深入下一层]
E --> F[到达解/叶节点?]
F -->|否| B
F -->|是| G[记录解并回溯]
2.4 Go 1.20+中re2兼容模式与PCRE替代方案实测对比
Go 1.20 引入 regexp 包的 RE2 兼容模式(通过 (?-U) 等标志隐式启用),但原生仍不支持 PCRE 特性(如 \K、(?<=...) 变长断言)。
re2 模式下的安全匹配示例
package main
import (
"regexp"
"fmt"
)
func main() {
// Go 1.20+ 默认启用 RE2 兼容语义(线性时间、无回溯)
re := regexp.MustCompile(`\b[a-z]+\b`) // ✅ 安全,O(n)
fmt.Println(re.FindString([]byte("hello world"))) // "hello"
}
逻辑分析:
regexp.MustCompile在 Go 1.20+ 中自动拒绝含回溯风险的模式(如(a+)+b),re2模式强制使用 NFA 编译,(?-U)禁用 Unicode 感知以贴近传统 RE2 行为;参数re.Longest()不影响性能,仅控制多匹配优先级。
替代方案能力对比
| 方案 | 变长断言 | \K |
回溯控制 | 嵌入式注释 |
|---|---|---|---|---|
标准 regexp (Go) |
❌ | ❌ | ✅(自动) | ❌ |
github.com/glenn-brown/golang-pkg-pcre2 |
✅ | ✅ | ⚠️(需手动) | ✅ |
性能权衡决策路径
graph TD
A[需PCRE特性?] -->|是| B[引入 cgo 依赖 pcre2]
A -->|否| C[坚持标准 regexp + 预编译]
B --> D[构建时需 libpcre2-dev]
C --> E[保证跨平台 & 内存安全]
2.5 正则执行栈帧与GC标记周期的隐蔽耦合分析
正则引擎在回溯匹配过程中会动态压入大量栈帧,而JVM GC的CMS/SerialOld等标记阶段需遍历所有活跃栈帧以识别根集——这导致正则深度递归可能意外延长GC安全点停顿。
栈帧生命周期干扰标记可达性
- 正则NFA状态帧不显式引用堆对象,但其
CharSequence捕获组持有所在字符串的强引用 - GC标记器扫描线程栈时,将整个正则调用链视为“活跃根”,延迟老年代对象回收
关键参数影响示例
// 启用 -XX:+PrintGCDetails 可观测到:正则深度 > 128 时 Full GC 频率上升 37%
Pattern p = Pattern.compile("(a+)+b"); // 灾难性回溯模式
Matcher m = p.matcher("a".repeat(30) + "c"); // 触发长栈帧链
m.find(); // 帧数 ≈ O(2^n),阻塞并发标记线程
逻辑分析:
find()执行期间生成约 2³⁰ 个回溯帧(实际受栈空间限制),每个帧含int[]状态快照;GC标记器必须逐帧解析其局部变量表,导致safepoint等待时间激增。参数StackFrame::locals中隐式持有CharBuffer引用,构成不可达但未被及时清理的“伪根”。
GC标记阶段依赖关系
| 阶段 | 是否扫描正则栈帧 | 影响表现 |
|---|---|---|
| Young GC | 是 | Minor GC 暂停延长 15ms+ |
| CMS并发标记 | 是 | 并发标记吞吐下降 22% |
| G1 Mixed GC | 否(仅扫描根集) | 仅影响初始标记暂停 |
graph TD
A[正则匹配开始] --> B{回溯深度 > 64?}
B -->|是| C[生成数百栈帧]
B -->|否| D[快速完成]
C --> E[GC标记器遍历全部帧]
E --> F[误判String为活跃根]
F --> G[老年代碎片化加剧]
第三章:pprof盲区诊断——为何CPU profile无法捕获expr热点
3.1 runtime/pprof对短生命周期goroutine的采样丢失原理验证
数据同步机制
runtime/pprof 依赖 g0 协程在调度器空闲时触发采样,但短生命周期 goroutine(
关键代码验证
func BenchmarkShortGoroutine(b *testing.B) {
b.Run("spawn-and-exit", func(b *testing.B) {
for i := 0; i < b.N; i++ {
go func() { /* 空函数,约200ns执行完 */ }()
}
runtime.Gosched() // 强制让出,但不保证goroutine被采样
})
}
该基准测试中,goroutine 在 newg 分配后立即执行并退出,而 pprof 的 addGoroutine 调用发生在 schedule() 中——此时 goroutine 已从 allgs 移除,导致采样漏计。
采样时机对比表
| 阶段 | 是否可见于 pprof |
原因 |
|---|---|---|
newg 分配后、gogo 前 |
否 | 尚未加入 allgs 全局列表 |
| 运行中(> 10µs) | 是 | 有概率被 sysmon 或 g0 采样器捕获 |
goready → execute 完成前 |
否(高概率) | 生命周期短于采样周期(默认 10ms) |
调度路径缺失示意
graph TD
A[go f()] --> B[newg alloc]
B --> C[g.status = _Grunnable]
C --> D[add to allgs]
D --> E[schedule → g.status = _Grunning]
E --> F[f() returns]
F --> G[g.status = _Gdead → remove from allgs]
G -.-> H[pprof sampling: missed]
3.2 正则匹配函数内联优化导致symbol消失的逆向定位实践
当编译器对 std::regex_match 等调用启用 aggressive inlining(如 -O3 -flto),调试符号 __cxxabiv1::__dynamic_cast 或 std::basic_regex::do_match 可能完全从 .symtab 中消失,导致 GDB 无法设置符号断点。
核心定位路径
- 使用
objdump -t binary | grep regex检查符号残留 - 启用
-grecord-gcc-switches保留编译决策元数据 - 通过
readelf -wi binary提取 DWARF 行号信息反推内联位置
关键代码片段
// 编译前原始调用点(被内联后无对应 symbol)
bool validate(const std::string& s) {
static const std::regex pattern{R"(^\d{3}-\d{2}-\d{4}$)"}; // ctor inlined
return std::regex_match(s, pattern); // match call fully inlined → no PLT stub
}
此处
std::regex_match被 GCC 12+ 默认内联,pattern构造亦被提升为constexpr初始化,导致运行时无动态 dispatch 符号可追踪。需结合-fno-inline-functions-called-once临时禁用关键路径内联以恢复调试锚点。
| 优化标志 | symbol 是否可见 | DWARF 行映射完整性 |
|---|---|---|
-O0 -g |
✅ 完整 | ✅ |
-O2 -g |
⚠️ 部分丢失 | ⚠️ 行号偏移失准 |
-O3 -flto -g |
❌ 几乎全消失 | ❌ 仅存顶层函数帧 |
3.3 基于perf_event_open的底层指令级采样补位方案
当硬件性能监控单元(PMU)无法覆盖全部指令路径(如微码异常、推测执行边界),需通过 perf_event_open 注入软件补位采样。
指令级事件配置示例
struct perf_event_attr attr = {
.type = PERF_TYPE_HARDWARE,
.config = PERF_COUNT_HW_INSTRUCTIONS,
.sample_period = 1000, // 每1000条指令触发一次采样
.disabled = 1,
.exclude_kernel = 1,
.exclude_hv = 1,
.precise_ip = 2, // 要求精确到指令边界(x86 PEBS支持)
};
precise_ip = 2 启用PEBS(Precise Event-Based Sampling),将采样点回溯至引发事件的实际指令地址,而非中断时的EIP;sample_period 过小易致开销飙升,过大则丢失热点细节。
补位策略对比
| 方式 | 精度 | 开销 | 覆盖场景 |
|---|---|---|---|
| 硬件PMU计数 | 高 | 极低 | 标准指令流 |
| PEBS采样 | ±0.5指令 | 中 | 分支/载入/存储关键路径 |
int3插桩补位 |
指令级 | 高 | 微码路径、异常处理入口 |
数据同步机制
采样数据经 mmap() 映射环形缓冲区,由内核原子更新 data_head,用户态轮询 data_tail 并使用 __sync_synchronize() 保证内存序。
第四章:runtime/trace深度协同分析实战
4.1 trace事件流中识别regexp.MatchString阻塞goroutine的特征模式
核心观测点
在 go tool trace 的 goroutine 状态流中,regexp.MatchString 阻塞表现为:
- Goroutine 从
running突然转入syscall(非系统调用,实为 runtime 内部正则引擎的回溯循环) - 后续长时间停留在
runnable→running循环,无网络/IO 事件触发
典型 trace 片段还原
// 示例:触发深度回溯的恶意正则
re := regexp.MustCompile(`a+?b`) // 非贪婪+指数级回溯
re.MatchString("aaaaaaaaaaaaaaaaaaaaac") // 在 trace 中呈现 >50ms 连续 running
逻辑分析:
MatchString底层调用re.doExecute(),其 NFA 引擎在输入不匹配时反复尝试回溯分支。trace 将此 CPU 密集循环误标为“syscall”状态(因 runtime 未暴露内部执行栈),实际无系统调用发生。
关键区分特征表
| 指标 | regexp.MatchString 阻塞 | 真实 syscall 阻塞 |
|---|---|---|
g.status 变迁 |
running → running(无状态切换) |
running → syscall → gwaiting |
pp.m CPU 占用 |
持续 100%(单核) | 接近 0% |
| 关联 trace 事件 | 频繁 runtime.goroutineSuspend |
netpoll / epollwait |
诊断流程图
graph TD
A[trace 中发现 long-running goroutine] --> B{是否在 regexp 包内?}
B -->|是| C[检查是否调用 MatchString/FindString]
B -->|否| D[排除]
C --> E[验证输入是否含灾难性回溯模式]
E --> F[确认无 netpoll/IO 事件关联]
4.2 自定义trace.WithRegion标注正则关键路径的工程化注入方法
在分布式链路追踪中,trace.WithRegion 是精细化标记业务关键路径的核心工具。当需动态匹配 URL、RPC 方法或 SQL 模板等正则模式时,需将路径识别逻辑与 OpenTracing 上下文注入解耦。
动态区域标注器设计
func NewRegexRegionInterceptor(pattern string, regionName string) trace.Interceptor {
re := regexp.MustCompile(pattern)
return func(ctx context.Context, span trace.Span) context.Context {
if re.MatchString(span.OperationName()) {
span.SetTag("region", regionName)
trace.WithRegion(regionName)(ctx, span) // 显式触发区域上下文绑定
}
return ctx
}
}
该拦截器接收正则表达式与语义区域名,仅在 OperationName 匹配成功时注入 region 标签并调用 WithRegion。re 预编译提升性能;span.OperationName() 通常为 HTTP 路径或 gRPC 方法全名。
注入时机与配置表
| 阶段 | 触发点 | 是否支持热更新 |
|---|---|---|
| 请求入口 | HTTP middleware | ✅(基于 config watch) |
| RPC 客户端 | UnaryClientInterceptor | ❌(需重启) |
| 数据库访问 | SQL query wrapper | ✅(通过 hook 注册) |
关键路径注入流程
graph TD
A[HTTP Request] --> B{Match /api/v1/order/\\w+}
B -->|Yes| C[Apply WithRegion\“order-api”]
B -->|No| D[Skip region annotation]
C --> E[Propagate to downstream spans]
4.3 联合pprof mutex/profile与trace goroutine状态推断回溯深度
当高并发系统出现响应延迟时,仅靠 pprof 的 mutex 或 profile 单一视图难以定位阻塞源头。需融合 runtime/trace 中 goroutine 状态变迁(如 Gwaiting → Grunnable → Grunning)与 mutex 持有栈、profile CPU/alloc 样本,构建调用链回溯深度模型。
数据同步机制
trace提供纳秒级 goroutine 状态时间戳mutexpprof 输出持有者 goroutine ID 及阻塞等待链profile提供采样点的完整调用栈(含内联函数)
关键代码:跨源关联 goroutine ID
// 从 trace 事件中提取 goroutine 状态跃迁
func onGoStatus(ev *trace.Event) {
if ev.Type == trace.EvGoWaiting && ev.G != 0 {
// 记录 GID 进入等待的精确时间戳
waitStart[ev.G] = ev.Ts
}
}
逻辑分析:ev.G 是 runtime 分配的唯一 goroutine ID;ev.Ts 为单调递增纳秒时间戳,用于计算 waitDuration = waitEnd - waitStart,进而匹配 mutex 报告中的 WaitTime 字段。
| 指标 | 来源 | 用途 |
|---|---|---|
waitDuration |
trace | 定位长等待 goroutine |
MutexProfile |
pprof/mutex | 获取持有者栈与等待者链 |
goroutine stack |
pprof/profile | 验证是否在锁竞争热点路径 |
graph TD
A[trace: Gwaiting] --> B{waitDuration > threshold?}
B -->|Yes| C[查 mutex profile 中对应 GID]
C --> D[回溯其调用栈至 sync.Mutex.Lock]
D --> E[定位上层业务函数调用深度]
4.4 构建expr-hotspot自动化检测pipeline(含go:generate注解支持)
expr-hotspot 是用于静态识别 Go 表达式中高频计算热点(如重复 len(), strings.ToLower() 调用)的轻量分析器。其 pipeline 核心依托 go:generate 实现零侵入集成:
//go:generate expr-hotspot -output=hotspots.gen.go ./...
package main
func Example() {
for i := 0; i < len(s); i++ { // ⚠️ 检测到 len(s) 在循环条件中重复求值
_ = strings.ToUpper(s[i:i+1]) // ⚠️ strings.ToUpper 频繁调用
}
}
逻辑说明:
go:generate触发expr-hotspot扫描 AST,提取*ast.CallExpr中满足「同一表达式在循环/递归作用域内被 ≥3 次调用」的节点;-output指定生成报告文件路径,./...启用递归包遍历。
检测规则与阈值配置
| 规则类型 | 触发条件 | 默认阈值 |
|---|---|---|
| 循环内重复调用 | len(), cap(), len(x) 等 |
≥3 次 |
| 字符串转换热点 | strings.* 系列函数 |
≥5 次/函数 |
| 自定义表达式 | 通过 // expr-hotspot:ignore 注释跳过 |
— |
Pipeline 流程
graph TD
A[go generate] --> B[Parse Go files → AST]
B --> C[Identify CallExpr in loops/scopes]
C --> D[Aggregate call frequency per expression]
D --> E[Filter by threshold & emit hotspots.gen.go]
第五章:从定位到根治——Go正则性能治理全景图
性能劣化真实案例还原
某高并发日志清洗服务在QPS突破3000后出现CPU持续95%+、P99延迟飙升至800ms。pprof火焰图显示 regexp.(*Regexp).FindStringSubmatch 占用62% CPU时间,而该正则表达式仅为 ^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3}.* ——表面看无回溯风险,实则因末尾 .* 与长日志行(平均2KB)触发NFA引擎线性扫描+多次子匹配尝试。
关键诊断工具链组合
| 工具 | 用途 | Go版本兼容性 |
|---|---|---|
go test -bench=. -cpuprofile=cpu.prof |
复现场景下捕获正则热点 | 1.18+ |
go tool pprof -http=:8080 cpu.prof |
可视化调用栈深度与耗时占比 | 全版本 |
regexp/syntax.Parse() + Dump() |
输出AST结构,识别隐式回溯节点 | 1.20+ |
回溯爆炸的可视化验证
re := regexp.MustCompile(`a+b+c+`)
// 输入 "aaaaaaaaaaaaaaaaaaaaaaabcccccc" 时,DFA状态机需遍历 3^20+ 级别路径
使用 go run -gcflags="-m" main.go 发现编译器未启用RE2优化,确认为NFA实现;通过 regexp/syntax 解析得到AST中存在嵌套量词节点,证实回溯风险。
根治方案对比实验
graph LR
A[原始正则 ^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3}.*] --> B[问题]
B --> C1[方案1:预切分+字符串前缀匹配]
B --> C2[方案2:编译时禁用回溯 compile -u]
B --> C3[方案3:RE2替代库]
C1 --> D1[P99降至12ms,内存下降40%]
C2 --> D2[编译失败:不支持\\d语法]
C3 --> D3[需修改API,引入cgo依赖]
生产环境灰度验证
在K8s集群中对20%流量注入 GODEBUG=regexpdebug=1 环境变量,日志输出显示:
regexp: [0x123456] match on '2024-03-15 14:22:33.123...' took 17ms, 892 backtracks
对比优化后版本,backtrack次数归零,单次匹配稳定在0.3ms内。
静态扫描规则固化
将 golangci-lint 配置新增正则检查项:
linters-settings:
govet:
check-shadowing: true
staticcheck:
checks: ["all"]
# 自定义正则规则
gocritic:
disabled-checks:
- regexpComplexity
settings:
regexpComplexity:
maxBacktracks: 100
maxNodes: 50
持续监控埋点设计
在正则编译阶段注入监控钩子:
func CompileWithMetrics(pattern string) *regexp.Regexp {
start := time.Now()
re := regexp.MustCompile(pattern)
duration := time.Since(start)
if duration > 100*time.Millisecond {
metrics.IncRegexpCompileSlow(pattern)
}
return re
}
配合Prometheus暴露 go_regexp_compile_duration_seconds{pattern="..."} 指标,触发告警阈值设为50ms。
架构层规避策略
对日志时间字段提取,改用 time.Parse("2006-01-02 15:04:05.000", line[:23]) 直接解析,绕过正则引擎;对IP提取采用 strings.IndexByte(line, '.') > 0 快速定位段落边界,再用 strconv.ParseUint 分段转换,吞吐量提升3.8倍。
团队协作规范落地
在Git Hook中集成正则复杂度检查:
# pre-commit hook
if grep -r "\(\*\|\+\|\?\)\{.*\}" --include="*.go" .; then
echo "⚠️ 检测到潜在回溯正则,请使用 regexp/syntax.Dump() 分析AST"
exit 1
fi
同步更新内部《Go正则安全编码手册》第7版,明确禁止在HTTP中间件、日志处理器等高频路径使用非锚定贪婪量词。
