WeHelp
我的老天額系列 #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。** 能跑不代表漂亮。