Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa
18/38
Bài 18 / 38~26 phútPattern matching & StringMiễn phí lượt xem

Case study — Lucene index & ClamAV signature

Hai hệ thống thật: Lucene dùng cấu trúc gì để search nhanh, và ClamAV quét triệu signature virus bằng Aho-Corasick ra sao.

TL;DR: ClamAV phải đối chiếu file người dùng tải về với hơn 8 triệu chữ ký virus — nếu dùng KMP chạy từng chữ ký, mỗi file cần hàng triệu lần duyệt. Aho-Corasick xây automaton một lần từ toàn bộ chữ ký, duyệt file một lần duy nhất và phát hiện mọi chữ ký khớp. Lucene giải quyết bài toán khác nhưng liên quan: với hàng triệu document, tra cứu term cần cấu trúc nhanh hơn hash table — FST (Finite State Transducer) nén toàn bộ term dictionary vào bộ nhớ cho O(|term|) lookup. Hai hệ thống dùng hai cấu trúc khác nhau vì bài toán khác nhau: ClamAV cần quét đồng thời nhiều pattern cố định, Lucene cần lookup nhanh trong tập term khổng lồ.

ClamAV
ClamAV

Aho-Corasick — quét đồng thời hơn 8 triệu chữ ký trong một lần đọc file

Lucene
Lucene

FST + inverted index — lookup term nhanh trong tập document khổng lồ

Phần A — ClamAV: Aho-Corasick cho multi-signature scanning

1. Vấn đề: 8 triệu chữ ký, mỗi file quét trong millisecond

ClamAV (Clam AntiVirus) là antivirus mã nguồn mở được dùng rộng rãi trong mail server và gateway. Cơ sở dữ liệu chữ ký virus của ClamAV chứa hơn 8 triệu signature (thực tế 2024-2025 đã vượt 10 triệu) — mỗi signature là một chuỗi byte đặc trưng xuất hiện trong file nhiễm virus.

Khi một file được tải qua mail server, ClamAV phải kiểm tra xem file đó có chứa bất kỳ signature nào không. Bài toán: tìm đồng thời nhiều pattern trong một text.

Phương án naive: chạy KMP cho từng signature → O(|file| × 8,000,000). Với file 1 MB và 8 triệu signature, đây là 8 × 10¹² phép so sánh — không thể thực hiện trong thời gian thực.

ClamAV thực tế phối hợp nhiều matcher — Aho-Corasick cho chữ ký đa mẫu (trọng tâm bài này), Boyer-Moore cho chữ ký đơn, hash table cho exact-match toàn file, và PCRE cho chữ ký dạng regex. Phần lõi quét đa mẫu — Aho-Corasick — giải bài toán bằng cách xây một automaton duy nhất từ toàn bộ tập signature, sau đó duyệt file một lần duy nhất:

  • Build phase — O(tổng độ dài các signature): xây trie từ tất cả pattern, sau đó tính failure link cho mỗi node (giống lps của KMP nhưng trên cây).
  • Search phase — O(|file| + số lần khớp): duyệt file một lần, automaton tự chuyển trạng thái theo từng byte đọc được.

Cho tập pattern {"he", "she", "his", "hers"}:

Trie của bốn pattern he, she, his, hers; mỗi node ghi kèm đích failure link của nó

Node có dấu * là node chấp nhận (output node) — khi automaton đến đây, một pattern đã được tìm thấy.

Failure link — tương tự failure function của KMP — chỉ đến node dài nhất là suffix của đường dẫn đến node hiện tại mà đồng thời cũng là prefix trong trie. Ví dụ: failure link của node she (path s-h-e) trỏ đến node he (path h-e) vì "he" là suffix dài nhất của "she" có trong trie.

Plain Text
function buildAhoCorasick(patterns):
    -- Bước 1: xây trie từ tất cả pattern
    root <- newNode()
    for each pattern P trong patterns:
        node <- root
        for each char c trong P:
            if node không có child c:
                node.children[c] <- newNode()
            node <- node.children[c]
        node.isOutput <- true
        node.outputPatterns.add(P)

    -- Bước 2: tính failure link bằng BFS
    Queue Q <- []
    for each child v của root:
        v.failure <- root
        Q.enqueue(v)

    while Q không rỗng:
        u <- Q.dequeue()
        for each (char c, child v) của u:
            -- failure của v = state đạt được khi follow failure link của u và đọc c
            f <- u.failure
            while f != root và f không có child c:
                f <- f.failure
            v.failure <- f.children[c] nếu có, không thì root
            -- Output link: kế thừa output từ failure chain
            v.outputLink <- v.failure nếu v.failure.isOutput, không thì v.failure.outputLink
            Q.enqueue(v)

// Time build: O(tổng_m × |alphabet|)  Space: O(tổng_m × |alphabet|)

3. Search phase — một lần duyệt file

Plain Text
function ahoCorasickSearch(text, root):
    results <- []
    state <- root

    for i from 0 to text.length - 1:
        c <- text[i]
        -- Chuyển trạng thái: follow failure link cho đến khi tìm được transition hợp lệ
        while state != root và state không có child c:
            state <- state.failure
        if state có child c:
            state <- state.children[c]
        -- Thu thập output: node hiện tại và toàn bộ output link chain
        node <- state
        while node != null:
            if node.isOutput:
                for each pattern P trong node.outputPatterns:
                    results.append((i - P.length + 1, P))
            node <- node.outputLink

    return results
// Time: O(n + tổng_m + k)  -- k = tổng số lần khớp

4. ClamAV mở rộng Aho-Corasick cho byte signature

Signature virus trong ClamAV không phải chuỗi ASCII đơn giản — chúng là chuỗi hex byte với wildcardrange:

Plain Text
-- Ví dụ ClamAV signature (dạng đơn giản):
-- "4D5A{10-80}50450000" nghĩa là:
--   4D 5A (MZ header của PE file)
--   rồi 10 đến 80 byte bất kỳ
--   rồi 50 45 00 00 (PE signature)

ClamAV xử lý bằng cách tách signature thành các segment cố định (phần không có wildcard), xây Aho-Corasick trên các segment, rồi khi tìm thấy segment, kiểm tra thêm điều kiện wildcard và vị trí tương đối.

Chữ ký có wildcard bị cắt thành hai đoạn cố định, automaton quét đoạn, rồi một bước kiểm lại khoảng cách giữa chúng

Automaton không hiểu wildcard và cũng không cần hiểu — nó chỉ trả về vị trí các đoạn cố định. Phần {10-80} được kiểm sau, trên vài vị trí tìm được chứ không phải trên cả file.

So sánh trực tiếp:

Phương phápThời gian quét 1 fileGhi chú
KMP từng signatureO(file
Aho-CorasickO(file

Thử ngẫmmột signature ClamAV mới có 5 đoạn wildcard xen kẽ thay vì 1. Bước kiểm khoảng cách sau khi automaton báo vị trí có còn rẻ như minh hoạ ở trên không?

Phần B — Lucene: FST cho term dictionary

5. Vấn đề: term dictionary của search engine

Apache Lucene là thư viện search engine nền tảng của Elasticsearch, Solr và nhiều công cụ tìm kiếm khác. Lucene index hoạt động theo mô hình inverted index: với mỗi term (từ), lưu danh sách document chứa term đó (posting list).

Để tìm kiếm term "algorithm" trong index chứa 100 triệu document:

  1. Tra term dictionary để tìm term "algorithm" và lấy offset của posting list.
  2. Đọc posting list — danh sách document ID chứa term.

Term dictionary của Lucene chứa hàng chục triệu term phân biệt. Cần cấu trúc cho phép:

  • Tra cứu chính xác (exact match) nhanh.
  • Prefix search (tất cả term bắt đầu bằng "algo...").
  • Tốn ít RAM (dictionary nằm on-disk, một phần được memory-map).

6. FST — Finite State Transducer cho term dictionary

Lucene dùng FST (Finite State Transducer) — một dạng automaton đặc biệt có thể vừa nhận diện chuỗi vừa map chuỗi sang giá trị. FST là trie được nén tối đa: các state được merge khi chúng có cùng "suffix behaviour".

Ví dụ trie naïve của {"mop", "moth", "pop", "star", "stop", "top"} có 18 node (kể cả root). FST nén xuống còn 10 state bằng cách nhận ra "mop", "pop", "top" có cùng suffix "op""stop" cũng kết thúc bằng "op" — các nhánh chia sẻ state S2 và S3.

FST mười state của tập từ mop, moth, pop, star, stop, top; state 2 được dùng chung cho bốn từ kết thúc bằng op

Chỗ tiết kiệm nằm ở đúng một state: mop, pop, topstop đều đi qua một nút "phần còn lại là op", thay vì mỗi từ dựng riêng một nhánh o → p. Đây cũng là lý do FST làm được prefix search mà hash table không làm được — cấu trúc còn giữ nguyên quan hệ tiền tố giữa các từ.

Trong FST thực tế của Lucene, mỗi transition cũng mang một "output" (giá trị tích luỹ) — đường đi từ state đầu đến state chấp nhận cho biết offset của posting list trong file .tim (term index).

Tại sao không dùng hash table? Hash table cho O(1) exact lookup nhưng:

  • Không hỗ trợ prefix search (tất cả term bắt đầu bằng "algo...").
  • Không hỗ trợ range query (tất cả term giữa "aab""aaz").
  • Tốn nhiều bộ nhớ hơn FST đáng kể (hash table phải lưu cả key string, còn FST chia sẻ prefix/suffix nên nén rất chặt).

FST với 100 triệu term chỉ chiếm cỡ 150-200 MB RAM — đủ nhỏ để memory-map hoặc giữ trong heap, lookup O(|term|) với constant nhỏ, mà vẫn hỗ trợ prefix/range query.

7. Posting list — bên dưới term dictionary

Khi đã tra được term trong FST, Lucene đọc posting list — danh sách (docID, tf, positions[]) trong file .doc:

Plain Text
term "algorithm" → posting list:
  doc 42:  tf=3, positions=[12, 47, 103]
  doc 157: tf=1, positions=[8]
  doc 891: tf=5, positions=[1, 4, 7, 11, 23]
  ...

Lucene nén posting list bằng delta encoding (lưu hiệu docID thay vì docID tuyệt đối, vì delta thường nhỏ) + VByte encoding (số nguyên nhỏ dùng ít byte hơn).

Chi tiết về inverted index, scoring (TF-IDF, BM25), và ranking sẽ được đào sâu trong Module 6 — Search engine. Module này chỉ chú trọng phần term lookup.

Thử ngẫmteam bạn build search engine nội bộ và chỉ cần tra đúng từ khoá, không cần prefix hay range query. FST của Lucene có còn đáng giá phần phức tạp thêm vào so với hash table không?

Vì sao chọn thuật toán nào

Cây chọn thuật toán: hỏi số pattern trước, rồi hỏi cần tất định hay tập pattern có cố định

Bài toánThuật toánLý do
Tìm 1 pattern trong file lớnKMPTất định O(n+m), không spurious hit
Tìm đồng thời triệu signature trong fileAho-CorasickMột lần duyệt O(n+tổng_m+k)
Tra cứu term trong search indexFSTPrefix search + memory-efficient
Nhiều pattern thay đổi liên tụcRabin-Karp multi-patternKhông cần rebuild automaton
Tìm kiếm có ranked scoringLucene inverted index + BM25Vượt ra ngoài exact string matching

Liên hệ các bài khác

  • KMP: failure function là nền tảng trực tiếp của Aho-Corasick — failure link trên trie chính là lps đa mẫu. Đọc KMP trước khi đọc phần ClamAV sẽ giúp phần failure link dễ hiểu hơn nhiều.
  • Aho-Corasick: bài concept đầy đủ về cấu trúc trie + failure link + output link. Case study này là ứng dụng thực tế của bài đó.
  • Rabin-Karp: khi số pattern thay đổi liên tục (không cố định), Aho-Corasick không phù hợp vì phải rebuild — Rabin-Karp multi-pattern hoặc Bloom filter phù hợp hơn.
  • Module 6 — Search engine (sắp ra): inverted index đầy đủ, BM25 scoring, shard và replica trong Elasticsearch — xây trực tiếp trên FST term dictionary giới thiệu ở đây.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    ClamAV có 8 triệu chữ ký. Tại sao chạy KMP từng chữ ký lần lượt không khả thi, trong khi Aho-Corasick giải quyết được?
  2. Q2
    Failure link trong Aho-Corasick là gì? Nó liên hệ với failure function (lps) của KMP như thế nào?
  3. Q3
    Lucene dùng FST thay vì hash table cho term dictionary. Hai trường hợp cụ thể nào mà FST vượt trội hash table?
  4. Q4
    Aho-Corasick có 'output link' ngoài failure link. Output link để làm gì?
  5. Q5
    ClamAV signature có wildcard như '4D5A{10-80}50450000'. Aho-Corasick chuẩn không xử lý được wildcard — ClamAV giải quyết bằng cách nào?

Bài tiếp theo: Module 2 — Tổng kết & cheat sheet

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Module 2 — Tổng kết & cheat sheet