Tran Manh Tuan

Discord lập chỉ mục hàng nghìn tỷ tin nhắn như thế nào?

Search tưởng như chỉ là một tính năng phụ: người dùng gõ từ khóa rồi nhận kết quả. Nhưng khi dữ liệu là hàng nghìn tỷ tin nhắn, search trở thành một hệ thống dữ liệu độc lập, với queue, indexer, routing, cluster và cả quy trình migration riêng.

Bài viết gốc của Vicki Niu trên Discord Engineering mô tả cách hạ tầng search đã đi từ một thiết kế phù hợp cho hàng tỷ tin nhắn sang một kiến trúc có thể vận hành ở quy mô lớn hơn nhiều. Điều đáng học không nằm ở việc “dùng Elasticsearch” hay “chạy Kubernetes” một cách riêng lẻ. Đó là cách Discord thay đổi ranh giới failure domain, bảo toàn message khi downstream gặp sự cố, và khiến mỗi bulk request chỉ chạm đúng nơi nó cần đến.

Thiết kế ban đầu đã đúng cho bài toán ban đầu

Năm 2017, Discord chia dữ liệu search theo guild hoặc DM. Mỗi guild giữ toàn bộ tin nhắn trong cùng vùng dữ liệu logic để truy vấn nhanh hơn; đồng thời hệ thống không phải tạo một index khổng lồ cho toàn bộ Discord. Tin nhắn được lazy-index vì không phải mọi người dùng đều search, còn worker lấy từng cụm message từ queue để tận dụng bulk indexing của Elasticsearch.

Đây là một tối ưu hợp lý ở giai đoạn đầu: ít cluster hơn, vận hành đơn giản hơn và bulk request giảm chi phí ghi index. Vấn đề xuất hiện khi quy mô tăng. Một lựa chọn từng giảm độ phức tạp bắt đầu đưa quá nhiều failure mode vào cùng một đường xử lý.

Bốn điểm gãy khi quy mô tăng

1. Queue Redis bắt đầu làm rơi message

Redis từng chịu được realtime indexing queue. Nhưng khi Elasticsearch backlog, nhất là lúc node gặp sự cố, queue phình lên. CPU Redis bị bão hòa và chính queue trở thành nguồn lỗi, làm rơi message cần được index.

Điểm cần lưu ý là downstream chậm không chỉ làm chậm downstream. Nếu queue không chịu được backlog, sự cố ở indexer có thể lan ngược lên tầng nhận message.

2. Bulk request fan-out quá rộng

Lỗi lan truyền trong pipeline indexing cũRedis queue gửi batch chứa nhiều destination tới một Elasticsearch cluster lớn. Một node lỗi làm toàn bộ bulk batch retry, khiến backlog trong queue tăng lên.

lỗi

Queue.

Worker.

Batch.

A.

B.

Lỗi.

Retry.

Sơ đồ vừa khung

Một batch chứa nhiều destination tạo failure domain quá rộng: chỉ một node không sẵn sàng có thể buộc toàn bộ batch retry.

Worker lấy một batch message từ queue và gửi vào Elasticsearch. Nhưng các message trong cùng batch có thể thuộc nhiều index và nhiều node khác nhau. Một batch 50 message có thể fan-out tới 50 node.

Khi một node không sẵn sàng, chỉ một operation lỗi cũng khiến toàn bộ bulk request bị coi là thất bại. Tất cả message trong batch quay lại queue để retry, kể cả những message vốn không liên quan đến node lỗi. Discord đưa ví dụ: với cluster 100 node và batch 50 message phân bố đều, một node hỏng có thể khiến xác suất một batch chạm node đó vào khoảng 40%. Một lỗi đơn lẻ vì thế có thể biến thành một tỷ lệ retry rất lớn.

3. Cluster lớn làm coordination trở nên đắt đỏ

Khi lượng message tăng, cách mở rộng trực tiếp là thêm index và thêm node vào cluster. Nhưng điều này cũng làm bulk request fan-out rộng hơn. Cluster state lớn hơn, master node phải điều phối nhiều hơn và khả năng có node lỗi ở đâu đó cũng cao hơn.

Discord đã gặp tình trạng master node OOM. Hệ quả không chỉ là một node điều phối gặp lỗi: indexing thất bại, backlog tăng, truy vấn chậm và timeout xuất hiện cùng lúc. Đây là ví dụ điển hình cho việc scale capacity không đồng nghĩa với scale operability.

4. Không thể nâng cấp an toàn

Thiết kế cũ quá nhạy với việc một node không khả dụng. Rolling restart hay software upgrade vì vậy trở nên khó thực hiện. Khi cluster đã có hơn 200 node và nhiều terabyte dữ liệu, drain từng node để bảo đảm service availability mất thời gian quá lâu.

Hệ thống bị giữ lại ở các phiên bản OS và Elasticsearch cũ. Trong trường hợp Log4Shell, Discord phải đưa toàn bộ search offline để restart tất cả Elasticsearch node với cấu hình mới. Một hệ thống không chịu được maintenance thì sớm muộn cũng tích lũy rủi ro vận hành.

Bước chuyển: cell nhỏ thay cho cluster khổng lồ

Discord chọn chạy Elasticsearch trên Kubernetes với Elastic Kubernetes Operator. Nhưng thay đổi quan trọng hơn là chuyển từ vài cluster rất lớn sang nhiều cluster nhỏ, được nhóm logic thành các cell.

Một cell không phải một Elasticsearch cluster duy nhất. Nó là lớp grouping cao hơn, bên trong có nhiều cluster nhỏ.

Kiến trúc search cellMột cell logic nhóm nhiều Elasticsearch cluster nhỏ. Mỗi cluster tách role master eligible, ingest và data node, đồng thời trải qua các availability zone.

Cell.

A.

B.

C.

Master.

Ingest.

Data.

Master.

Ingest.

Data.

Master.

Ingest.

Data.

Sơ đồ vừa khung

Một cell logic nhóm các cluster nhỏ, trong khi mỗi cluster tách role master, ingest và data.

Cách chia này giảm cluster-state overhead và thu nhỏ blast radius khi có lỗi. Discord cũng chủ động tạo thêm index để giữ mỗi index trong khuyến nghị khoảng 200 triệu message và 50 GB dữ liệu, thay vì để index của các guild lớn tăng mãi tới giới hạn Lucene.

Trong từng cluster, node role được tách rõ:

  • Master-eligible nodes chuyên điều phối cluster.
  • Ingest nodes nhận phần preprocessing và routing, không giữ data nên có thể scale theo spike.
  • Data nodes tập trung heap cho indexing và query.

Các pod role khác nhau được đặt trên machine type và nodepool phù hợp. Thiết kế cũng trải replica qua zone để chịu được lỗi theo vùng. Bài học ở đây là không chỉ chia cluster nhỏ đi, mà còn chia đúng loại tài nguyên theo công việc chúng phải làm.

Đổi queue: backlog phải an toàn hơn downstream

Discord chuyển realtime indexing queue từ Redis sang PubSub. Lý do cốt lõi là guaranteed message delivery và khả năng chịu backlog lớn. Elasticsearch có thể chậm hoặc lỗi; indexing có thể bị trì hoãn; nhưng message không được phép biến mất chỉ vì downstream đang có vấn đề.

Đây là ranh giới kiến trúc quan trọng: queue không nên chỉ là vùng đệm cho lúc mọi thứ khỏe mạnh. Nó phải là lớp cách ly để sự cố của consumer không phá hỏng dữ liệu đầu vào.

Chỉ bulk những message có cùng đích

Đây là thay đổi trực tiếp giải quyết fan-out của bulk request. Discord vẫn bulk index để giữ hiệu năng, nhưng worker không còn gom bất kỳ message nào đang có sẵn trong queue vào một request.

Một message router đọc stream từ PubSub, xác định Destination của từng message, tức Elasticsearch cluster và index mà message phải tới.

Bulk indexing theo destinationPubSub stream đi vào router. Router gom message theo destination, rồi các task độc lập chỉ gửi bulk request tới Elasticsearch cluster và index tương ứng.

PubSub.

Router.

Task A.

Task B.

Task C.

Bulk A.

Bulk B.

Bulk C.

Sơ đồ vừa khung

Router batch message theo physical destination, giới hạn mỗi bulk operation vào đúng index và cluster tương ứng.

Router tạo channel và Tokio task theo Destination. Task đó chỉ gom message cùng đích trước khi gửi bulk request.

Kết quả là một bulk operation chỉ nói chuyện với một Elasticsearch index và node liên quan. Nếu node ấy lỗi, ảnh hưởng bị giới hạn trong các message thực sự phải đến node đó. Những message dành cho destination khác vẫn tiếp tục được xử lý thay vì cùng bị retry.

Đây là một nguyên tắc có thể áp dụng rộng hơn search: batching chỉ hiệu quả khi batch tôn trọng partition. Gom nhiều item để giảm overhead nhưng bỏ qua placement của chúng thường biến một lỗi cục bộ thành lỗi của cả batch.

Guild lớn không còn bị khóa vào một index

Ban đầu, Discord shard message theo guild. Cách này thuận tiện cho truy vấn, nhưng guild cực lớn có thể đẩy một Lucene index tới giới hạn khoảng 2 tỷ document. Khi chạm giới hạn, indexing cho index đó thất bại. Phương án cứu hỏa trước đây là làm việc với Safety team để tìm và xóa các guild chỉ tạo spam message, nhưng cách này không giải quyết nhu cầu của community hợp lệ tiếp tục tăng trưởng.

Cell architecture mở ra một abstraction cao hơn index. Discord có thể giữ mapping để một guild lớn dùng nhiều index, nhiều cluster hoặc được phân bố theo cell, trong khi search layer vẫn biết cần query những nơi nào. Cách tách logical ownership khỏi physical placement giúp hệ thống có đường phát triển thay vì đợi một partition chạm hard limit rồi xử lý khẩn cấp.

Migration không phải một lần cutover

Đưa search từ hạ tầng cũ sang cell architecture là bài toán dữ liệu và tính đúng đắn, không chỉ là deploy cluster mới. Discord cần backfill dữ liệu lịch sử, đồng thời giữ realtime indexing chạy cho message mới.

Trình tự migration search an toànDữ liệu lịch sử được backfill vào cell mới trong khi message realtime vẫn tiếp tục đi vào. Validation diễn ra trước cutover query để kết quả không được trả từ destination chưa đầy đủ dữ liệu.

Lịch sử.

Backfill.

Realtime.

Indexer.

Validate.

Cut over.

Sơ đồ vừa khung

Backfill và realtime write chạy đồng thời; validation phải hoàn tất trước khi query search cut over sang cell mới.

Migration phải có giai đoạn kiểm tra để xác nhận data đã đến đúng nơi, rồi mới chuyển query traffic.

Điểm đáng giữ lại là thứ tự ưu tiên: làm cho write path đáng tin cậy trước, chứng minh dữ liệu có mặt và query đúng sau, rồi mới cut over. Nếu đảo thứ tự, hệ thống có thể trả kết quả từ hạ tầng mới khi dữ liệu lịch sử hoặc dữ liệu realtime chưa hoàn chỉnh.

Những bài học có thể mang sang hệ thống khác

Không phải hệ thống nào cũng cần Elasticsearch cell hay PubSub. Nhưng các quyết định của Discord đưa ra một checklist hữu ích khi một pipeline bắt đầu lớn hơn thiết kế ban đầu:

  • Xác định failure domain của batch. Một phần tử lỗi có làm retry cả batch không?
  • Để queue chịu được backlog và bảo toàn dữ liệu khi consumer chậm.
  • Chia cluster theo giới hạn vận hành, không chỉ theo tổng CPU hoặc storage.
  • Tách node role khi coordination, ingest và data có nhu cầu tài nguyên khác nhau.
  • Đặt partition key vào routing và batching, không chỉ vào data model.
  • Thiết kế migration như một quy trình đồng thời: backfill, realtime write, validation rồi cutover.
  • Theo dõi khả năng nâng cấp, restart và xử lý node failure ngay từ đầu. Đây là một phần của capacity, không phải việc để sau.

Kết luận

Khi dữ liệu còn nhỏ, kiến trúc đơn giản thường là lựa chọn đúng. Khi dữ liệu đã lên hàng nghìn tỷ message, câu hỏi không còn là “cluster có đủ lớn không?”. Câu hỏi là một lỗi có thể lan đi xa đến đâu, backlog có làm mất dữ liệu không, batch có đang kết hợp những phần việc không liên quan không, và hệ thống có thể được nâng cấp mà không phải dừng service hay không.

Discord giải bài toán đó bằng cách thu nhỏ cluster, tăng thêm lớp cell, bảo toàn queue và route batch theo destination. Đây không phải công thức cố định. Đó là một cách suy nghĩ: tăng quy mô bằng cách giới hạn phạm vi của lỗi và giữ data path đúng ngay cả khi một phần hệ thống đang không khỏe.

Nguồn và đọc thêm