下的連接占用與解決方案)
并行查詢一上來服務端直接拋“連接已被占用”查了好幾天索引、慢SQL、連接池配置最后才發(fā)現(xiàn)問題根本不在數(shù)據(jù)庫而在 SqlSugarClient 自己身上。這不是冷門問題.NET 項目里只要用 SqlSugar 且并發(fā)量一上來幾乎都會撞上這面墻。我把 SqlSugarClient 在多線程下的線程安全陷阱完整拆一遍再給出幾種可以直接落地的解決方案和排查套路。1. 先拆開 SqlSugarClient它到底藏了多少共享狀態(tài)1.1 一個 Client 不只是一個“連接”先糾正一個常見誤解SqlSugarClient不是傳統(tǒng)意義上的無狀態(tài)工具類它更像一個“數(shù)據(jù)庫操作上下文”。實例內(nèi)部通常持有一個核心DbConnection對象同時還帶著當前正在執(zhí)行的命令對象、參數(shù)集合、事務狀態(tài)、AOP 回調(diào)、實體映射緩存和查詢條件拼裝的臨時狀態(tài)。這些狀態(tài)在單線程下沒有任何問題但一旦進入多線程就成了一個個臨界區(qū)。把想象力打開一點SqlSugarClient就像一位廚師。一個廚師同時服務幾十桌客人菜譜是同一本鍋只有一口??腿?A 點魚香肉絲客人 B 點宮保雞丁廚師剛切完肉絲就被 B 喊走去切雞丁最后端上桌的菜大概率是混雜的。多線程共用SqlSugarClient就是這種場景查詢條件、參數(shù)、命令、連接全混在一個實例里。線程 A 剛拼好 SQL 準備執(zhí)行線程 B 把參數(shù)對象改了A 實際拿到的就是 B 的查詢結(jié)果。這類問題表現(xiàn)非常隨機可能一會兒正常一會兒查出臟數(shù)據(jù)一會兒又報連接錯誤而且大概率在低并發(fā)壓測時測不出來。1.2 第一次并發(fā)踩踏現(xiàn)場典型的翻車代碼最經(jīng)典的翻車寫法是這樣的// 一個靜態(tài)全局單例全站共享 private static readonly SqlSugarClient Db new SqlSugarClient(new ConnectionConfig { ConnectionString Server...;Database...;Uid...;Pwd...;, DbType DbType.SqlServer }); // 并行查詢一批訂單 Parallel.ForEach(orderIds, id { var order Db.QueryableOrder().Where(o o.Id id).First(); Console.WriteLine(order?.Id); });這段代碼在并發(fā)量低的時候可能跑很久都不報錯。因為數(shù)據(jù)庫操作很快線程 A 執(zhí)行完釋放連接線程 B 才拿到來用剛好錯開??梢坏﹥蓚€線程同時進入查詢方法底層同一個DbConnection會被同時下發(fā)兩條命令通常就會報類似這樣的異常SqlSugarException: connection is already occupied ...如果是底層 ADO.NET 的驅(qū)動直接接手還可能出現(xiàn)There is already an open DataReader associated with this Command which must be closed first.這兩個異常已經(jīng)非常能說明問題同一個連接對象在同一時間被多個執(zhí)行流使用數(shù)據(jù)庫驅(qū)動根本扛不住。2. 高頻踩坑場景你是哪種翻車姿勢2.1 靜態(tài)全局單例 Parallel 并發(fā)我見過不少項目把SqlSugarClient定義成public static readonly的全局單例然后在服務層到處用。這種做法在小型內(nèi)部系統(tǒng)、管理后臺里可能幾年不炸因為并發(fā)低到可以忽略。但只要哪天對外提供服務、或接入了消息隊列的批量處理并發(fā)請求一多異常立刻爆發(fā)。而且由于問題是“概率性”的往往很難復現(xiàn)容易讓團隊浪費大量時間排查 SQL、數(shù)據(jù)庫鎖、連接池。這里要特別強調(diào)一個迷惑點不是并發(fā)就一定會報錯。SqlSugarClient內(nèi)部很多查詢是先拼 SQL、再執(zhí)行、最后關(guān)閉連接整個過程只有幾毫秒。兩個線程在毫秒級窗口內(nèi)踩踏的概率本來就低所以低并發(fā)下表現(xiàn)是“看起來正常”。但一旦每個請求的操作時間變長比如慢 SQL、大表查詢、事務包裹重疊窗口變大異常就會顯著增加。2.2 DI 容器里 AddSingleton 注冊 ClientASP.NET Core 項目里另一個高頻問題出在依賴注入注冊方式上。// 錯誤示范 services.AddSingletonISqlSugarClient(new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer, IsAutoCloseConnection true }));AddSingleton意味著容器里只有唯一實例所有請求注入的都是同一個SqlSugarClient。這和前面的靜態(tài)單例本質(zhì)上沒有區(qū)別只是換了一個容器來管理生命周期。更隱蔽的是有些人用了AddSingletonSqlSugarClient短期壓測沒問題因為IsAutoCloseConnection true讓每次操作后連接都關(guān)了。但連接關(guān)閉不等于線程安全同一個實例內(nèi)部的查詢上下文、事務狀態(tài)、命令狀態(tài)依然共享。2.3 手動 new Client 但從不釋放還有一種情況是沒有用 DI而是每次請求手動new SqlSugarClient(...)。這個本身沒問題前提是操作完要釋放并且要開啟IsAutoCloseConnection。但我在實際項目里見過不少代碼只 new 不 dispose也不開啟IsAutoCloseConnection true。這時候問題會變成每來一個請求就 new 一個 Client 并保持一個數(shù)據(jù)庫連接數(shù)秒甚至更久數(shù)據(jù)庫連接池很快被打滿后續(xù)請求全部排隊等連接表現(xiàn)為服務整體變慢最后連接超時。// 錯誤示范new 了不釋放且連接長期持有 public Order GetOrder(long id) { var client new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer }); return client.QueryableOrder().Where(o o.Id id).First(); }這種寫法在并發(fā)低時不會立刻暴露因為 Client 被局部變量持有方法結(jié)束后雖然沒顯式調(diào)用Dispose但最終會被 GC 回收連接會延遲釋放。不過一旦并發(fā)上來連接池一直被占用情況就非常糟糕。2.4 在 Task 里共享同一個 Client還有一種比較隱蔽的用法數(shù)據(jù)上下文通過構(gòu)造函數(shù)注入Controller 里又開Task.Run或Parallel.ForEach執(zhí)行多個子任務直接共享注入進來的同一個實例。public async TaskIActionResult Batch() { // orders 來自同一個作用域注入的 ISqlSugarClient await Task.WhenAll(ids.Select(async id { var order await _db.QueryableOrder().Where(o o.Id id).FirstAsync(); _ order?.Id; })); return Ok(); }這種情況哪怕_db是 Scoped 注冊也只代表“同一個請求內(nèi)共享”。如果請求內(nèi)部自行并發(fā)那等于還是在多個線程里共享同一個 Client只是把踩踏范圍縮小到了單個請求內(nèi)。并發(fā)子任務一多照樣會出問題。3. 正確姿勢落地三種可上線的多線程方案3.1 方案一SqlSugarScope 全局單例官方推薦的省心方案SqlSugar 官方提供了一種專門解決這個問題的類型SqlSugarScope。它同樣實現(xiàn)了ISqlSugarClient但從設(shè)計上就面向多線程場景。注冊方式services.AddSingletonISqlSugarClient(sp { return new SqlSugarScope(new ConnectionConfig { ConnectionString Server...;Database...;Uid...;Pwd...;, DbType DbType.SqlServer, IsAutoCloseConnection true, MoreSettings new ConnMoreSettings { // 可按需開啟其他配置 IsAutoRemoveDataCache true } }); });看到?jīng)]還是AddSingleton但 Client 類型換成了SqlSugarScope。它內(nèi)部通過AsyncLocal為每個異步控制流維護一份獨立的SqlSugarClient實例外部所有線程拿著同一個ISqlSugarClient引用實際訪問到的卻是各自上下文里的獨立實例??梢赃@樣理解AsyncLocal每個異步調(diào)用鏈都背著一個隱形背包背包里裝著自己的SqlSugarClient。同一個異步流程內(nèi)await來回切換背包不會丟不同線程、不同異步流程之間各拿各的包互不干擾。這樣既保證了線程隔離又不需要頻繁創(chuàng)建和銷毀底層 Client。高并發(fā) Web 服務、后臺任務、長期運行的服務進程都適合這個方案。實際操作中需要注意幾點接口層面全部面向ISqlSugarClient。SqlSugarScope和SqlSugarClient的公開方法基本一致所以業(yè)務代碼幾乎零改動只需要把注入類型換掉。AOP 回調(diào)仍然是共享的。SqlSugarScope雖然是單例但你在初始化時掛的Aop.OnLogExecuting、Aop.OnError這些回調(diào)本身就是同一個委托。如果回調(diào)里寫了共享集合、靜態(tài)變量多線程下依然有競爭問題需要自己保證回調(diào)邏輯線程安全。不要在超長生命周期任務里一直掛著 Scope 不動。SqlSugarScope為每個異步流創(chuàng)建底層實例后會跟隨該異步流存活。如果一個 Task 長期存在相當于有一個 Client 一直被留著底層連接也會一直被持有。3.2 方案二AddScoped 按請求隔離傳統(tǒng) Web 項目最穩(wěn)如果項目結(jié)構(gòu)比較簡單都是請求-響應模式?jīng)]有大量后臺任務那用AddScoped也是完全正確且不容易出錯的方案。services.AddScopedISqlSugarClient(sp { return new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer, IsAutoCloseConnection true }); });每個請求第一次注入時創(chuàng)建一個SqlSugarClient請求結(jié)束由 DI 容器自動調(diào)用Dispose。并發(fā)請求之間天然隔離不存在共享狀態(tài)問題。這個方案的優(yōu)點是簡單、直觀、符合依賴注入的默認心智模型缺點也明顯如果請求內(nèi)部自己用Task.Run并發(fā)依然會踩踏。因此在使用時需要和團隊成員約定清楚不要在一個請求內(nèi)部去開多個并行子任務操作同一個注入的 Client。另外如果項目里大量使用后臺輪詢?nèi)蝿铡⑾㈥犃邢M、控制臺程序沒有“請求作用域”的概念這時候再用AddScoped就比較別扭更適合SqlSugarScope。3.3 方案三并行任務中創(chuàng)建獨立實例需要手動管理生命周期對于明確的并行批處理場景比如后臺定時任務要同時處理 100 個文件每個文件獨立操作數(shù)據(jù)庫那可以用每個任務獨立創(chuàng)建 Client 的方式await Task.WhenAll(fileIds.Select(async fileId { using var client new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer, IsAutoCloseConnection true }); var record await client.QueryableFileRecord() .Where(f f.Id fileId) .FirstAsync(); // 業(yè)務處理... }));每個任務都有自己的獨立 Client互不干擾。using確保任務結(jié)束后釋放。不過這種方式有一個性能注意點底層連接池是按連接串維度復用的頻繁創(chuàng)建 Client 本身不算特別昂貴的操作但如果任務數(shù)量很大連接池的分配和回收壓力會上升。更推薦的做法是直接用SqlSugarScope單例然后在任務里正常使用既隔離又高效。4. 坑邊實錄排查線程安全問題的幾個硬辦法4.1 看異常信息快速定位問題類型多線程下的異常有時候會被吞掉有時候會層層包裝但只要方向?qū)α司湍芸焖倥袛?。下面是我在實踐里整理的異常速查表異常現(xiàn)象根因處理方向運行時報連接被占用同一個 DbConnection 被多線程并發(fā)使用換 SqlSugarScope 或每線程獨立 Client報 DataReader 未關(guān)閉連接對象同時執(zhí)行多條命令同上并檢查是否有未釋放的查詢偶發(fā)數(shù)據(jù)串號、結(jié)果錯誤查詢上下文、參數(shù)被其他線程覆蓋線程隔離避免共享 Client事務互相干擾、隨機回滾事務狀態(tài)被多線程切換事務不要跨線程用 Scope 隔離連接池耗盡、超時Client 未釋放、連接長期占用開啟 IsAutoCloseConnection 確保 Dispose需要說明的是異常信息不一定每次都一樣。低并發(fā)下可能毫無異常高并發(fā)下才冒出 DataReader 相關(guān)錯誤所以如果出現(xiàn)“間歇性數(shù)據(jù)庫異?!钡谝粫r間要懷疑 Client 的共享方式而不是先去調(diào)數(shù)據(jù)庫。4.2 用日志和連接池狀態(tài)驗證如果項目里已經(jīng)掛了 AOP 日志比如Aop.OnLogExecuting可以直接在這里打印線程 ID 和 SQLclient.Aop.OnLogExecuting (sql, parameters) { Console.WriteLine($Thread {Environment.CurrentManagedThreadId}: {sql}); };當并發(fā)請求進來會看到多個不同線程 ID 同時往同一個輸出流里打日志。如果這些日志對應的 SQL 經(jīng)常交錯、或者同一個請求里出現(xiàn)其他請求的 SQL基本可以斷定是共享實例導致的上下文污染。另一個硬核驗證手段是查數(shù)據(jù)庫側(cè)連接SELECT DB_NAME(dbid) AS DatabaseName, COUNT(*) AS ConnectionCount, loginame FROM sys.sysprocesses GROUP BY dbid, loginame ORDER BY ConnectionCount DESC;如果應用側(cè)某臺服務器的活躍連接數(shù)異常高且一直不回落配合代碼里 Client 的生命周期檢查很容易定位到連接泄漏或未釋放的問題。4.3 事務和多線程的坑位最多優(yōu)先排查多線程共享 SqlSugarClient 時事務是最容易出問題的重災區(qū)。典型場景是線程 A 開啟事務向訂單表插入數(shù)據(jù)線程 B 也在同一實例上開啟事務。A 的BeginTran被 B 覆蓋或者 A 提交的時候把 B 的事務狀態(tài)一起處理了結(jié)果就是隨機回滾、數(shù)據(jù)丟失、死鎖。正確的做法是事務操作必須限制在同一個異步流內(nèi)完成并且優(yōu)先使用SqlSugar提供的事務包裝方法而不是手動跨線程管理事務對象。await _db.UseTranAsync(async () { await _db.Insertable(order).ExecuteCommandAsync(); // 業(yè)務校驗失敗時拋異常事務自動回滾 if (order.Amount 0) throw new Exception(金額不合法); });UseTranAsync會把事務控制在當前調(diào)用鏈的上下文內(nèi)配合SqlSugarScope的異步流隔離能避免跨線程事務互相覆蓋的問題。如果業(yè)務代碼里直接用了client.Ado.BeginTran()并且和并發(fā)任務混在一起我建議盡快改造。5. 避坑清單與選型建議5.1 多線程下使用 SqlSugar 的五條軍規(guī)根據(jù)我踩過和看過的坑整理了一份合規(guī)清單直接貼在項目文檔里當約定絕不共享同一個 SqlSugarClient 實例做并發(fā)操作。不管靜態(tài)、單例、還是注入到多個服務里只要多線程執(zhí)行就是風險。全局并發(fā)優(yōu)先選用 SqlSugarScope AddSingleton。它內(nèi)部做了異步流隔離是最接近“既能復用、又線程安全”的方案。必須開啟 IsAutoCloseConnection true。這是連接及時回池的基本保障尤其在頻繁創(chuàng)建獨立 Client 時。事務、異步任務都保持在同一個上下文內(nèi)。不要在一個 Task 里開啟事務跑到另一個 Task 里去提交。AOP 回調(diào)里不要直接寫共享集合。比如把 SQL 日志統(tǒng)一收集到一個線程安全的隊列里然后再由單獨消費者處理而不是直接往靜態(tài) List 里 Add。5.2 不同項目形態(tài)下的選型建議項目形態(tài)推薦方案原因ASP.NET Core Web API常規(guī)請求響應AddScoped SqlSugarClient 或 SqlSugarScope請求天然隔離簡單直接高并發(fā)對外服務、網(wǎng)關(guān)、微服務AddSingleton SqlSugarScope支持高并發(fā)性能和隔離兼顧后臺任務、消息隊列消費、控制臺程序AddSingleton SqlSugarScope沒有請求作用域Scope 模型更合適批處理、并行跑大量任務每個任務獨立 Client 或 SqlSugarScope避免上下文污染也能控制連接池壓力遷移老項目歷史代碼用全局單例直接替換為 SqlSugarScope接口一致改動量小風險最低這里再補一個個人經(jīng)驗很多人習慣在 Controller 的構(gòu)造函數(shù)里直接注入ISqlSugarClient這本身沒問題。但如果項目里已經(jīng)開始引入類似UnitOfWork的思想建議把ISqlSugarClient的注冊統(tǒng)一放到一個擴展方法里并強制規(guī)范團隊成員通過容器獲取實例防止某些人為了“省事”直接new SqlSugarClient到處飛。5.3 如果已經(jīng)出了線上事故按這個順序排查真遇到線上并發(fā)異常先別慌我一般按這個順序處理先看異常日志確認是不是連接占用、DataReader、事務回滾這幾種多線程經(jīng)典異常。找出SqlSugarClient的注冊方式看是不是AddSingleton或靜態(tài)單例。如果是先把實現(xiàn)替換成SqlSugarScope做一輪小流量驗證。再把IsAutoCloseConnection確認打開檢查所有手動 new 的地方是否有using或Dispose。最后檢查 AOP 回調(diào)里有沒有寫共享集合比如往static List里塞日志。這套排查流程走完大概率能找到問題根源。數(shù)據(jù)庫層面的表鎖、慢 SQL 當然也可能引發(fā)并發(fā)問題但和 ORM 上下文共享引發(fā)的隨機異常相比前者錯誤信息更穩(wěn)定、更容易復現(xiàn)。結(jié)尾我個人在多個項目里把SqlSugarClient從全局單例切到SqlSugarScope之后線上因為“連接占用”和“DataReader 沖突”導致的報錯率基本歸零而且業(yè)務代碼改動量非常小。關(guān)鍵在于理解SqlSugarScope到底隔離了什么它隔離的是每個異步流底層的 Client 實例而不是把所有線程安全責任都包攬下來。AOP 回調(diào)、共享靜態(tài)數(shù)據(jù)、跨線程事務這類問題依然要靠自己的代碼習慣來控制。最后分享一個小技巧升級到SqlSugarScope后保留一段時間的 AOP 日志觀察是否有異常線程交叉。如果日志里同一個請求的 SQL 不再和其他請求穿插那說明線程隔離已經(jīng)生效如果還出現(xiàn)串號、錯亂優(yōu)先檢查是不是業(yè)務代碼里自己又額外緩存了某個SqlSugarClient實例。多線程 ORM 的坑大多不在框架而在對象生命周期的邊界上邊界畫對了問題自然就沒了。