← Chủ đề

Đồng bộ số dư giữa các sản phẩm: hiển thị số dư thời gian thực của sản phẩm khác qua WebSocket

Khi sản phẩm A chuyển số dư của người dùng sang sản phẩm B và ứng dụng của A hiển thị số dư ở B, polling qua backend của A khiến năng lực của A phụ thuộc độ trễ của B. Cách để B đẩy mỗi thay đổi qua WebSocket giải quyết điều đó, kèm sơ đồ.

Một cách làm thường gặp giữa hai sản phẩm: người dùng chuyển một phần số dư từ sản phẩm A sang sản phẩm B rồi dùng ở B. Trong lúc người dùng tiêu hoặc thắng ở B, ứng dụng của A vẫn phải hiển thị theo thời gian thực số tiền còn lại ở B. Phiên bản đầu thường hỏi qua HTTP: ứng dụng của A polling backend của A, và backend của A đi hỏi B. Mỗi lần polling trở thành một chuỗi yêu cầu xuyên qua cả hai sản phẩm, và khi lưu lượng tăng, thứ giới hạn số người dùng bạn phục vụ được không phải sản phẩm nào cả, mà là chuỗi này.

Chuỗi yêu cầu tích tụ như thế nào

Chuyển số dư

Ứng dụng của A
POST /transfer
Backend của Achờ B
POST /deposit
Backend của B

Hiển thị số dư ở B trong ứng dụng của A

Ứng dụng của Amỗi 5 giây
GET /b-balance
Backend của Achờ B
GET /balance
Backend của Bmỗi yêu cầu một worker
Ứng dụng của Amỗi 5 giây
GET /b-balance
Backend của Achờ B
GET /balance
Backend của Bmỗi yêu cầu một worker
Trước: với mỗi người dùng, vài giây một lần, ứng dụng của A hỏi backend của A, và backend của A hỏi B

Mỗi yêu cầu HTTP đều giữ một thứ gì đó trong lúc chờ: một luồng worker, một kết nối trong pool của client, thường cả một kết nối cơ sở dữ liệu. Yêu cầu của A không trả lời được cho đến khi B trả lời, nên nó giữ tất cả những thứ đó trong thời gian xử lý của chính nó cộng toàn bộ thời gian B cần. Định luật Little cho ra con số: số yêu cầu đang xử lý = số yêu cầu mỗi giây × số giây mỗi yêu cầu mất. Và B nhận một yêu cầu từ mỗi người dùng A đang mở trang, vài giây một lần, dù số dư có đổi hay không.

Tốn bao nhiêu worker

Đây là ví dụ tính toán, không phải kết quả đo (viết ngày 2026-11-09): 5.000 người dùng của A đang thấy số dư ở B trên màn hình và ứng dụng của A polling mỗi 5 giây, nên 1.000 yêu cầu mỗi giây đi qua A tới B.

Hàng cuối là chỗ đau. Với pool 200 worker, chỉ cần B chậm một lúc là đủ dùng hết pool của A: chính các yêu cầu của A, gồm cả đăng nhập và thanh toán, chờ trong hàng đợi, rồi hết thời gian, rồi client thử lại và thêm yêu cầu mới. Khả năng xử lý đồng thời của A giảm đúng lúc lưu lượng cao nhất, vì một sản phẩm mà A không vận hành. Trong khi đó B cũng tốn năng lực của mình để trả lời những lần polling phần lớn đều báo "không đổi".

Sau: B phát mỗi thay đổi một lần

Ứng dụng của A
POST /transfer
Backend của A
POST /deposit
Backend của Bghi sổ cái của B
Backend của Bsau mỗi thay đổi
phát balance.updated
Hibiraprivate-b-balance-42
WebSocket
Ứng dụng của Avà ứng dụng của chính B
Sau: B phát mọi thay đổi số dư của người dùng, và ứng dụng của A nhận trực tiếp
Ứng dụng của ABackend của ABackend của BHibiraChuyển 1.000 sang BPOST /depositkhóa idempotency200số dư ở B 1.000, phiên bản 1balance.updatedprivate-b-balance-42balance.updated1.000, phiên bản 1Sau đó người dùng tiêu 120 ở Bbalance.updated880, phiên bản 2balance.updated880, phiên bản 2
Người dùng chuyển 1.000 sang B rồi tiêu 120 ở đó; ứng dụng của A thấy cả hai ngay

B phát một lần sau mỗi thay đổi nó ghi, kể cả lần nhận tiền chuyển vào. Ứng dụng của A giữ một kết nối WebSocket và nhận số dư mới trong thời gian một vòng mạng. Số dư ở B không còn đi qua backend của A, nên B chậm hay bận cũng không giữ được worker của A, và B không còn phải trả lời hàng nghìn lần polling.

Cách thiết lập

  • Số dư ở B do B quản lý. Sổ cái của B là nguồn sự thật về số tiền còn lại ở B. Sau mỗi lần commit, B phát balance.updated kèm số dư mới và số phiên bản, qua HTTP API của bất kỳ thư viện máy chủ Pusher nào.
  • Mỗi người dùng một kênh riêng tư. Ví dụ private-b-balance-42. Nếu A và B dùng chung một ứng dụng, endpoint xác thực của A ký đăng ký sau khi kiểm tra người dùng đang đăng nhập là 42. Nếu B chạy ứng dụng riêng, ứng dụng của A kết nối bằng khóa ứng dụng của B và endpoint xác thực của B ký. Dùng private-encrypted-b-balance-42 nếu bên chuyển tiếp chỉ nên thấy bản mã; các kênh riêng tư khác được TLS bảo vệ khi truyền và được mã hóa khi lưu.
  • Dùng số phiên bản. Bỏ qua sự kiện có phiên bản thấp hơn bản đang hiển thị. Khi ứng dụng của A kết nối hoặc kết nối lại, đọc số dư ở B một lần qua HTTP rồi mới áp dụng sự kiện.
  • Backend của A cũng nhận được. Nếu A cần số dư ở B trên máy chủ, chẳng hạn trước khi cho người dùng chuyển tiền về, nó có thể giữ một bản sao cập nhật từ chính các sự kiện đó bằng thư viện client Pusher, thay vì gọi B mỗi lần.

Những gì giữ nguyên

Việc chuyển từ A sang B vẫn là yêu cầu HTTP có khóa idempotency, nên thử lại không bao giờ chuyển tiền hai lần, và B vẫn tự kiểm tra số dư mỗi khi ghi. WebSocket mang tin rằng số dư ở B đã đổi, không mang chính thay đổi đó. Khi sản phẩm phía bên kia là nhà cung cấp API bên ngoài, chính việc chuyển tiền cũng có thể làm tràn hàng đợi: xem chuyển tiền ví với nhà cung cấp API.

Câu hỏi thường gặp

Sao không polling thưa hơn?

Khoảng cách dài hơn nghĩa là ít yêu cầu hơn nhưng số dư cũ hơn: tiền thắng ở B hiện lên trong ứng dụng của A muộn trọn một khoảng, và mỗi lần polling vẫn phải chờ B. Đẩy thay đổi chỉ mất một vòng mạng, và không gửi gì khi không có gì thay đổi.

Nếu ứng dụng của A bỏ lỡ sự kiện khi mất kết nối thì sao?

pusher-js và các thư viện client khác tự kết nối lại. Sau khi kết nối lại, đọc số dư ở B một lần qua HTTP và chỉ áp dụng sự kiện có phiên bản cao hơn. Số phiên bản cũng khiến sự kiện trùng hay đến muộn trở nên vô hại.

A có phải sửa mã của B không?

B chỉ cần thêm một việc: phát balance.updated sau khi ghi số dư, và ký đăng ký nếu B chạy ứng dụng riêng. API chuyển tiền giữ nguyên.

Hibira đếm các sự kiện này thế nào?

Mỗi lần phát tính là một tin nhắn. Trên các gói trả phí, lượt gửi tới người đăng ký không được tính, nên cùng một số dư hiển thị ở cả ứng dụng của A và của B cũng tốn như khi chỉ hiển thị ở một nơi. Xem bảng giá.

Tên sản phẩm là nhãn hiệu của chủ sở hữu.