Khối xây dựng Nâng cao 5 phút đọc

Search trong .NET: Postgres FTS, Elasticsearch, OpenSearch

Khi query LIKE hết scale và cần search thật. Cách dây Postgres full-text search và Elasticsearch vào service .NET, kèm trade-off chọn cái nào.

Mục lục
  1. Khi nào search trong app vượt query ?
  2. Ngân sách số nào cho tier search?
  3. Kiến trúc tối thiểu trông thế nào?
  4. Cấu hình .NET 10 cho Postgres FTS?
  5. Cấu hình .NET 10 cho Elasticsearch?
  6. Thêm search tạo failure mode nào?
  7. Khi nào hạ tầng search là câu trả lời sai?
  8. Đi tiếp đâu từ đây?

Lần đầu WHERE name LIKE '%' || @q || '%' mất 3 giây trên bảng 5M row, bạn đã chạm giới hạn search. Chương này cho thấy nên rút gọn thành đâu - Postgres FTS cho phần lớn, Elasticsearch cho ca Postgres hết đường - và sync index với Postgres ra sao mà không mất một thứ Bảy.

Khi nào search trong app vượt query LIKE?

Ba tín hiệu.

Latency. LIKE '%term%' không dùng được B-tree index, nên Postgres scan tuần tự bảng. 100K row thì ổn; 5M row mất nhiều giây. Trigram index (pg_trgm) giúp, nhưng chỉ cho prefix ngắn.

Ranking. LIKE trả row theo thứ tự primary key, không theo chất lượng match. User mong "iPhone" xếp cao hơn "iPhone case wallet vintage style". Cần scoring, mà LIKE không có.

Trọng số nhiều field. Search "C# tutorial" nên đề cao match title hơn body và xếp post mới trên post cũ. Thủ công bằng SQL khả thi; bảo trì khi tính năng tăng là dự án mỗi quý.

Nếu không cái nào xảy ra, đừng thêm hạ tầng search. Một query LIKE cộng index GIN nhỏ trên tsvector phủ đuôi dài.

Backend                Index size    Latency p99      Phức tạp ops
Postgres FTS           tới ~10M      ~50-200 ms       miễn phí (cùng DB)
Elasticsearch (1 node) tới ~10M      ~10-50 ms        một cluster
Elasticsearch (cluster) ~100M+       ~10-50 ms        shard, JVM
OpenSearch (managed)   tới ~100M     ~20-100 ms       hoá đơn cloud
Algolia / Typesense    không giới hạn ~5-20 ms        SaaS lock-in

Postgres FTS miễn phí nếu đã chạy Postgres. Elasticsearch tốn cluster phụ, cộng overhead giữ sync. Algolia tốn tiền nhưng không tốn ops. Toán từ chương 2 quyết định bạn đáp ở đâu.

Kiến trúc tối thiểu trông thế nào?

Hai hình dạng. Chỉ Postgres:

flowchart LR
    App[ASP.NET Core] --> PG[(Postgres<br/>tsvector + GIN index)]

Postgres + Elasticsearch với outbox:

flowchart LR
    App[ASP.NET Core] --> PG[(Postgres)]
    PG --> Outbox[(Bảng Outbox)]
    Worker[Worker indexer] --> Outbox
    Worker --> ES[(Elasticsearch)]
    App -. đọc .-> ES

Write vào Postgres, cùng transaction insert outbox row, worker rút outbox và update Elasticsearch. Read vào Elasticsearch. Pattern outbox được xử lý ở chương 10; tái dùng ở đây.

Cấu hình .NET 10 cho Postgres FTS?

Thêm cột tsvector và index GIN, rồi dùng EF Core raw SQL cho search:

// Migration EF Core:
migrationBuilder.Sql("""
    ALTER TABLE products ADD COLUMN search_vector tsvector
        GENERATED ALWAYS AS (
            setweight(to_tsvector('english', coalesce(name,'')), 'A') ||
            setweight(to_tsvector('english', coalesce(description,'')), 'B')
        ) STORED;
    CREATE INDEX ix_products_search_vector ON products USING GIN (search_vector);
""");

// Query:
public async Task<List<Product>> SearchAsync(string query, CancellationToken ct)
{
    return await db.Products
        .FromSqlInterpolated($"""
            SELECT *, ts_rank(search_vector, plainto_tsquery('english', {query})) AS rank
            FROM products
            WHERE search_vector @@ plainto_tsquery('english', {query})
            ORDER BY rank DESC
            LIMIT 50
        """)
        .ToListAsync(ct);
}

Ba chi tiết. Generated column STORED nghĩa là vector tự bảo trì. Weight A (title) xếp cao hơn B (description), cho ranking cơ bản miễn phí. plainto_tsquery parse input user an toàn - không bao giờ ghép input thô vào query string.

Cấu hình .NET 10 cho Elasticsearch?

Hai phần: client và worker indexer.

// Program.cs - đăng ký client typed
builder.Services.AddSingleton<ElasticsearchClient>(_ =>
{
    var settings = new ElasticsearchClientSettings(
        new Uri(builder.Configuration["Elastic:Url"]!));
    return new ElasticsearchClient(settings);
});

// Consumer indexer (MassTransit + event outbox từ chương 10)
public class ProductChangedConsumer(ElasticsearchClient es)
    : IConsumer<ProductChanged>
{
    public async Task Consume(ConsumeContext<ProductChanged> ctx)
    {
        var doc = ctx.Message.ToProductDocument();
        await es.IndexAsync(doc, x => x
            .Index("products")
            .Id(doc.Id), ctx.CancellationToken);
    }
}

// Endpoint search
public async Task<List<ProductDocument>> SearchAsync(string q, CancellationToken ct)
{
    var resp = await es.SearchAsync<ProductDocument>(s => s
        .Index("products")
        .Query(qd => qd.MultiMatch(m => m
            .Query(q)
            .Fields(new[] { "name^3", "description" })
            .Fuzziness(new Fuzziness("AUTO"))
        ))
        .Size(50), ct);
    return resp.Documents.ToList();
}

Ba chi tiết. name^3 boost field name gấp ba lần description (tương đương setweight trong Postgres). Fuzziness bắt typo. Indexer phải idempotent - consumer trong chương 6 giải thích vì sao.

Thêm search tạo failure mode nào?

Khi nào hạ tầng search là câu trả lời sai?

Khi user filter chứ không search. Danh sách sản phẩm có facet với checkbox cho category và khoảng giá là filter - mệnh đề SQL WHERE trên cột có index. Không tsvector, không Elasticsearch, không ranking. Sai lầm coi filter là search làm phình hạ tầng mà không được lợi. Dùng search khi user gõ free text và mong ranking theo độ liên quan.

Đi tiếp đâu từ đây?

Chương kế tiếp: phong cách auth trong .NET - chọn giữa JWT và cookie auth ra sao, khi nào thêm OIDC, và vòng đời token nằm trong service .NET thật ra sao. Sau đó nhóm building blocks hoàn tất và chương reliability lắp lên.

Câu hỏi thường gặp

Sao không bắt đầu thẳng với Elasticsearch?
Chi phí vận hành. Chạy cluster Elasticsearch là một việc - tune JVM, rebalance shard, nâng version, câu chuyện backup riêng. Postgres FTS thêm một cột và GIN index vào database bạn đã vận hành. 80% service nhu cầu search đơn giản (tìm sản phẩm theo tên, search blog) tới đó miễn phí; để dành Elasticsearch cho ca Postgres FTS chạm giới hạn.
Khi nào Postgres FTS chạm giới hạn?
Ba tín hiệu: (1) cần ranking BM25 / TF-IDF và ranking tsvector của Postgres quá thô; (2) index vượt ~10M document và latency query xuống cấp; (3) cần faceting, autocomplete, geo, fuzzy match - các tính năng Elasticsearch ship còn Postgres chỉ xấp xỉ. Trước đó, FTS là câu trả lời đơn giản hơn.
Sync search index với Postgres ra sao?
Ba lựa chọn. (a) Đồng bộ khi write - đơn giản nhất, hỏng nếu index down. (b) Outbox + worker - pattern outbox chương 10 ghi cả row và event trong một transaction; worker reindex async. Đây là mặc định production. (c) CDC (Debezium / logical replication Postgres) stream thay đổi - mạnh nhưng nặng, chỉ đáng khi (b) không theo kịp.
Elasticsearch hay OpenSearch?
Phần lớn cùng engine - OpenSearch là fork sau khi Elasticsearch đổi license. Chọn OpenSearch trên AWS (managed integration), Elasticsearch trên Elastic Cloud hoặc self-host nơi khác. Client .NET (Elastic.Clients.Elasticsearch cho Elastic, OpenSearch.Client cho OpenSearch) gần như giống nhau. Lời khuyên kiến trúc chuyển được.