Có một tính năng regex trông vô hại nhưng thực ra chạm tới ranh giới sâu nhất của lý thuyết tính toán: backreference (\1, \2...). Với nó, bạn viết được những mẫu "thông minh" như tìm từ bị gõ lặp hai lần hay khớp thẻ đóng đúng với thẻ mở. Nhưng cái giá phải trả không phải chuyện cú pháp — mà là: backreference khiến regex không còn là "regular" theo nghĩa toán học, và hệ quả trực tiếp là engine buộc phải dùng backtracking (mở đường cho ReDoS ở phần 6), còn các engine tuyến tính như RE2 (phần 7) thì từ chối hỗ trợ nó hoàn toàn. Bài này (phần 5 loạt Regex) đo thật cả hai mặt: sức mạnh của backreference trong Python, và sự từ chối thẳng thừng của Go.
Backreference: khớp lại đúng cái đã bắt
Khi bạn đặt một phần mẫu trong ngoặc (...), engine bắt (capture) đoạn văn bản khớp được vào một nhóm đánh số 1, 2, 3... Một backreference \1 sau đó nghĩa là: "khớp lại đúng chuỗi văn bản mà nhóm 1 đã bắt" — không phải khớp cùng mẫu, mà khớp đúng nội dung đã thấy. Vài ví dụ:
(\w+)\s+\1— bắt một từ, rồi đòi đúng từ đó xuất hiện lại: tìm từ gõ lặp nhưthe the.<(\w+)>.*?</\1>— bắt tên thẻ, rồi đòi thẻ đóng</...>mang đúng tên đó:<b>...</b>khớp,<b>...</i>thì không.(['"]).*?\1— bắt dấu mở (nháy đơn hoặc kép), rồi đòi dấu đóng đúng loại đã mở.
Điểm mấu chốt: backreference đòi engine nhớ một chuỗi độ dài tùy ý đã khớp trước đó, để so lại sau.

Hình 1: Backreference \1 khớp lại đúng chuỗi nhóm đã bắt (từ lặp, thẻ mở-đóng, nháy); vì đòi "nhớ chuỗi độ dài tùy ý" nên vượt khỏi máy trạng thái hữu hạn (ngôn ngữ {w w} không phải regular), buộc engine backtracking, và RE2 từ chối hỗ trợ.
Vì sao "không còn regular"
Đây là chỗ lý thuyết ngôn ngữ hình thức bước vào. Một biểu thức regular thuần (theo định nghĩa toán học) tương đương một máy trạng thái hữu hạn — bộ nhớ cố định, không phụ thuộc độ dài input. Nhưng "một chuỗi lặp lại chính nó" (ww, với w bất kỳ) không thể nhận diện bằng bộ nhớ cố định: để so nửa sau với nửa đầu, bạn phải nhớ nửa đầu, mà nửa đầu dài tùy ý. Ngôn ngữ {ww} (giống aⁿbⁿ quen thuộc) không phải ngôn ngữ regular — điều này chứng minh được bằng bổ đề bơm (pumping lemma).
Nên khi một "regex" có backreference, nó mạnh hơn lớp ngôn ngữ regular. Không có DFA hữu hạn nào biểu diễn được nó, và engine buộc phải dùng cơ chế thử-và-quay-lui (backtracking): thử một khả năng bắt của nhóm, đi tiếp, nếu \1 không khớp thì quay lui thử khả năng khác. Chính cơ chế backtracking bắt buộc này là mầm mống của thảm họa hiệu năng ở phần 6.
Đo thật: Python làm được, Go RE2 từ chối

Hình 2: Chạy thật — python (\b\w+)\s+\1\b bắt ['the','on','mat']; <(\w+)>.*?</\1> cho ['b','i','b']; ([\'"]).*?\1 khớp "..." và '...' nhưng không khớp khi mở " đóng '; Go regexp.Compile với \1 báo invalid escape sequence: \1, còn mẫu không backreference cho err=`.
- Python bắt từ lặp và thẻ khớp:
(\b\w+)\s+\1\btrênthe the cat sat on on the mat matcho đúng['the','on','mat']— mỗi từ bị gõ đôi.<(\w+)>.*?</\1>cho['b','i','b']: mỗi thẻ đóng khớp đúng thẻ mở. Và([\'"]).*?\1khớp"xin chao"(mở"đóng") và'don', nhưng không khớp"xin chao'— vì\1đòi dấu đóng đúng là", không phải'. Không có backreference thì không cách nào diễn đạt các ràng buộc "phải trùng nhau" này. - Go RE2 từ chối thẳng:
regexp.Compile((\w+)\s+\1)trả lỗi thậterror parsing regexp: invalid escape sequence:\1``. RE2 không có khái niệm backreference nên coi\1là escape không hợp lệ. Để đối chiếu, mẫu không backreference(\w+)\s+\w+biên dịch bình thường (err=<nil>). Đây không phải thiếu sót — đó là đánh đổi có chủ đích để giữ cam kết chạy tuyến tính (phần 7).
Đánh đổi cần cân nhắc
Backreference tiện, nhưng thường là dấu hiệu bạn cần một parser. Dùng <(\w+)>.*?</\1> để khớp một cặp thẻ phẳng thì ổn; nhưng nếu dữ liệu có thẻ lồng (<b><b>...</b></b>), backreference vẫn bó tay vì không đếm được độ sâu (giới hạn cùng loại với phần 2). Khi ràng buộc "phải trùng nhau" bắt đầu chồng lên "phải lồng đúng", đó là lúc bỏ regex mà dùng parser thật.
Backreference mở cửa cho ReDoS. Vì buộc backtracking, một mẫu có backreference (hoặc chỉ cần backtracking nói chung) có thể bị đầu vào độc làm bùng nổ thời gian mũ — chủ đề chính của phần 6. Nếu bạn chạy regex trên đầu vào từ người dùng (biểu mẫu, request), cân nhắc dùng engine tuyến tính (RE2) và chấp nhận mất backreference, đổi lấy an toàn.
\1 khi thay thế khác \1 khi khớp. Trong nhiều công cụ (sed, re.sub), \1 ở phần thay thế nghĩa là "chèn lại nhóm 1 đã bắt" — đó là chuyện bình thường, không liên quan tính regular và không cần backtracking (chỉ là dán lại text). Đừng nhầm hai vai trò: \1 trong mẫu là backreference (phá vỡ regular), \1 trong chuỗi thay thế chỉ là tham chiếu nhóm để nội suy (phần 10 sẽ nói kỹ).
Ba ý mang về
- Backreference khớp lại đúng chuỗi đã bắt: đo thật
(\b\w+)\s+\1\bbắt từ lặp['the','on','mat'],<(\w+)>.*?</\1>khớp thẻ đóng đúng thẻ mở['b','i','b'], và([\'"]).*?\1đòi dấu đóng trùng dấu mở — những ràng buộc "phải trùng nhau" không viết được nếu thiếu nó. - Nó khiến regex "không còn regular": "nhớ chuỗi độ dài tùy ý" vượt khỏi máy trạng thái hữu hạn (
{ww}không phải ngôn ngữ regular theo bổ đề bơm), nên engine buộc phải backtracking — mầm mống của ReDoS. - Engine tuyến tính từ chối nó: đo thật Go RE2 trả
invalid escape sequence:\1`` khi biên dịch, trong khi mẫu không backreference choerr=<nil>— đánh đổi có chủ đích để giữ tuyến tính; nếu chạy regex trên đầu vào người dùng, cân nhắc mất backreference để đổi lấy an toàn.
Nguồn
- Russ Cox — Regular Expression Matching Can Be Simple And Fast (backreference & NP): https://swtch.com/~rsc/regexp/regexp1.html
- Python docs — re (backreferences, groups): https://docs.python.org/3/library/re.html
- Go — RE2 syntax (Why RE2 doesn't support backreferences): https://github.com/google/re2/wiki/Syntax
Phần sau ta đo tận mắt mặt tối của backtracking: catastrophic backtracking và ReDoS — một mẫu tưởng vô hại như (a+)+$ gặp đúng input độc có thể treo cả CPU, và ta sẽ đo thời gian nổ theo cấp số nhân.