Regex thực chiến: Anchor, quantifier và capture group
Tóm tắt (TL;DR)
Ngày 2 tháng 7 năm 2019, Cloudflare để trượt khoảng 50% lưu lượng toàn cầu trong 27 phút chỉ vì một regex duy nhất — mẫu .*(?:.*=.*) trong quy tắc WAF mới, kiểu suýt match nhưng không match. Backtracking bùng nổ, một core 100% và mọi request đứng lại. Bài học trung thực nhất mà sự cố đó dạy về regex là: regex giỏi giấu chi phí. Lúc bình thường nó nhỏ gọn, nhanh và đáng yêu, nhưng chỉ “một dòng đầu vào sai” cũng đủ làm sập cả một hệ thống. Vì thế giọng bài này hơi hoài nghi. Regex thực tế đứng trên vài mảnh ghép: anchor định vị đầu, cuối và ranh giới từ; character class mô tả “một trong các ký tự này”; quantifier quyết định “bao nhiêu lần”; group dùng để capture, tham chiếu hoặc đóng gói lựa chọn. Khi dùng đúng, các mẫu ngắn và dễ đọc giải quyết tốt những bài toán phổ biến như match email sơ bộ, trích xuất trường từ log, chuẩn hoá số điện thoại. Khi dùng sai, nó match quá nhiều hoặc quá ít, hoặc như trên, làm treo engine. Đừng parse HTML, JSON, XML bằng regex. Ngôn ngữ regular không mô tả được lồng cân đối. Khác biệt giữa engine cũng lớn — ECMAScript, PCRE (Perl/PHP), Python re, Go RE2 khác nhau về look-around, back-reference và xử lý unicode; sự cố Cloudflare là do mô hình backtracking của PCRE (sẽ không xảy ra trên Go RE2).
Bối cảnh và khái niệm
Regex là một văn phạm nhỏ dành cho matching chuỗi. Các mảnh ghép hay dùng có thể chia theo tên như sau.
Anchor không tiêu thụ ký tự mà khẳng định vị trí. ^ là đầu đầu vào (trong chế độ multiline là đầu dòng), $ là cuối, \b là ranh giới từ. abc không anchor sẽ match ở bất kỳ đâu trong chuỗi dài, còn ^abc$ chỉ match khi cả chuỗi là abc, và \babc\b chỉ match khi abc là từ độc lập.
Character class mô tả một ký tự “thuộc tập nào”. [abc] là một trong a, b, c; [a-z] là chữ thường; [^0-9] là “tất cả trừ số”. Có các rút gọn phổ biến: \d (số), \w (ký tự từ — chữ, số, gạch dưới), \s (khoảng trắng). Chữ hoa \D, \W, \S là bổ. . mặc định match mọi ký tự trừ xuống dòng, và cờ s (dotall) thay đổi hành vi đó.
Quantifier gắn vào token ngay trước và quyết định số lần lặp. * là 0 lần trở lên, + là 1 lần trở lên, ? là 0 hoặc 1, {n,m} là “n đến m” (mỗi biên có thể bỏ). Mặc định là tham lam (greedy): match nhiều nhất có thể rồi chỉ nhường từng bước khi phần sau thất bại. Thêm ? để chuyển sang lười (lazy) — *?, +?, ?? — match ít nhất có thể và chỉ mở rộng khi bị ép. Một số engine hỗ trợ quantifier chiếm hữu (possessive) (*+, ++) — match tham lam nhưng không nhường dù thất bại. Điều này chặn bùng nổ backtracking trong đầu vào bệnh lý — chính công cụ mà Cloudflare thiếu trong sự cố nói trên.
Group đóng gói mẫu con. (pattern) là capture group, được đánh số từ 1 theo thứ tự ngoặc mở, có thể tham chiếu bằng \1 trong mẫu hoặc bằng chỉ số trong host language. (?<name>pattern) là capture có tên. (?:pattern) là non-capturing group, dùng thuần cho lựa chọn ((?:cat|dog)) hoặc đóng gói quantifier mà không ghi lại match. | trong group là alternation.
Cuối cùng, flag điều chỉnh engine. i bỏ qua hoa thường, m là multiline (^ và $ khớp ranh giới dòng), s là dotall, u là unicode. Trong JavaScript, cờ u còn bật các unicode property escape như \p{L} (“mọi chữ cái”).
So sánh và dữ liệu
| Quantifier | Tham lam (mặc định) | Lười (? suffix) | Chiếm hữu (nếu hỗ trợ) |
|---|---|---|---|
* / *? / *+ | Nhiều nhất có thể, nhường khi thất bại | Ít nhất có thể, mở rộng khi bị ép | Nhiều nhất có thể, không nhường |
+ / +? / ++ | Từ 1, tham lam | Từ 1, lười | Từ 1, không nhường |
? / ?? / ?+ | 0–1, ưu tiên 1 | 0–1, ưu tiên 0 | 0–1, không nhường |
{n,m} / {n,m}? / {n,m}+ | Khoảng, tham lam | Khoảng, lười | Khoảng, không nhường |
Mặc định tham lam thường đúng. Lười cần khi “mẫu phía sau” quá rộng. Để lấy cặp <b>...</b> trong trích HTML, bạn phải dùng lười <b>(.*?)</b> để không ăn nhiều thẻ (dù sao, đừng dùng regex cho HTML thật). Possessive quantifier và atomic group (?>...) hữu ích để chặn tra ngược theo hàm mũ ở các mẫu suýt match. PCRE, Java và Oniguruma của Ruby hỗ trợ; ECMAScript và Python re thì không (Python từ 3.11 đã thêm atomic group).
Tình huống thực tế
Tình huống 1 — Match email thực dụng. Cú pháp email đầy đủ theo RFC 5322 cho phép comment, local-part trong dấu nháy, literal IP lồng nhau, nên một regex bao phủ tất cả nổi tiếng là khổng lồ (6.425 ký tự là nỗ lực nổi tiếng nhất) mà vẫn không phải parser thật. Mẫu tôi gần như luôn dùng trong vận hành là ^[^\s@]+@[^\s@]+\.[^\s@]+$ — “không rỗng, không có khoảng trắng, không có @ ở chỗ sai, domain có ít nhất một dấu chấm”. Đủ để lọc lỗi đánh máy; muốn biết địa chỉ có thực sự tồn tại thì phải gửi mail mới biết. Hình dáng dùng regex, sự tồn tại dùng email.
Tình huống 2 — Số điện thoại với định dạng quốc tế. +82 2-1234-5678, (02) 1234-5678, 82-2-1234-5678 là cùng một số Seoul viết khác nhau. Mẫu kiểu ^\+?\d{1,3}[-\s().]*\d{1,4}[-\s().]*\d{3,4}[-\s().]*\d{3,4}$ chấp nhận dấu câu thông thường, rồi bước chuẩn hoá sau đó xoá dấu câu để còn lại dạng chính tắc chỉ có số. Khi xử lý thực sự quan trọng (định tuyến, lưu trữ, quay số), hãy dùng libphonenumber của Google. Kết luận cuộc họp nhanh nhất tôi từng thấy ở một team là “không dùng regex cho cái này”, tránh được một tuần kỹ thuật nợ. Vai trò của regex chỉ là kiểm tra bề mặt “có trông giống số điện thoại không”.
Tình huống 3 — Trích trường từ một dòng log. Dòng như 2026-04-13T02:11:05Z 192.0.2.42 "GET /search?q=foo HTTP/1.1" 200 1534 có thể cắt bằng một mẫu: ^(?<ts>\S+)\s+(?<ip>\S+)\s+"(?<method>\w+)\s+(?<path>\S+)\s+\S+"\s+(?<status>\d+)\s+(?<bytes>\d+)$. Đây là nơi group có tên phát huy giá trị: object match trả về như dictionary và từng trường được truy cập theo tên. Khi format log đổi, chính mẫu này đóng vai trò tài liệu cho format.
Những hiểu lầm thường gặp
“Regex có thể xác thực email hoàn hảo.” Chỉ về hình dáng. RFC 5322 vượt ngoài mức có thể mã hoá hợp lý bằng regex, và dù mã hoá được thì “đúng hình dáng địa chỉ” không có nghĩa là “hộp thư tồn tại”. Tiêu chuẩn trong ngành là regex đơn giản + mail xác nhận.
“Tham lam luôn chậm hơn lười.” Không đúng. Nếu sub-pattern của quantifier rất hạn chế, match tham lam có thể kết thúc trong một lần tiến dài và nhanh hơn. Lười thắng khi mẫu phía sau neo match như <b>(.*?)</b>. Đừng phản xạ thêm ? trước khi benchmark bằng dữ liệu thực.
“Engine regex đều giống nhau.” Không. ECMAScript thiếu possessive quantifier và atomic group (cờ v cải thiện chút ít); Python re có tập unicode property riêng; PCRE hỗ trợ cả back-reference và pattern đệ quy. Oniguruma của Ruby và crate onig của Rust là biến thể khác. Go RE2 bỏ back-reference và look-around để đổi lấy đảm bảo thời gian chạy tuyến tính theo độ dài đầu vào — chính engine Cloudflare cân nhắc chuyển sang sau sự cố. Copy mẫu từ tutorial Perl mà không chạy được trên JavaScript là chuyện thường.
“Có thể parse HTML (hoặc JSON, XML) bằng regex.” Ngôn ngữ regular không mô tả được lồng cân đối. Regex hữu ích để trích mẫu con được định nghĩa rõ như giá trị của một attribute cụ thể, nhưng không thể parse đúng cả cây. Với định dạng lồng, dùng parser chuyên dụng (DOMParser, JSON.parse, thư viện XML, CSV reader). Truyền thuyết “regex vs HTML” trên Stack Overflow (câu trả lời 2009) không phải tranh luận — là cảnh báo.
Danh sách kiểm tra
- Đầu vào là gì và phản ví dụ là gì? Viết cả hai trước khi viết mẫu.
- Dữ liệu có cấu trúc lồng hoặc đệ quy không? Nếu có, dùng parser. Regex là sai công cụ.
- Nhắm engine nào? JS, Python, Go, PCRE khác nhau về look-around, back-reference, unicode.
- Bạn cần match hay chỉ cần yes/no? Group chỉ dành cho quantifier hoặc alternation thì dùng
(?:...)không capture. - Mẫu là input người dùng hay áp lên đầu vào không tin cậy? Dùng atomic group, timeout, hoặc engine thời gian tuyến tính như RE2 để chặn backtracking bùng nổ. Nếu không, bạn thành Cloudflare.
- Có bước chuẩn hoá text phía sau không? Đừng nhồi tất cả vào một mẫu; tách thành kiểm tra hình dáng đơn giản cộng hậu xử lý nhẹ.
- Regex có được ghi chú không? Comment trong chế độ
x(extended) hoặc một dòng mô tả ngay trên đó là bảo hiểm lớn cho người sau.
Công cụ liên quan
Trình test regex của Patrache Studio chạy mẫu trên input mẫu và hiển thị capture ngay tại chỗ, nhanh hơn việc nhảy qua lại giữa các tab. Nếu đối tượng match là chuỗi có cấu trúc như JSON trong log, hãy kết hợp với Khác biệt giữa format JSON, kiểm tra cú pháp và JSON Schema và kiểm tra đoạn đã trích bằng parser đúng chuẩn chứ không phải regex thứ hai. Một mục tiêu regex thường gặp là UUID nhúng trong URL; So sánh UUID v1, v4, v7 và thiết kế khoá chính DB cho biết tại sao cùng một chuỗi 36 ký tự lại mang ý nghĩa khác nhau theo bit version.
Tài liệu tham khảo
- MDN, hướng dẫn “Regular Expressions” — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions
- IETF RFC 5322, “Internet Message Format” (cú pháp email) — https://datatracker.ietf.org/doc/html/rfc5322
- regex101, tester tương tác cho phép chọn engine — https://regex101.com/
- Google, RE2 — engine regex thời gian tuyến tính — https://github.com/google/re2/wiki/Syntax