ELASTICSEARCH & GOLANG
MỤC LỤC
PHẦN 1: NỀN TẢNG VỮNG CHẮC - TƯ DUY KIẾN TRÚC & CÔNG CỤ
Chương 1: Tại Sao Lại Là Elasticsearch? Góc Nhìn Của Một Kiến Trúc Sư
- 1.1. Thế giới trước khi có Elasticsearch: Nỗi đau của
LIKE '%...%' - 1.2. Elasticsearch không phải là một Database - So sánh chuyên sâu
- So sánh với RDBMS (PostgreSQL, MySQL)
- So sánh với các NoSQL khác
- 1.3. Các Usecase Vàng của Elasticsearch trong thực tế
- Full-text Search, Logging & Analytics (ELK/EFK), APM, Recommendation Engine, Geospatial Search
- 1.4. Khi nào KHÔNG nên dùng Elasticsearch? Những sai lầm chết người
- 1.1. Thế giới trước khi có Elasticsearch: Nỗi đau của
Chương 2: Giải Phẫu Elasticsearch - Các Khái Niệm Cốt Lõi Phải Nằm Lòng
- 2.1. Từ vật lý đến logic: Cluster, Node, Shard, Replica
- 2.2. Tổ chức dữ liệu: Index, Document, và sự ra đi của "Type"
- 2.3. Quá trình Phân tích (Analysis): Phép thuật đằng sau Full-text Search (Character Filters, Tokenizer, Token Filters)
- 2.4. Inverted Index (Chỉ mục ngược): Vũ khí tối thượng
Chương 3: Dựng Chiến Trường - Thiết Lập Môi Trường Phát Triển Chuẩn Production
- 3.1. Docker & Docker Compose: Người bạn đồng hành không thể thiếu
- 3.2. Cấu hình
docker-compose.ymlchi tiết cho Elasticsearch và Kibana - 3.3. Khởi tạo dự án Golang Echo và cấu trúc thư mục chuẩn
Chương 4: Kết Nối Đến Elasticsearch Bằng Golang - The Official Go Client
- 4.1. Các kiểu kết nối: Single-node, Multi-node, Authentication
- 4.2. Thiết kế Singleton Client: Best practice trong mọi dự án
- 4.3. Kiểm tra "sức khỏe" của Cluster (
Ping,Info,Health)
PHẦN 2: THAO TÁC CƠ BẢN - CRUD & LẬP CHỈ MỤC DỮ LIỆU
Chương 5: Quản Lý Index - Xây Dựng "Ngôi Nhà" Cho Dữ Liệu
- 5.1. Struct Golang và Mapping trong Elasticsearch
- 5.2. Tạo một Index: Cách làm chuyên nghiệp với Mapping và Settings tùy chỉnh
- Phân tích sâu về
settings:number_of_shards&number_of_replicas - Phân tích sâu về
mappings: Các kiểu dữ liệu (text,keyword,nested...) và multi-fields
- Phân tích sâu về
- 5.3. Các thao tác khác với Index: Exists, Delete
Chương 6: Thao Tác Với Document - Thêm, Sửa, Xóa, Lấy Dữ Liệu
- 6.1. Indexing a Document (Create & Update) và Optimistic Concurrency Control
- 6.2. Lấy một Document (Get API)
- 6.3. Cập nhật một Document (Update API) với Partial Update
- 6.4. Xóa một Document (Delete API)
- 6.5. Tăng tốc với Bulk API - Kỹ thuật bắt buộc cho hiệu năng cao
- Cấu trúc request Bulk, xây dựng trong Golang và xử lý lỗi chi tiết
PHẦN 3: TRÁI TIM CỦA ELASTICSEARCH - NGHỆ THUẬT TÌM KIẾM
Chương 7: Query DSL - Ngôn Ngữ Giao Tiếp Với Cỗ Máy Tìm Kiếm
- 7.1. Cấu trúc của một Search Request (
query,from,size,sort,_source...) - 7.2. Khái niệm cốt lõi: Query Context và Filter Context - Chìa khóa tối ưu hiệu năng
- 7.1. Cấu trúc của một Search Request (
Chương 8: Các Truy Vấn Full-text - Tìm Kiếm "Giống Như Google"
- 8.1.
matchquery: Người lính đa năng - 8.2.
match_phrasequery: Tìm kiếm cụm từ chính xác - 8.3.
match_phrase_prefixquery: Gợi ý tìm kiếm (search-as-you-type) - 8.4.
multi_matchquery: Tìm kiếm trên nhiều trường (phân tíchbest_fields,most_fields...)
- 8.1.
Chương 9: Các Truy Vấn Term-level - Tìm Kiếm Dữ Liệu Có Cấu Trúc
- 9.1. Sự khác biệt chết người giữa
textvàkeyword - 9.2.
termvàtermsquery - 9.3.
rangequery - 9.4.
existsquery - 9.5.
wildcardvàregexp(và lời cảnh báo khi sử dụng)
- 9.1. Sự khác biệt chết người giữa
Chương 10: Truy Vấn Kết Hợp - The Almighty
boolQuery- 10.1. Cấu trúc 4 mệnh đề:
must,filter,should,must_not - 10.2. Xây dựng truy vấn phức tạp trong Golang - Ví dụ E-commerce thực chiến
- 10.1. Cấu trúc 4 mệnh đề:
PHẦN 4: AGGREGATIONS - BIẾN DỮ LIỆU THÀNH TRI THỨC
Chương 11: Tư Duy Aggregation - Buckets và Metrics
- 11.1. Buckets (Những chiếc xô): Khái niệm phân loại
- 11.2. Metrics (Các chỉ số): Khái niệm tính toán
- 11.3. Lồng ghép Aggregations và tối ưu với
"size": 0
Chương 12: Bucket Aggregations - Nghệ Thuật Phân Loại
- 12.1.
TermsAggregation: Tương đươngGROUP BY - 12.2.
Range&Date RangeAggregation: Phân loại theo khoảng tùy chỉnh - 12.3.
Histogram&Date HistogramAggregation: Phân loại theo khoảng cố định - 12.4.
NestedAggregation: Xử lý dữ liệu lồng nhau
- 12.1.
Chương 13: Metric Aggregations - Các Phép Toán Trên Dữ Liệu
- 13.1. Các Metric cơ bản:
Sum,Avg,Min,Max - 13.2.
Stats&Extended Stats: Lấy nhiều chỉ số trong một lần - 13.3.
CardinalityAggregation: Đếm số lượng giá trị duy nhất (gần đúng)
- 13.1. Các Metric cơ bản:
Chương 14: Xây Dựng Báo Cáo Phức Tạp Bằng Code Golang
- 14.1. Ví dụ thực chiến: Xây dựng API báo cáo dashboard với Aggregation lồng 3 cấp
PHẦN 5: TÍCH HỢP & TỐI ƯU HÓA TRONG DỰ ÁN GOLANG ECHO
Chương 15: Tích Hợp Elasticsearch vào Echo Framework - Xây Dựng API Toàn Diện
- 15.1. Thiết lập Server Echo và Dependency Injection
- 15.2. Xây dựng Tầng Service và Handler cho Product
- 15.3. Hoàn thiện các API endpoint: Create, Get, Search, Bulk, Dashboard
Chương 16: Tối Ưu Hóa Hiệu Năng - Tư Duy Của Một DBA 30 Năm Kinh Nghiệm
- 16.1. Tối Ưu Hóa Thiết Kế Mapping (Schema)
- 16.2. Tối Ưu Hóa Truy Vấn:
search_aftercho deep pagination,_sourcefiltering - 16.3. Tối Ưu Hóa Indexing: Tinh chỉnh Bulk Request,
refresh_interval,number_of_replicas - 16.4. Tối Ưu Hóa Kiến Trúc và Vận Hành Cluster: JVM Heap Size, Sharding Strategy, Time-based Indices
PHẦN 6: CÁC CHỦ ĐỀ NÂNG CAO & BEST PRACTICES CUỐI CÙNG
Chương 17: Mapping Chuyên Sâu - Hơn Cả Các Kiểu Dữ Liệu Cơ Bản
- 17.1. Analyzer Tùy Chỉnh (Custom Analyzer) cho Tiếng Việt
- 17.2. Kiểu dữ liệu
search_as_you_typecho gợi ý tìm kiếm tức thì - 17.3.
Dynamic Templatesđể kiểm soát dữ liệu không đồng nhất
Chương 18: Quản Lý và Vận Hành Cluster - Những Công Cụ Hữu Ích
- 18.1. Các API Chẩn Đoán: Cat APIs, Node Stats, Explain API
- 18.2. Index Lifecycle Management (ILM) để tự động hóa vòng đời dữ liệu
- 18.3. Reindexing và Aliases: Nâng cấp mapping không downtime
Chương 19: Các Mẫu Thiết Kế (Design Patterns) và Anti-Patterns
- 19.1. Mẫu Thiết Kế Tốt: Denormalization, Data Synchronization, Circuit Breaker
- 19.2. Các Anti-Patterns Cần Tránh: ES as Primary Datastore, Joining in Application Layer
Chương 20: Tổng Kết - Con Đường Trở Thành Chuyên Gia
- 20.1. Những điểm cốt lõi cần khắc cốt ghi tâm
- 20.2. Lời khuyên cho việc thực hành và phát triển chuyên môn
PHẦN 1: NỀN TẢNG VỮNG CHẮC - TƯ DUY KIẾN TRÚC & CÔNG CỤ
Chương 1: Tại Sao Lại Là Elasticsearch? Góc Nhìn Của Một Kiến Trúc Sư
Trước khi chúng ta viết một dòng code Go nào, điều tối quan trọng là phải hiểu rõ "tại sao". Lựa chọn sai công cụ cho một bài toán cũng giống như cố gắng đóng một chiếc đinh bằng tua-vít vậy. Nó có thể hoạt động, nhưng sẽ rất vất vả và kết quả thì thảm hại.
1.1. Thế giới trước khi có Elasticsearch: Nỗi đau của LIKE '%...%'
Tôi đã sống qua cái thời mà tính năng tìm kiếm trên một trang web đồng nghĩa với một câu lệnh SQL như thế này:
SELECT * FROM products WHERE name LIKE '%apple macbook pro%';Nhìn có vẻ đơn giản, nhưng đằng sau nó là một cơn ác mộng về hiệu năng và trải nghiệm người dùng:
- Hiệu năng tồi tệ:
LIKEvới ký tự%ở đầu sẽ buộc database phải thực hiện một "full table scan", tức là đọc qua từng dòng trong bảngproductsđể so sánh. Với một bảng có vài triệu sản phẩm, query này có thể mất hàng chục giây, thậm chí hàng phút. Nó không thể sử dụng index một cách hiệu quả. - Không có Relevancy (Sự liên quan): Kết quả trả về không được sắp xếp theo mức độ liên quan. Một sản phẩm có tên "Ốp lưng cho Apple Macbook Pro" có thể xuất hiện trước sản phẩm "Apple Macbook Pro 16 inch M2". Database không hiểu được rằng người dùng đang tìm kiếm "máy tính" chứ không phải "ốp lưng". Nó không có khái niệm về "điểm số liên quan" (
relevancy score). - Không linh hoạt:
- Làm sao để xử lý lỗi chính tả? (gõ "macbok" thay vì "macbook")
- Làm sao để xử lý từ đồng nghĩa? ("điện thoại" và "smartphone")
- Làm sao để tìm kiếm theo nhiều trường (tên, mô tả, danh mục) và cho điểm khác nhau cho mỗi trường? (từ khóa xuất hiện trong tên quan trọng hơn trong mô tả)
- Làm sao để xử lý các biến thể của từ? (tìm "running" ra cả "run" và "ran")
Tất cả những vấn đề này đều bắt nguồn từ một sự thật cơ bản: Cơ sở dữ liệu quan hệ được thiết kế để lưu trữ và truy xuất dữ liệu có cấu trúc một cách chính xác, không phải để tìm kiếm văn bản tự do một cách thông minh.
Elasticsearch ra đời để giải quyết chính xác nỗi đau này. Nó được xây dựng từ đầu dựa trên thư viện tìm kiếm cực kỳ mạnh mẽ là Apache Lucene. Thay vì quét từng dòng, nó sử dụng một cấu trúc dữ liệu gọi là Inverted Index (Chỉ mục ngược), cho phép nó tìm thấy các tài liệu chứa một từ khóa gần như ngay lập tức, bất kể bạn có bao nhiêu tài liệu.
1.2. Elasticsearch không phải là một Database - Hãy khắc cốt ghi tâm!
Đây là sai lầm phổ biến nhất mà các lập trình viên trẻ thường mắc phải. Họ thấy Elasticsearch lưu trữ dữ liệu JSON, có API để thêm/sửa/xóa và nghĩ rằng nó có thể thay thế hoàn toàn cho PostgreSQL hay MongoDB. Đây là một tư duy cực kỳ nguy hiểm.
Hãy coi Elasticsearch là một cỗ máy tìm kiếm và phân tích dữ liệu, một chỉ mục thứ cấp (secondary index) chuyên biệt cho dữ liệu của bạn. Nguồn dữ liệu chính thống (Source of Truth) của bạn VẪN NÊN nằm ở một hệ quản trị cơ sở dữ liệu truyền thống (RDBMS hoặc một NoSQL phù hợp khác).
So sánh với RDBMS (PostgreSQL, MySQL):
| Tính năng | RDBMS (PostgreSQL) | Elasticsearch | Ghi chú của Kiến trúc sư |
|---|---|---|---|
| Mục đích chính | Lưu trữ dữ liệu có cấu trúc, đảm bảo tính toàn vẹn (ACID transactions). | Tìm kiếm full-text, phân tích, tổng hợp dữ liệu. | Dùng đúng công cụ cho đúng việc. |
| Schema | Ràng buộc chặt chẽ (Schema-on-write). Phải định nghĩa bảng trước. | Linh hoạt (Schema-on-read), có thể tự động nhận diện schema. | Mặc dù linh hoạt, nhưng trong production, bạn luôn phải định nghĩa mapping (schema) rõ ràng cho Elasticsearch để đảm bảo hiệu năng và tính chính xác. |
| Transactions | Hỗ trợ ACID transaction mạnh mẽ. | Không hỗ trợ transaction ACID trên nhiều document. | Đây là lý do lớn nhất không thể dùng ES làm primary datastore cho các hệ thống nghiệp vụ (ngân hàng, TMĐT). Hãy tưởng tượng việc chuyển tiền bị lỗi giữa chừng! |
| Consistency | Strong Consistency (nhất quán mạnh). | Near Real-Time (NRT) & Eventual Consistency. | Dữ liệu sau khi index cần một khoảng thời gian nhỏ (mặc định 1 giây) để có thể được tìm thấy. Điều này chấp nhận được với tìm kiếm, nhưng không chấp nhận được với các nghiệp vụ yêu cầu đọc ngay sau khi ghi. |
| Joins | Hỗ trợ join các bảng rất mạnh mẽ. | Hạn chế. Có nested objects, parent-join nhưng phức tạp và không hiệu quả bằng RDBMS. | Tư duy khi thiết kế document trên ES là denormalization (phi chuẩn hóa). Thay vì join, bạn sẽ nhồi thêm dữ liệu liên quan vào cùng một document. Ví dụ: document product sẽ chứa cả thông tin category_name thay vì chỉ category_id. |
| Truy vấn | Ngôn ngữ SQL. | Query DSL (dựa trên JSON). | Query DSL mạnh mẽ hơn SQL rất nhiều cho các bài toán tìm kiếm và phân tích. |
Luồng dữ liệu chuẩn trong một hệ thống:
Client -> Go Service (Echo) -> Ghi vào PostgreSQL (Source of Truth) -> Ghi vào Elasticsearch (cho mục đích tìm kiếm)
Việc đồng bộ dữ liệu từ PostgreSQL sang Elasticsearch có thể được thực hiện bằng nhiều cách:
- Dual Write: Ghi đồng thời vào cả hai. Đơn giản nhưng có thể gây mất nhất quán nếu một trong hai lần ghi thất bại.
- Asynchronous Messaging: Ghi vào PostgreSQL, sau đó publish một message vào message queue (RabbitMQ, Kafka), một service khác (worker) sẽ consume message và ghi vào Elasticsearch. Đây là cách làm bền vững và được khuyên dùng.
- Change Data Capture (CDC): Sử dụng các công cụ như Debezium để theo dõi log của PostgreSQL và tự động đẩy thay đổi sang Elasticsearch. Đây là cách làm hiện đại và mạnh mẽ nhất.
1.3. Các Usecase Vàng của Elasticsearch trong thực tế
- Full-text Search: Đây là lý do tồn tại của Elasticsearch. Bất cứ đâu có ô tìm kiếm, từ trang e-commerce, blog, hệ thống tài liệu, đến tìm kiếm trong source code... Elasticsearch đều tỏa sáng.
- Logging & Analytics (The ELK/EFK Stack):
- ELK: Elasticsearch - Logstash - Kibana.
- EFK: Elasticsearch - Fluentd - Kibana.
- Đây là bộ ba huyền thoại để thu thập, lưu trữ, tìm kiếm và trực quan hóa log từ hàng ngàn server. Thay vì phải SSH vào từng máy để
greplog, bạn có thể ngồi ở Kibana và tìm kiếm, lọc, tạo dashboard trên toàn bộ log của hệ thống trong vài giây.
- Metrics & APM (Application Performance Monitoring): Elastic APM là một sản phẩm của Elastic cho phép bạn theo dõi hiệu năng ứng dụng (thời gian response của API, các query đến database, lỗi...), gửi dữ liệu về Elasticsearch và trực quan hóa trên Kibana. Nó giúp bạn nhanh chóng xác định các điểm nghẽn trong hệ thống.
- Hệ thống Gợi ý (Recommendation Engine): Elasticsearch có thể là bước đệm rất tốt để xây dựng các tính năng gợi ý đơn giản như "Các sản phẩm tương tự" dựa trên các truy vấn
more_like_thishoặc cácboolquery phức tạp để tìm các sản phẩm có cùng thuộc tính. - Tìm kiếm không gian địa lý (Geospatial Search): Tìm kiếm các địa điểm trong một bán kính, trong một vùng đa giác, sắp xếp kết quả theo khoảng cách... Elasticsearch xử lý các bài toán này cực kỳ hiệu quả với kiểu dữ liệu
geo_pointvàgeo_shape.
1.4. Khi nào KHÔNG nên dùng Elasticsearch? Những sai lầm chết người.
- Làm primary database cho các hệ thống yêu cầu transaction (OLTP): Như đã nói ở trên, đây là sai lầm tồi tệ nhất. Hệ thống ngân hàng, đặt vé, xử lý đơn hàng... tuyệt đối không được dùng Elasticsearch làm nơi lưu trữ dữ liệu gốc.
- Khi bạn cần join nhiều và phức tạp: Nếu nghiệp vụ của bạn yêu cầu các query join qua 5-7 bảng, hãy ở lại với RDBMS. Cố gắng "bẻ cong" Elasticsearch để làm việc này sẽ dẫn đến các document khổng lồ, khó quản lý và hiệu năng cập nhật rất tệ.
- Lưu trữ dữ liệu dạng blob/file lớn: Elasticsearch không được thiết kế để lưu trữ file ảnh, video, hay các file nhị phân lớn. Hãy lưu chúng ở S3, và chỉ lưu trữ metadata của chúng trên Elasticsearch.
- Khi chi phí là vấn đề lớn và dữ liệu của bạn hoàn toàn có cấu trúc: Elasticsearch khá ngốn tài nguyên (đặc biệt là RAM). Nếu bạn chỉ cần lọc dữ liệu theo các cột có sẵn, không cần full-text search, thì một RDBMS với index phù hợp có thể sẽ kinh tế và hiệu quả hơn.
Lời khuyên từ lão làng: Hãy coi Elasticsearch như một nhân viên chuyên trách về "Tìm kiếm và Phân tích" trong team của bạn. Anh ta cực giỏi việc đó, nhưng đừng bắt anh ta đi làm kế toán hay quản lý kho. Hãy để PostgreSQL làm công việc của một kế toán viên cẩn thận và đáng tin cậy.
Chương 2: Giải Phẫu Elasticsearch - Các Khái Niệm Cốt Lõi Phải Nằm Lòng
Để làm việc hiệu quả với bất kỳ công nghệ nào, bạn phải hiểu được cấu trúc và các thành phần cơ bản của nó. Với Elasticsearch, việc nắm vững các khái niệm này sẽ giúp bạn đưa ra các quyết định thiết kế đúng đắn, tránh được các vấn đề về hiệu năng và khả năng mở rộng sau này.
2.1. Từ vật lý đến logic: Cluster, Node, Shard, Replica
Hãy tưởng tượng bạn đang xây dựng một thư viện khổng lồ.
- Cluster (Cụm): Là toàn bộ thư viện của bạn. Nó là một thực thể duy nhất, có một cái tên duy nhất để định danh. Khi bạn tương tác với Elasticsearch, bạn đang nói chuyện với một Cluster.
- Node (Nút): Là một nhân viên trong thư viện (một server chạy tiến trình Elasticsearch). Một thư viện có thể có nhiều nhân viên. Các nhân viên này nói chuyện với nhau để hoàn thành công việc. Có nhiều loại nhân viên chuyên trách:
master-eligible node: Người quản lý. Chịu trách nhiệm quản lý trạng thái của cluster, tạo/xóa index. Trong một cluster, chỉ có một node được bầu làmmastertại một thời điểm.data node: Người giữ sách. Chịu trách nhiệm lưu trữ dữ liệu (shards) và thực hiện các thao tác tìm kiếm, tổng hợp. Đây là loại node ngốn nhiều tài nguyên nhất (CPU, RAM, Disk).ingest node: Người xử lý sách trước khi cất vào kho. Có thể tiền xử lý document trước khi chúng được index (ví dụ: thêm một trường, xóa một trường, parse text...).coordinating node: Người tiếp tân. Nhận request từ client, điều phối đến cácdata nodephù hợp, tổng hợp kết quả và trả về cho client.- Trong một môi trường nhỏ, một node có thể đóng nhiều vai trò. Trong môi trường production lớn, người ta thường tách riêng các vai trò này ra các server khác nhau để tối ưu.
- Shard (Mảnh): Sách trong thư viện quá nhiều, không thể để hết trên một kệ. Bạn quyết định chia một cuốn từ điển (ví dụ: từ A-M ở kệ 1, N-Z ở kệ 2). Mỗi kệ đó là một Shard.
- Khi bạn tạo một
index(ví dụ:products), bạn phải quyết định chia nó thành bao nhiêuprimary shards. - Số lượng primary shards không thể thay đổi sau khi tạo index. Đây là một quyết định kiến trúc cực kỳ quan trọng.
- Một shard thực chất là một instance Lucene độc lập. Elasticsearch phân phối các shard này trên các
data node. Đây chính là bí mật của khả năng mở rộng theo chiều ngang (horizontal scaling). Nếu bạn có nhiều dữ liệu hơn, bạn chỉ cần thêmdata nodevào cluster, Elasticsearch sẽ tự động di chuyển các shard để cân bằng tải.
- Khi bạn tạo một
- Replica (Bản sao): Để phòng trường hợp một kệ sách bị cháy, bạn tạo ra các bản sao của mỗi kệ và đặt chúng ở các phòng khác nhau. Đó chính là Replica Shards.
- Mỗi
primary shardcó thể có 0 hoặc nhiềureplica shards. - Mục đích:
- Tính sẵn sàng cao (High Availability): Nếu một node chứa primary shard bị chết, Elasticsearch sẽ tự động "nâng cấp" một replica shard trên một node khác lên làm primary. Cluster vẫn hoạt động bình thường.
- Tăng thông lượng đọc (Read Throughput): Cả primary và replica shard đều có thể phục vụ các request tìm kiếm. Khi có nhiều request tìm kiếm, Elasticsearch có thể phân phối chúng đến cả các bản sao để tăng tốc.
- Mỗi
Quy tắc vàng: Elasticsearch sẽ không bao giờ đặt một replica shard trên cùng một node với primary shard của nó.
2.2. Tổ chức dữ liệu: Index, Document, và sự ra đi của "Type"
| RDBMS | Elasticsearch |
|---|---|
| Database | (Không có khái niệm tương đương trực tiếp, thường là cả Cluster) |
| Table | Index |
| Row | Document |
| Column | Field |
| Schema | Mapping |
- Index: Là một tập hợp các
documentcó cấu trúc tương tự nhau. Tên index phải là chữ thường. Ví dụ:products,logs-2023-10-27,articles. - Document: Là đơn vị thông tin cơ bản có thể được index. Nó được biểu diễn dưới dạng JSON. Ví dụ một document trong index
products:json{ "id": 123, "name": "Apple Macbook Pro 16 inch M2", "description": "Siêu phẩm máy tính xách tay mạnh mẽ nhất từ Apple.", "price": 55990000, "in_stock": true, "tags": ["apple", "macbook", "laptop"], "created_at": "2023-10-27T10:00:00Z" } - Sự ra đi của "Type": Trong các phiên bản cũ của Elasticsearch, một
indexcó thể chứa nhiềutype. Ví dụ: indexblogcó thể có typepostsvàcomments. Tuy nhiên, điều này gây ra nhiều vấn đề vì các document trong cùng một shard thực chất được lưu trữ chung, dẫn đến xung đột field. Từ phiên bản 7.x,typeđã bị loại bỏ hoàn toàn. Quy tắc bây giờ rất đơn giản: Một index, một loại document. Nếu bạn cópostsvàcomments, hãy tạo hai index riêng biệt:postsvàcomments.
2.3. Quá trình Phân tích (Analysis): Phép thuật đằng sau Full-text Search
Đây là phần cốt lõi làm nên sự khác biệt của Elasticsearch. Khi bạn index một document, các trường text sẽ trải qua một quá trình gọi là Analysis.
Ví dụ, với câu: "The 2 QUICK Brown-Foxes jumped over the lazy dog's bone."
Quá trình analysis có thể diễn ra như sau:
Character Filters: Dọn dẹp chuỗi ký tự thô.
- Ví dụ:
HTML Strip Character Filtersẽ loại bỏ các thẻ HTML như<p>,<b>. - Trong ví dụ của chúng ta, bước này có thể không làm gì.
- Ví dụ:
Tokenizer: Chặt chuỗi ký tự thành các "mẩu" nhỏ gọi là token.
- Ví dụ:
Standard Tokenizersẽ chặt câu trên thành:[ The, 2, QUICK, Brown-Foxes, jumped, over, the, lazy, dog's, bone ].
- Ví dụ:
Token Filters: Xử lý, biến đổi các token đã được tạo ra. Đây là bước mạnh mẽ nhất.
Lowercase Token Filter: Chuyển tất cả token thành chữ thường.[ the, 2, quick, brown-foxes, jumped, over, the, lazy, dog's, bone ]Stop Token Filter: Loại bỏ các từ phổ biến, không có nhiều ý nghĩa (stop words) như "the", "a", "an", "is"...[ 2, quick, brown-foxes, jumped, over, lazy, dog's, bone ]Stemming Token Filter: Đưa các từ về dạng gốc của nó. Ví dụ "jumped" -> "jump", "dogs" -> "dog".[ 2, quick, brown-fox, jump, over, lazi, dog, bone ](stemmer có thể không hoàn hảo)
- Analyzer: Là một pipeline kết hợp một
Tokenizervà không hoặc nhiềuToken Filters(và Character Filters). Elasticsearch cung cấp nhiều analyzer dựng sẵn (standard,simple,whitespace,keyword...) và cho phép bạn tự tạo các analyzer tùy chỉnh để phù hợp với ngôn ngữ và nghiệp vụ của mình (ví dụ: analyzer cho Tiếng Việt).
2.4. Inverted Index (Chỉ mục ngược): Vũ khí tối thượng
Sau quá trình Analysis, Elasticsearch sẽ xây dựng một cấu trúc dữ liệu gọi là Inverted Index. Thay vì ánh xạ từ Document ID -> Nội dung, nó làm ngược lại: ánh xạ từ Term (token sau khi xử lý) -> Danh sách các Document ID chứa term đó.
Giả sử chúng ta có 2 document:
Doc_1: "Golang is fast"Doc_2: "I love fast cars and Golang"
Sau khi analysis (lowercase, stop-word removal), chúng ta có các term: golang, fast, love, car.
Inverted Index sẽ trông như thế này:
| Term | Documents |
|---|---|
car | [ Doc_2 ] |
fast | [ Doc_1, Doc_2 ] |
golang | [ Doc_1, Doc_2 ] |
love | [ Doc_2 ] |
Bây giờ, khi bạn tìm kiếm từ "fast", Elasticsearch không cần phải đọc qua toàn bộ document. Nó chỉ cần nhìn vào inverted index, thấy ngay term fast xuất hiện ở Doc_1 và Doc_2, và trả về kết quả gần như tức thì. Đây chính là lý do tại sao Elasticsearch nhanh một cách đáng kinh ngạc cho các truy vấn full-text.
Chương 3: Dựng Chiến Trường - Thiết Lập Môi Trường Phát Triển Chuẩn Production
Lý thuyết đã đủ, giờ là lúc bắt tay vào thực hành. Một môi trường phát triển tốt phải tương đồng với môi trường production nhất có thể để tránh những bất ngờ không mong muốn khi deploy. Docker và Docker Compose là công cụ hoàn hảo cho việc này.
3.1. Docker & Docker Compose: Người bạn đồng hành không thể thiếu
Docker cho phép chúng ta đóng gói ứng dụng và các phụ thuộc của nó vào một "container" nhẹ, độc lập và có thể chạy ở bất cứ đâu. Docker Compose cho phép chúng ta định nghĩa và chạy các ứng dụng đa container (multi-container), ví dụ như một container cho Elasticsearch, một cho Kibana, và một cho ứng dụng Go của chúng ta.
Lợi ích:
- Nhất quán: Môi trường trên máy của mọi developer và trên server production là giống hệt nhau.
- Cô lập: Các container không ảnh hưởng lẫn nhau.
- Dễ dàng cài đặt: Chỉ cần một file
docker-compose.ymlvà một lệnhdocker-compose up, toàn bộ hệ thống đã sẵn sàng.
3.2. Cấu hình docker-compose.yml cho Elasticsearch và Kibana
Tạo một file tên là docker-compose.yml trong thư mục gốc dự án của bạn với nội dung sau:
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.10.4 # Luôn dùng phiên bản cụ thể
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster
- discovery.type=single-node # Quan trọng cho môi trường dev một node
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # Giới hạn RAM cho ES, rất quan trọng!
- xpack.security.enabled=false # Tắt security cho môi trường dev để đơn giản
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es_data:/usr/share/elasticsearch/data # Persist dữ liệu của ES
ports:
- "9200:9200"
- "9300:9300"
networks:
- es_net
kibana:
image: docker.elastic.co/kibana/kibana:8.10.4
container_name: kibana01
ports:
- "5601:5601"
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200 # Kibana nói chuyện với ES qua network nội bộ
depends_on:
- elasticsearch
networks:
- es_net
volumes:
es_data:
driver: local
networks:
es_net:
driver: bridgeGiải thích chi tiết:
services: Định nghĩa các container sẽ chạy.elasticsearch:image: Chỉ định image Docker của Elasticsearch. Pro-tip: Luôn luôn chỉ định một phiên bản cụ thể (8.10.4) thay vìlatestđể tránh các breaking changes không mong muốn.environment: Các biến môi trường để cấu hình Elasticsearch.discovery.type=single-node: Rất quan trọng khi chạy một cluster chỉ có một node. Nếu không có cái này, ES sẽ cố gắng tìm các node khác và có thể không khởi động được.bootstrap.memory_lock=true: Ngăn không cho JVM swap ra disk, cải thiện hiệu năng.ES_JAVA_OPTS: Cực kỳ quan trọng! Mặc định, Elasticsearch có thể cố gắng chiếm rất nhiều RAM. Trong môi trường dev, chúng ta giới hạn nó ở mức 512MB.xpack.security.enabled=false: Mặc định từ phiên bản 8, Elasticsearch bật security (username/password, TLS). Để đơn giản hóa cho việc học, chúng ta tạm tắt nó đi. Trong production, bạn PHẢI bật security.
ulimits: Cấu hình cần thiết chobootstrap.memory_lock=true.volumes: Ánh xạ một volume tên làes_datavào trong container. Điều này giúp dữ liệu của Elasticsearch không bị mất khi bạn khởi động lại container.ports: Ánh xạ cổng 9200 (HTTP API) và 9300 (Giao tiếp giữa các node) ra máy host của bạn.networks: Kết nối container này vào một network ảo tên làes_net.
kibana:ELASTICSEARCH_HOSTS: Chỉ cho Kibana biết địa chỉ của Elasticsearch. Lưu ý chúng ta dùng tên serviceelasticsearchchứ không phảilocalhost, vì chúng đang nói chuyện với nhau qua Docker network.depends_on: Đảm bảo container Elasticsearch được khởi động trước Kibana.
Khởi chạy: Mở terminal trong thư mục chứa file docker-compose.yml và chạy:
docker-compose up -dLệnh -d (detached) sẽ chạy các container ở chế độ nền.
Kiểm tra:
- Mở trình duyệt và truy cập
http://localhost:9200. Bạn sẽ thấy một response JSON từ Elasticsearch. - Mở trình duyệt và truy cập
http://localhost:5601. Bạn sẽ thấy giao diện của Kibana.
3.3. Khởi tạo dự án Golang Echo và cài đặt thư viện
Bây giờ, chúng ta sẽ tạo dự án Golang.
Tạo thư mục dự án và khởi tạo Go module:
bashmkdir golang-es-project cd golang-es-project go mod init github.com/your-username/golang-es-projectCài đặt các thư viện cần thiết:
bash# Thư viện Echo Framework go get github.com/labstack/echo/v4 # Thư viện chính thức của Elasticsearch cho Go go get github.com/elastic/go-elasticsearch/v8Cấu trúc thư mục dự án (gợi ý): Một cấu trúc tốt sẽ giúp dự án dễ dàng bảo trì và mở rộng. Đây là một cấu trúc tôi thường dùng:
golang-es-project/ ├── cmd/ │ └── main.go # Entry point của ứng dụng ├── internal/ │ ├── config/ # Quản lý cấu hình (ES host, port...) │ │ └── config.go │ ├── es_client/ # Singleton client để kết nối ES │ │ └── client.go │ ├── product/ # Một domain nghiệp vụ, ví dụ "product" │ │ ├── handler.go # Echo handlers (tầng API) │ │ ├── repository.go # Tầng truy cập dữ liệu (Elasticsearch) │ │ ├── service.go # Tầng xử lý business logic │ │ └── model.go # Structs định nghĩa model │ └── server/ │ └── server.go # Nơi khởi tạo và cấu hình Echo server ├── go.mod ├── go.sum └── docker-compose.ymlChúng ta sẽ điền code vào cấu trúc này trong các chương tiếp theo.
Chương 4: Kết Nối Đến Elasticsearch Bằng Golang - The Official Go Client
Thư viện github.com/elastic/go-elasticsearch/v8 là thư viện chính thức, được duy trì bởi chính Elastic. Nó rất mạnh mẽ, hiệu năng cao và cung cấp đầy đủ các API để tương tác với Elasticsearch.
4.1. Các kiểu kết nối
Thư viện này cung cấp một cấu trúc elasticsearch.Config để cấu hình client.
Kết nối cơ bản đến một node (dùng cho môi trường dev):
import "github.com/elastic/go-elasticsearch/v8"
// ...
cfg := elasticsearch.Config{
Addresses: []string{
"http://localhost:9200",
},
}
es, err := elasticsearch.NewClient(cfg)
if err != nil {
log.Fatalf("Error creating the client: %s", err)
}Kết nối đến nhiều node (dùng cho môi trường staging/production): Client sẽ tự động thực hiện round-robin giữa các node và xử lý failover nếu một node bị chết.
cfg := elasticsearch.Config{
Addresses: []string{
"http://es-node1:9200",
"http://es-node2:9200",
"http://es-node3:9200",
},
// Các cấu hình khác như Retry, Timeout...
}Xác thực bằng Username/Password (khi xpack.security.enabled=true):
cfg := elasticsearch.Config{
Addresses: []string{"http://localhost:9200"},
Username: "elastic",
Password: "your_password",
}4.2. Thiết kế Singleton Client: Best practice trong mọi dự án
elasticsearch.Client được thiết kế để thread-safe và nên được tạo một lần duy nhất trong toàn bộ vòng đời của ứng dụng. Việc tạo mới client cho mỗi request sẽ rất tốn kém vì nó phải thiết lập connection pool. Do đó, chúng ta sẽ sử dụng pattern Singleton.
Hãy tạo file internal/es_client/client.go:
package es_client
import (
"log"
"sync"
"github.com/elastic/go-elasticsearch/v8"
)
var (
esClient *elasticsearch.Client
once sync.Once
)
// GetESClient trả về một instance singleton của elasticsearch.Client
func GetESClient() *elasticsearch.Client {
once.Do(func() {
// Trong một dự án thực tế, các cấu hình này nên được đọc từ file config hoặc biến môi trường
cfg := elasticsearch.Config{
Addresses: []string{
"http://localhost:9200",
},
// Có thể thêm các cấu hình khác ở đây
// Username: "foo",
// Password: "bar",
}
client, err := elasticsearch.NewClient(cfg)
if err != nil {
log.Fatalf("Error creating the Elasticsearch client: %s", err)
}
// Kiểm tra kết nối ban đầu
res, err := client.Info()
if err != nil {
log.Fatalf("Error getting Elasticsearch info: %s", err)
}
defer res.Body.Close()
if res.IsError() {
log.Fatalf("Error response from Elasticsearch: %s", res.String())
}
log.Printf("Elasticsearch client initialized. Server version: %s", res.String())
esClient = client
})
return esClient
}Giải thích:
sync.Onceđảm bảo rằng đoạn code bên trongonce.Dochỉ được thực thi đúng một lần, dùGetESClient()được gọi bao nhiêu lần từ nhiều goroutine khác nhau.- Hàm
client.Info()được gọi ngay sau khi tạo client để xác nhận rằng chúng ta có thể kết nối và giao tiếp thành công với server Elasticsearch. Đây là một bước sanity check rất quan trọng.
Bây giờ, ở bất kỳ đâu trong code, khi cần dùng client, chúng ta chỉ cần gọi es_client.GetESClient().
4.3. Kiểm tra "sức khỏe" của Cluster (Ping, Info, Health)
Thư viện Go cung cấp các API tương ứng để gọi đến các endpoint health check của Elasticsearch.
Hãy viết một ví dụ trong cmd/main.go để thử nghiệm:
package main
import (
"context"
"log"
"github.com/your-username/golang-es-project/internal/es_client"
)
func main() {
// Lấy client singleton
es := es_client.GetESClient()
// 1. Lấy thông tin cơ bản của cluster (tương đương GET /)
res, err := es.Info()
if err != nil {
log.Fatalf("Error getting Info: %s", err)
}
defer res.Body.Close()
log.Println("--- INFO ---")
log.Println(res)
// 2. Ping để kiểm tra xem node có trả lời hay không
res, err = es.Ping()
if err != nil {
log.Fatalf("Error pinging: %s", err)
}
defer res.Body.Close()
log.Println("--- PING ---")
log.Println(res)
// 3. Kiểm tra sức khỏe của cluster (quan trọng nhất)
// GET /_cluster/health
res, err = es.Cluster.Health(
es.Cluster.Health.WithContext(context.Background()),
)
if err != nil {
log.Fatalf("Error getting cluster health: %s", err)
}
defer res.Body.Close()
log.Println("--- HEALTH ---")
if res.IsError() {
log.Printf("Error response from health check: %s", res.String())
} else {
log.Println(res.String())
}
}Khi bạn chạy chương trình này (go run cmd/main.go), bạn sẽ thấy các thông tin về phiên bản Elasticsearch, trạng thái kết nối và quan trọng nhất là "sức khỏe" của cluster.
Kết quả của Health() sẽ chứa một trường status.
green: Tất cả primary và replica shards đều đang hoạt động tốt. Trạng thái lý tưởng.yellow: Tất cả primary shards đều hoạt động, nhưng có ít nhất một replica shard chưa được cấp phát (ví dụ: bạn cấu hình 1 replica nhưng chỉ có 1 node, replica không có chỗ để phân bổ). Cluster vẫn hoạt động đầy đủ, nhưng có rủi ro về high availability.red: Có ít nhất một primary shard không hoạt động. Cluster đang ở trạng thái rất tệ, một phần dữ liệu không thể truy cập, các truy vấn có thể thất bại.
Việc theo dõi trạng thái này là cực kỳ quan trọng trong môi trường production.
PHẦN 2: THAO TÁC CƠ BẢN - CRUD & LẬP CHỈ MỤC DỮ LIỆU
Ở Phần 1, chúng ta đã xây dựng xong "chiến trường" và hiểu rõ các quy tắc. Bây giờ là lúc đưa quân vào trận - tức là đưa dữ liệu vào Elasticsearch. Phần này sẽ tập trung vào các thao tác cơ bản nhưng tối quan trọng: tạo ra không gian lưu trữ (Index) và quản lý vòng đời của từng mẩu dữ liệu (Document). Nắm vững những kỹ thuật này là điều kiện tiên quyết trước khi bước vào thế giới tìm kiếm phức tạp hơn.
Chương 5: Quản Lý Index - Xây Dựng "Ngôi Nhà" Cho Dữ Liệu
Nếu Document là những món đồ nội thất, thì Index chính là ngôi nhà chứa chúng. Xây một ngôi nhà cẩu thả, không có bản vẽ thiết kế (Mapping) rõ ràng sẽ dẫn đến thảm họa sau này. Đồ đạc sẽ bị đặt sai chỗ, khó tìm kiếm và cả ngôi nhà có thể sụp đổ khi lượng đồ tăng lên. Vì vậy, việc định nghĩa một Index với Mapping và Settings chuẩn xác ngay từ đầu là một trong những quyết định kiến trúc quan trọng nhất khi làm việc với Elasticsearch.
5.1. Struct Golang và Mapping trong Elasticsearch
Trong Go, chúng ta định nghĩa dữ liệu bằng struct. Khi được gửi đến Elasticsearch, struct này sẽ được marshal thành một tài liệu JSON. Mapping trong Elasticsearch chính là bản thiết kế, định nghĩa cho Elasticsearch biết mỗi trường (field) trong tài liệu JSON đó có kiểu dữ liệu gì và cần được xử lý như thế nào.
Hãy lấy một ví dụ thực tế: một sản phẩm trong hệ thống E-commerce.
Trong Go, chúng ta có thể định nghĩa nó như sau:
// file: internal/product/model.go
package product
import "time"
type Product struct {
ID int `json:"id"`
SKU string `json:"sku"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
Stock int `json:"stock"`
IsActive bool `json:"is_active"`
Brand Brand `json:"brand"`
Categories []Category `json:"categories"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
type Brand struct {
ID int `json:"id"`
Name string `json:"name"`
}
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
}Các json:"..." tag rất quan trọng, chúng quyết định tên của các field khi struct được chuyển thành JSON.
5.2. Tạo một Index (Index Create API)
Chúng ta sẽ dùng API indices.Create của client Go để tạo một index mới.
Cách 1: Tạo Index với cấu hình mặc định (KHÔNG KHUYẾN KHÍCH TRONG PRODUCTION)
Nếu bạn chỉ đơn giản index một document vào một index chưa tồn tại, Elasticsearch sẽ tự động tạo index đó và "đoán" kiểu dữ liệu cho từng trường. Đây được gọi là Dynamic Mapping.
// Ví dụ về việc ES tự tạo index
// Đừng làm theo cách này trong dự án thật!
es := es_client.GetESClient()
// ... index một document vào index "products-bad" ...Tại sao cách này tệ?
- Đoán sai kiểu dữ liệu: Vấn đề kinh điển nhất là với các chuỗi ký tự. Elasticsearch sẽ mặc định map một chuỗi thành kiểu
textvà thêm một sub-field.keyword. Nhưng nếu bạn có một trường nhưSKU("ABC-12345") mà bạn chỉ muốn tìm kiếm chính xác, sắp xếp hoặc aggregate, thì việc nó bị phân tích (analyzed) như mộttextlà không cần thiết và lãng phí. Hay một trường version "2.3" có thể bị đoán nhầm thànhfloatthay vìkeyword. - Không kiểm soát được Analyzer: Bạn không thể chỉ định analyzer nào được dùng cho các trường
text, dẫn đến kết quả tìm kiếm không như mong muốn, đặc biệt với các ngôn ngữ phức tạp như Tiếng Việt. - Khó thay đổi: Một khi mapping đã được tạo, việc thay đổi kiểu dữ liệu của một trường hiện có là rất khó khăn (thường là phải reindex toàn bộ dữ liệu).
Lời khuyên từ lão làng: Dynamic mapping rất tiện cho việc thử nghiệm nhanh hoặc cho các trường hợp dữ liệu không thể đoán trước (unstructured). Nhưng với 99% các trường hợp trong production, nơi bạn biết rõ cấu trúc dữ liệu của mình, LUÔN LUÔN định nghĩa mapping một cách tường minh.
Cách 2: Tạo Index với Mapping và Settings tùy chỉnh (CÁCH LÀM CHUYÊN NGHIỆP)
Đây là cách chúng ta sẽ làm trong một dự án thực tế. Chúng ta sẽ định nghĩa toàn bộ cấu trúc của index trong một chuỗi JSON và gửi nó cùng với request tạo index.
Hãy tạo file internal/product/repository.go và bắt đầu với hàm tạo index.
package product
import (
"context"
"fmt"
"log"
"strings"
"github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esapi"
)
const (
IndexNameProducts = "products"
)
type ProductRepository struct {
es *elasticsearch.Client
}
func NewProductRepository(es *elasticsearch.Client) *ProductRepository {
return &ProductRepository{es: es}
}
// CreateIndexIfNotExists tạo index products nếu nó chưa tồn tại.
func (r *ProductRepository) CreateIndexIfNotExists(ctx context.Context) error {
// 1. Kiểm tra xem index đã tồn tại chưa
res, err := r.es.Indices.Exists([]string{IndexNameProducts})
if err != nil {
return fmt.Errorf("cannot check index existence: %w", err)
}
// Phải đóng body của response để tránh leak connection
defer res.Body.Close()
// Nếu res.StatusCode là 200, index tồn tại, không làm gì cả.
if res.StatusCode == 200 {
log.Printf("Index [%s] already exists.", IndexNameProducts)
return nil
}
// Nếu status code khác 404, đó là một lỗi thực sự.
if res.StatusCode != 404 {
return fmt.Errorf("error checking index existence, status: %s", res.Status())
}
log.Printf("Index [%s] does not exist. Creating...", IndexNameProducts)
// 2. Định nghĩa mapping và settings cho index
mapping := `
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1
},
"mappings": {
"properties": {
"id": { "type": "integer" },
"sku": { "type": "keyword" },
"name": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
},
"description": { "type": "text" },
"price": { "type": "double" },
"stock": { "type": "integer" },
"is_active": { "type": "boolean" },
"brand": {
"properties": {
"id": { "type": "integer" },
"name": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
}
}
},
"categories": {
"type": "nested",
"properties": {
"id": { "type": "integer" },
"name": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
}
}
},
"tags": { "type": "keyword" },
"created_at": { "type": "date" }
}
}
}`
// 3. Gửi request tạo index
createReq := esapi.IndicesCreateRequest{
Index: IndexNameProducts,
Body: strings.NewReader(mapping),
}
createRes, err := createReq.Do(ctx, r.es)
if err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
defer createRes.Body.Close()
if createRes.IsError() {
return fmt.Errorf("failed to create index, response: %s", createRes.String())
}
log.Printf("Index [%s] created successfully.", IndexNameProducts)
return nil
}Bây giờ, hãy phân tích sâu vào bản thiết kế mapping ở trên.
Phân tích sâu về settings:
"number_of_shards": 1: Đây là số lượng Primary Shards. Như đã giải thích ở Phần 1, đây là đơn vị cơ bản để chia nhỏ dữ liệu của bạn.- Quyết định kiến trúc: Con số này KHÔNG THỂ THAY ĐỔI sau khi index được tạo. Nó quyết định khả năng mở rộng tối đa của index đó. Nếu bạn đặt là 1, toàn bộ dữ liệu của index này sẽ nằm trên 1 node, bạn không thể chia tải ghi cho index này ra nhiều node được.
- Rule of thumb:
- Với môi trường dev hoặc các index nhỏ (< 10GB), để là 1 là hợp lý.
- Với production, hãy ước lượng dung lượng dữ liệu của bạn sẽ tăng trưởng trong 1-2 năm tới. Một lời khuyên phổ biến là giữ cho mỗi shard có dung lượng từ 10GB đến 50GB để có hiệu năng tốt nhất. Ví dụ, nếu bạn dự đoán index sẽ có 200GB dữ liệu, bạn có thể chọn
number_of_shardslà 5 (mỗi shard ~40GB).
"number_of_replicas": 1: Đây là số lượng bản sao (Replica Shard) cho mỗi Primary Shard.- Quyết định vận hành: Con số này CÓ THỂ THAY ĐỔI bất cứ lúc nào mà không cần downtime.
0: Không có bản sao. Nếu node chứa primary shard chết, bạn sẽ mất dữ liệu (trạng thái clusterred). Chỉ dùng cho dev hoặc các trường hợp dữ liệu không quan trọng, có thể tái tạo.1(hoặc cao hơn): Cung cấp High Availability. Nếu bạn có N nodes, bạn có thể có tối đa N-1 replicas.
Phân tích sâu về mappings.properties và các kiểu dữ liệu:
"sku": { "type": "keyword" }: Keyword vs Text là khái niệm quan trọng nhất.keyword: Dùng cho dữ liệu có cấu trúc, các giá trị mà bạn muốn tìm kiếm chính xác, sắp xếp (sorting), hoặc tổng hợp (aggregations). Ví dụ: SKU, status, category ID, tags, mã quốc gia... Dữ liệukeywordkhông bị phân tích (analyzed), nó được coi là một giá trị duy nhất.text: Dùng cho dữ liệu văn bản đầy đủ (full-text) mà bạn muốn tìm kiếm "giống như Google". Ví dụ: tên sản phẩm, mô tả, nội dung bài viết... Dữ liệutextsẽ trải qua quá trình Analysis (chặt từ, chuyển thành chữ thường, loại bỏ stop-word...) để xây dựng Inverted Index. Mặc định, bạn không thể sắp xếp hay aggregate trên trườngtext.
"name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } }: Đây là một kỹ thuật cực kỳ phổ biến gọi là multi-fields. Chúng ta muốn trườngname:- Có thể tìm kiếm full-text (ví dụ tìm "điện thoại samsung" ra "Điện thoại Samsung Galaxy"). Đây là chức năng của
type: "text". - Có thể sắp xếp chính xác theo alphabet hoặc dùng để aggregate (ví dụ đếm số sản phẩm có tên chính xác là "iPhone 14 Pro Max"). Đây là chức năng của sub-field
name.keywordcótype: "keyword".
- Có thể tìm kiếm full-text (ví dụ tìm "điện thoại samsung" ra "Điện thoại Samsung Galaxy"). Đây là chức năng của
"categories": { "type": "nested", ... }: Nested vs Object là một khái niệm nâng cao nhưng quan trọng.- Mặc định, nếu bạn có một mảng các object như
categories, Elasticsearch sẽ "làm phẳng" (flatten) nó. - Ví dụ JSON:
"categories": [ { "id": 1, "name": "Điện thoại" }, { "id": 10, "name": "Hàng mới" } ] - ES sẽ lưu nó thành:
categories.id: [1, 10]vàcategories.name: ["Điện thoại", "Hàng mới"]. - Vấn đề: Mối liên kết giữa
id: 1vàname: "Điện thoại"bị mất. Bạn không thể thực hiện một truy vấn "tìm sản phẩm có category với id=1 VÀ name='Hàng mới'". "type": "nested"giải quyết vấn đề này. Nó sẽ coi mỗi object trong mảng là một document con, độc lập. Điều này giữ lại mối quan hệ giữa các trường bên trong object đó, cho phép bạn thực hiện các truy vấn phức tạp. Tuy nhiên, nó cũng tốn nhiều tài nguyên hơn một chút.
- Mặc định, nếu bạn có một mảng các object như
"created_at": { "type": "date" }: Cho phép Elasticsearch hiểu đây là dữ liệu thời gian. Bạn có thể thực hiện các truy vấn khoảng (range queries) như "tìm các sản phẩm được tạo trong 30 ngày qua".
5.3. Các thao tác khác với Index
Bên cạnh việc tạo, bạn cũng cần biết cách kiểm tra sự tồn tại và xóa index.
Kiểm tra sự tồn tại của Index (Index Exists API) Chúng ta đã sử dụng nó trong hàm CreateIndexIfNotExists. Client Go cung cấp một hàm helper tiện lợi es.Indices.Exists().
Xóa Index (Index Delete API) Thao tác này sẽ xóa toàn bộ index, bao gồm cả mapping và tất cả các document bên trong. Hãy cực kỳ cẩn thận khi sử dụng lệnh này trong production!
func (r *ProductRepository) DeleteIndex(ctx context.Context) error {
deleteReq := esapi.IndicesDeleteRequest{
Index: []string{IndexNameProducts},
}
res, err := deleteReq.Do(ctx, r.es)
if err != nil {
return fmt.Errorf("failed to delete index: %w", err)
}
defer res.Body.Close()
if res.IsError() {
// Nếu index không tồn tại, ES sẽ trả về lỗi 404, có thể ta muốn bỏ qua lỗi này.
if res.StatusCode == 404 {
log.Printf("Index [%s] not found, nothing to delete.", IndexNameProducts)
return nil
}
return fmt.Errorf("failed to delete index, response: %s", res.String())
}
log.Printf("Index [%s] deleted successfully.", IndexNameProducts)
return nil
}Chương 6: Thao Tác Với Document - Thêm, Sửa, Xóa, Lấy Dữ Liệu
Ngôi nhà đã được xây dựng kiên cố. Giờ là lúc chúng ta dọn đồ vào. Các thao tác này tương đương với C-R-U-D trong thế giới database quan hệ.
6.1. Indexing a Document (Create & Update)
"Indexing" là thuật ngữ của Elasticsearch cho việc thêm hoặc cập nhật một document.
- API
Index: Nếu document với ID được cung cấp đã tồn tại, nó sẽ ghi đè (update). Nếu chưa, nó sẽ tạo mới (create). Đây là hành vi "upsert". - API
Create: Chỉ tạo mới document. Nếu document với ID được cung cấp đã tồn tại, nó sẽ trả về lỗi.
Hãy thêm một phương thức vào ProductRepository để index một sản phẩm.
// ... trong file internal/product/repository.go
import (
"encoding/json"
// ... các import khác
)
func (r *ProductRepository) IndexProduct(ctx context.Context, p Product) error {
// Marshal struct Product thành JSON
body, err := json.Marshal(p)
if err != nil {
return fmt.Errorf("error marshaling product: %w", err)
}
req := esapi.IndexRequest{
Index: IndexNameProducts,
DocumentID: fmt.Sprintf("%d", p.ID), // Sử dụng ID của sản phẩm làm Document ID
Body: bytes.NewReader(body),
Refresh: "true", // Yêu cầu ES refresh index ngay lập tức
}
res, err := req.Do(ctx, r.es)
if err != nil {
return fmt.Errorf("error indexing document: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return fmt.Errorf("error indexing document, response: %s", res.String())
}
log.Printf("Successfully indexed product with ID: %d", p.ID)
return nil
}Lưu ý quan trọng:
DocumentID: Chúng ta nên tự cung cấp một ID duy nhất cho document (thường là primary key từ database quan hệ). Nếu bạn không cung cấp, Elasticsearch sẽ tự sinh ra một ID ngẫu nhiên. Việc tự cung cấp ID giúp chúng ta dễ dàng cập nhật và xóa document sau này.Refresh: Mặc định, Elasticsearch không làm cho document có thể tìm kiếm được ngay lập tức sau khi index (Near Real-Time). Nó đợi một khoảng "refresh interval" (mặc định 1 giây). Việc setRefresh: "true"sẽ ép Elasticsearch refresh shard ngay lập tức. Điều này hữu ích cho testing, nhưng trong production, việc refresh liên tục có thể ảnh hưởng đến hiệu năng. Hãy để mặc định hoặc cân nhắc dùngRefresh: "wait_for"trong các trường hợp cần thiết.
Kiểm soát đồng thời (Optimistic Concurrency Control)
Trong một hệ thống có nhiều request xảy ra cùng lúc, có thể có hai tiến trình cùng đọc một document, cùng sửa đổi và cùng ghi lại. Tiến trình ghi sau sẽ ghi đè lên thay đổi của tiến trình ghi trước mà không hay biết. Đây là vấn đề "lost update".
Elasticsearch cung cấp một cơ chế để giải quyết vấn đề này. Mỗi document có hai metadata là _seq_no và _primary_term. Khi bạn cập nhật một document, bạn có thể cung cấp if_seq_no và if_primary_term mà bạn đã đọc được trước đó. Nếu giá trị trên server đã thay đổi (do một tiến trình khác đã cập nhật), request của bạn sẽ thất bại với lỗi version conflict.
6.2. Lấy một Document (Get API)
Đây là thao tác đơn giản nhất: lấy một document dựa trên ID của nó.
func (r *ProductRepository) GetProductByID(ctx context.Context, id int) (*Product, error) {
req := esapi.GetRequest{
Index: IndexNameProducts,
DocumentID: fmt.Sprintf("%d", id),
}
res, err := req.Do(ctx, r.es)
if err != nil {
return nil, fmt.Errorf("error getting document: %w", err)
}
defer res.Body.Close()
if res.IsError() {
if res.StatusCode == 404 {
return nil, nil // Không tìm thấy document, không coi là lỗi
}
return nil, fmt.Errorf("error getting document, response: %s", res.String())
}
// Response của Get API có cấu trúc {"_source": { ... } }
// Chúng ta cần parse nó để lấy ra document gốc
var getResponse struct {
Source Product `json:"_source"`
}
if err := json.NewDecoder(res.Body).Decode(&getResponse); err != nil {
return nil, fmt.Errorf("error decoding response body: %w", err)
}
return &getResponse.Source, nil
}Bạn cũng có thể chỉ lấy một vài trường nhất định để giảm lượng dữ liệu truyền qua mạng bằng cách thêm tham số SourceIncludes hoặc SourceExcludes.
6.3. Cập nhật một Document (Update API)
Thay vì lấy toàn bộ document, sửa đổi trong code Go, rồi index lại toàn bộ (read-modify-write), Elasticsearch cung cấp một API Update hiệu quả hơn nhiều, đặc biệt là cho việc cập nhật một phần (partial update).
// Cập nhật chỉ stock và trạng thái active của sản phẩm
func (r *ProductRepository) UpdateProductStock(ctx context.Context, id int, newStock int, isActive bool) error {
// Dữ liệu cần cập nhật
updateDoc := map[string]interface{}{
"doc": map[string]interface{}{
"stock": newStock,
"is_active": isActive,
},
}
body, _ := json.Marshal(updateDoc)
req := esapi.UpdateRequest{
Index: IndexNameProducts,
DocumentID: fmt.Sprintf("%d", id),
Body: bytes.NewReader(body),
Refresh: "true",
}
res, err := req.Do(ctx, r.es)
if err != nil {
return fmt.Errorf("error updating document: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return fmt.Errorf("error updating document, response: %s", res.String())
}
return nil
}Cấu trúc {"doc": {...}} báo cho Elasticsearch biết đây là một partial update. Nó sẽ merge các trường bạn cung cấp vào document hiện có.
6.4. Xóa một Document (Delete API)
Thao tác này sẽ xóa một document cụ thể khỏi index.
func (r *ProductRepository) DeleteProduct(ctx context.Context, id int) error {
req := esapi.DeleteRequest{
Index: IndexNameProducts,
DocumentID: fmt.Sprintf("%d", id),
Refresh: "true",
}
res, err := req.Do(ctx, r.es)
if err != nil {
return fmt.Errorf("error deleting document: %w", err)
}
defer res.Body.Close()
if res.IsError() {
if res.StatusCode == 404 {
log.Printf("Document with ID %d not found, nothing to delete.", id)
return nil
}
return fmt.Errorf("error deleting document, response: %s", res.String())
}
return nil
}6.5. Tăng tốc với Bulk API - Kỹ thuật bắt buộc cho hiệu năng cao
Tất cả các API trên đều xử lý từng document một. Nếu bạn cần index 1 triệu sản phẩm hoặc 10 triệu dòng log, việc gửi 10 triệu request riêng lẻ sẽ cực kỳ chậm và tạo ra một lượng overhead khổng lồ.
Bulk API là câu trả lời. Nó cho phép bạn thực hiện hàng ngàn thao tác (index, create, update, delete) trong một request duy nhất. Đây là kỹ thuật bắt buộc phải sử dụng cho bất kỳ tác vụ ghi dữ liệu hàng loạt nào.
Cấu trúc body của một request Bulk khá đặc biệt, nó là một chuỗi các JSON được nối với nhau bằng ký tự xuống dòng (\n):
{ "index" : { "_index" : "products", "_id" : "100" } }
{ "id": 100, "name": "Product A", ... }
{ "delete" : { "_index" : "products", "_id" : "101" } }
{ "update" : {"_id" : "102", "_index" : "products"} }
{ "doc" : {"in_stock" : false} }Mỗi thao tác bao gồm 2 dòng (trừ delete chỉ cần 1 dòng):
- Dòng "action and metadata": định nghĩa thao tác (
index,create,update,delete) và các thông tin như_index,_id. - Dòng "source" (tùy chọn): chứa body của document (cho
index,create) hoặc body của update (choupdate).
Việc xây dựng chuỗi body này trong Go cần được thực hiện một cách hiệu quả. Sử dụng strings.Builder hoặc bytes.Buffer sẽ tốt hơn nhiều so với việc nối chuỗi thông thường.
// IndexProductsBulk thực hiện index một loạt sản phẩm bằng Bulk API
func (r *ProductRepository) IndexProductsBulk(ctx context.Context, products []Product) error {
if len(products) == 0 {
return nil
}
var body strings.Builder
for _, p := range products {
// Dòng 1: Action and Metadata
meta := map[string]interface{}{
"index": map[string]interface{}{
"_index": IndexNameProducts,
"_id": fmt.Sprintf("%d", p.ID),
},
}
metaBytes, _ := json.Marshal(meta)
body.Write(metaBytes)
body.WriteByte('\n')
// Dòng 2: Source
sourceBytes, _ := json.Marshal(p)
body.Write(sourceBytes)
body.WriteByte('\n')
}
req := esapi.BulkRequest{
Body: strings.NewReader(body.String()),
Refresh: "true", // Cân nhắc bỏ đi trong production
}
res, err := req.Do(ctx, r.es)
if err != nil {
return fmt.Errorf("bulk request failed: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return fmt.Errorf("bulk request has errors: %s", res.String())
}
// **Quan trọng: Xử lý lỗi trên từng document**
// Response của Bulk API có thể thành công ở mức request tổng,
// nhưng từng thao tác bên trong có thể thất bại.
var bulkResponse struct {
Errors bool `json:"errors"`
Items []map[string]struct {
Index string `json:"_index"`
ID string `json:"_id"`
Status int `json:"status"`
Error struct {
Type string `json:"type"`
Reason string `json:"reason"`
} `json:"error,omitempty"`
} `json:"items"`
}
if err := json.NewDecoder(res.Body).Decode(&bulkResponse); err != nil {
return fmt.Errorf("failed to decode bulk response: %w", err)
}
if bulkResponse.Errors {
var errorMessages []string
for _, item := range bulkResponse.Items {
// Lấy ra thao tác đầu tiên trong map, ví dụ "index"
for _, op := range item {
if op.Error.Type != "" {
errMsg := fmt.Sprintf("document ID %s failed: [%d] %s: %s", op.ID, op.Status, op.Error.Type, op.Error.Reason)
errorMessages = append(errorMessages, errMsg)
}
}
}
return fmt.Errorf("some documents failed to index: %s", strings.Join(errorMessages, "; "))
}
log.Printf("Successfully indexed %d products in bulk.", len(products))
return nil
}Phần xử lý lỗi của Bulk API là cực kỳ quan trọng. Bạn phải kiểm tra trường "errors": true trong response, sau đó lặp qua mảng items để tìm ra chính xác những document nào đã thất bại và lý do tại sao. Bỏ qua bước này có thể dẫn đến việc bạn bị mất dữ liệu một cách âm thầm.
PHẦN 3: TRÁI TIM CỦA ELASTICSEARCH - NGHỆ THUẬT TÌM KIẾM
Đây là phần mà Elasticsearch thực sự tỏa sáng, là lý do chính mà người ta chọn nó thay vì các hệ quản trị cơ sở dữ liệu khác. Chúng ta sẽ không chỉ học cách tìm kiếm, mà còn học cách "nói chuyện" với Elasticsearch bằng ngôn ngữ của nó - Query DSL, để ra lệnh cho nó thực hiện những tác vụ tìm kiếm từ đơn giản đến cực kỳ phức tạp.
Chương 7: Query DSL - Ngôn Ngữ Giao Tiếp Với Cỗ Máy Tìm Kiếm
Query DSL (Domain Specific Language) là một bộ API phong phú được cung cấp bởi Elasticsearch, được thể hiện dưới dạng JSON, để thực thi các truy vấn. Thay vì một chuỗi truy vấn phẳng như SQL, Query DSL có cấu trúc dạng cây, cho phép bạn lồng các truy vấn vào nhau để thể hiện những logic phức tạp.
7.1. Cấu trúc của một Search Request
Một request tìm kiếm cơ bản đến endpoint /_search có cấu trúc như sau:
POST /products/_search
{
"query": {
// ... Định nghĩa truy vấn của bạn ở đây ...
},
"from": 0,
"size": 10,
"sort": [
{ "created_at": "desc" },
{ "_score": "desc" }
],
"_source": ["id", "name", "price", "brand"],
"aggs": {
// ... Định nghĩa các aggregations (sẽ học ở phần sau) ...
}
}Hãy mổ xẻ từng thành phần:
"query": Đây là nơi chứa logic tìm kiếm chính của bạn. Chúng ta sẽ dành phần lớn thời gian của chương này để khám phá các loại query khác nhau có thể đặt vào đây."from": Vị trí bắt đầu lấy kết quả. Tương đương vớiOFFSETtrong SQL.from: 0nghĩa là bắt đầu từ kết quả đầu tiên."size": Số lượng kết quả tối đa cần trả về. Tương đương vớiLIMITtrong SQL. Cặpfromvàsizeđược dùng để phân trang (pagination)."sort": Mặc định, kết quả được sắp xếp theo điểm relevancy_scoregiảm dần. Bạn có thể tùy chỉnh việc sắp xếp ở đây. Ví dụ trên sẽ ưu tiên sắp xếp theo ngày tạo mới nhất, nếu ngày tạo trùng nhau thì sẽ sắp xếp theo_score."_source": Mặc định, Elasticsearch trả về toàn bộ document gốc. Để tối ưu tốc độ mạng, bạn có thể chỉ định chỉ trả về các trường cần thiết. Nó tương đương vớiSELECT id, name, price...trong SQL."aggs"(hoặc"aggregations"): Dùng để thực hiện các phép toán tổng hợp, thống kê trên tập kết quả. Ví dụ: "đếm số sản phẩm theo từng thương hiệu", "tính giá trung bình". Nó mạnh hơnGROUP BYcủa SQL rất nhiều. Chúng ta sẽ có một chương riêng về nó.
7.2. Hai bối cảnh truy vấn: Query Context và Filter Context
Đây là khái niệm quan trọng nhất, cốt lõi nhất để viết các truy vấn hiệu năng cao trong Elasticsearch. Bỏ qua khái niệm này là sai lầm phổ biến nhất của người mới bắt đầu, dẫn đến các truy vấn chậm chạp không cần thiết.
Hãy tưởng tượng bạn đang làm một bài kiểm tra:
- Query Context (Bối cảnh Truy vấn): Giống như một câu hỏi tự luận. Giám khảo không chỉ hỏi "đúng hay sai", mà còn hỏi "đúng đến mức độ nào?". Elasticsearch sẽ tính toán một điểm số liên quan (
_score) cho mỗi document để đánh giá mức độ khớp của nó với truy vấn. Một document khớp nhiều hơn hoặc khớp ở một trường quan trọng hơn sẽ có_scorecao hơn. Các truy vấn nhưmatchhoạt động trong bối cảnh này. - Filter Context (Bối cảnh Lọc): Giống như một câu hỏi trắc nghiệm. Câu trả lời chỉ có thể là "Yes" hoặc "No", không có "hơi đúng" hay "rất đúng". Elasticsearch chỉ cần kiểm tra xem document có khớp với điều kiện lọc hay không, không cần tốn công sức tính toán
_score.
Tại sao sự khác biệt này lại quan trọng đến vậy?
- Hiệu năng: Các truy vấn trong Filter Context nhanh hơn rất nhiều so với Query Context vì chúng không cần tính điểm.
- Caching: Elasticsearch có thể cache kết quả của các filter. Nếu một filter đã được thực thi trước đó, lần sau gặp lại nó trên một truy vấn khác, Elasticsearch có thể lấy ngay kết quả từ cache mà không cần thực thi lại. Điều này làm tăng tốc độ truy vấn lên đáng kể, đặc biệt với các bộ lọc phổ biến (ví dụ:
status: "published",is_active: true). Query Context không được cache vì điểm_scorecó thể phụ thuộc vào nhiều yếu tố thay đổi.
Quy tắc vàng của Kiến trúc sư: Bất cứ khi nào bạn có một điều kiện truy vấn mà câu trả lời chỉ đơn giản là "khớp" hoặc "không khớp" (Yes/No), hãy LUÔN LUÔN đặt nó vào Filter Context. Chỉ sử dụng Query Context cho các điều kiện tìm kiếm full-text, nơi mà bạn thực sự quan tâm đến mức độ liên quan.
Chúng ta sẽ thấy cách áp dụng quy tắc này trong Chương 10 khi tìm hiểu về bool query.
Chương 8: Các Truy Vấn Full-text - Tìm Kiếm "Giống Như Google"
Đây là các truy vấn hoạt động trong Query Context. Chúng được thiết kế để tìm kiếm trên các trường text đã được phân tích (analyzed).
8.1. match query: Người lính đa năng
Đây là truy vấn full-text tiêu chuẩn và được sử dụng nhiều nhất. Khi bạn đưa một chuỗi tìm kiếm vào match query, nó sẽ làm 2 việc:
- Phân tích chuỗi tìm kiếm: Nó sẽ áp dụng cùng một
analyzerđã được định nghĩa cho trường đó lên chuỗi tìm kiếm của bạn. Ví dụ: "Điện thoại Samsung" sẽ được phân tích thành[điện, thoại, samsung]. - Xây dựng truy vấn con: Nó sẽ tạo ra một
boolquery bên dưới để tìm các document chứa các token này. Mặc định, nó sẽ tìm các document chứa "điện" HOẶC "thoại" HOẶC "samsung".
Ví dụ JSON:
{
"query": {
"match": {
"name": "Điện thoại Samsung"
}
}
}Các tham số quan trọng:
"operator": "and": Thay đổi hành vi mặc định từORthànhAND. Với tham số này, truy vấn trên sẽ chỉ tìm các document chứa tất cả các từ "điện", "thoại", "samsung"."minimum_should_match": "75%": Một cách kiểm soát linh hoạt hơnoperator. Nó yêu cầu một tỷ lệ phần trăm hoặc một số lượng tối thiểu các từ phải khớp. Ví dụ, với chuỗi tìm kiếm có 4 từ vàminimum_should_match: "75%"thì ít nhất 3 từ phải khớp.
8.2. match_phrase query: Tìm kiếm cụm từ chính xác
match_phrase cũng phân tích chuỗi tìm kiếm, nhưng nó yêu cầu các token phải xuất hiện theo đúng thứ tự và liền kề nhau trong document.
Ví dụ JSON:
{
"query": {
"match_phrase": {
"description": "máy tính xách tay"
}
}
}Truy vấn này sẽ tìm thấy "máy tính xách tay mạnh mẽ", nhưng sẽ không tìm thấy "máy tính mạnh mẽ xách tay".
8.3. match_phrase_prefix query: Gợi ý tìm kiếm (search-as-you-type)
Truy vấn này tương tự match_phrase, nhưng từ cuối cùng trong chuỗi tìm kiếm được coi là một tiền tố (prefix). Nó cực kỳ hữu ích để xây dựng tính năng autocomplete.
Ví dụ JSON: Khi người dùng gõ "macbook pro 1", truy vấn này sẽ được gửi đi:
{
"query": {
"match_phrase_prefix": {
"name": "macbook pro 1"
}
}
}Nó có thể khớp với các sản phẩm có tên "Macbook Pro 13 inch", "Macbook Pro 16 inch"...
8.4. multi_match query: Tìm kiếm trên nhiều trường
Trong thực tế, người dùng muốn tìm kiếm trên nhiều trường cùng lúc (ví dụ: tên, mô tả, tên thương hiệu). multi_match là công cụ hoàn hảo cho việc này.
Ví dụ JSON:
{
"query": {
"multi_match": {
"query": "iphone 14 pro",
"fields": ["name^3", "description", "brand.name"],
"type": "best_fields"
}
}
}"fields": Một mảng các trường cần tìm kiếm."name^3": Dấu^được dùng để "boost" (tăng trọng số). Ở đây, chúng ta nói với Elasticsearch rằng một kết quả khớp ở trườngnamesẽ quan trọng gấp 3 lần so với khớp ởdescriptionhoặcbrand.name. Điều này ảnh hưởng trực tiếp đến_score.
Phân tích các loại type của multi_match:
"type": "best_fields"(mặc định): Tìm kiếm trên từng trường một cách độc lập và lấy điểm_scorecao nhất từ bất kỳ trường nào. Rất phù hợp khi bạn tìm kiếm các thực thể khác nhau (như tên và mô tả) và muốn ưu tiên kết quả khớp nhất ở một trường duy nhất. Đây là loại phổ biến nhất."type": "most_fields": Tìm kiếm trên tất cả các trường và cộng điểm_scoretừ các trường khớp lại. Hữu ích khi bạn muốn tìm các document khớp với càng nhiều trường càng tốt. Ví dụ, tìm một bài blog khớp từ khóa trong cả tiêu đề, nội dung và tags."type": "cross_fields": Xử lý các trường như thể chúng là một trường lớn duy nhất. Rất hữu ích khi các trường có cùng một khái niệm. Ví dụ: tìm "John Smith" trên các trườngfirst_namevàlast_name.cross_fieldssẽ hiểu "John" trongfirst_namevà "Smith" tronglast_namelà một sự khớp tốt, trong khibest_fieldssẽ coi chúng là hai sự khớp riêng biệt yếu hơn.
Chương 9: Các Truy Vấn Term-level - Tìm Kiếm Dữ Liệu Có Cấu Trúc
Các truy vấn này hoạt động trong Filter Context (mặc dù cũng có thể dùng trong Query Context, nhưng ít phổ biến hơn). Chúng được dùng để tìm kiếm các giá trị chính xác trên các trường không được phân tích (analyzed), chủ yếu là các trường keyword, numeric, date, boolean.
9.1. Sự khác biệt chết người giữa text và keyword
Hãy nhắc lại:
- Tìm "Apple" trên trường
textcó thể khớp với "apple", "APPLE". - Tìm "Apple" trên trường
keywordsẽ CHỈ khớp với "Apple". Nó phân biệt chữ hoa/thường và coi toàn bộ chuỗi là một giá trị duy nhất.
Sử dụng sai truy vấn trên sai kiểu dữ liệu là một trong những lỗi phổ biến nhất. Dùng term query trên trường text thường sẽ không trả về kết quả như mong đợi.
9.2. term query: Tìm kiếm giá trị chính xác
Đây là truy vấn cơ bản nhất để lọc. Use case: Lọc sản phẩm có status: "published", hoặc SKU: "ABC-12345".
Ví dụ JSON:
{
"query": {
"term": {
"brand.name.keyword": "Samsung"
}
}
}Lưu ý: Chúng ta đang query trên brand.name.keyword, không phải brand.name. Vì brand.name là kiểu text (để tìm kiếm full-text), còn sub-field .keyword của nó mới là nơi lưu trữ giá trị gốc, không bị phân tích, để chúng ta lọc chính xác. Đây là ứng dụng của multi-fields đã học ở Chương 5.
9.3. terms query: Tìm kiếm một trong nhiều giá trị
Tương đương với mệnh đề IN trong SQL. Use case: Lọc các sản phẩm thuộc danh mục 1, 5, hoặc 9.
Ví dụ JSON:
{
"query": {
"terms": {
"categories.id": [1, 5, 9]
}
}
}9.4. range query: Truy vấn theo khoảng
Dùng cho các trường số (integer, double) và ngày (date). Use case: Tìm sản phẩm có giá từ 10.000.000 đến 20.000.000, tìm các bài viết trong 30 ngày qua.
Ví dụ JSON:
{
"query": {
"range": {
"price": {
"gte": 10000000, // greater than or equal
"lte": 20000000 // less than or equal
}
}
}
}Các toán tử khác bao gồm gt (greater than), lt (less than). Với trường date, bạn có thể dùng các biểu thức ngày tháng như "now-1M/M" (một tháng qua, làm tròn đến đầu tháng).
9.5. exists query: Kiểm tra sự tồn tại của một trường
Tìm các document có chứa một trường cụ thể (không phải null). Use case: Tìm tất cả các sản phẩm có khuyến mãi (trường discount_price tồn tại).
9.6. wildcard và regexp: Thận trọng khi sử dụng!
wildcard: Cho phép tìm kiếm với các ký tự đại diện như*(khớp với 0 hoặc nhiều ký tự) và?(khớp với 1 ký tự).regexp: Cho phép tìm kiếm với biểu thức chính quy (regular expression).
Cảnh báo từ lão làng: Hãy hạn chế tối đa việc sử dụng hai loại truy vấn này, đặc biệt là với ký tự đại diện ở đầu chuỗi (ví dụ
"*term"hoặc".*term"). Các truy vấn này cực kỳ chậm và ngốn nhiều CPU vì chúng không thể tận dụng Inverted Index một cách hiệu quả và phải quét qua tất cả các term trong từ điển. Nếu bạn cần tính năng autocomplete, hãy dùngmatch_phrase_prefixhoặc các kiểu dữ liệu chuyên dụng hơn nhưsearch_as_you_type.
Chương 10: Truy Vấn Kết Hợp - The Almighty bool Query
Đây chính là lúc chúng ta kết hợp tất cả những gì đã học. bool query cho phép bạn kết hợp nhiều truy vấn lá (leaf queries) lại với nhau bằng các toán tử logic quen thuộc. Đây là truy vấn mà bạn sẽ sử dụng trong 95% các kịch bản tìm kiếm thực tế.
10.1. Cấu trúc của bool query
bool query có 4 mệnh đề:
must: Tương đươngAND. Tất cả các truy vấn con trong mệnh đề này phải khớp. Các truy vấn này hoạt động trong Query Context, tức là chúng có đóng góp vào việc tính_score.filter: Cũng tương đươngAND. Tất cả các truy vấn con trong mệnh đề này phải khớp. Nhưng chúng hoạt động trong Filter Context, tức là chúng không tính_scorevà có thể được cache. Đây là nơi để đặt tất cả cácterm,terms,rangequery.should: Tương đươngOR. Ít nhất một truy vấn con trong mệnh đề này phải khớp (trừ khi cómusthoặcfilter, lúc đóshouldchỉ dùng để tăng điểm). Các truy vấn này hoạt động trong Query Context và đóng góp vào_score.must_not: Tương đươngNOT. Tất cả các truy vấn con trong mệnh đề này phải không khớp. Chúng hoạt động trong Filter Context.
10.2. Xây dựng truy vấn trong Golang và Ví dụ thực chiến
Lý thuyết đã đủ, giờ là lúc code. Chúng ta sẽ xây dựng một hàm SearchProducts trong ProductRepository để thực hiện một truy vấn phức tạp cho trang e-commerce.
Yêu cầu nghiệp vụ:
- Tìm kiếm sản phẩm theo một chuỗi văn bản (ví dụ: "điện thoại samsung"). Chuỗi này cần được tìm trong
name(quan trọng nhất),description, vàbrand.name. - Lọc theo một danh sách các brand ID (ví dụ: chỉ lấy Samsung, Apple).
- Lọc theo một khoảng giá.
- Lọc các sản phẩm phải còn hàng (
is_active: true). - Có thể tùy chọn loại trừ một số tag nhất định (ví dụ: không lấy hàng "trưng bày").
- Ưu tiên hiển thị các sản phẩm có tag là "hàng mới" hoặc "khuyến mãi".
- Hỗ trợ phân trang.
Bước 1: Định nghĩa các struct cho query và kết quả
Việc dùng struct thay vì map[string]interface{} sẽ giúp code của bạn an toàn, dễ đọc và dễ bảo trì hơn rất nhiều.
// file: internal/product/repository.go
// ... các import và struct cũ
type SearchCriteria struct {
Query string
BrandIDs []int
MinPrice float64
MaxPrice float64
TagsExclude []string
TagsBoost []string
From int
Size int
}
type SearchResult struct {
Total int64
Products []*Product
}Bước 2: Viết hàm SearchProducts
// ... trong file internal/product/repository.go
func (r *ProductRepository) SearchProducts(ctx context.Context, criteria SearchCriteria) (*SearchResult, error) {
var body strings.Builder
// Xây dựng phần query
query := map[string]interface{}{
"bool": map[string]interface{}{},
}
// Lấy ra bool query để dễ dàng thêm các mệnh đề
boolQuery := query["bool"].(map[string]interface{})
// 1. Mệnh đề `must` cho tìm kiếm full-text
if criteria.Query != "" {
boolQuery["must"] = []map[string]interface{}{
{
"multi_match": map[string]interface{}{
"query": criteria.Query,
"fields": []string{"name^3", "description", "brand.name"},
"type": "best_fields",
},
},
}
}
// 2. Mệnh đề `filter` cho các điều kiện lọc chính xác (NHANH!)
filters := []map[string]interface{}{}
filters = append(filters, map[string]interface{}{
"term": map[string]interface{}{
"is_active": true, // Chỉ lấy sản phẩm còn hàng
},
})
if len(criteria.BrandIDs) > 0 {
filters = append(filters, map[string]interface{}{
"terms": map[string]interface{}{
"brand.id": criteria.BrandIDs,
},
})
}
if criteria.MinPrice > 0 || criteria.MaxPrice > 0 {
rangeQuery := map[string]interface{}{}
if criteria.MinPrice > 0 {
rangeQuery["gte"] = criteria.MinPrice
}
if criteria.MaxPrice > 0 {
rangeQuery["lte"] = criteria.MaxPrice
}
filters = append(filters, map[string]interface{}{
"range": map[string]interface{}{
"price": rangeQuery,
},
})
}
boolQuery["filter"] = filters
// 3. Mệnh đề `must_not` để loại trừ
if len(criteria.TagsExclude) > 0 {
boolQuery["must_not"] = []map[string]interface{}{
{
"terms": map[string]interface{}{
"tags": criteria.TagsExclude,
},
},
}
}
// 4. Mệnh đề `should` để tăng điểm (boost)
if len(criteria.TagsBoost) > 0 {
shoulds := []map[string]interface{}{}
for _, tag := range criteria.TagsBoost {
shoulds = append(shoulds, map[string]interface{}{
"term": map[string]interface{}{
"tags": tag,
},
})
}
boolQuery["should"] = shoulds
// Nếu có must/filter, should chỉ dùng để boost score.
// Nếu không có must/filter, ta cần `minimum_should_match: 1` để OR hoạt động
}
// Đóng gói toàn bộ request
searchRequest := map[string]interface{}{
"query": query,
"from": criteria.From,
"size": criteria.Size,
"sort": []map[string]interface{}{
{"_score": "desc"}, // Sắp xếp theo điểm relevancy
{"created_at": "desc"},
},
}
// Mã hóa request body
if err := json.NewEncoder(&body).Encode(searchRequest); err != nil {
return nil, fmt.Errorf("error encoding query: %w", err)
}
// Thực hiện tìm kiếm
res, err := r.es.Search(
r.es.Search.WithContext(ctx),
r.es.Search.WithIndex(IndexNameProducts),
r.es.Search.WithBody(strings.NewReader(body.String())),
r.es.Search.WithTrackTotalHits(true), // Lấy tổng số kết quả chính xác
)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return nil, fmt.Errorf("search request has errors: %s", res.String())
}
// Parse kết quả trả về
var esResponse struct {
Hits struct {
Total struct {
Value int64 `json:"value"`
} `json:"total"`
Hits []struct {
Score float64 `json:"_score"`
Source Product `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
if err := json.NewDecoder(res.Body).Decode(&esResponse); err != nil {
return nil, fmt.Errorf("error decoding search response: %w", err)
}
result := &SearchResult{
Total: esResponse.Hits.Total.Value,
Products: make([]*Product, len(esResponse.Hits.Hits)),
}
for i, hit := range esResponse.Hits.Hits {
result.Products[i] = &hit.Source
}
return result, nil
}Phân tích code:
- Chúng ta đã xây dựng động một
boolquery dựa trên các tiêu chí đầu vào. Đây là cách làm rất phổ biến. multi_matchđược đặt trongmust, vì nó là điều kiện tìm kiếm chính và cần tính điểm.- Tất cả các điều kiện lọc Yes/No (
is_active,brand.id,price) đều được đặt trongfilterđể tận dụng tốc độ và cache. must_notđược dùng để loại bỏ các document không mong muốn.shouldđược dùng để tăng điểm cho các document có các tag đặc biệt, giúp chúng nổi bật hơn trong kết quả tìm kiếm.r.es.Search.With...là cách dùng "functional options" của thư việngo-elasticsearch, giúp code dễ đọc hơn.WithTrackTotalHits(true)rất quan trọng để lấy được tổng số kết quả chính xác cho việc phân trang.- Cuối cùng, chúng ta parse response JSON để trích xuất tổng số kết quả và danh sách các document.
PHẦN 4: AGGREGATIONS - BIẾN DỮ LIỆU THÀNH TRI THỨC
Nếu như Query DSL là cách bạn đặt câu hỏi "Cái gì?" (Tìm cho tôi các sản phẩm X), thì Aggregations là cách bạn đặt những câu hỏi sâu hơn: "Bao nhiêu?", "Như thế nào?", và "Tại sao?". Nó cho phép bạn thực hiện các phép toán phức tạp trên một tập dữ liệu, tóm tắt và trích xuất những insight quý giá mà việc chỉ nhìn vào từng document riêng lẻ không thể nào thấy được.
Trong thế giới SQL, bạn có GROUP BY kết hợp với các hàm như COUNT(), SUM(), AVG(). Aggregations của Elasticsearch làm được tất cả những điều đó và còn hơn thế nữa, với một sự linh hoạt và sức mạnh đáng kinh ngạc.
Chương 11: Tư Duy Aggregation - Buckets và Metrics
Để hiểu về aggregations, bạn cần nắm vững hai khái niệm cốt lõi: Buckets (Những chiếc xô) và Metrics (Các chỉ số).
Hãy tưởng tượng bạn có một đống lego khổng lồ (toàn bộ các document sản phẩm của bạn).
- Buckets: Là quá trình bạn phân loại đống lego đó vào những chiếc xô khác nhau dựa trên một tiêu chí nào đó.
- "Chia lego theo màu sắc": Mỗi chiếc xô sẽ chứa lego của một màu (đỏ, xanh, vàng...). Đây là một
TermsAggregation theo trườngcolor. - "Chia lego theo kích thước": Xô 1 chứa lego 1x1, xô 2 chứa lego 2x2, xô 3 chứa lego 2x4... Đây cũng là một
TermsAggregation theo trườngsize. - "Chia lego theo giá": Xô 1 chứa các sản phẩm dưới 1 triệu, xô 2 từ 1-5 triệu, xô 3 trên 5 triệu. Đây là một
RangeAggregation.
- "Chia lego theo màu sắc": Mỗi chiếc xô sẽ chứa lego của một màu (đỏ, xanh, vàng...). Đây là một
- Metrics: Là quá trình bạn thực hiện một phép tính toán trên những viên lego bên trong mỗi chiếc xô.
- "Đếm số lego trong mỗi xô màu": Đây là metric
doc_count(mặc định có sẵn). - "Tính tổng giá trị của các sản phẩm trong mỗi xô thương hiệu": Đây là một
SumAggregation trên trườngprice. - "Tìm giá sản phẩm cao nhất trong mỗi xô danh mục": Đây là một
MaxAggregation.
- "Đếm số lego trong mỗi xô màu": Đây là metric
Sức mạnh thực sự: Elasticsearch cho phép bạn lồng các aggregation vào nhau. Bạn có thể đặt những chiếc xô nhỏ hơn bên trong những chiếc xô lớn.
Ví dụ: "Đầu tiên, chia sản phẩm theo thương hiệu (xô lớn). Sau đó, bên trong mỗi xô thương hiệu, lại chia tiếp theo danh mục (xô nhỏ). Cuối cùng, trong mỗi xô danh mục nhỏ đó, hãy tính giá trung bình."
Đây là một cấu trúc lồng nhau: Terms Aggregation (brand) -> Terms Aggregation (category) -> Avg Aggregation (price). Khả năng này vượt xa một câu lệnh GROUP BY brand, category đơn giản của SQL.
Cấu trúc trong một Search Request: Aggregations được định nghĩa trong mục "aggs" (hoặc "aggregations") ở cùng cấp với "query".
POST /products/_search
{
"query": {
"match_all": {} // Áp dụng aggregation trên tất cả document
},
"aggs": {
"ten_agg_cua_ban": {
"loai_agg": {
// ... cấu hình của aggregation ...
},
"aggs": { // Lồng một aggregation khác vào đây
"ten_agg_con": {
// ...
}
}
}
},
"size": 0 // Rất quan trọng!
}Quy tắc vàng: Khi bạn chỉ quan tâm đến kết quả của aggregation mà không cần danh sách các document khớp, hãy đặt
"size": 0. Điều này báo cho Elasticsearch không cần tốn công sức lấy và trả về các document trong phầnhits, giúp truy vấn nhanh hơn đáng kể.
Chương 12: Bucket Aggregations - Nghệ Thuật Phân Loại
Đây là những aggregation tạo ra các "xô" (buckets). Mỗi document sẽ được đặt vào một hoặc nhiều xô dựa trên giá trị trường của nó.
12.1. Terms Aggregation: Người bạn thân của GROUP BY
Đây là bucket aggregation phổ biến nhất. Nó tạo ra một bucket cho mỗi giá trị duy nhất tìm thấy trong một trường. Use case:
- Đếm số sản phẩm theo từng thương hiệu.
- Liệt kê 10 tags được sử dụng nhiều nhất.
- Tìm ra những quốc gia có nhiều người dùng nhất.
Ví dụ: Đếm số sản phẩm cho mỗi thương hiệu.
JSON Request:
POST /products/_search
{
"size": 0,
"aggs": {
"products_by_brand": {
"terms": {
"field": "brand.name.keyword",
"size": 10
}
}
}
}"products_by_brand": Là tên bạn đặt cho aggregation này. Tên này sẽ được dùng làm key trong kết quả trả về."field": "brand.name.keyword": Cực kỳ quan trọng! Luôn luôn thực hiệntermsaggregation trên một trườngkeyword(hoặc các trường không bị phân tích khác như numeric, date). Nếu bạn chạy nó trên trườngtext, nó sẽ aggregate trên từng token riêng lẻ ("samsung", "galaxy" thay vì "Samsung Galaxy"), dẫn đến kết quả sai."size": 10: Trả về 10 buckets (thương hiệu) có nhiều document nhất.
JSON Response (lược bỏ):
{
"aggregations": {
"products_by_brand": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 50,
"buckets": [
{
"key": "Apple",
"doc_count": 125
},
{
"key": "Samsung",
"doc_count": 110
},
{
"key": "Xiaomi",
"doc_count": 85
}
// ... 7 buckets khác
]
}
}
}"buckets": Mảng chứa các kết quả."key": Giá trị của trường mà chúng ta group by (ví dụ: "Apple")."doc_count": Số lượng document trong bucket đó.
12.2. Range & Date Range Aggregation: Phân loại theo khoảng tùy chỉnh
Cho phép bạn tự định nghĩa các khoảng (ranges) và Elasticsearch sẽ tạo một bucket cho mỗi khoảng đó. Use case:
- Phân loại sản phẩm theo các khoảng giá: "Dưới 1 triệu", "1-5 triệu", "5-10 triệu", "Trên 10 triệu".
- Phân tích bài viết theo thời gian đăng: "Trong tuần này", "Trong tháng này", "Trong năm nay".
Ví dụ: Phân loại sản phẩm theo giá.
POST /products/_search
{
"size": 0,
"aggs": {
"products_by_price": {
"range": {
"field": "price",
"ranges": [
{ "key": "Dưới 5 triệu", "to": 5000000 },
{ "key": "Từ 5-15 triệu", "from": 5000000, "to": 15000000 },
{ "key": "Trên 15 triệu", "from": 15000000 }
]
}
}
}
}"key": Bạn có thể đặt một cái tên tùy ý cho mỗi bucket để kết quả dễ đọc hơn.
12.3. Histogram & Date Histogram Aggregation: Phân loại theo khoảng cố định
Thay vì tự định nghĩa các khoảng, bạn chỉ cần đưa ra một khoảng cách (interval) cố định, Elasticsearch sẽ tự động tạo ra các buckets. Use case:
Histogram: Vẽ biểu đồ phân bố giá sản phẩm, với mỗi cột là một khoảng giá 1.000.000.Date Histogram: Cực kỳ mạnh mẽ cho time-series data. Vẽ biểu đồ số lượng đơn hàng theo từng ngày, từng tuần, hoặc từng tháng. Đây là aggregation nền tảng cho hầu hết các biểu đồ thời gian trên Kibana.
Ví dụ: Thống kê số lượng sản phẩm được tạo ra theo từng tháng.
POST /products/_search
{
"size": 0,
"aggs": {
"products_per_month": {
"date_histogram": {
"field": "created_at",
"calendar_interval": "month",
"format": "yyyy-MM"
}
}
}
}"calendar_interval": "month": Chỉ định khoảng cách là một tháng dương lịch. Các giá trị khác có thể làday,week,quarter,year..."format": Định dạng cho giá trịkeytrả về.
12.4. Nested Aggregation: Xử lý dữ liệu lồng nhau
Nhớ lại ở Chương 5, chúng ta đã map trường categories là kiểu nested để giữ lại mối quan hệ giữa id và name của mỗi category. Nếu bạn cố gắng thực hiện một terms aggregation thông thường trên categories.name.keyword, kết quả sẽ sai vì nó không hiểu được cấu trúc lồng.
Bạn cần bọc aggregation của mình trong một nested aggregation. Use case: Đếm số sản phẩm theo tên danh mục, khi categories là một mảng các object lồng nhau.
JSON Request:
POST /products/_search
{
"size": 0,
"aggs": {
"all_categories": {
"nested": {
"path": "categories" // Chỉ định đường dẫn đến trường nested
},
"aggs": {
"category_names": {
"terms": {
"field": "categories.name.keyword"
}
}
}
}
}
}nested aggregation sẽ "đi vào" mảng categories, xử lý mỗi object như một document riêng, sau đó terms aggregation bên trong nó sẽ hoạt động chính xác.
Chương 13: Metric Aggregations - Các Phép Toán Trên Dữ Liệu
Metric aggregations tính toán các giá trị trên các document trong một bucket. Chúng thường được lồng bên trong các bucket aggregations.
13.1. Các Metric cơ bản: Sum, Avg, Min, Max, Value Count
Sum: Tính tổng.Avg: Tính trung bình.Min: Tìm giá trị nhỏ nhất.Max: Tìm giá trị lớn nhất.Value Count: Đếm số lượng giá trị (khác vớidoc_countđếm số document). Hữu ích cho các trường mảng.
Ví dụ: Với mỗi thương hiệu, tính tổng doanh thu (giả sử giá * số lượng) và giá trung bình. (Lưu ý: phép nhân cần được thực hiện bằng script, chúng ta sẽ đơn giản hóa bằng cách tính tổng giá).
POST /products/_search
{
"size": 0,
"aggs": {
"sales_by_brand": {
"terms": {
"field": "brand.name.keyword"
},
"aggs": {
"total_revenue": {
"sum": { "field": "price" }
},
"average_price": {
"avg": { "field": "price" }
}
}
}
}
}Kết quả trả về cho mỗi bucket thương hiệu sẽ chứa thêm các mục total_revenue và average_price.
13.2. Stats & Extended Stats: Lấy nhiều chỉ số trong một lần
Thay vì phải định nghĩa 5 aggregation riêng lẻ (min, max, sum, avg, count), bạn có thể dùng một stats aggregation duy nhất để lấy tất cả chúng. Ví dụ:
"aggs": {
"price_stats": {
"stats": { "field": "price" }
}
}Response sẽ chứa một object price_stats với các trường count, min, max, avg, sum. extended_stats còn cung cấp thêm sum_of_squares, variance, std_deviation.
13.3. Cardinality Aggregation: Đếm số lượng giá trị duy nhất
Tương đương với COUNT(DISTINCT column) trong SQL. Use case:
- Có bao nhiêu khách hàng duy nhất đã mua hàng trong tháng này?
- Website của bạn có bao nhiêu địa chỉ IP truy cập duy nhất?
Ví dụ: Đếm số thương hiệu duy nhất có trong index.
POST /products/_search
{
"size": 0,
"aggs": {
"unique_brands": {
"cardinality": {
"field": "brand.id"
}
}
}
}Cảnh báo từ lão làng:
cardinalityaggregation là một phép toán gần đúng (approximate). Nó sử dụng một thuật toán gọi là HyperLogLog++ để ước tính số lượng giá trị duy nhất mà không cần tốn quá nhiều bộ nhớ. Với hàng triệu giá trị duy nhất, nó cực kỳ hiệu quả. Tuy nhiên, kết quả có thể có một sai số nhỏ. Bạn có thể tăng độ chính xác bằng tham sốprecision_threshold, nhưng sẽ phải trả giá bằng việc sử dụng nhiều RAM hơn. Với các trường hợp yêu cầu chính xác 100% và số lượng giá trị duy nhất không quá lớn, hãy dùngtermsaggregation và đếm số lượng bucket.
Chương 14: Xây Dựng Báo Cáo Phức Tạp Bằng Code Golang
Giờ là lúc kết hợp tất cả lại để trả lời một câu hỏi nghiệp vụ phức tạp bằng Go.
Yêu cầu nghiệp vụ: Tạo một báo cáo tổng quan về sản phẩm, bao gồm:
- Tổng số sản phẩm đang active.
- Giá trung bình của tất cả sản phẩm.
- Phân loại sản phẩm theo 5 thương hiệu hàng đầu.
- Trong mỗi thương hiệu đó, cho biết số liệu thống kê về giá (min, max, avg).
- Cũng trong mỗi thương hiệu đó, liệt kê 3 danh mục phổ biến nhất của họ.
Đây là một truy vấn lồng nhau 3 cấp độ.
Bước 1: Viết hàm trong ProductRepository
// file: internal/product/repository.go
// ...
// Structs để parse kết quả aggregation phức tạp
type PriceStats struct {
Count int64 `json:"count"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Avg float64 `json:"avg"`
Sum float64 `json:"sum"`
}
type CategoryBucket struct {
Key string `json:"key"`
DocCount int64 `json:"doc_count"`
}
type BrandBucket struct {
Key string `json:"key"`
DocCount int64 `json:"doc_count"`
PriceStats PriceStats `json:"price_stats"`
TopCategories struct {
Buckets []CategoryBucket `json:"buckets"`
} `json:"top_categories"`
}
type DashboardReport struct {
TotalActiveProducts int64
AvgProductPrice float64
BrandBreakdown []BrandBucket
}
func (r *ProductRepository) GetDashboardReport(ctx context.Context) (*DashboardReport, error) {
// Xây dựng query JSON bằng map[string]interface{}
query := map[string]interface{}{
"size": 0, // Không cần document hits
"query": map[string]interface{}{
"term": map[string]interface{}{
"is_active": true, // Chỉ tính trên các sản phẩm active
},
},
"aggs": map[string]interface{}{
"avg_price": map[string]interface{}{
"avg": map[string]interface{}{"field": "price"},
},
"brands": map[string]interface{}{
"terms": map[string]interface{}{
"field": "brand.name.keyword",
"size": 5,
},
"aggs": map[string]interface{}{
"price_stats": map[string]interface{}{
"stats": map[string]interface{}{"field": "price"},
},
"top_categories": map[string]interface{}{
"nested": map[string]interface{}{
"path": "categories",
},
"aggs": map[string]interface{}{
"names": map[string]interface{}{
"terms": map[string]interface{}{
"field": "categories.name.keyword",
"size": 3,
},
},
},
},
},
},
},
}
var body strings.Builder
if err := json.NewEncoder(&body).Encode(query); err != nil {
return nil, fmt.Errorf("error encoding aggregation query: %w", err)
}
// Thực hiện search request
res, err := r.es.Search(
r.es.Search.WithContext(ctx),
r.es.Search.WithIndex(IndexNameProducts),
r.es.Search.WithBody(strings.NewReader(body.String())),
)
if err != nil {
return nil, fmt.Errorf("aggregation request failed: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return nil, fmt.Errorf("aggregation request has errors: %s", res.String())
}
// Parse response JSON vào các struct đã định nghĩa
var esResponse struct {
Hits struct {
Total struct {
Value int64 `json:"value"`
} `json:"total"`
} `json:"hits"`
Aggregations struct {
AvgPrice struct {
Value float64 `json:"value"`
} `json:"avg_price"`
Brands struct {
Buckets []BrandBucket `json:"buckets"`
} `json:"brands"`
} `json:"aggregations"`
}
if err := json.NewDecoder(res.Body).Decode(&esResponse); err != nil {
return nil, fmt.Errorf("error decoding aggregation response: %w", err)
}
// Đóng gói kết quả vào struct báo cáo cuối cùng
report := &DashboardReport{
TotalActiveProducts: esResponse.Hits.Total.Value,
AvgProductPrice: esResponse.Aggregations.AvgPrice.Value,
BrandBreakdown: esResponse.Aggregations.Brands.Buckets,
}
// Chú ý: `top_categories` aggregation bị lồng thêm một cấp do `nested`
// và có tên là `names`. Cần sửa lại struct parse hoặc xử lý ở đây.
// Đây là một ví dụ về sự phức tạp khi parse response.
// Để đơn giản, ta sẽ giả định struct parse đã đúng.
return report, nil
}Phân tích code:
- Xây dựng Query: Chúng ta đã tạo một cấu trúc
map[string]interface{}lồng nhau phức tạp để định nghĩa toàn bộ aggregation. Điều này thể hiện sức mạnh của Query DSL. - Lọc trước khi Aggregate: Lưu ý rằng chúng ta đặt một
queryở cấp cao nhất để lọcis_active: true. Điều này đảm bảo tất cả các aggregation sau đó chỉ được tính trên tập con dữ liệu này. Đây là một kỹ thuật cực kỳ phổ biến. - Parse Response: Parsing response của aggregation có thể phức tạp. Việc định nghĩa các
structrõ ràng cho từng phần của kết quả (nhưBrandBucket,PriceStats) giúp cho việc truy cập dữ liệu sau này trở nên an toàn và dễ dàng hơn rất nhiều so với việc loay hoay vớimap[string]interface{}. - Thực thi: Quá trình gửi request và nhận response vẫn tương tự như search query thông thường, chỉ khác ở phần body của request và cấu trúc của response.
Tuyệt vời! Chúng ta đã xây dựng được một nền tảng cực kỳ vững chắc về cách thao tác và khai thác dữ liệu trong Elasticsearch. Giờ là lúc kết nối tất cả các mảnh ghép lại, đưa chúng vào một ứng dụng web thực tế, và sau đó mài giũa chúng để đạt được hiệu năng cao nhất. Phần này sẽ tập trung vào việc tích hợp logic Elasticsearch vào Echo framework, đồng thời trang bị cho bạn những tư duy và kỹ thuật tối ưu hóa của một DBA/Kiến trúc sư lão làng.
PHẦN 5: TÍCH HỢP & TỐI ƯU HÓA TRONG DỰ ÁN GOLANG ECHO
Lý thuyết và các hàm riêng lẻ là một chuyện, nhưng việc tích hợp chúng một cách mượt mà, dễ bảo trì, và hiệu năng cao trong một framework cụ thể như Echo lại là một câu chuyện khác. Chúng ta sẽ xây dựng các API endpoint thực tế, xử lý request, gọi đến tầng repository đã viết, và đồng thời tìm hiểu các bí quyết để "vắt kiệt" hiệu năng từ Elasticsearch.
Chương 15: Tích Hợp Elasticsearch vào Echo Framework - Xây Dựng API Toàn Diện
Chúng ta sẽ quay lại với cấu trúc dự án đã định nghĩa ở Phần 1 và điền code vào các tầng handler, service.
Mô hình luồng dữ liệu:HTTP Request -> Echo Handler -> Service Layer -> Repository Layer (Elasticsearch)
- Handler: Chịu trách nhiệm nhận request HTTP, validate input (query params, request body), gọi đến service, và trả về response HTTP (JSON, status code). Tầng này không chứa business logic.
- Service: Chứa business logic. Nó điều phối các cuộc gọi đến repository (hoặc nhiều repository khác nhau), xử lý logic nghiệp vụ, và trả kết quả về cho handler.
- Repository: Tầng truy cập dữ liệu. Chỉ chịu trách nhiệm giao tiếp với Elasticsearch, che giấu đi sự phức tạp của Query DSL.
15.1. Thiết lập Server Echo và Dependency Injection
Đầu tiên, hãy tạo file internal/server/server.go để khởi tạo mọi thứ. Chúng ta sẽ sử dụng Dependency Injection (DI) để "tiêm" các dependencies (như ES client, repository, service) vào nơi cần chúng. Điều này giúp code dễ test và bảo trì hơn.
// file: internal/server/server.go
package server
import (
"log"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/your-username/golang-es-project/internal/es_client"
"github.com/your-username/golang-es-project/internal/product"
)
func Run() {
e := echo.New()
// Middlewares
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// === Dependency Injection ===
// 1. Khởi tạo ES client
esClient := es_client.GetESClient()
// 2. Khởi tạo Repository
productRepo := product.NewProductRepository(esClient)
// Rất quan trọng: Khởi tạo index khi ứng dụng bắt đầu
// Trong production, bước này có thể được thực hiện bởi một script riêng (migration script)
// thay vì chạy mỗi khi app khởi động.
err := productRepo.CreateIndexIfNotExists(context.Background())
if err != nil {
log.Fatalf("Failed to create index on startup: %v", err)
}
// 3. Khởi tạo Service
productSvc := product.NewProductService(productRepo)
// 4. Khởi tạo Handler (tiêm service vào handler)
productHandler := product.NewProductHandler(productSvc)
// === Routes ===
apiGroup := e.Group("/api/v1")
// Đăng ký các routes cho product
product.RegisterProductRoutes(apiGroup, productHandler)
// Start server
log.Println("Starting server on :8080")
if err := e.Start(":8080"); err != nil {
log.Fatal(err)
}
}Và file cmd/main.go sẽ chỉ còn một dòng:
// file: cmd/main.go
package main
import "github.com/your-username/golang-es-project/internal/server"
func main() {
server.Run()
}15.2. Xây dựng Tầng Service và Handler cho Product
Tầng Service (internal/product/service.go):
package product
import "context"
// IProductRepository định nghĩa interface cho repository, giúp cho việc testing (mocking) dễ dàng hơn.
type IProductRepository interface {
IndexProduct(ctx context.Context, p Product) error
GetProductByID(ctx context.Context, id int) (*Product, error)
SearchProducts(ctx context.Context, criteria SearchCriteria) (*SearchResult, error)
IndexProductsBulk(ctx context.Context, products []Product) error
GetDashboardReport(ctx context.Context) (*DashboardReport, error)
}
type ProductService struct {
repo IProductRepository
}
func NewProductService(repo IProductRepository) *ProductService {
return &ProductService{repo: repo}
}
// Các phương thức của service thường sẽ gọi trực tiếp đến repo
// Trong các ứng dụng phức tạp hơn, nó có thể chứa logic như:
// - Kiểm tra quyền hạn người dùng
// - Kết hợp dữ liệu từ nhiều nguồn (ví dụ: lấy thông tin kho từ một service khác)
// - Gửi sự kiện (event) sau khi một hành động được thực hiện
func (s *ProductService) CreateProduct(ctx context.Context, p Product) error {
// Có thể thêm validation logic ở đây
return s.repo.IndexProduct(ctx, p)
}
func (s *ProductService) FindProductByID(ctx context.Context, id int) (*Product, error) {
return s.repo.GetProductByID(ctx, id)
}
func (s *ProductService) Search(ctx context.Context, criteria SearchCriteria) (*SearchResult, error) {
// Đặt các giá trị mặc định cho phân trang
if criteria.Size <= 0 {
criteria.Size = 10
}
return s.repo.SearchProducts(ctx, criteria)
}
func (s *ProductService) CreateProductsBulk(ctx context.Context, products []Product) error {
return s.repo.IndexProductsBulk(ctx, products)
}
func (s *ProductService) GenerateDashboard(ctx context.Context) (*DashboardReport, error) {
return s.repo.GetDashboardReport(ctx, ctx)
}Tầng Handler (internal/product/handler.go):
package product
import (
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
)
type ProductHandler struct {
svc *ProductService
}
func NewProductHandler(svc *ProductService) *ProductHandler {
return &ProductHandler{svc: svc}
}
// RegisterProductRoutes đăng ký các API endpoint cho product
func RegisterProductRoutes(g *echo.Group, h *ProductHandler) {
productGroup := g.Group("/products")
productGroup.POST("", h.CreateProduct)
productGroup.POST("/_bulk", h.CreateProductsBulk)
productGroup.GET("/_search", h.SearchProducts)
productGroup.GET("/_dashboard", h.GetDashboard)
productGroup.GET("/:id", h.GetProductByID)
}
// POST /api/v1/products
func (h *ProductHandler) CreateProduct(c echo.Context) error {
var p Product
if err := c.Bind(&p); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if err := h.svc.CreateProduct(c.Request().Context(), p); err != nil {
// Log lỗi thực tế ở đây
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create product"})
}
return c.JSON(http.StatusCreated, p)
}
// GET /api/v1/products/:id
func (h *ProductHandler) GetProductByID(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid product ID"})
}
product, err := h.svc.FindProductByID(c.Request().Context(), id)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get product"})
}
if product == nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "product not found"})
}
return c.JSON(http.StatusOK, product)
}
// GET /api/v1/products/_search
func (h *ProductHandler) SearchProducts(c echo.Context) error {
// Parse các query param thành struct SearchCriteria
criteria := SearchCriteria{
Query: c.QueryParam("q"),
}
if brandIDsStr := c.QueryParam("brand_ids"); brandIDsStr != "" {
ids := strings.Split(brandIDsStr, ",")
for _, idStr := range ids {
id, _ := strconv.Atoi(idStr)
if id > 0 {
criteria.BrandIDs = append(criteria.BrandIDs, id)
}
}
}
// Parse các param khác như min_price, max_price, from, size...
// (code lược bỏ cho ngắn gọn)
result, err := h.svc.Search(c.Request().Context(), criteria)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "search failed"})
}
return c.JSON(http.StatusOK, result)
}
// POST /api/v1/products/_bulk
func (h *ProductHandler) CreateProductsBulk(c echo.Context) error {
var products []Product
if err := c.Bind(&products); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if err := h.svc.CreateProductsBulk(c.Request().Context(), products); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"status": "success", "indexed": len(products)})
}
// GET /api/v1/products/_dashboard
func (h *ProductHandler) GetDashboard(c echo.Context) error {
report, err := h.svc.GenerateDashboard(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate dashboard"})
}
return c.JSON(http.StatusOK, report)
}Bây giờ, bạn có thể chạy ứng dụng (go run cmd/main.go) và dùng một công cụ như curl hoặc Postman để tương tác với các API của mình. Toàn bộ luồng xử lý đã được kết nối.
Chương 16: Tối Ưu Hóa Hiệu Năng - Tư Duy Của Một DBA 30 Năm Kinh Nghiệm
Viết code chạy đúng là một chuyện, viết code chạy nhanh và hiệu quả trên quy mô lớn lại là một nghệ thuật. Với kinh nghiệm đối mặt với hàng tỷ bản ghi và hàng triệu truy vấn mỗi phút, đây là những bí quyết xương máu của tôi.
16.1. Thiết Kế Mapping (Schema) là Nền Tảng
Mọi vấn đề về hiệu năng đều bắt nguồn từ một thiết kế mapping tồi.
- Dùng đúng kiểu dữ liệu: Đừng bao giờ dùng
textcho một trường mà bạn chỉ cần lọc chính xác. Hãy dùngkeyword. Điều này tiết kiệm không gian đĩa và CPU vì không cần phân tích (analyze) và không cần lưudoc_valuescho full-text search. - Tắt những gì không cần thiết:
- Nếu bạn không bao giờ cần tìm kiếm full-text trên một trường, hãy tắt nó đi:
"index": false. Dữ liệu vẫn được lưu trong_sourcenhưng không thể tìm kiếm được, tiết kiệm đáng kể tài nguyên indexing. - Nếu bạn không cần sắp xếp hay aggregate trên một trường
text, hãy tắtdoc_values:"doc_values": false. - Nếu bạn không cần lấy lại toàn bộ document gốc (ví dụ: chỉ dùng ES để tìm ID rồi query vào DB chính), bạn có thể tắt
_source:"_source": { "enabled": false }.
- Nếu bạn không bao giờ cần tìm kiếm full-text trên một trường, hãy tắt nó đi:
- Cẩn trọng với
nested: Kiểunestedrất mạnh mẽ nhưng cũng tạo ra các document con ẩn, làm tăng số lượng document và có thể ảnh hưởng đến hiệu năng. Chỉ dùng khi thực sự cần truy vấn trên các trường con một cách độc lập. - Sử dụng
ignore_above: Với các trườngkeyword, đặt giới hạnignore_above(ví dụ:256). Bất kỳ chuỗi nào dài hơn giới hạn này sẽ không được index. Điều này ngăn chặn các lỗi "term too long" và các giá trị ngoại lệ (ví dụ: một URL rất dài) làm phình to index.
16.2. Tối Ưu Hóa Truy Vấn (Query Optimization)
- Filter, Filter, và Filter!: Nhắc lại lần thứ N, đây là quy tắc quan trọng nhất. Chuyển mọi điều kiện Yes/No từ
mustsangfiltertrongboolquery. Sự khác biệt về hiệu năng có thể lên tới hàng chục, thậm chí hàng trăm lần do khả năng cache của filter context. - Tránh các truy vấn đắt đỏ: Hạn chế tối đa
wildcard,regexp,scriptquery. Nếu cần tìm kiếm prefix, hãy dùngmatch_phrase_prefix. Nếu cần logic phức tạp, hãy xem xét tiền xử lý dữ liệu lúc index (ingest pipeline) thay vì tính toán lúc truy vấn. - Phân trang hiệu quả:
- Vấn đề của
from/size: Phân trang bằngfrom/sizehoạt động tốt cho vài trang đầu. Nhưng khi bạn truy vấn trang 1000 (from: 10000, size: 10), Elasticsearch phải lấy10010kết quả từ mỗi shard, tập hợp chúng lại trên coordinating node, sắp xếp, rồi mới bỏ đi 10000 kết quả đầu tiên. Việc này cực kỳ tốn bộ nhớ và CPU. - Giải pháp
search_after: Đây là cách được khuyên dùng cho "deep pagination" hoặc "infinite scroll". Thay vì dùngfrom, bạn sẽ lấy giá trịsortcủa document cuối cùng ở trang hiện tại và truyền nó vào tham sốsearch_aftercho request tiếp theo. Elasticsearch sẽ bắt đầu tìm kiếm ngay sau điểm đó, cực kỳ hiệu quả. - Giải pháp
Point in Time (PIT): Nếu bạn cần giữ một "snapshot" của dữ liệu tại một thời điểm để phân trang nhất quán (tránh việc dữ liệu thay đổi giữa các lần gọi API), hãy sử dụng PIT.
- Vấn đề của
- Sử dụng
_sourcefiltering: Chỉ yêu cầu những trường bạn thực sự cần hiển thị."_source": ["id", "name", "price"]. Giảm lượng dữ liệu truyền qua mạng, đặc biệt hiệu quả khi document của bạn lớn.
16.3. Tối Ưu Hóa Indexing
- Sử dụng Bulk API: Luôn luôn dùng Bulk API cho việc ghi dữ liệu hàng loạt. Đừng bao giờ index từng document trong một vòng lặp.
- Tinh chỉnh Bulk Request:
- Kích thước hợp lý: Kích thước tối ưu cho một bulk request phụ thuộc vào nhiều yếu tố (kích thước document, cấu hình cluster). Một điểm khởi đầu tốt là khoảng 5-15MB mỗi request. Đừng gửi request quá nhỏ (overhead cao) hoặc quá lớn (gây áp lực bộ nhớ lớn cho node).
- Thực hiện song song: Gửi nhiều bulk request cùng lúc từ nhiều goroutine để tận dụng tối đa băng thông và khả năng xử lý của cluster. Sử dụng
semaphorehoặcworker poolđể kiểm soát số lượng request đồng thời.
- Tối ưu Refresh Interval:
- Mặc định là
1s. Mỗi lần refresh là một thao tác I/O khá tốn kém. - Khi bạn đang thực hiện một tác vụ index lớn (ví dụ: reindex toàn bộ dữ liệu), hãy tạm thời tăng
refresh_intervallên một giá trị cao hơn (ví dụ30shoặc60s) hoặc thậm chí tắt nó đi (-1). - Sau khi index xong, hãy trả nó về giá trị mặc định.
- Lệnh:
PUT /your_index/_settings { "index": { "refresh_interval": "30s" } }
- Mặc định là
- Số lượng Replicas: Tương tự, khi đang index dữ liệu lớn, tạm thời đặt
number_of_replicasvề0. Điều này giúp giảm một nửa công việc ghi (chỉ cần ghi vào primary shard). Sau khi index xong, hãy đặt lại số replica về giá trị mong muốn. Elasticsearch sẽ tự động sao chép dữ liệu.
16.4. Kiến Trúc và Vận Hành Cluster
- Cấu hình JVM Heap Size: Đây là cấu hình quan trọng nhất của Elasticsearch.
- Quy tắc: Đặt
XmsvàXmxbằng nhau để tránh JVM thay đổi kích thước heap lúc runtime. - Quy tắc: Không cấp quá 50% RAM của server cho heap. Hệ điều hành cần RAM còn lại cho file system cache, đây là yếu tố cực kỳ quan trọng cho hiệu năng của Lucene.
- Quy tắc: Không cấp quá 30-31GB cho heap. Do một kỹ thuật gọi là "compressed ordinary object pointers" (oops), JVM có thể dùng con trỏ 32-bit cho heap size dưới 32GB, tiết kiệm bộ nhớ đáng kể. Vượt qua ngưỡng này, nó sẽ chuyển sang con trỏ 64-bit và gây lãng phí. Nếu cần nhiều RAM hơn, hãy scale-out (thêm node) thay vì scale-up (tăng RAM một node).
- Quy tắc: Đặt
- Sharding Strategy:
- Số lượng Shard: Như đã nói, con số này không đổi. Hãy lên kế hoạch cho tương lai. Một shard quá lớn (>50GB) có thể chậm chạp, đặc biệt khi cluster cần di chuyển shard đó để cân bằng tải. Một shard quá nhỏ (<1GB) sẽ gây ra "shard overhead", vì mỗi shard đều tiêu tốn một lượng tài nguyên nhất định.
- Time-based Indices: Đối với dữ liệu log hoặc time-series, đừng bao giờ dùng một index duy nhất. Hãy tạo index theo ngày, tuần, hoặc tháng (ví dụ
logs-2023-10-28). Cách làm này mang lại nhiều lợi ích:- Dễ dàng xóa dữ liệu cũ (chỉ cần xóa index cũ, nhanh hơn nhiều so với
delete_by_query). - Các truy vấn thường chỉ nhắm vào dữ liệu gần đây, nên chỉ các index mới được active, các index cũ "lạnh" hơn.
- Bạn có thể dùng Index Lifecycle Management (ILM) để tự động hóa quy trình này: di chuyển index cũ sang các node có ổ cứng chậm hơn (warm/cold tier), và cuối cùng là xóa chúng.
- Dễ dàng xóa dữ liệu cũ (chỉ cần xóa index cũ, nhanh hơn nhiều so với
Tuyệt vời! Chúng ta đã cùng nhau đi một chặng đường rất dài, từ những viên gạch nền tảng đầu tiên cho đến việc xây dựng và tối ưu hóa một tòa nhà vững chắc. Giờ là lúc chúng ta bước lên tầng thượng, ngắm nhìn toàn cảnh và khám phá một vài "căn phòng bí mật" - những chủ đề nâng cao có thể tạo ra sự khác biệt lớn trong các dự án phức tạp. Phần cuối cùng này sẽ là những mảnh ghép hoàn thiện, giúp bạn trở thành một chuyên gia Elasticsearch thực thụ trong hệ sinh thái Golang.
PHẦN 6: CÁC CHỦ ĐỀ NÂNG CAO & BEST PRACTICES CUỐI CÙNG
Phần này sẽ không đi theo một luồng tuần tự như trước, mà sẽ là tập hợp các kỹ thuật, công cụ và tư duy quan trọng mà tôi đã đúc kết được sau nhiều năm chinh chiến. Đây là những kiến thức giúp bạn giải quyết các bài toán khó nhằn, tránh được những cạm bẫy tiềm ẩn và đưa dự án của bạn lên một tầm cao mới.
Chương 17: Mapping Chuyên Sâu - Hơn Cả Các Kiểu Dữ Liệu Cơ Bản
Chúng ta đã làm quen với text, keyword, date, nested... Nhưng sức mạnh của mapping còn đi xa hơn thế.
17.1. Analyzer Tùy Chỉnh (Custom Analyzer) cho Tiếng Việt
standard analyzer của Elasticsearch hoạt động tốt với tiếng Anh, nhưng lại không hiệu quả với tiếng Việt. Nó sẽ chặt câu "điện thoại di động" thành [điện, thoại, di, động], điều này làm mất đi ngữ nghĩa của các cụm từ có hai âm tiết trở lên.
Để tìm kiếm tiếng Việt hiệu quả, chúng ta cần một analyzer tùy chỉnh. Chúng ta có thể kết hợp các char_filter, tokenizer và token_filter có sẵn hoặc cài thêm plugin. Một cách tiếp cận phổ biến là sử dụng icu_tokenizer (hỗ trợ nhiều ngôn ngữ) và icu_folding (loại bỏ dấu).
Ví dụ định nghĩa Analyzer Tiếng Việt trong Mapping:
{
"settings": {
"analysis": {
"analyzer": {
"vietnamese_analyzer": {
"type": "custom",
"tokenizer": "icu_tokenizer",
"filter": [
"icu_folding",
"lowercase"
]
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "vietnamese_analyzer", // Áp dụng analyzer tùy chỉnh
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}Với analyzer này:
icu_tokenizer: Sẽ cố gắng giữ lại các từ ghép một cách thông minh hơnstandard.icu_folding: Sẽ biến "điện thoại" thành "dien thoai". Điều này cho phép người dùng tìm kiếm không dấu vẫn ra kết quả có dấu.lowercase: Đảm bảo tất cả đều là chữ thường.
Khi tìm kiếm, Elasticsearch cũng sẽ áp dụng vietnamese_analyzer này cho chuỗi query, đảm bảo cả lúc index và lúc search đều được xử lý theo cùng một quy tắc.
17.2. Search-as-you-type: Gợi Ý Tìm Kiếm Tức Thì
Kiểu dữ liệu search_as_you_type là một giải pháp chuyên dụng và hiệu quả hơn match_phrase_prefix để xây dựng tính năng autocomplete. Nó tạo ra các n-gram (các chuỗi con) của văn bản tại thời điểm index, giúp cho việc tìm kiếm prefix cực kỳ nhanh chóng.
Ví dụ Mapping:
{
"mappings": {
"properties": {
"name_suggest": {
"type": "search_as_you_type"
}
}
}
}Bạn sẽ cần index tên sản phẩm vào cả trường name (để tìm kiếm đầy đủ) và name_suggest (để gợi ý).
Ví dụ Query: Khi người dùng gõ "sams gal", bạn sẽ thực hiện một multi_match query trên trường name_suggest.
{
"query": {
"multi_match": {
"query": "sams gal",
"type": "bool_prefix",
"fields": [
"name_suggest",
"name_suggest._2gram",
"name_suggest._3gram"
]
}
}
}type: "bool_prefix" sẽ đảm bảo rằng tất cả các từ (trừ từ cuối) phải khớp chính xác, còn từ cuối cùng sẽ khớp theo tiền tố. Việc query trên các sub-field _2gram, _3gram giúp xử lý các lỗi chính tả nhỏ.
17.3. Dynamic Templates: Kiểm Soát Dữ Liệu Không Đồng Nhất
Đôi khi, bạn không biết trước tất cả các trường sẽ có trong document (ví dụ: dữ liệu logs, metadata tùy chỉnh của người dùng). dynamic_templates cho phép bạn định nghĩa các quy tắc để tự động áp dụng mapping cho các trường mới dựa trên tên hoặc kiểu dữ liệu của chúng.
Ví dụ: Mặc định tất cả các trường chuỗi mới đều được map thành keyword thay vì text.
{
"mappings": {
"dynamic_templates": [
{
"strings_as_keywords": {
"match_mapping_type": "string",
"mapping": {
"type": "keyword"
}
}
}
]
}
}Chương 18: Quản Lý và Vận Hành Cluster - Những Công Cụ Hữu Ích
Làm chủ API là một chuyện, giữ cho cluster "sống khỏe" lại là một chuyện khác.
18.1. Các API Chẩn Đoán (Diagnostic APIs)
Kibana Dev Tools là người bạn thân của bạn, nhưng bạn cũng có thể gọi các API này từ code Go hoặc curl.
- Cluster Health API:
GET /_cluster/health- Đã tìm hiểu. Luôn theo dõi trạng tháigreen,yellow,red. - Cat APIs:
GET /_cat/nodes?v,GET /_cat/indices?v,GET /_cat/shards?v- Cung cấp một cái nhìn dạng bảng, dễ đọc về trạng thái của node, index, shard. Cực kỳ hữu ích để nhanh chóng kiểm tra heap usage, disk usage, số lượng document... - Node Stats API:
GET /_nodes/stats- Cung cấp thông tin chi tiết đến từng milimet về mọi thứ đang diễn ra trên các node: JVM, I/O, network, thread pools... - Explain API:
GET /my-index/_explain/my-doc-id { "query": ... }- Cho bạn biết chính xác tại sao một document cụ thể lại khớp (hoặc không khớp) với một query, bao gồm cả cách tính điểm_scorechi tiết. Cực kỳ hữu ích để debug các vấn đề về relevancy.
18.2. Index Lifecycle Management (ILM)
ILM là một tính năng cực kỳ mạnh mẽ để tự động hóa vòng đời của các time-based indices (như logs, metrics). Bạn có thể định nghĩa một policy, ví dụ:
- Hot phase: Dữ liệu mới sẽ được ghi vào một index. Giữ trong 7 ngày.
- Warm phase: Sau 7 ngày, tự động
rollover(tạo một index mới), và chuyển index cũ sang trạng tháiread-only. Di chuyển index này đến các "warm nodes" (có ổ cứng HDD rẻ hơn). - Cold phase: Sau 30 ngày, di chuyển index sang "cold nodes".
- Delete phase: Sau 90 ngày, tự động xóa index.
Tất cả đều được tự động hóa, giúp bạn tiết kiệm chi phí lưu trữ và công sức quản lý.
18.3. Reindexing và Aliases
Khi bạn cần thay đổi mapping của một index (ví dụ: thay đổi analyzer), bạn không thể sửa trực tiếp. Quy trình chuẩn là:
- Tạo một index mới với mapping mới (ví dụ
products-v2). - Sử dụng Reindex API (
POST /_reindex) để sao chép toàn bộ dữ liệu từ index cũ (products-v1) sang index mới. API này thực hiện việc này ở phía server, rất hiệu quả. - Trong suốt quá trình này, ứng dụng của bạn không nên "hard-code" tên index. Thay vào đó, hãy sử dụng Aliases. Một alias giống như một con trỏ hoặc symlink trỏ đến một hoặc nhiều index.
- Ban đầu, bạn tạo alias
productstrỏ đếnproducts-v1. - Ứng dụng của bạn chỉ biết đến alias
products. - Sau khi reindex xong, bạn thực hiện một thao tác
atomic(nguyên tử) để chuyển aliasproductstrỏ từproducts-v1sangproducts-v2. POST /_aliases { "actions": [ { "remove": { "index": "products-v1", "alias": "products" } }, { "add": { "index": "products-v2", "alias": "products" } } ] }- Thao tác này diễn ra ngay lập tức, ứng dụng của bạn sẽ bắt đầu sử dụng index mới mà không cần bất kỳ downtime hay thay đổi code nào.
- Ban đầu, bạn tạo alias
Chương 19: Các Mẫu Thiết Kế (Design Patterns) và Anti-Patterns
19.1. Mẫu Thiết Kế Tốt
- Denormalization (Phi chuẩn hóa): Như đã nói, hãy nhồi dữ liệu liên quan vào cùng một document để tránh phải "join" lúc truy vấn. Thay vì chỉ lưu
brand_id, hãy lưu cảbrand: { id: 1, name: "Apple" }. Điều này làm tăng một chút dung lượng lưu trữ nhưng tăng tốc độ đọc lên đáng kể. - Data Synchronization (Đồng bộ dữ liệu): Sử dụng Message Queue (Kafka, RabbitMQ) hoặc Change Data Capture (Debezium) để đồng bộ dữ liệu từ database chính (PostgreSQL, MySQL) sang Elasticsearch một cách bất đồng bộ. Điều này giúp hệ thống của bạn bền vững, tách rời và chịu lỗi tốt hơn.
- Circuit Breaker (Cầu dao): Trong service Go của bạn, hãy implement một cơ chế circuit breaker (ví dụ: dùng thư viện
sony/gobreaker) khi gọi đến Elasticsearch. Nếu Elasticsearch gặp sự cố và bắt đầu trả về lỗi liên tục, circuit breaker sẽ "ngắt mạch", tạm thời ngừng gửi request và trả về lỗi ngay lập tức, tránh làm sập toàn bộ ứng dụng của bạn vì phải chờ timeout. - Time-based Indices: Luôn dùng cho logs, metrics, events.
19.2. Các Anti-Patterns Cần Tránh
- Elasticsearch as Primary Datastore: Nhắc lại lần cuối, đừng bao giờ làm vậy cho các hệ thống OLTP yêu cầu transaction.
- Joining in Application Layer: Tránh việc lấy danh sách ID từ Elasticsearch rồi thực hiện một vòng lặp để query chi tiết từng ID trong database chính (vấn đề N+1 query). Hãy cố gắng denormalize để Elasticsearch có đủ dữ liệu bạn cần hiển thị. Nếu vẫn cần, hãy lấy danh sách ID và thực hiện một câu lệnh
IN (...)duy nhất vào database. - Large Documents: Tránh tạo ra các document quá lớn (hàng MB). Nó làm chậm quá trình indexing và tốn bộ nhớ heap.
- Ignoring Cluster Health: Không theo dõi trạng thái
yellow/redlà một quả bom nổ chậm. Trạng tháiyellowcó nghĩa là bạn đã mất đi khả năng chịu lỗi. Một sự cố node tiếp theo sẽ biến nó thànhredvà gây mất dữ liệu.
Chương 20: Tổng Kết - Con Đường Trở Thành Chuyên Gia
Chúng ta đã đi qua một hành trình cực kỳ dài và chi tiết. Từ việc hiểu rõ "tại sao" phải dùng Elasticsearch, cho đến việc nắm vững từng khái niệm cốt lõi, xây dựng các tính năng CRUD, tìm kiếm, phân tích, và cuối cùng là tích hợp, tối ưu hóa và vận hành chúng trong một dự án thực tế.
Những điểm cốt lõi cần khắc cốt ghi tâm:
- Dùng đúng công cụ cho đúng việc: Elasticsearch là cỗ máy tìm kiếm và phân tích, không phải là cơ sở dữ liệu quan hệ.
- Mapping là Vua: Một thiết kế mapping tốt quyết định 90% hiệu năng và sự thành công của dự án. Hãy đầu tư thời gian vào nó.
- Hiểu rõ Query vs. Filter Context: Đây là chìa khóa vàng để tối ưu hóa mọi truy vấn tìm kiếm.
- Bulk API là bắt buộc: Đối với mọi tác vụ ghi dữ liệu hàng loạt, không có lựa chọn nào khác.
- Aggregations là kho báu: Học cách sử dụng chúng để biến dữ liệu thô thành những insight kinh doanh quý giá.
- Sử dụng Aliases: Luôn dùng alias để trỏ đến index, giúp cho việc nâng cấp, reindex trở nên liền mạch và không downtime.
- Giám sát và Theo dõi: Một cluster Elasticsearch giống như một cơ thể sống. Hãy thường xuyên "khám sức khỏe" cho nó bằng các API chẩn đoán và các công cụ giám sát.
Con đường trở thành một chuyên gia không dừng lại ở việc đọc xong tài liệu này. Nó đòi hỏi sự thực hành liên tục, sự tò mò không ngừng nghỉ và dũng cảm đối mặt với các vấn đề thực tế.
- Hãy tự mình xây dựng một dự án nhỏ.
- Hãy thử index một lượng lớn dữ liệu (vài triệu document) và xem hệ thống hoạt động ra sao.
- Hãy đọc tài liệu chính thức của Elastic - đó là nguồn thông tin đầy đủ và cập nhật nhất.
- Hãy tham gia vào các cộng đồng, diễn đàn để học hỏi từ người khác.
Với nền tảng vững chắc mà cẩm nang này đã cung cấp, tôi tin rằng bạn đã có đủ hành trang để tự tin chinh phục bất kỳ thử thách nào liên quan đến Elasticsearch và Golang. Chúc bạn thành công trên con đường trở thành một kiến trúc sư phần mềm tài ba.