# Đồ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ơ đồ.

Source: https://hibira.io/vi/topics/balance-sync-websocket
Updated: 2026-11-09

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

```text
flow: 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
== Chuyển số dư
Ứng dụng của A --POST /transfer--> Backend của A|chờ 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 A|mỗi 5 giây --GET /b-balance--> Backend của A|chờ B --GET /balance--> Backend của B|mỗi yêu cầu một worker
Ứng dụng của A|mỗi 5 giây --GET /b-balance--> Backend của A|chờ B --GET /balance--> Backend của B|mỗi yêu cầu một worker
```

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](https://en.wikipedia.org/wiki/Little%27s_law) 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.

| | Polling qua backend của A | B đẩy mỗi thay đổi qua WebSocket |
| --- | --- | --- |
| Yêu cầu số dư mỗi giây tới A và B | mỗi bên 1.000, phần lớn không đổi | không có; đọc một lần khi ứng dụng kết nối |
| Thời gian mỗi yêu cầu giữ worker của A | 10 ms việc của nó + lời gọi B 60 ms = 70 ms | không đi qua A |
| Số worker của A bận chuyển tiếp số dư của B | 1.000 × 0,07 giây = 70 | 0 |
| Khi B chậm lại còn 600 ms | 1.000 × 0,61 giây = 610, gấp ba pool 200 | vẫn 0: lần cập nhật tiếp theo chỉ đến muộn hơn |

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

```text
flow: 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 A --POST /transfer--> Backend của A --POST /deposit--> Backend của B|ghi sổ cái của B
Backend của B|sau mỗi thay đổi --phát balance.updated--> *Hibira|private-b-balance-42 --WebSocket--> Ứng dụng của A|và ứng dụng của chính B
```

```text
sequence: 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
Ứng dụng của A | Backend của A | Backend của B | *Hibira
1 > 2: Chuyển 1.000 sang B
2 > 3: POST /deposit | khóa idempotency
3 >> 2: 200 | số dư ở B 1.000, phiên bản 1
3 > 4: balance.updated | private-b-balance-42
4 > 1: balance.updated | 1.000, phiên bản 1
note: Sau đó người dùng tiêu 120 ở B
3 > 4: balance.updated | 880, phiên bản 2
4 > 1: balance.updated | 880, phiên bản 2
```

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](https://hibira.io/vi/topics/wallet-transfers-api-providers).

## 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á](https://hibira.io/vi/pricing).
