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
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.
Ngân sách số nào cho tier search?
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?
- Index drift - search index không khớp Postgres. Nguyên nhân: outbox lỗi, drop event, sửa data tay. Phát hiện: job đối soát so số row mỗi giờ. Sửa: replay outbox hoặc full reindex.
- Query nóng - một query nặng (regex, aggregation sâu) làm đói cluster. Phòng: timeout query, log slow query, kill query dài.
- Đổi mapping - đổi type field cần full reindex. Phòng: index
alias (
products→products_v2), reindex sang v2, rồi swap alias atomically. - Áp lực JVM heap - Elasticsearch thích nhiều RAM và OOM tệ khi cấu hình quá nhỏ. Quy tắc thô: 50% máy cho JVM heap, 50% cho page cache OS.
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?
Khi nào Postgres FTS chạm giới hạn?
Sync search index với Postgres ra sao?
Elasticsearch hay OpenSearch?
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.