Xây dựng hệ thống báo giá chứng khoán theo thời gian thực ở quy mô lớn

Kiến trúc vận hành thực tế có thể xử lý hơn 1.900 tin nhắn mỗi giây và hàng chục nghìn kết nối đồng thời với Kafka, Redis Pub/Sub, .NET Channels và SSE.

Mofid Brokerage là một trong những công ty môi giới lớn tại Iran. Bài viết này chia sẻ kinh nghiệm thực tế của đội ngũ kỹ thuật khi xây dựng hệ thống hiển thị giá cổ phiếu và quỹ theo thời gian thực trên ứng dụng Mofid.

Mục tiêu ban đầu nghe có vẻ đơn giản: đưa giá thị trường mới nhất từ hệ thống giao dịch đến màn hình người dùng với độ trễ thấp nhất. Tuy nhiên, phía sau yêu cầu đó là một chuỗi bài toán về lưu lượng dữ liệu, khả năng mở rộng, giới hạn trình duyệt, xác thực, quản lý bộ nhớ, xử lý lỗi và giám sát khi hệ thống chịu tải lớn.

Ngay từ đầu, đội ngũ phải trả lời một số câu hỏi quan trọng:

  • Dữ liệu được tạo ra ở đâu?
  • Dữ liệu phát sinh với tốc độ bao nhiêu?
  • Kích thước lưu lượng có thể tăng đến mức nào?
  • Làm sao phân phối dữ liệu đến hàng triệu người dùng mà vẫn giữ được độ trễ thấp, tính ổn định và sự đơn giản?
  • Nên dùng phương thức nào để gửi dữ liệu đến thiết bị của người dùng?

Phần còn lại của bài viết trình bày các quyết định về kiến trúc, những vấn đề gặp phải trong quá trình triển khai và các giải pháp đã được áp dụng.

Nguồn dữ liệu giá theo thời gian thực và những thách thức ban đầu

Bước đầu tiên là tìm hiểu thật rõ nguồn dữ liệu.

Giá theo thời gian thực được tạo ra ở đâu và với tốc độ nào?

Dữ liệu quan trọng của thị trường, bao gồm giá cổ phiếu và giá quỹ, được tạo ra bởi hệ thống lõi của Tổ chức Chứng khoán, gọi là RLC.

Để tiếp nhận dữ liệu nhanh nhất có thể, một đội ngũ chuyên trách tại Mofid chịu trách nhiệm đọc luồng dữ liệu này. Họ sử dụng C++ vì đây là lựa chọn có hiệu năng cao, gần với phần cứng và giúp giảm độ trễ trong quá trình đọc dữ liệu.

Sau khi xử lý sơ bộ, đội ngũ đưa các tin nhắn vào cụm Kafka bằng định dạng Protobuf đã được tối ưu. Cách này giúp giảm kích thước tin nhắn và tăng tốc độ tuần tự hóa.

Đến đây, hệ thống đã có một đường ống rất nhanh để chuyển dữ liệu từ lõi giao dịch vào Kafka. Câu hỏi tiếp theo là: thực tế hệ thống phải xử lý bao nhiêu dữ liệu?

Tốc độ tạo tin nhắn phụ thuộc trực tiếp vào khối lượng giao dịch trong ngày. Vì vậy, khi thiết kế hệ thống, đội ngũ cần ước lượng chính xác cả điều kiện thông thường lẫn thời điểm thị trường biến động mạnh.

Phân tích cho thấy có bốn luồng dữ liệu chính:

  1. Luồng giá giao dịch gần nhất: khoảng 1.000 tin nhắn mỗi giây.
  2. Luồng giá đóng cửa: khoảng 500 tin nhắn mỗi giây.
  3. Luồng thay đổi tài sản của người dùng: khoảng 200 tin nhắn mỗi giây.
  4. Luồng thay đổi lợi nhuận của người dùng: khoảng 200 tin nhắn mỗi giây.

Như vậy, trong điều kiện thị trường thông thường, hệ thống phải xử lý khoảng 1.900 tin nhắn mỗi giây.

Trong những ngày thị trường biến động mạnh, con số này có thể tăng gấp hai hoặc gấp ba trong một khoảng thời gian ngắn. Do đó, kiến trúc phải được chuẩn bị cho tình huống xấu nhất thay vì chỉ tối ưu cho mức tải trung bình.

Chiến lược chuyển dữ liệu đến các dịch vụ nội bộ

Thách thức tiếp theo là chuyển một lượng lớn tin nhắn từ Kafka vào lớp dịch vụ phía máy chủ với độ trễ thấp nhất. Hệ thống cần một lớp trung gian có khả năng truyền dữ liệu rất nhanh.

Các lựa chọn được xem xét gồm:

  • RabbitMQ Streams
  • Kafka
  • Redis Pub/Sub

Các tiêu chí đánh giá là thông lượng, độ trễ, khả năng mở rộng, mức độ dễ bảo trì và lượng tài nguyên tiêu thụ.

Lựa chọn: Redis Pub/Sub

Kafka là lựa chọn rất phù hợp cho việc tiếp nhận dữ liệu và duy trì luồng dữ liệu chính. Tuy nhiên, lớp phân phối dữ liệu theo thời gian thực đến các dịch vụ SSE cần khả năng phát nhanh, đơn giản và tạm thời hơn là khả năng phát lại hoặc lưu trữ bền vững.

Vì vậy, đội ngũ chọn Redis Pub/Sub làm lớp trung gian nhẹ và có độ trễ thấp giữa Kafka và các dịch vụ phía máy chủ.

Mục tiêu của lớp này không phải là lưu lịch sử tin nhắn hay phát lại dữ liệu. Mục tiêu là đưa các thay đổi giá mới nhất đến phía máy chủ nhanh nhất có thể.

Redis Pub/Sub cung cấp ngữ nghĩa at-most-once, rất phù hợp với loại dữ liệu này. Nếu một cập nhật giá từ vài giây trước bị mất, nó sẽ nhanh chóng được thay thế bằng cập nhật tiếp theo.

Ngược lại, Kafka, Redis Streams và RabbitMQ Streams phù hợp hơn với các tình huống cần phát lại, lưu trữ bền vững, quản lý vị trí đọc, xác nhận hoặc xử lý đáng tin cậy hơn.

Mofid vốn đã sử dụng Redis trong hệ thống công nghệ của mình, nên lựa chọn này không tạo thêm gánh nặng vận hành cho đội SRE. Việc phát triển cũng tương đối đơn giản.

Cách triển khai

Phối hợp với đội SRE, nhóm sử dụng Redpanda Connect để chuyển dữ liệu từ các chủ đề Kafka vào các kênh Redis Pub/Sub.

Một ưu điểm của Redpanda Connect là đường ống giữa Kafka và Redis có thể được mô tả bằng cấu hình, đồng thời dễ quan sát, mà không cần viết thêm một dịch vụ trung gian riêng.

Để đạt hiệu năng cao nhất, nhóm cấu hình một nút Redis chuyên dụng, chỉ hoạt động trong bộ nhớ và không sử dụng lưu trữ bền vững.

Đây là một đánh đổi có chủ ý. Nếu Redis khởi động lại, một khoảng dữ liệu ngắn có thể bị mất và giá có thể ngừng cập nhật trong vài giây. Vì cơ sở dữ liệu chính vốn được cập nhật nền với độ trễ khoảng năm phút, mức rủi ro này được xem là chấp nhận được đối với màn hình giá theo thời gian thực.

Với khối lượng công việc trên, tài nguyên Redis sử dụng trong môi trường thực tế vẫn rất thấp. Có thể nói, tải này gần như không đáng kể đối với Redis.

Đưa dữ liệu đến thiết bị người dùng

Sau khi dữ liệu đến Redis với tốc độ rất cao, thách thức lớn tiếp theo là gửi hơn 1.900 tin nhắn mỗi giây đến hàng trăm nghìn trình duyệt và thiết bị di động đang kết nối.

Các phương án giao tiếp theo thời gian thực được đánh giá gồm:

  • SignalR
  • Socket.IO
  • Lightstreamer
  • SSE

Hệ thống cần một công nghệ có các đặc điểm sau:

  1. Khả năng mở rộng cao.
  2. Hoạt động tốt trên nhiều trình duyệt và thiết bị.
  3. Không phụ thuộc vào thư viện phía máy khách quá nặng.
  4. Tương thích với nền tảng .NET hiện có.
  5. Dễ phát triển và triển khai.

Lựa chọn: Server-Sent Events

Việc lựa chọn không dễ dàng, nhưng các tiêu chí khá rõ ràng: công nghệ phải miễn phí, dễ mở rộng, dễ bảo trì, không phụ thuộc vào các dịch vụ bị hạn chế, tương thích hoàn toàn với nền tảng .NET và hoạt động thuận tiện trên nhiều loại thiết bị.

Với yêu cầu đó, SSE là lựa chọn phù hợp nhất.

Khác với WebSocket, vốn cung cấp một kết nối hai chiều phức tạp hơn, hệ thống này chỉ cần đẩy dữ liệu từ máy chủ đến máy khách.

SSE là một tiêu chuẩn web đơn giản hoạt động trên HTTP. API JavaScript EventSource được tích hợp sẵn trong trình duyệt. SSE tương thích tốt với hạ tầng HTTP tiêu chuẩn và có cơ chế tự kết nối lại.

Khả năng tương thích trình duyệt của SSE cũng rất tốt vì công nghệ này đã được hỗ trợ trong nhiều năm.

Đội ngũ nhanh chóng xây dựng một bản thử nghiệm nguyên lý với GitHub Copilot. Kết quả rất khả quan: mã nguồn đơn giản, hiệu năng tốt và quyết định sử dụng SSE trở nên rõ ràng.

Ví dụ mã phía máy chủ

[HttpGet]
public async Task GetPrices()
{
    Response.Headers.Append("Content-Type", "text/event-stream");
    Response.Headers.Append("Cache-Control", "no-cache");
    Response.Headers.Append("Connection", "keep-alive");
    Response.Headers.Append("X-Accel-Buffering", "no");

    var id = Guid.NewGuid().ToString("N");
    var cancellationToken = HttpContext.RequestAborted;
    var reader = SubscribersCoordinatorHostedService
        .Subscribe(id, cancellationToken);

    await foreach (var (eventName, eventData) in
        reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
    {
        await Response.WriteAsync(
            $"event: {eventName}\ndata: {eventData}\n\n",
            cancellationToken).ConfigureAwait(false);

        await Response.Body.FlushAsync(cancellationToken)
            .ConfigureAwait(false);
    }
}

Ví dụ mã phía trình duyệt

const source = new EventSource("/prices/stream".);

source.onmessage = (event) => {
  const price = JSON.parse(event.data);
  render(price);
};

source.onerror = () => {
  console.log("Retrying...");
};

Triển khai phía máy khách và cách vượt qua các giới hạn

Triển khai SSE ở phía máy khách cũng khó không kém phần phía máy chủ. Mục tiêu là đưa dòng dữ liệu liên tục lên màn hình mà không làm giảm chất lượng trải nghiệm.

Đội ngũ xác định sáu thách thức chính và xây dựng chiến lược riêng cho từng vấn đề.

1. Giới hạn kết nối đồng thời của trình duyệt

Với HTTP/1.1, mỗi miền thường bị giới hạn khoảng sáu kết nối mở đồng thời.

Giải pháp: Hạ tầng của hệ thống đã chạy hoàn toàn trên HTTP/2. Nhờ cơ chế ghép nhiều luồng trên cùng một kết nối, giới hạn này gần như được loại bỏ.

Nhờ đó, hệ thống vượt qua nút thắt này mà không cần thay đổi đặc biệt ở phía máy khách và cũng không phải triển khai SharedWorker.

2. Xác thực và gửi mã truy cập

Đối tượng JavaScript EventSource gốc chỉ hỗ trợ phương thức GET và không cung cấp cách đơn giản để đặt các tiêu đề tùy biến như:

Authorization: Bearer <token>

Đội ngũ có ba phương án.

  • Phương án một: sử dụng cookie và thông tin xác thực để trình duyệt tự gửi cookie cùng yêu cầu.
  • Phương án hai: đặt mã truy cập vào chuỗi truy vấn trong URL. Cách này không được khuyến nghị vì mã có thể xuất hiện trong nhật ký của máy chủ hoặc proxy.
  • Phương án ba, cũng là lựa chọn cuối cùng: sử dụng gói eventsource.

Gói eventsource cung cấp API tương thích với EventSource, đồng thời cho phép kiểm soát tốt hơn API Fetch bên dưới. Nhờ vậy, ứng dụng có thể tùy biến yêu cầu và thêm tiêu đề xác thực như Authorization, trong khi vẫn giữ được mô hình lập trình quen thuộc của EventSource.

Cách này giúp giảm độ phức tạp của mã nguồn để đội ngũ tập trung vào logic nghiệp vụ.

3. Cơn bão cập nhật

Với tốc độ phát sinh tin nhắn cao, nếu cập nhật trạng thái giao diện cho từng sự kiện, trình duyệt sẽ phải thực hiện rất nhiều lần kết xuất tốn kém.

Điều đó làm giảm khả năng phản hồi của giao diện.

Giải pháp: sử dụng chiến lược Batch Update. Dữ liệu đến được tạm thời gom trong một ref. Sau mỗi khoảng thời gian xác định, toàn bộ dữ liệu mới được đưa vào giao diện trong một lần.

Cách làm này giúp trải nghiệm mượt hơn đáng kể và loại bỏ hiện tượng giật hoặc trễ khi có nhiều giá thay đổi cùng lúc.

4. Xử lý lỗi và kết nối lại

Trong môi trường mạng di động và Wi-Fi, việc mất kết nối là không thể tránh khỏi.

Giải pháp: SSE vốn đã có mô hình kết nối lại tích hợp sẵn. Tuy nhiên, để kiểm soát chính xác hơn, đội ngũ chủ động quản lý hành vi thử lại.

Khi xảy ra lỗi, kết nối cũ được đóng và kết nối mới được tạo sau một khoảng trễ hợp lý. Khoảng trễ này giúp tránh tạo thêm áp lực không cần thiết lên máy chủ.

5. Quản lý tài nguyên khi chuyển trang

Khi người dùng chuyển giữa các trang chi tiết cổ phiếu hoặc quỹ, những kết nối của trang cũ không còn cần thiết. Nếu tiếp tục giữ chúng, cả tài nguyên phía máy khách lẫn máy chủ đều bị lãng phí.

Giải pháp: dọn dẹp kết nối.

Khi trang, quỹ được chọn thay đổi hoặc thành phần giao diện bị tháo bỏ, kết nối hiện tại được đóng ngay lập tức. Sau đó, ứng dụng mở một kết nối mới cho dữ liệu cần thiết của trang mới.

Bước tưởng như đơn giản này tạo ra tác động lớn trong việc giảm mức sử dụng bộ nhớ của trình duyệt và tải trên máy chủ.

6. Giá trị ban đầu

Kết nối SSE chỉ chịu trách nhiệm cung cấp dữ liệu theo thời gian thực. Để tránh trạng thái không nhất quán, trước tiên máy khách lấy ảnh chụp giá mới nhất thông qua API thông thường, vốn có độ trễ nhỏ.

Sau đó, luồng SSE chỉ áp dụng các thay đổi phát sinh tiếp theo.

Quy trình lấy ảnh chụp và đồng bộ lại cũng được thực hiện sau khi kết nối lại. Nhờ đó, việc mất một số tin nhắn Redis Pub/Sub hoặc mất mạng tạm thời không khiến giao diện hiển thị dữ liệu cũ.

Nút thắt ở phía máy chủ: xử lý ở tốc độ tối đa

Sau khi đưa dữ liệu vào Redis và chọn phương án gửi đến máy khách, câu hỏi lớn tiếp theo là: làm sao đọc khoảng 1.900 tin nhắn mỗi giây từ nhiều luồng khác nhau trong dịch vụ .NET và phân phối chúng đến đúng người dùng?

Dữ liệu gồm hai nhóm:

  • Dữ liệu công khai: chẳng hạn giá cổ phiếu, giống nhau với mọi người dùng.
  • Dữ liệu riêng tư: chẳng hạn tài sản của một người dùng, chỉ thuộc về người dùng đó.

Nếu người dùng bán một phần tài sản hoặc mua thêm tài sản, thay đổi đó phải được phản ánh ngay trong danh mục của họ.

Kiến trúc phải hỗ trợ cả hai loại dữ liệu, đồng thời đủ linh hoạt để bổ sung các luồng mới trong tương lai.

Các thách thức chính gồm:

  • Lưu lượng tin nhắn theo thời gian thực rất lớn.
  • Kết hợp dữ liệu công khai và dữ liệu riêng tư theo từng người dùng.
  • Số lượng thao tác đồng thời cao.
  • Kiến trúc có thể mở rộng khi thêm luồng dữ liệu mới.
  • Số lượng người dùng đồng thời lớn, cần phát dữ liệu theo từng nhóm phù hợp.

Hai hướng tiếp cận chính trong .NET được đánh giá là:

  • TPL Dataflow
  • Channels

Lựa chọn: System.Threading.Channels

Các tiêu chí chính là sự đơn giản khi triển khai, tốc độ phát triển, độ trễ thấp, hiệu quả cao và mức sử dụng RAM, CPU được tối ưu.

TPL Dataflow là lựa chọn mạnh cho các đường ống phức tạp, xử lý nhiều giai đoạn, gom nhóm hoặc mô hình giống tác tử. Tuy nhiên, System.Threading.Channels được thiết kế đặc biệt cho mô hình nhà sản xuất và người tiêu thụ có hiệu năng cao, với chi phí vận hành thấp.

Channels trong .NET hoạt động giống một đường ống FIFO bất đồng bộ và thông minh. Trong kinh nghiệm thực tế của đội ngũ, chúng hoạt động rất tốt.

Channels an toàn khi truy cập đồng thời và được tối ưu cho các tình huống nhà sản xuất và người tiêu thụ cần chi phí thấp. Các phép đo của Microsoft cũng cho thấy Channels có thể đạt thông lượng rất cao với lượng cấp phát bộ nhớ cực thấp trong các tình huống phù hợp.

Nhờ đó, mã nguồn trở nên rõ ràng và có khả năng mở rộng hơn, mà không cần tự quản lý quá nhiều cơ chế đồng bộ hoặc phối hợp hàng đợi.

Thiết kế hàng đợi có giới hạn

Đội ngũ xây dựng một kiến trúc phân lớp và mô-đun.

Mỗi kênh Redis được một HostedService xử lý. Dịch vụ này đưa tin nhắn vào một Channel nội bộ.

Để hàng đợi không tăng vô hạn, hệ thống sử dụng BoundedChannel với sức chứa 100.000 phần tử và chính sách DropOldest.

Điều đó có nghĩa là Channel chỉ giữ tối đa 100.000 tin nhắn. Thiết kế này có hai lý do.

Thứ nhất, đội ngũ không muốn sử dụng hàng đợi không giới hạn. Nếu tốc độ xử lý giảm, hệ thống không được phép tích lũy tin nhắn mãi mãi.

Thứ hai, sức chứa cố định giúp hành vi của Channel dễ dự đoán hơn khi chịu tải cao.

Nếu có thêm tin nhắn đến sau khi Channel đầy, chính sách DropOldest sẽ loại bỏ tin nhắn cũ nhất. Đây là một đánh đổi phù hợp vì dữ liệu giá có thể thay thế: giá mới quan trọng hơn giá cũ.

Phân phối dữ liệu theo từng người dùng

Đội ngũ bổ sung một Coordinator để quản lý các kênh Redis. Coordinator tạo một Channel riêng cho từng người dùng hoặc từng đăng ký dữ liệu phù hợp.

Hệ thống không gửi tất cả tin nhắn đến tất cả người dùng. Mỗi kết nối chỉ nhận phần dữ liệu thực sự cần thiết, dựa trên ngữ cảnh của trang, tài sản của người dùng hoặc danh sách theo dõi.

Bộ lọc này giúp kiểm soát số lần phát dữ liệu thực tế và ngăn số lượng sự kiện gửi đi tăng theo tích của tổng số mã trên thị trường và tổng số người dùng.

Toàn bộ đường đi của dữ liệu có thể tóm tắt như sau:

RLC
-> C++ Ingestion
-> Kafka/Protobuf
-> Redpanda Connect
-> Redis Pub/Sub
-> .NET Hosted Services
-> Coordinator
-> Kênh theo người dùng hoặc đăng ký
-> SSE
-> Bộ kết xuất theo lô ở máy khách

Triển khai thật nhưng có dây an toàn

Sau khi hoàn thiện kiến trúc và vượt qua các bài kiểm thử tải nặng trong nhiều môi trường với sự hỗ trợ của đội SRE, thời điểm đưa hệ thống vào vận hành thực tế đã đến.

Chiến lược của đội ngũ là: đưa vào vận hành, nhưng phải có dây an toàn.

Unleash được sử dụng làm công cụ Feature Toggle.

Ở bước đầu, tính năng chỉ được bật cho 30% người dùng. Trong thời gian đó, đội ngũ theo dõi sát các màn hình giám sát và bảng điều khiển Grafana.

May mắn là mọi thứ hoạt động tốt. Không có vấn đề đáng kể hay dấu hiệu suy giảm hiệu năng ở phía máy chủ và hạ tầng.

Tuy nhiên, trong giai đoạn triển khai cho 30% người dùng, đội ngũ nhận được phản hồi quan trọng từ người dùng và quản lý sản phẩm. Hiệu ứng hình ảnh khi giá thay đổi chưa đủ trực quan và chưa truyền tải rõ cảm giác cập nhật theo thời gian thực.

Một lập trình viên phía giao diện và tác giả đã tham khảo hiệu ứng thay đổi giá của TradingView. Đây là hiệu ứng quen thuộc, đã được kiểm chứng và mang lại trải nghiệm tốt.

Điểm đáng chú ý là đội ngũ không muốn thêm JavaScript chỉ để xử lý hiệu ứng hình ảnh. Việc bổ sung xử lý phía máy khách ở quy mô lớn có thể tạo thêm chi phí không cần thiết.

Thay vào đó, đội ngũ giao diện tái hiện hiệu ứng tương tự bằng một vài kỹ thuật CSS đơn giản. Cách làm này rất nhẹ và kết quả hiển thị tốt.

Sau khi hoàn tất thay đổi giao diện và xác nhận hệ thống ổn định, tính năng được bật cho 50% người dùng. Mức này được giữ trong vài ngày để thu thập đủ dữ liệu về hành vi hệ thống và người dùng.

Sau đó, tỷ lệ được tăng dần lên 70% và cuối cùng là 100%.

Các con số tự lên tiếng

Thiết kế kiến trúc trên giấy là một chuyện. Quan sát cách hệ thống hoạt động dưới tải thực tế lại là chuyện hoàn toàn khác.

Một trong những mối quan tâm lớn nhất là mức tiêu thụ tài nguyên máy chủ. Vì dự kiến có hàng nghìn kết nối mở đồng thời, quản lý bộ nhớ là yếu tố đặc biệt quan trọng.

Đội ngũ từng lo ngại: nếu các hàng đợi đang đầy dần mà không ai phát hiện thì sao?

Kết quả cuối cùng thậm chí còn tốt hơn kỳ vọng. Nhờ cấu trúc nhẹ của SSE và cách quản lý bộ nhớ hiệu quả trong .NET, đặc biệt là chi phí thấp của System.Threading.Channels, hệ thống xử lý được lưu lượng lớn với lượng tài nguyên rất hạn chế.

Prometheus được dùng để thu thập số liệu và Grafana được dùng để trực quan hóa. Tuy nhiên, các số liệu mặc định chưa đủ, nên đội ngũ bổ sung các chỉ số riêng cho Channels:

  • Độ sâu Channel: số tin nhắn đang chờ xử lý. Nếu con số này tăng, người tiêu thụ đang xử lý chậm hơn tốc độ dữ liệu đến.
  • Tốc độ nạp và rút: so sánh tốc độ dữ liệu đi vào Channel với tốc độ dữ liệu được lấy ra.
  • Số kết nối SSE đang hoạt động: số người dùng trực tuyến trên từng nút.

Các bảng điều khiển này giúp đội ngũ phát hiện nút thắt trước khi người dùng nhận thấy hệ thống chậm.

Dù nhận khoảng 1.900 tin nhắn mỗi giây, biểu đồ cho thấy tại một thời điểm, mỗi Channel chỉ còn tối đa khoảng 15 tin nhắn chờ xử lý. Chúng gần như được rút ra ngay lập tức.

Điều đó cho thấy độ trễ xử lý nội bộ gần như bằng không và chứng minh hiệu quả của Channels trong mô hình này.

Đo kiểm và hiệu năng

Việc lựa chọn Redis trong bộ nhớ, SSE và Channels đã mang lại kết quả như thế nào?

Kết quả trong môi trường thực tế vượt kỳ vọng:

  • Kiến trúc: cụm Kubernetes với 6 Pod đang hoạt động.
  • Mức sử dụng RAM: mỗi Pod chỉ dùng trung bình khoảng 55 MB.
  • Mức sử dụng CPU: khoảng 20% trên mỗi Pod trong thời điểm thị trường cao tải.
  • Khả năng phục vụ: cụm nhỏ này xử lý được hàng chục nghìn kết nối mở đồng thời mà người dùng không nhận thấy suy giảm đáng kể.

Điều đó cho thấy một hạ tầng tương đối nhẹ vẫn có thể phục vụ hàng chục nghìn người dùng mà không làm giảm rõ rệt chất lượng hoặc độ trễ.

Những chi tiết nhỏ nhưng có ảnh hưởng lớn

Trong quá trình triển khai, đội ngũ gặp một số vấn đề khó. Những kinh nghiệm này có thể hữu ích cho các nhóm xây dựng hệ thống tương tự.

Nút thắt do proxy

Ở lần triển khai đầu tiên, một số người dùng nhận dữ liệu chậm, trong khi những người khác bị mất kết nối hoàn toàn.

Nguyên nhân không nằm trong mã nguồn mà đến từ hạ tầng mạng và các proxy như Nginx hoặc proxy doanh nghiệp.

SSE sử dụng kết nối mở lâu dài. Nhiều proxy mặc định gom và đệm phản hồi trước khi chuyển tiếp, làm hỏng hành vi theo thời gian thực của SSE.

Giải pháp: thiết lập tiêu đề sau:

X-Accel-Buffering: no

Đồng thời, cấu hình proxy phải cho phép các luồng chạy lâu đi qua mà không bị đệm.

Thách thức rò rỉ bộ nhớ

Tình huống nguy hiểm nhất xảy ra khi người dùng mất mạng hoặc đóng tab trình duyệt, trong khi máy chủ vẫn tiếp tục ghi dữ liệu vào Channel riêng của họ.

Nếu các Channel không được dọn dẹp, máy chủ có thể gặp vấn đề bộ nhớ nghiêm trọng sau một thời gian.

Giải pháp: xử lý nghiêm túc cơ chế RequestAborted trong HttpContext.

Ngay khi máy chủ phát hiện kết nối của máy khách đã bị hủy, Channel tương ứng được giải phóng và xóa khỏi Coordinator.

Khả năng mở rộng

Điều gì xảy ra vào một ngày thị trường sôi động nếu tải tăng gấp đôi? Kiến trúc có thật sự được thiết kế cho tình huống xấu nhất không?

Giải pháp: sử dụng HPA, tức Horizontal Pod Autoscaler của Kubernetes.

Khi số người dùng tăng và mức sử dụng CPU hoặc RAM vượt ngưỡng, Kubernetes tự động tăng số lượng Pod. Nhờ đó, hệ thống luôn sẵn sàng phục vụ người dùng Mofid trong các đợt tăng lưu lượng.

Xử lý kết nối bị ngắt

Khi máy khách gửi yêu cầu SSE, có thể xuất hiện khoảng thời gian không có dữ liệu mới. Ví dụ, một cổ phiếu hoặc quỹ tạm thời không giao dịch nên không phát sinh giá mới.

Những kết nối không hoạt động trong thời gian dài cũng có thể bị proxy, bộ cân bằng tải, cổng mạng hoặc hạ tầng mạng khác đóng lại.

Giải pháp: sử dụng cơ chế heartbeat tương tự các giao thức mạng.

Cứ khoảng 20 giây, máy chủ tạo một tin nhắn HB. Tin nhắn này giúp xác nhận kết nối vẫn khỏe và ngăn kết nối rơi vào trạng thái hoàn toàn không hoạt động.

Kết luận

Dự án này cho thấy trong các hệ thống thời gian thực, công cụ phức tạp nhất không phải lúc nào cũng là lựa chọn tốt nhất.

Kafka được giữ lại ở nơi cần độ bền dữ liệu và khả năng phát lại.

Redis Pub/Sub được chọn cho việc phát nhanh và tạm thời.

Ở phía máy chủ, System.Threading.Channels tạo ra một đường dẫn nhẹ, ít chi phí và hiệu quả để phân phối tin nhắn.

Ở phía máy khách, SSE cho phép đẩy giá với độ trễ thấp mà không cần một giao thức hai chiều phức tạp.

Điểm quan trọng nhất là phải hiểu bản chất của dữ liệu trước khi chọn công nghệ.

Một giá theo thời gian thực là dữ liệu có thể thay thế, không phải một sự kiện bắt buộc phải được bảo toàn tuyệt đối. Nhận thức này cho phép đội ngũ chủ động chấp nhận một mức mất dữ liệu giới hạn ở những lớp như Redis Pub/Sub và chính sách DropOldest, đổi lại là dữ liệu luôn mới, kiến trúc đơn giản và hệ thống ổn định.

Cuối cùng, thành công của kiến trúc không chỉ đến từ việc chọn đúng công nghệ. Nó là kết quả của sự kết hợp giữa:

  • Giám sát liên tục.
  • Triển khai theo từng giai đoạn.
  • Lấy ảnh chụp và đồng bộ lại sau khi kết nối.
  • Cơ chế heartbeat.
  • Feature Toggle.
  • Quản lý hàng đợi có giới hạn.
  • Dọn dẹp tài nguyên khi kết nối bị hủy.
  • Phối hợp chặt chẽ giữa đội sản phẩm, backend, frontend và SRE.

Đó là nền tảng giúp hệ thống báo giá hoạt động nhanh, ổn định và có thể mở rộng trong điều kiện thị trường biến động mạnh.