我的老天額系列 #2:上了 SignalR Backplane,通知通通變兩份
2026-05-17 23:05:32
# Redis 在 .NET 專案裡的幾種用法,以及我踩過的 SignalR Backplane 坑
這篇分兩部分:前半快速過一下 Redis 在我們專案裡的幾種用法,後半才是重點——一個我為了讓兩台 AP 的 SignalR 同步而上 Backplane,結果通知通通變兩份的故事
## 介紹一些用法
### 讀取與存入
最基本的 KV 存取,當快取用。
```csharp
var db = _redis.GetDatabase();
await db.StringSetAsync("user:1001:nickname", "ShengHao", TimeSpan.FromMinutes(30));
var nickname = await db.StringGetAsync("user:1001:nickname");
```
熱資料、Session、短期查詢結果,通常都丟這層擋住 DB。
### Pub/Sub
訊息發布訂閱,發出去當下沒人訂閱就沒了,不留訊息。
```csharp
var sub = _redis.GetSubscriber();
// A 服務訂閱
await sub.SubscribeAsync("order:created", (channel, message) =>
{
// 收到訊息做事
});
// B 服務發布
await sub.PublishAsync("order:created", orderJson);
```
服務之間的即時通知、廣播事件用這個很方便。
### Stream
跟 Pub/Sub 最大差別是訊息會留著,有 Consumer Group 概念,可以多個消費者分流、可以回放、可以記錄消費位移。
```csharp
await db.StreamAddAsync("orders", new NameValueEntry[]
{
new("orderId", "A001"),
new("amount", "1000")
});
var entries = await db.StreamReadGroupAsync("orders", "group1", "consumer1", ">");
```
需要可靠投遞、不能掉訊息的場景才上 Stream,不然 Pub/Sub 就夠用。
### Backplane
SignalR 專用的概念。當你的 Web AP 不只一台時,SignalR 預設只認自己這台的連線,需要一個「背板」幫忙把訊息廣播到其他 AP,Redis 就可以扮演這個角色。
```csharp
services.AddSignalR()
.AddStackExchangeRedis("localhost:6379", options =>
{
options.Configuration.ChannelPrefix = "MyApp";
});
```
裝完之後 SignalR 跨機器的事它自己會處理。看起來很美好——但這就是接下來故事的開場。
---
## 踩過的坑坑
### 坑 1:AP 與 AP 之間的通訊
當 A Server 跟 B Server 要溝通的時候,第一直覺是開 API 互打,但這兩台明明都是我們自己系統內部的服務,何必呢?
於是我就想:那不就 Redis Pub/Sub 一發一收就好了嗎?前台想通知後台、後台想聯絡前台,就各自開 Channel 互相 Pub/Sub。
簡單、漂亮、自我感覺良好。
### 坑 2:前端與後端之間的通訊
前端跟後端的即時通訊我們用 SignalR,client 訂閱 Hub,server 把訊息推下去,這部分一直跑得好好的。
直到有一天我發現一件事——
**我們前台有兩台 AP,前面掛 LoadBalancer 分流。**
意思就是 User A 連到第一台、User B 連到第二台,後端如果只丟到第一台的 SignalR,第二台上的人就收不到訊息。這不是 bug,是 SignalR 預設行為,每台 AP 只認自己記憶體裡的連線清單。
那怎麼辦?上 Backplane 啊。
```csharp
services.AddSignalR()
.AddStackExchangeRedis(redisConn);
```
兩行設定。Redis 當背板,訊息自動跨機器同步,第一台收到推送會幫第二台一起發。問題解決,下班。
### 坑 3:每個通知都收到兩次(我的老天額)
接著噩夢就來了。
上線之後 User 開始反應:「為什麼每個通知都跳兩次?」、「Alert 一次按兩下確定才會消失」。
我打開 console 一看——對,每則訊息確實收到兩次...,估計是哪裡出了問題 XD
我盯著程式碼看了半天才想通。問題出在我把兩件事疊在一起做了:
**Backplane 的底層也是 Redis Pub/Sub。**
它不是什麼神奇的同步機制,它就是在 Redis 上開一個 channel,A 機器發訊息,B 機器訂閱,然後 B 機器再把訊息推給自己這邊的 client。所以當訊息進入「任何一台」SignalR 的 Hub,Backplane 都會幫你廣播到所有機器,每個 client 該收的都會收到,使命必達。
但我之前還留著「後台手寫 Pub 訊息到前台 channel、前台 Sub 之後再呼叫 SignalR 推下去」這條路。
於是訊息流變成這樣:
```
後台 Pub → 前台 AP1 Sub → SignalR.Send → Backplane 廣播 → AP1、AP2 都推給 client
↘ 前台 AP2 Sub → SignalR.Send → Backplane 廣播 → AP1、AP2 都推給 client
```
每台前台 AP 都 Sub 到了那則訊息,然後每台都各自呼叫了一次 SignalR.Send,Backplane 又各自廣播一次。N 台 AP 就會收到 N 次通知,合情合理。
**正解是:後台不要自己手寫 Pub/Sub,直接用 `IHubContext<TFrontendHub>` 把訊息推到 SignalR Hub 就好,Backplane 會處理跨機器分發這件事。**
```csharp
public class NotificationService
{
private readonly IHubContext<FrontendHub> _hub;
public NotificationService(IHubContext<FrontendHub> hub)
{
_hub = hub;
}
public async Task NotifyUserAsync(string userId, string message)
{
// 後台直接呼叫,Backplane 自己會把訊息送到對的那台 AP
await _hub.Clients.User(userId).SendAsync("ReceiveNotification", message);
}
}
```
不用自己 Pub、不用自己 Sub、不用想「現在這個 User 連在哪台」,那些都是 Backplane 的責任。
### 坑 4:後台根本沒有 Hub class,要怎麼 `IHubContext<FrontendHub>`?
理論講完了,回到現實——`IHubContext<T>` 那個 `T` 是前台的 Hub class,但我這個發通知的程式碼是寫在**後台 repo** 裡的,後台根本沒有這個 class 的定義。
理想做法當然是把 Hub class 抽到一個共用 Lib,前後台都引用同一份。但我們前台已經發展了一堆 Hub、定義了一堆 channel 跟介面,現在要整包搬到 Lib 還要兩邊都改引用,工程量不算小。
於是我做了一件很髒但會動的事——
**在後台 repo 裡,刻意用跟前台一模一樣的 namespace + class name 重新宣告一個 Hub class。**
```csharp
// 前台 repo
namespace MyApp.Frontend.Hubs
{
public class FrontendHub : Hub
{
// 前台才會跑的東西:連線初始化、推一些初始資料給 user 等等
public override async Task OnConnectedAsync()
{
await Clients.Caller.SendAsync("Init", /* ... */);
await base.OnConnectedAsync();
}
// 還有一堆前台的 Hub 方法
}
}
// 後台 repo(為了讓 Backplane 認得,namespace 跟 class name 必須一字不差)
namespace MyApp.Frontend.Hubs
{
public class FrontendHub : Hub { } // 空的就好
}
```
後台這邊為什麼可以是空 class?因為 **client 是連到前台 AP 的,所有 `OnConnectedAsync`、Hub 方法那些「client 真的會呼叫到」的程式碼都只會在前台跑**。後台從來不會有 client 直接連進來,它只是透過 `IHubContext<FrontendHub>` 從外部「往這個 Hub 丟訊息」而已。
對 Backplane 來說,它只需要從 type 名稱算出 channel key、把訊息丟到 Redis;前台 AP 那邊訂閱了同名 channel,自然會收到並推給對應的 client。所以後台的這個 class 純粹是「為了讓 `IHubContext<T>` 的 T 對得上」而存在的占位符,裡面什麼邏輯都不用寫。
為什麼可以這樣搞?因為 **Backplane 在 Redis 上用來識別「這則訊息是要送到哪個 Hub」的 key,是用 Hub 的 type 完整名稱(namespace + class name)算出來的。**只要兩邊的字串拼起來一樣,Backplane 就會把訊息送到對的 channel,前台訂閱的那個 Hub 就會收到。
對,它認的是名字,不是同一份 assembly。
舉個具體的例子。假設前台的 Hub 長這樣:
```csharp
// 前台 repo
namespace MyApp.Frontend.Hubs
{
public class FrontendHub : Hub { }
}
```
當前台連上來的 client 訂閱了這個 Hub,SignalR Backplane 會在 Redis 上開一個 channel,名字大概是:
```
MyApp:MyApp.Frontend.Hubs.FrontendHub:all
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
就是 Hub 的 namespace + class name
```
(前綴 `MyApp` 是你在 `AddStackExchangeRedis` 設定的 `ChannelPrefix`,後綴 `:all` 是廣播給所有 client 用的;如果是 `Clients.User(xxx)` 會是另一個 channel,但邏輯一樣。)
然後後台呼叫:
```csharp
// 後台 repo,class 是空的,但 namespace + class name 跟前台一樣
namespace MyApp.Frontend.Hubs
{
public class FrontendHub : Hub { }
}
// 發通知
await _hub.Clients.All.SendAsync("ReceiveNotification", message);
```
Backplane 在後台這邊算出來的 channel 名也會是:
```
MyApp:MyApp.Frontend.Hubs.FrontendHub:all
```
字串對上了,前台就收得到。如果哪天前台把 class 改名叫 `NotificationHub`,前台會在新 channel 訂閱,後台還在用舊名字發到舊 channel,**程式不會報錯**,訊息就靜靜地飛到沒人聽的地方。
這做法能跑、上線了、沒事。但說真的不漂亮——複製貼上的 class 沒有單一事實來源,哪天前台改了 Hub 名稱或搬了 namespace,後台這邊不會報錯,只會無聲無息地推不到通知。
下次開新專案,我會一開始就把 Hub class 放在共用 Lib 裡。這次就先這樣。
---
## 後記
這個坑教我的事是:**當你引入一個工具,先搞清楚它幫你做了什麼,再決定你還要自己做什麼**。
我一開始把「跨 AP 通訊用 Pub/Sub」和「跨 AP 推播 SignalR 用 Backplane」當成兩件獨立的事在解,沒意識到 Backplane 底層就是 Pub/Sub,兩條路其實在同一個 Redis 上打架。
如果一開始就知道 Backplane = SignalR 版本的 Pub/Sub,這個坑根本不會存在。但話說回來,沒踩過大概也不會這麼有印象就是了。
順便附贈一條給未來的自己:**Hub class 一開始就放共用 Lib,不要等到後台需要它的時候才在那邊靠 namespace 一致來騙過 Backplane。** 能跑不代表漂亮。
點擊複製文章連結