Cứ vài tháng lại có một trình phân tích HTML nhanh hơn xuất hiện, benchmark lại được share ầm ầm, rồi ai đó lên tiếng rằng thế hệ cũ đã “hết thời”. Nhưng đến lúc bạn cần chọn toàn bộ các đoạn văn có chứa một từ cụ thể, hoặc lấy phần tử cha của một node đã khớp, bạn sẽ lại nhớ vì sao lxml vẫn đang nằm mở ở một tab khác của mình.
lxml là một binding của libxml2 đã tồn tại 20 năm. Nó không màu mè. Nó không mới. Và với một nhiệm vụ rất cụ thể — bất cứ thứ gì cần XPath “xịn” — gần như không có lựa chọn nào khác trong hệ sinh thái Python phổ thông cạnh tranh nổi. Đây là bài review thực chiến về những gì nó làm được, chỗ nào nó thắng rất âm thầm, và vài điểm mặc định có thể khiến bạn “vấp” nếu không biết trước.
lxml trong một đoạn: Nó thực sự là gì?
lxml là một Python binding cho các thư viện C libxml2 và libxslt. Nó là trình phân tích và trình tuần tự hóa, không phải web scraper cũng không phải trình duyệt — nó biến markup thành một cây để bạn truy vấn, chỉnh sửa, rồi chuyển cây đó trở lại thành bytes. Nó cung cấp API tương thích ElementTree, một engine XPath 1.0 đầy đủ, XSLT 1.0 và xác thực schema, được Stefan Behnel duy trì với khẩu hiệu "thư viện giàu tính năng và dễ dùng nhất để xử lý XML và HTML trong Python".
Đây là trạng thái của nó, dựa trên ảnh chụp GitHub và PyPI lấy vào 2026-07-14:
| Hạng mục | Giá trị |
|---|---|
| Repo | lxml/lxml |
| Stars | 3,043 |
| Forks | 620 |
| Vấn đề đang mở | 16 |
| Giấy phép | BSD-3-Clause |
| Tạo lúc | 2011-02-11 |
| Lần push gần nhất | 2026-07-02 |
| Bản ổn định trên PyPI | 6.1.1 (2026-05-18) |
| Engine đi kèm | libxml2 2.14.6 + libxslt 1.1.43 |
Có một điều cần nói rõ trước khi ai đó nghĩ tôi đang “nói quá”: bài viết này không có bí kíp bí mật nào cả. lxml đã đủ lâu đời để mọi hành vi ở đây đều có tài liệu ở đâu đó trong tài liệu lxml, changelog của libxml2 hoặc một thread trên launchpad. Tôi không tìm ra “mẹo” nào chưa từng được công bố, và tôi cũng không định bịa ra một cái. Giá trị của phần dưới đây nằm ở chỗ nó được hệ thống hóa, định lượng và tổ chức xoay quanh lxml như đối tượng chính — chứ không phải vì nó mang tin nóng.
Thiết lập kiểm thử (và vì sao số đo thời gian là số được tái sử dụng)
Bài review này dùng hai nhóm dữ liệu, đến từ hai nguồn khác nhau, nên tôi sẽ nói rõ nhóm nào là nhóm nào.
Các bài kiểm thử năng lực — hành vi XPath, hai API parser, namespace, mã hóa, vòng đời node — tôi chạy mới trên một máy: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Mọi con số trong các file artifacts/raw/*.json đều do script tính ra, không nhập tay. Kiểm thử năng lực là các giá trị boolean và enum mang tính xác định, nên chỉ cần một lần chạy là ổn định — tải máy không làm thay đổi việc //a/@href trả về chuỗi thuộc tính hay không.
Các số đo thời gian và dung lượng bộ nhớ không đến từ bộ này. Chúng được dùng lại nguyên văn từ gói benchmark selectolax trước đó — cùng máy, cùng virtual environment, cùng bản build lxml và libxml2, benchmark tính đến 2026-07-13 — và tôi không chạy lại ở đây. Đó là chủ ý. Chạy benchmark thời gian đồng thời với một loạt script kiểm thử năng lực rất dễ tạo ra tranh chấp CPU làm “bẩn” số đo tái sử dụng, và như vậy sẽ thành bài làm lại: lxml vốn đã là thư viện đối chứng được đo đầy đủ trong gói kia rồi. Dùng lại cùng bộ đo giúp mọi thứ “cùng mặt bằng” thay vì tạo ra một phép đo thứ hai hơi khác đi một cách rất tinh vi. Vì vậy, khi bạn thấy một con số mili-giây bên dưới, hãy hiểu nó là “cùng bộ test, tính đến 2026-07-13”, chứ không phải “tôi vừa đo lại hôm nay”.
Các phát hiện đều có nhãn độ tin cậy: single-observation cho các kiểm thử năng lực xác định, triple-run cho các phân phối thời gian được tái sử dụng, và hypothesis khi tôi đang đề xuất một cơ chế chưa tách riêng được.
XPath: thứ duy nhất selectolax và BeautifulSoup không có
Đây là điểm nhấn, nên tôi sẽ bắt đầu từ đây.

Tôi đưa xpath() của lxml qua một ma trận 37 mục đã được đăng ký trước — kết quả kỳ vọng cho từng trường hợp đã được ghi vào mã nguồn trước khi chạy test, nên tôi không thể vô tình chấm “theo cảm tính”. Mười trục (axes), chín kiểu predicate, mười hàm dựng sẵn, ba kiểu trả về vô hướng, và năm trường hợp “bẫy” cố ý dùng cú pháp chỉ XPath 2.0, thứ mà engine XPath 1.0 của lxml đáng lẽ phải từ chối.
| Danh mục | Phạm vi | Kết quả |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 đạt |
| Predicates | [1] / last() / position()<n / so sánh thuộc tính / kiểm tra tồn tại thuộc tính / and / or / lồng [.//a] / not() | 9/9 đạt |
| Functions | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 đạt |
| Kiểu trả về | boolean / number scalars | 3/3 đạt |
| Trường hợp bẫy | matches() / sequences / if-then-else / except / lỗi cú pháp | 5/5 bị từ chối đúng |
Điểm số là 37/37, và cột “bẫy” mới là phần đáng nói. matches(), biểu thức sequence, if/then/else và except đều là cú pháp XPath 2.0, còn engine 1.0 của libxml2 không hỗ trợ kiểu nửa vời — nó ném XPathEvalError và dừng lại, thay vì âm thầm trả về một tập node sai. Vì vậy đây là điểm tuyệt đối sau khi đã thử phá nó, chứ không phải điểm tuyệt đối từ những câu hỏi dễ. Mọi hành vi ở đây đều đúng như tài liệu XPath của lxml mô tả, và đó chính là ý nghĩa.
Tôi cũng phải thừa nhận một chỗ harness chấm sai — nhưng theo cách khiến bạn mới có thể tin được 37/37. Bộ kỳ vọng ban đầu cho //div[.//a[@href]] dự đoán có hai kết quả; khi chạy thì chỉ có một. Tôi đã tưởng lxml sai trong khoảng ba mươi giây, rồi kiểm tra fixture và phát hiện phần tử thứ hai là <footer>, không phải <div> — tức là kỳ vọng của tôi sai, không phải engine. Tôi sửa bộ kỳ vọng và giữ lại ghi chú lỗi trong source. Đó mới là thứ tự đổ lỗi đúng: hãy nghi ngờ bài test của chính mình trước khi nghi ngờ một thư viện C 20 tuổi.
XPath vs CSS: những gì CSS không thể diễn đạt, dù muốn cũng không được
Mệnh đề “XPath mạnh hơn” cần một con số cụ thể, nên tôi định lượng luôn khoảng cách đó. lxml cho bạn cả .xpath() lẫn .cssselect() (cái sau chuyển CSS sang XPath ở dưới lớp). Tôi lấy mười mục tiêu truy vấn và kiểm tra xem CSS có diễn đạt được hay không.

| Mục tiêu | XPath | CSS (cssselect) |
|---|---|---|
Lọc theo nội dung văn bản (contains(text(),"bargain")) | Có | Không có text predicate |
Chọn phần tử cha từ phần tử con (//b/parent::p) | Có | Không có selector cha |
Trả về giá trị thuộc tính (//a/@href) | Có | Chỉ trả về element |
Trả về text node (//p/text()) | Có | Không có text node |
Ancestor axis (//td/ancestor::div) | Có | Không có điều hướng ngược lên trên |
Lọc phần tử cha theo số lượng con (//ul[count(li)=4]) | Có | Không có predicate đếm |
Lọc theo độ dài văn bản (string-length(text())>5) | Có | Không có predicate độ dài |
nth-child / last-child / adjacent sibling | Có | Có (3 baseline) |
Có bảy trong mười mục tiêu không có tương đương CSS nào cả. Lọc theo nội dung text, di chuyển ngược lên cha và tổ tiên, lấy trực tiếp giá trị thuộc tính hoặc một text node làm kết quả, các predicate dựa trên số lượng — CSS không diễn đạt được. Chỉ ba mục (nth-child, last-child, adjacent sibling) chạy được ở cả hai bên. Đó là câu trả lời đã được định lượng cho câu hỏi “tôi thực sự được gì khi chọn lxml”. selectolax chỉ dùng CSS và không hề có xpath(), nên bảy kiểu truy vấn kia либо phải biến thành các vòng lặp Python nhiều bước, либо không làm được. Nếu logic scraping của bạn dựa vào bất kỳ kiểu nào trong số đó, quyết định đã rất rõ rồi.
(Và đúng, harness bắt lỗi tôi thêm một lần nữa ở đây: tôi dự đoán tập rỗng cho string-length(text())>5, nhưng có hai chuỗi dài sáu ký tự khớp. Sửa kỳ vọng, không sửa công cụ.)
Ba chế độ “độ nghiêm”: etree, recover và lxml.html
XPath là lý do để chọn lxml. Ba mức kiểm soát độ nghiêm là lý do để tiếp tục giữ nó.

Phần lớn parser chỉ cho bạn một cách xử lý với input lỗi. lxml cho bạn ba cách, và chúng đủ dự đoán được để tôi nạp sáu nhóm markup lỗi qua từng chế độ rồi đăng ký sẵn cách mỗi đường đi phải phản ứng.
| Input lỗi | lxml.etree (nghiêm ngặt) | etree + recover=True | lxml.html (nới lỏng) |
|---|---|---|---|
Thẻ chưa đóng <root><a>x</root> | báo lỗi | tự khôi phục | chấp nhận |
Lồng sai <b><i></b></i> | báo lỗi | tự khôi phục | chấp nhận |
Entity không định nghĩa | báo lỗi | tự khôi phục | chấp nhận |
Dấu & trần (Tom & Jerry) | báo lỗi | tự khôi phục | chấp nhận |
Nhiều root <a>1</a><b>2</b> | báo lỗi | tự khôi phục | chấp nhận |
| XML hợp lệ | chấp nhận | chấp nhận (0 lỗi) | chấp nhận |
Thuộc tính boolean <input disabled> | báo lỗi | tự khôi phục | chấp nhận |
Bảy trên bảy trường hợp khớp đúng kỳ vọng đã đăng ký trước. lxml.etree ném XMLSyntaxError ở cả sáu nhóm lỗi. Thêm recover=True vào cùng parser, nó nuốt các lỗi đó và tái tạo một cây có thể dùng được — và phần đáng giá ở đây là parser.error_log sẽ liệt kê toàn bộ lỗi đã bị nó nuốt. lxml.html thì chấp nhận tất cả mà không than phiền.
Bộ phân loại quyết định “báo lỗi / được khôi phục / chấp nhận” cũng dựa trên độ dài error_log lúc chạy, chứ không hard-code, nên một tài liệu hợp lệ chạy với recover=True vẫn được gắn nhãn đúng là “chấp nhận” (log rỗng), chứ không phải “tự khôi phục”. Phiên bản đầu của bộ phân loại của tôi cứ coi hễ recover=True là “tự khôi phục” và gắn nhãn sai cho input sạch; đọc error_log thật đã sửa được điều đó.
Điều này có ích gì trong thực tế: khi bạn cần xác thực nghiêm ngặt và feed hỏng phải fail thật rõ, hãy dùng lxml.etree. Khi gặp HTML bẩn ngoài đời mà chỉ cần “nuốt” cho xong, hãy dùng lxml.html. Và trong trường hợp ở giữa mà hầu hết công cụ không làm được — “hãy nới lỏng, nhưng nói cho tôi biết chính xác chỗ nào hỏng để tôi ghi log” — hãy dùng recover=True rồi đọc error log. selectolax chỉ có chế độ nới lỏng, không có strict mode và cũng không có error log.
iterparse: chế độ streaming mà selectolax hoàn toàn không có
Đây là một năng lực, không phải một nút tăng tốc. selectolax chỉ nhận cả chuỗi đầu vào một lần — không có giao diện tăng dần. Còn iterparse của lxml trả ra element khi chúng đóng lại, và nếu ghép với mẫu fast_iter kinh điển (gọi elem.clear() và xóa các sibling trước đó khi đi tới), nó giữ bộ nhớ phẳng bất kể tài liệu lớn đến đâu.

Tôi đo trực tiếp đặc tính bộ nhớ — RSS đỉnh qua ru_maxrss, mỗi đối tượng chạy trong một process mới, trên 300,000 phần tử <record> với tổng dung lượng khoảng 26.7 MB (26,744,801 bytes).
| Chế độ | Chênh lệch RSS đỉnh | Ghi chú |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | giải phóng dần khi chạy; phẳng theo số lượng |
iterparse không clear | ~386 MB | giữ reference; nặng như load toàn bộ |
etree.parse (load toàn bộ, mốc đối chiếu) | ~386 MB | vốn đã nặng; chứng minh đồng hồ đo đang đọc đúng độ lớn |
Chế độ có clear giữ chênh lệch RSS đỉnh ở khoảng 1-2 MB so với mức load toàn bộ ~386 MB — chênh lệch cỡ 0.3-0.4% — và sự kiện record đầu tiên đã xuất hiện trước khi file đọc xong, nên đây là streaming thật, không phải streaming “giả”. Đường số đo đáng chú ý là hàng giữa. Chạy chính cùng vòng lặp iterparse nhưng bỏ clear(), bộ nhớ lại leo về ~386 MB, vì bạn đang giữ reference cho mọi thứ. Phần thắng nằm ở clear(), không phải ở iterparse tự thân. Mốc load toàn bộ cao vượt trội so với chế độ có clear cũng xác nhận rằng đồng hồ RSS thực sự nhìn thấy khác biệt lớn, chứ không đọc mù. (Bài test bộ nhớ này là một đo mới tôi chạy trong bộ này — đây là đo footprint, khác với các số đo thời gian được tái sử dụng.)
Phiên bản đời thực của điều này: một file XML xuất ra hàng gigabyte mà không thể nhét vừa RAM thì selectolax hoàn toàn không có đường đi. Hoặc dùng parser streaming của lxml, hoặc đổi sang ngôn ngữ khác.
Namespace: RSS, SVG và cái bẫy namespace mặc định
Mười hai trường hợp namespace, gồm RSS trên ba namespace, SVG với namespace mặc định cộng xlink, và XML dùng default-namespace. Cả mười hai đều qua.
lxml lấy //dc:creator/text() từ một RSS feed ra đúng ["Alice", "Bob"], phân giải //atom:link/@href và //content:encoded qua ba namespace riêng trong cùng tài liệu, xử lý //s:rect và //s:use/@xlink:href trong namespace thứ hai của SVG, tách tên Clark-notation {uri}local bằng QName, và introspect qua nsmap. Đây là hành vi đã được duy trì và có tài liệu, và nó là một chiều năng lực mà selectolax không chạm tới, vì selectolax chỉ phục vụ HTML5 và không xử lý namespace XML tùy ý.
Có một cái bẫy được tài liệu hóa mà bạn nên nhớ thật kỹ. XPath không có khái niệm namespace mặc định. Trỏ //book vào một tài liệu khai báo xmlns="urn:..." thì bạn nhận về 0 kết quả — tiền tố rỗng là không xác định trong XPath, như tài liệu lxml đã nói rõ. Bạn phải tự gán một prefix giả (//c:book với namespaces={"c": "urn:..."}, và nó tìm được cả ba phần tử) hoặc dùng //*[local-name()='book'] (cũng là ba). Không phải bug — đây là XPath spec, được triển khai đúng như vậy. Chỉ là nó làm mọi người bất ngờ đúng một lần.
Những trang web bẩn ngoài đời: độ trung thực trên 11 bản scrape thật
Các test tổng hợp thì sạch sẽ; web thì không. Tôi tái sử dụng mười một trang đã capture thật từ bộ fixture của pack selectolax (tính đến 2026-07-10, chỉ đọc) và đưa lxml.html qua chúng với lxml là đối tượng chính.
| Fixture | Kích thước | Số link | Số lỗi được libxml2 khôi phục | Strict XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | raised |
| wiki_scraping.html | 227 KB | 460 | 0 | raised |
| gov_whitehouse.html | 289 KB | 154 | 0 | raised |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | raised |
| forum_reddit.html | 129 KB | 318 | 0 | raised |
| docs_python.html | 80 KB | 341 | 2 | raised |
| ecommerce_books.html | 51 KB | 94 | 0 | raised |
| news_hackernews.html | 35 KB | 229 | 0 | raised |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | raised |
| spa_quotes_js.html | 6 KB | 5 | 0 | raised |
Cả mười một trang đều parse được bằng lxml.html, và số link, heading, image đều khớp với số đo lxml đã được tái sử dụng từ pack selectolax ở cả 11 trường hợp — cross-check true. Sự khớp đó cho tôi biết việc tái sử dụng là “cùng mặt bằng” thật sự, chứ không phải hai phép đo khác nhau nhưng dán cùng một nhãn.
Phát hiện phụ: parser XML nghiêm ngặt đã báo lỗi trên mười trong mười một trang. Trang web ngoài đời phần lớn không phải XML hợp lệ, và chính vì thế chế độ HTML recovery của libxml2 mới tồn tại để “nuốt” chúng. Ngoại lệ duy nhất là BBC News, được render bằng Next.js và đủ chuẩn để vượt qua strict XML parsing. Không phải cái gì gắn nhãn “HTML” cũng cần chế độ recovery.
Một lưu ý về cách đếm rất dễ nhầm. Trên docs_python.html, //a[@href] (tồn tại thuộc tính) đếm được 343, trong khi pack selectolax dùng if n.get("href") (giá trị truthy) đếm được 341. Hai link chênh lệch thêm là các href="" rỗng. Đó là khác biệt về quy ước đếm — thuộc tính có tồn tại hay thuộc tính có giá trị không rỗng — chứ không phải khác biệt hành vi của lxml, và số liệu sẽ khớp lại khi bạn thống nhất predicate. Điều này đáng nhớ khi scrape: href rỗng có được tính hay không là lựa chọn của filter, không phải của parser.
Giới hạn độ sâu trông như bug, nhưng không phải
Pack selectolax đã ghi nhận lxml bỏ mất nội dung sâu nhất trên markup <div> lồng 1,000 và 5,000 cấp, và mô tả nó như “lxml âm thầm làm mất nội dung sâu nhất”. Tôi muốn biết cơ chế nên đã chạy parser mặc định đối chiếu với huge_tree=True.

| Độ sâu yêu cầu | Parser mặc định tới được | huge_tree=True tới được |
|---|---|---|
| 300 | 253 (bỏ phần còn lại) | 299 (khôi phục) |
| 1000 | 253 (bỏ phần còn lại) | 999 (khôi phục) |
| 5000 | 253 (bỏ phần còn lại) | 2045 (vẫn bị cắt) |
Parser mặc định cắt ở khoảng 253 cấp và âm thầm bỏ qua phần sâu hơn. Đó không phải bug — đó là cơ chế phòng DoS của libxml2, một giới hạn lồng nhau khoảng 256 cấp để ngăn tài liệu độc hại làm tràn stack, và nó đã được tài liệu hóa trong thread lxml launchpad về XML_PARSE_HUGE. Bật huge_tree=True thì độ sâu 300 và 1,000 quay trở lại đầy đủ. Nhưng độ sâu 5,000 vẫn chỉ tới 2,045 ngay cả khi bật huge_tree — có một giới hạn đệ quy thứ hai, cứng hơn, nằm trên giới hạn có thể cấu hình, và huge_tree không gỡ được nó.
Vì vậy hành động cần làm rất cụ thể: khi bạn parse markup rất sâu từ một nguồn đáng tin, hãy dùng lxml.html.HTMLParser(huge_tree=True). Điều pack này bổ sung thêm so với quan sát tái sử dụng là cơ chế (một “hàng rào an toàn”, không phải hỏng dữ liệu), cách khắc phục (huge_tree), và thực tế rằng vẫn còn một ngưỡng thứ hai mà cách khắc phục đó không chạm tới.
DOM đọc/ghi, serialization, encoding
lxml là một cây đọc/ghi đầy đủ, không phải công cụ chỉ đọc, và tôi đã kiểm tra mặt chỉnh sửa theo từng trường hợp. Cả tám thao tác DOM đều đạt: SubElement, insert, remove, replace, strip_tags (bỏ thẻ nhưng giữ text), strip_elements (bỏ cả thẻ lẫn text của nó), drop_tree (riêng của lxml.html), và mô hình hai “ô” text/tail — thứ hay làm người mới lúng túng: trong <p>head<b>bold</b>tail</p>, p.text là "head", b.text là "bold", và b.tail là "tail".
Serialization cũng đạt 5/5: tostring ở chế độ XML và HTML (HTML giữ đúng phần tử rỗng mà không tự đóng), pretty_print, canonicalization C14N (method="c14n", một điểm riêng của lxml), và round-trip sạch sẽ.
Encoding là chỗ lxml lặng lẽ tách mình ra. Ném bytes không phải UTF-8 vào nó — "<p>café éè</p>".encode("latin-1") qua lxml.html.fromstring — và nó phục hồi nguyên café éè, không có ký tự thay thế U+FFFD, không mất byte nào. Điều này tái hiện trực tiếp vai trò “reference sạch” của nó trong pack selectolax, nơi cùng input đó bị hỏng ngầm dưới hai engine còn lại (Lexbor sinh ký tự thay thế, Modest bỏ luôn byte). Khả năng nhận diện charset của lxml dựa trên libxml2 đơn giản là ổn định hơn ở đây.
Mặt ngược lại là sự nghiêm ngặt với cách bạn khai báo encoding. encoding="latin-1" trong XML declaration sẽ ném XMLSyntaxError: Unsupported encoding: latin-1, trong khi tên chuẩn IANA encoding="ISO-8859-1" parse bình thường và trả về café. libxml2 chỉ chấp nhận tên mã hóa chuẩn, không chấp nhận alias — một chi tiết đã được ghi lại từ launchpad #613302. Khó chịu nếu bạn không biết, nhưng rất nhỏ khi bạn đã biết.
Cuối cùng là vòng đời node. Tôi chạy ba tình huống handle “cũ” trong các subprocess độc lập (nếu crash nặng xảy ra, process sẽ thoát với mã khác 0): giữ một node sau khi cây của nó bị garbage-collect, đọc một handle sau drop_tree(), và dùng một node sau remove(). Không có segfault trong bất kỳ trường hợp nào — lxml giữ tham chiếu của node tới cây của nó còn sống để tránh lỗi use-after-free. selectolax cũng có “phiếu sức khỏe sạch” ở bài test này.
Tốc độ và bộ nhớ (được tái sử dụng, và nói thật về nó)
Mọi thứ trong mục này đều được tái sử dụng từ pack selectolax, tính đến 2026-07-13. Bộ này không tạo ra con số thời gian nào của riêng nó, và tôi thích nói điều đó hai lần còn hơn để bạn tưởng tôi đã đo lại bất kỳ thứ gì.
| Hạng mục | Giá trị lxml | Cách đọc |
|---|---|---|
| Pure parse p50 (10 MB) | 77.9 ms | nhanh hơn selectolax-Lexbor khoảng 33-34% |
| Full parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | gần như ngang Lexbor ở kích thước nhỏ |
| Thông lượng CSS 100k node | 3,002,646 node/s | thuộc nhóm nhanh nhất trong ba engine C |
| Chênh lệch RSS 10 MB | 128.9 MB | tiết kiệm bộ nhớ nhất trong sáu parser, “gọn” hơn BeautifulSoup khoảng 1.7 lần |
| Thời gian import cold start | 14.1 ms | nhanh hơn import kiểu parsel khoảng 2.3 lần |
Các số pure-parse và throughput rất mạnh, và lxml là parser tiết kiệm bộ nhớ nhất trong sáu parser được đo. Tuy nhiên, bức tranh đa luồng cần một lưu ý. Dữ liệu tái sử dụng cho thấy tăng tốc wall-clock 4 thread chỉ 1.21x, và được đánh dấu là chưa kết luận — nhưng đó là đường đi dùng parser mặc định dùng chung. Câu hỏi thường gặp của lxml nói rất rõ rằng GIL chỉ được nhả trong lúc parse khi mỗi thread dùng parser riêng của mình (hoặc một bản copy của parser mặc định); dùng chung một parser thì sẽ tuần tự hóa truy cập. Tôi đã xác minh ở mức cấu trúc rằng API để làm đúng có tồn tại (XMLParser.copy(), get/set_default_parser, XPathEvaluator có lock nội bộ), nhưng tôi không đo tốc độ với parser riêng cho từng thread — vì đó sẽ là phép đo thời gian mới, mà bộ này không tạo ra loại số đó. Vì vậy hãy hiểu “1.21x” là “trên đường đi dùng chung ngây thơ”, chứ không phải trần threading của lxml.
Và còn một dấu sao cho tất cả các số liệu này: chúng là số đo trên một nền tảng duy nhất, macOS arm64. Khẳng định rằng pure parse của lxml vượt Lexbor đi ngược với quan điểm phổ biến rằng parser dựa trên Lexbor là nhanh nhất, nên nó thực sự cần được kiểm tra lại trên Linux x86_64 trước khi ai đó coi đó là kết luận cuối cùng.
Giấy phép: cú thắng “chán nhưng tốt”
lxml phát hành dưới BSD-3-Clause, và các thư viện C đi kèm — libxml2 và libxslt — đều là MIT. Đây là một chuỗi giấy phép hoàn toàn permissive, không có copyleft ở bất kỳ mắt xích nào, điều rất quan trọng ngay khi bạn có ý định phân phối lại. Để so sánh, wheel selectolax đóng gói Modest theo LGPL-2.1 và Lexbor theo Apache-2.0, nên lxml là câu chuyện “sạch” hơn nếu bạn ship trong một sản phẩm đóng.
Cũng có một lợi thế cài đặt rất thực tế: lxml phát hành sẵn wheel đã liên kết tĩnh libxml2 và libxslt, nên pip install lxml thường không cần libxml2 hệ thống và cũng không cần compiler trên máy bạn — một trải nghiệm khác hẳn so với tự build từ source.
lxml phù hợp ở đâu — và khi nào lớp trích xuất AI sẽ đảm nhận phần còn lại
Đã đến lúc làm rõ ranh giới, vì đây là một nhầm lẫn rất dễ mắc. lxml là một thư viện phân tích cú pháp. Nó đưa cho bạn một cây và một engine truy vấn rất mạnh, còn mọi thứ quanh cái cây đó vẫn là việc của bạn: lấy trang, render JavaScript, vượt anti-bot, viết và duy trì XPath, rồi format kết quả. Đó là một lớp khác so với dịch vụ trích xuất được host sẵn, và hai thứ này không hẳn là đối thủ mà giống hàng xóm hơn.
Với một developer không muốn tự gánh cả chuỗi fetch-render-select-maintain, lớp phía trên đó là nơi những thứ như Thunderbit tồn tại — và với nhóm độc giả này thì đó là API, MCP server và CLI, chứ không phải browser extension. Thunderbit Open API cung cấp POST /distill để biến một trang thành Markdown sạch và POST /extract để rút dữ liệu có cấu trúc dựa trên JSON Schema, có công tắc renderMode và batch jobs cho khối lượng lớn. Cùng engine đó cũng có sẵn dưới dạng MCP server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) cho agent và coding assistant, và dưới dạng CLI bạn có thể chạy thẳng trong terminal qua npx @thunderbit/thunderbit-cli. Nó xử lý render JS, anti-bot và CAPTCHA ngay từ đầu, rồi trả về JSON khớp schema — nghĩa là nó ở lớp phía trên parsing, không phải thay thế parsing.
Dùng thử Thunderbit để trích xuất dữ liệu web
Cách nhìn này rất đơn giản: chọn lxml khi bạn kiểm soát pipeline và muốn có XPath chính xác như dao mổ trên một cây bạn hiểu rõ. Chọn một API trích xuất AI khi bạn không muốn duy trì selector và rendering nữa. Nhiều hệ thống thực tế dùng cả hai — lxml cho các feed có cấu trúc mà họ kiểm soát, và dịch vụ trích xuất cho những trang dài đuôi, lộn xộn mà họ không kiểm soát.
Bài đánh giá này chưa kiểm tra những gì
Đây là một bài review tạm thời, không phải bảng chấm điểm cuối cùng, nên đây là những gì nó chưa bao phủ.
Mọi số liệu thời gian và bộ nhớ đều được tái sử dụng, chỉ trên một nền tảng (macOS arm64, Python 3.14), và mang theo các lưu ý của pack trước — kết quả “lxml nhanh hơn ở pure parsing” đi ngược đồng thuận và cần được kiểm tra lại trên Linux x86_64. Tăng tốc threading với parser riêng từng thread chưa được đo (vì sẽ cần timing mới). Tôi đã đo memory của iterparse trên 300k records nhưng chưa đo XML thật quy mô GB, chưa đo iterparse trên HTML so với XML, và cũng chưa làm bài soak test nhiều giờ. XSLT 1.0 của lxml, các bộ xác thực RelaxNG / XMLSchema / DTD, và phần mở rộng EXSLT hoàn toàn chưa được kiểm tra ở đây — đây là một bề mặt năng lực rất lớn, nhưng nằm ngoài lõi parsing và selection. Tôi quan sát được giới hạn độ sâu thứ hai ở 2,045 nhưng chưa xác định chính xác hằng số đệ quy của libxml2. Chỉ bản stable 6.1.1 được test, chưa phải alpha 7.0.0. Windows, build từ source, và bản free-threaded 3.14t cũng đều chưa được kiểm tra. Và trong chính XPath, tôi có kiểm tra built-in functions nhưng chưa kiểm tra biến XPath, custom Python extension functions hoặc việc tái sử dụng đối tượng etree.XPath đã biên dịch sẵn.
Kết luận
lxml không phải thứ mới nhất hay nhanh nhất, và chính điều đó lại là lý do nên chọn nó. Đây là một binding libxml2 đã sống qua hai thập kỷ, có engine XPath 1.0 đầy đủ mà không lựa chọn Python phổ thông nào khác sánh được, có ba chế độ kiểm soát độ nghiêm rất dễ đoán với error log ở giữa, có parser streaming thật cho tài liệu không vừa RAM, hỗ trợ multi-namespace và encoding đúng đắn, và có một giấy phép hoàn toàn permissive. Một vài điểm sắc cạnh — giới hạn độ sâu khoảng 253 cấp và con số threading khi dùng shared parser — đều đã được tài liệu hóa, có thể cấu hình, và giờ đã được giải thích rõ.
Nếu bạn tự sở hữu pipeline scraping của mình và phụ thuộc nhiều vào XPath, lxml vẫn là parser đáng chọn nhất. Nếu bạn không muốn duy trì selector và rendering nữa, đó là việc của một lớp trích xuất AI như Thunderbit API, MCP và CLI — một cách phân công công việc rõ ràng, không phải cuộc cạnh tranh. Dù chọn cách nào, hãy coi các con số này là tạm thời và kiểm tra lại timing trên nền tảng của bạn trước khi đưa chúng vào tài liệu thiết kế.
Dùng thử Thunderbit để trích xuất dữ liệu web Get Started Free
Câu hỏi thường gặp
lxml có phải là web scraper không?
Không. lxml là parser và serializer — một Python binding cho libxml2/libxslt, biến markup thành cây có thể chỉnh sửa và truy vấn. Nó không tự lấy trang, không render JavaScript, và cũng không xử lý anti-bot; bạn phải cung cấp lớp request (thông qua requests, httpx, trình duyệt headless hoặc một dịch vụ scraping) rồi đưa bytes vào lxml.
Khi nào nên dùng lxml thay vì BeautifulSoup hoặc selectolax? Hãy chọn lxml khi bạn cần XPath. BeautifulSoup thực ra có thể dùng lxml làm backend parser nhưng không cung cấp XPath native, còn selectolax chỉ dùng CSS và nhanh hơn trong phạm vi hẹp của nó. Nếu logic chọn của bạn cần lọc theo nội dung text, điều hướng lên cha hoặc tổ tiên, trích xuất thuộc tính/text node, hay predicate dựa trên số lượng, thì engine XPath của lxml là lựa chọn phổ thông trong Python có thể diễn đạt trực tiếp những thứ đó.
Vì sao lxml lại âm thầm bỏ nội dung lồng quá sâu?
Parser mặc định của nó giới hạn độ sâu lồng ở khoảng 253 cấp — đây là cơ chế phòng DoS của libxml2 chống tài liệu độc hại, không phải bug. Bật huge_tree=True (ví dụ lxml.html.HTMLParser(huge_tree=True)) thì nó khôi phục đầy đủ các độ sâu 300 và 1,000. Lưu ý vẫn còn một ngưỡng đệ quy cứng hơn quanh 2,045 cấp mà huge_tree không gỡ được.
lxml có nhả GIL khi parse đa luồng không? Chỉ trong điều kiện đúng. FAQ của lxml nêu rằng GIL được nhả trong lúc parsing khi mỗi thread dùng parser riêng hoặc một bản copy của parser mặc định; còn dùng chung một parser thì sẽ tuần tự hóa truy cập. Mức tăng tốc 4 thread 1.21x được tái sử dụng ở đây phản ánh đường đi dùng shared parser ngây thơ, không phải trần hiệu năng của parser riêng từng thread, và phần đó chưa được đo trong bài này.
lxml còn được duy trì trong năm 2026 không? Có. Bản stable 6.1.1 phát hành ngày 2026-05-18, repo được push lần cuối vào 2026-07-02, và hiện đang có alpha 7.0.0. Với khoảng 3,000 sao GitHub và libxml2 vẫn được duy trì tích cực bên dưới, đây vẫn là một thư viện hiện hành và được hỗ trợ tốt, không phải đồ cổ.


