# 跨产品余额同步：用 WebSocket 实时显示另一个产品的余额

> 产品 A 把用户余额转入产品 B、A 的客户端又要显示 B 的余额时，经由 A 的后端轮询会让 A 的处理能力受制于 B 的延迟。附流程图说明 B 通过 WebSocket 推送每次变动如何解决。

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

两个产品之间常见的做法：用户把一部分余额从产品 A 转入产品 B，再到 B 里使用。用户在 B 里花掉或赢得金额时，A 的客户端仍然要实时显示 B 里还剩多少。第一版通常用 HTTP 去问：A 的客户端轮询 A 的后端，A 的后端再去问 B。每一次轮询都变成横跨两个产品的请求链，流量一上来，限制你能服务多少用户的就不是任何一个产品，而是这条链。

## 请求链是怎么叠起来的

```text
flow: 改之前：每个用户每隔几秒，A 的客户端问 A 的后端，A 的后端再问 B
== 转入余额
A 的客户端 --POST /transfer--> A 的后端|等待 B --POST /deposit--> B 的后端
== 在 A 的客户端显示 B 的余额
A 的客户端|每 5 秒 --GET /b-balance--> A 的后端|等待 B --GET /balance--> B 的后端|每个请求占一个 worker
A 的客户端|每 5 秒 --GET /b-balance--> A 的后端|等待 B --GET /balance--> B 的后端|每个请求占一个 worker
```

每个 HTTP 请求在等待时都会占住资源：一个 worker 线程、客户端连接池里的一条连接，通常还有一条数据库连接。A 的请求要等 B 响应后才能响应，所以占住这些资源的时间是自己的处理时间，加上 B 花掉的全部时间。[利特尔定律](https://en.wikipedia.org/wiki/Little%27s_law)可以算出这个数字：处理中的请求数 = 每秒请求数 × 每个请求花的秒数。而且只要有 A 的用户开着页面，B 每隔几秒就会收到一个请求，不管余额有没有变。

## 要付出多少 worker

以下是推算示例，不是压测结果（写于 2026-11-09）：A 有 5,000 名用户的画面上显示着 B 的余额，A 的客户端每 5 秒轮询一次，所以每秒有 1,000 个请求经过 A 送到 B。

| | 经由 A 的后端轮询 | B 通过 WebSocket 推送每次变动 |
| --- | --- | --- |
| 每秒到达 A 和 B 的余额请求 | 各 1,000 个，大多数没有变化 | 没有；客户端连接时读一次 |
| 每个请求占住 A 的 worker 的时间 | 自身 10 ms + 调用 B 的 60 ms = 70 ms | 完全不经过 A |
| A 忙着转送 B 余额的 worker 数 | 1,000 × 0.07 秒 = 70 | 0 |
| B 变慢到 600 ms 时 | 1,000 × 0.61 秒 = 610，是 200 个 worker 连接池的三倍 | 仍然是 0：只是下一次更新晚一点到 |

痛点在最后一列。假设连接池有 200 个 worker，B 一时变慢就足以把 A 的用光：A 自己的请求，包括登录和付款，都在队列里排队、超时，客户端再重试，又加进更多请求。A 的并发量正好在流量高峰时下滑，原因却是一个 A 根本不经营的产品。同时，B 也把自己的处理能力花在响应大多是“没变”的轮询上。

## 改之后：B 每次变动只发布一次

```text
flow: 改之后：B 发布用户余额的每次变动，A 的客户端直接收到
A 的客户端 --POST /transfer--> A 的后端 --POST /deposit--> B 的后端|写入 B 的账本
B 的后端|每次变动后 --发布 balance.updated--> *Hibira|private-b-balance-42 --WebSocket--> A 的客户端|以及 B 自己的客户端
```

```text
sequence: 用户转 1,000 到 B，之后在 B 花了 120；A 的客户端两次都立刻看到
A 的客户端 | A 的后端 | B 的后端 | *Hibira
1 > 2: 转 1,000 到 B
2 > 3: POST /deposit | 幂等键
3 >> 2: 200 | B 余额 1,000，版本 1
3 > 4: balance.updated | private-b-balance-42
4 > 1: balance.updated | 1,000，版本 1
note: 之后用户在 B 花了 120
3 > 4: balance.updated | 880，版本 2
4 > 1: balance.updated | 880，版本 2
```

B 每写入一次变动（包括转入）就发布一次。A 的客户端保持一条 WebSocket 连接，一趟网络往返就收到新余额。B 的余额不再经过 A 的后端，所以变慢或忙碌的 B 占不住 A 的 worker，B 也不用再响应成千上万的轮询。

## 怎么设置

- **B 的余额由 B 负责。** B 的账本是 B 里剩多少的唯一事实来源。每次提交后，B 用任何 Pusher 服务器端库的 HTTP API 发布 `balance.updated`，带上新余额和版本号。
- **每个用户一个私有频道。** 例如 `private-b-balance-42`。如果 A 和 B 用同一个应用，A 的授权端点确认登录的用户是 42 之后为订阅签名。如果 B 有自己的应用，A 的客户端就用 B 的应用密钥连接，由 B 的授权端点签名。如果中继服务只该看到密文，就用 `private-encrypted-b-balance-42`；其他私有频道在传输中由 TLS 保护，保存时加密。
- **善用版本号。** 版本比画面上低的事件直接忽略。A 的客户端连接或重新连接时，先用 HTTP 读一次 B 的余额，再应用之后的事件。
- **A 的后端也能收。** 如果 A 在服务器上需要 B 的余额，例如让用户把钱转回来之前，可以用 Pusher 客户端库靠同样的事件维护一份副本，不必每次都调用 B。

## 不变的部分

从 A 转到 B 仍然是带幂等键的 HTTP 请求，重试也绝不会把钱转两次；B 每次写入时也仍会自己检查余额。WebSocket 发送的是“B 的余额变了”这个消息，不是变动本身。如果另一端的产品是外部 API 供应商，转账本身也可能塞爆队列：请看[与 API 供应商的钱包转账](https://hibira.io/zh-cn/topics/wallet-transfers-api-providers)。

## 常见问题

### 为什么不干脆降低轮询频率？

间隔拉长，请求变少，但余额也变旧：在 B 赢得的金额要晚一整个间隔才出现在 A 的客户端，而且每次轮询仍然要等 B。推送一次变动只要一趟网络往返，没有变动时什么都不送。

### A 的客户端断线时漏掉事件怎么办？

pusher-js 和其他客户端库会自动重新连接。重新连接后先用 HTTP 读一次 B 的余额，只应用版本更高的事件。版本号也让重复或迟到的事件无害。

### A 需要改 B 的代码吗？

B 只要加一件事：写入余额后发布 `balance.updated`，如果 B 有自己的应用，再为订阅签名。它的转账 API 维持不变。

### 这些事件在 Hibira 上怎么计算？

每次发布算一条消息。付费方案不计算送给订阅者的次数，所以同一个余额同时显示在 A 和 B 的客户端，费用和只显示在一个客户端相同。请看[价格](https://hibira.io/zh-cn/pricing)。
