Tracing Discord's Elixir Systems (Without Melting Everything)
Bài tóm tắt Discord 2026 3. Quy mô lớn Độ khó: Khó
Discord · 2026 · 3. Quy mô lớn · Độ khó: Khó — bài gốc
Bối cảnh
- Mỗi server Discord ("guild") là một process Elixir chạy độc lập, fan-out mỗi hành động tới các process "session", mỗi session đẩy xuống client. Một hành động của user là một chuỗi message giữa các process.
- Khi một guild không theo kịp hoạt động, on-call xem metric và log (như bảng đồng hồ xe: biết máy nóng, không biết hậu quả), rồi công cụ "guild timings" ghi trong RAM phần trăm thời gian mỗi phút cho từng loại hành động (quá nhiều nên chỉ giữ ngắn, và không thấy ảnh hưởng xuống dưới).
- Team khác đã có lợi lớn từ tracing. Nhưng tracing thường truyền context qua metadata như header HTTP, còn message passing của Elixir không có lớp metadata tương đương.
Thay đổi chính
- Thư viện Transport + Envelope: bọc message trong một struct có trường
trace_carrier(context đã serialize). Cung cấp bản thay thế choGenServer.call/cast; phía nhận gọihandle_message, hàm này chấp nhận cả message thường lẫn Envelope (message thường được bọc thành Envelope rỗng), giải context, gọi handler, rồi gỡ context.- Chấp nhận cả hai kiểu là then chốt để triển khai không downtime: không thể đổi mọi chỗ gửi một lần, và khi deploy thì node cũ/mới cùng chạy. Có cấu hình runtime để chọn cơ chế cho từng service.
- Fan-out làm nổ số span. Head sampling (quyết định ở span gốc, con theo cha) hợp với request/response; nhưng một message vào guild 1 triệu người online sẽ sinh 1 triệu span session. Lớp lấy mẫu thứ hai ở session theo kích thước fan-out: Envelope thêm trường
approximate_num_recipients; 1 người nhận giữ 100%, 100 người 10%, từ 10.000 người trở lên 0,1%. - Giảm chi phí của chính tracing (tracer không đo được overhead của nó, phải dùng công cụ khác):
- Guild cực lớn tụt lại sau khi bật trace; chụp stacktrace mỗi khoảng 100 ms thấy phần lớn thời gian là giải trace context, trong khi hơn 99% context là "không lấy mẫu". Sửa: chỉ gửi context khi trace được lấy mẫu (đổi lại, thao tác giữa đường có thể tự bắt đầu trace mới, ra trace không đủ).
- Session tốn thêm 10 điểm phần trăm CPU vì nhiều session tự bắt đầu trace mới sau fan-out. Sửa: cấm session tạo root span với message sau fan-out (chỉ được nối trace có sẵn): CPU 55% → 45%.
- Nối trace từ API service (Python, gửi context qua header gRPC) làm CPU gấp đôi; profiler (thử nghiệm từ Hack Week) cho thấy 75% thời gian xử lý gRPC là giải context. Sửa: đọc byte cờ cuối chuỗi context trước; bit "sampled" bằng 0 thì bỏ qua, không giải.
- Kết quả: một sự cố gần đây, trace cho thấy user mất gần 16 phút mới kết nối được tới guild bị suy giảm (client báo guild đang sập); trước đó không định lượng được ảnh hưởng tới user.
Vì sao
- Mô hình fan-out khiến chi phí tracing tỉ lệ với số người nhận, không phải số request; lấy mẫu phải theo hình dạng của fan-out (Realtime và fan-out, Tracing phân tán và log theo request).
Bài học cho SWE
- Khi đổi định dạng message giữa các process/service, phía nhận phải hiểu cả định dạng cũ và mới trước khi phía gửi đổi.
- Ở quy mô lớn, phần đắt của tracing có thể là việc vô ích (giải context của 99% request không lấy mẫu); tìm cách bỏ qua sớm.
- Cần công cụ ngoài tracing (stacktrace định kỳ, profiler) để thấy overhead của tracing.
Liên quan
#Elixir#GenServer#Message passing#OpenTelemetry#Trace context propagation#Envelope#Head sampling#Fan-out#Dynamic sampling#Profiling#gRPC
Được nhắc tới trong
Công ty (1)
Bài tóm tắt (3)