:從批量結算卡死到性能優(yōu)化)
簡介面向SQL Server數(shù)據(jù)庫初學者與需要鞏固可編程對象的中級開發(fā)者這份資源以真實業(yè)務場景中的存儲過程與用戶自定義函數(shù)為切入點幫助讀者理解預編譯SQL語句集合在性能、復用與權限控制上的實際價值。壓縮包共3個文件均為sql腳本整體約4KB體積輕量便于快速導入SQL Server Management Studio中調試運行腳本分別覆蓋供應鏈管理報表生成、訂單與庫存相關業(yè)務處理以及批次號序列化函數(shù)等典型用途可對照學習參數(shù)傳遞、結果集返回與函數(shù)調用的差異。資源已有2023人學習下載適合作為課堂示例或項目模板參考。讀者可從中獲得可直接執(zhí)行的建庫建過程腳本、報表類存儲過程的參數(shù)設計思路以及用用戶自定義函數(shù)生成唯一批次號的實現(xiàn)方式并借此理解存儲過程在封裝復雜邏輯、減少重復代碼和限制直接數(shù)據(jù)訪問方面的作用為后續(xù)學習事務控制與異常處理打下基礎。1. 從一次批量結算卡死說起為什么存儲過程值得單獨設計去年幫某公司做一套內部結算系統(tǒng)的數(shù)據(jù)庫改造業(yè)務側反饋“每月初跑批時頁面全卡死”排查下來發(fā)現(xiàn)應用層把兩萬條明細逐條INSERT再逐條UPDATE一次結算要發(fā)四萬多條 SQL網絡往返和鎖等待把連接池占滿。后來把這套邏輯收進一個存儲過程循環(huán)改成集合操作跑批時間從四十多分鐘壓到兩分半。這件事讓我重新審視“存儲過程”這個被很多人當成老古董的東西——它不玄學關鍵看你怎么設計。這份 SQLSERVER 存儲過程例子資源覆蓋的就是從基礎語法到事務、游標、異常處理、分頁、批量更新的完整寫法集合適合正在做數(shù)據(jù)庫課設、畢設或者要給現(xiàn)有系統(tǒng)補一套可維護數(shù)據(jù)訪問層的同學直接對照改。2. 需求拆解與技術選型存儲過程到底該承擔哪一層職責2.1 先把業(yè)務需求翻譯成數(shù)據(jù)庫側的四類任務拿到“寫一套存儲過程例子”這個題目很多人第一反應是打開 SSMS 敲一個CREATE PROCEDURE帶個SELECT就交差。這樣交上去的作業(yè)在答辯時基本撐不過三個問題。我一般會先把需求拆成四類任務再決定哪些放進存儲過程第一類是數(shù)據(jù)完整性任務比如插入訂單主表后必須同步寫明細、扣庫存這類跨表操作放在應用層容易出現(xiàn)“主表成功、明細失敗”的臟數(shù)據(jù)收進存儲過程用事務包住最穩(wěn)。第二類是批量計算任務像月度匯總、積分結算涉及大量行級運算逐條走應用層網絡開銷太大。第三類是復雜查詢封裝多表關聯(lián)加動態(tài)條件篩選參數(shù)組合多寫在存儲過程里便于統(tǒng)一調優(yōu)。第四類是權限收斂任務只給應用賬號存儲過程的執(zhí)行權限不給直接改表的權限減少誤操作面。這四類任務對應到資源里的例子基本能一一對上事務與異常處理對應第一類游標與集合更新對應第二類動態(tài) SQL 與分頁對應第三類GRANT EXECUTE對應第四類。選型時我的判斷標準很樸素——這段邏輯是否要求“要么全成要么全敗”或者是否要處理超過千行的數(shù)據(jù)滿足其一就優(yōu)先考慮存儲過程否則留在應用層反而更好維護。2.2 為什么是 SQLSERVER 而不是把邏輯全推給應用層有同學會問現(xiàn)在 ORM 框架這么成熟為什么還要寫存儲過程。這個問題在課設答辯里幾乎必被問到得提前想清楚。我的回答通常分三層執(zhí)行計劃復用、網絡往返削減、權限邊界清晰。SQLSERVER 對存儲過程有執(zhí)行計劃緩存機制第一次編譯后計劃留在計劃緩存里后續(xù)調用直接復用參數(shù)化查詢還能避免拼接 SQL 帶來的注入風險和計劃污染。網絡往返這塊前面那個結算案例就是活教材四萬次往返和一次調用的差距是數(shù)量級的。權限邊界則是工程上的考量應用賬號只能EXECUTE改表結構、刪數(shù)據(jù)的口子被堵死。當然也有代價調試不如應用層方便版本管理要單獨做腳本遷移復雜業(yè)務邏輯寫進 T-SQL 后可讀性下降。所以我的做法是把“數(shù)據(jù)密集 強一致”的部分下沉把“流程編排 外部調用”留在應用層兩邊各干各擅長的事。資源里的例子基本遵循這個邊界你在改的時候也別把所有邏輯一股腦塞進去。2.3 例子集合的組織方式按能力分層而不是按語法羅列一份能直接參考的例子集合組織方式很關鍵。按CREATE、ALTER、DROP這種語法順序羅列看著整齊實際用的時候找不到。我傾向按能力分層組織層級覆蓋內容典型例子基礎層參數(shù)定義、默認值、輸出參數(shù)、返回值帶輸入輸出參數(shù)的查詢過程控制層IF/ELSE、WHILE、CASE、游標逐行處理與條件分支事務層BEGIN TRAN、TRY/CATCH、回滾主從表同步寫入查詢層動態(tài) SQL、分頁、臨時表多條件篩選分頁運維層權限授予、執(zhí)行計劃查看、重編譯賬號授權腳本這樣分層的好處是你拿到一個具體需求能快速定位到對應層的例子去改而不是從頭翻到尾。下面幾章就按這個思路往下拆。3. 總體設計一個可復用的存儲過程骨架長什么樣3.1 參數(shù)設計與返回值約定先看一個我常用的骨架幾乎所有業(yè)務過程都從這個模板改出來CREATE PROCEDURE dbo.usp_OrderSettle OrderId INT, SettleDate DATE NULL, -- 不傳則取當天 RowsAffected INT 0 OUTPUT, -- 輸出實際影響行數(shù) ErrMsg NVARCHAR(4000) OUTPUT AS BEGIN SET NOCOUNT ON; -- 抑制行數(shù)消息減少網絡包 SET XACT_ABORT ON; -- 出錯自動回滾整個事務 IF SettleDate IS NULL SET SettleDate CAST(GETDATE() AS DATE); BEGIN TRY BEGIN TRANSACTION; -- 業(yè)務邏輯占位更新主表、寫明細、扣庫存 SET RowsAffected ROWCOUNT; COMMIT TRANSACTION; END TRY BEGIN CATCH IF TRANCOUNT 0 ROLLBACK TRANSACTION; SET ErrMsg ERROR_MESSAGE(); -- 記錄錯誤日志表便于事后排查 INSERT INTO dbo.ErrorLog(ProcName, ErrMsg, ErrTime) VALUES (OBJECT_NAME(PROCID), ErrMsg, GETDATE()); THROW; -- 向上拋出應用層能感知失敗 END CATCH END這段骨架里有幾個設計點值得說清楚。SET NOCOUNT ON是血淚經驗不加的話每條 DML 都會往客戶端發(fā)一個“n 行受影響”的消息批量調用時這些消息本身就很占帶寬。SET XACT_ABORT ON保證運行時錯誤能自動回滾不用在每個可能出錯的地方手動判斷。輸出參數(shù)RowsAffected和ErrMsg讓調用方能拿到執(zhí)行結果而不是只能靠異常判斷。THROW放在 CATCH 末尾既記了日志又把錯誤拋給上層應用層的事務邊界才能正確響應。參數(shù)默認值這塊SettleDate DATE NULL配合過程內的空值判斷比直接寫 GETDATE()更靈活——默認值在編譯期求值跨天調用會拿到舊日期這個坑我踩過。3.2 事務邊界與異常處理的配合方式事務邊界畫在哪里是存儲過程設計里最容易翻車的地方。常見錯誤有兩種一種是把事務開在過程外面過程內部又開一層嵌套事務的COMMIT只減計數(shù)不真正提交回滾時行為容易誤判另一種是 CATCH 里只記日志不回滾事務掛著不釋放連接歸還連接池后還帶著未提交事務后續(xù)請求全部阻塞。我的約定是事務邊界跟著業(yè)務原子操作走開在過程內部TRY 開頭 BEGIN TRANTRY 結尾 COMMITCATCH 里先判斷TRANCOUNT再 ROLLBACK。如果確實需要外層控制事務過程內部就用SAVE TRANSACTION設保存點出錯回滾到保存點而不是整個事務這樣外層還能決定是繼續(xù)還是整體放棄。異常處理還有一層要考慮THROW和RAISERROR的區(qū)別。THROW會保留原始錯誤號和行號RAISERROR需要自己拼錯誤信息。新寫的例子統(tǒng)一用THROW除非要兼容很老的版本。CATCH 塊里取錯誤信息用ERROR_MESSAGE()、ERROR_NUMBER()、ERROR_LINE()這幾個函數(shù)比ERROR可靠后者只反映上一條語句的狀態(tài)中間插一條賦值就被覆蓋了。3.3 命名規(guī)范與版本管理例子多了以后命名混亂比邏輯錯誤更折磨人。我一般用usp_前綴區(qū)分用戶存儲過程后面跟模塊名加動作比如usp_OrderSettle、usp_StockDeduct。系統(tǒng)自帶的sp_前綴絕對不要用SQLSERVER 會先在 master 庫找找不到才找當前庫白白多一次查找還可能和系統(tǒng)過程撞名。版本管理這塊存儲過程不像代碼文件能直接進 Git 對比我的做法是每個過程腳本頭部寫變更注釋塊記錄版本號、修改人、修改內容和日期同時把建表、建過程、授權腳本按序號命名放在同一個遷移目錄里部署時按序執(zhí)行。資源里的例子如果沒帶這套注釋建議你補上答辯時展示版本管理意識是加分項。4. 核心模塊實現(xiàn)從游標處理到動態(tài)分頁的落地寫法4.1 游標與集合操作的取舍游標是課設里出鏡率最高的東西也是最容易被濫用的。先看一個逐行處理的典型寫法DECLARE OrderId INT, Amount DECIMAL(18,2); DECLARE cur CURSOR LOCAL FAST_FORWARD FOR SELECT OrderId, Amount FROM dbo.Orders WHERE Status 0; OPEN cur; FETCH NEXT FROM cur INTO OrderId, Amount; WHILE FETCH_STATUS 0 BEGIN UPDATE dbo.Orders SET Status 1 WHERE OrderId OrderId; INSERT INTO dbo.SettleLog(OrderId, Amount) VALUES (OrderId, Amount); FETCH NEXT FROM cur INTO OrderId, Amount; END CLOSE cur; DEALLOCATE cur;這段能跑但兩萬行數(shù)據(jù)就是兩萬次UPDATE加兩萬次INSERT鎖持有時間長日志膨脹快。LOCAL FAST_FORWARD這兩個選項要帶上LOCAL限定作用域FAST_FORWARD是只進只讀的最優(yōu)游標類型不加的話默認游標可能變成動態(tài)游標性能差一截。同樣的邏輯用集合操作改寫B(tài)EGIN TRANSACTION; UPDATE dbo.Orders SET Status 1 OUTPUT inserted.OrderId, inserted.Amount INTO dbo.SettleLog(OrderId, Amount) WHERE Status 0; COMMIT TRANSACTION;一條語句搞定OUTPUT子句把更新前的行直接寫進日志表事務只開一次。我的原則是能用集合操作就不用游標游標只留給“每行處理邏輯不同、依賴上一行結果”的場景比如逐級匯總、樹形結構展開。資源里兩種寫法都有對照著看能理解什么時候該換。4.2 動態(tài) SQL 與參數(shù)化防注入多條件篩選查詢參數(shù)組合一多靜態(tài) SQL 寫起來就是一堆IF拼字符串。動態(tài) SQL 能解決但拼接方式不對就是注入漏洞CREATE PROCEDURE dbo.usp_OrderQuery CustomerId INT NULL, StartDate DATE NULL, EndDate DATE NULL, PageSize INT 20, PageIndex INT 1 AS BEGIN SET NOCOUNT ON; DECLARE sql NVARCHAR(MAX) N SELECT OrderId, CustomerId, Amount, CreateTime FROM dbo.Orders WHERE 1 1; DECLARE params NVARCHAR(MAX) N CustomerId INT, StartDate DATE, EndDate DATE, Offset INT, PageSize INT; IF CustomerId IS NOT NULL SET sql N AND CustomerId CustomerId; IF StartDate IS NOT NULL SET sql N AND CreateTime StartDate; IF EndDate IS NOT NULL SET sql N AND CreateTime DATEADD(DAY, 1, EndDate); SET sql N ORDER BY OrderId OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY; EXEC sp_executesql sql, params, CustomerId CustomerId, StartDate StartDate, EndDate EndDate, Offset (PageIndex - 1) * PageSize, PageSize PageSize; END關鍵在sp_executesql加參數(shù)聲明所有外部輸入都走參數(shù)綁定不拼進字符串。WHERE 1 1是個小技巧讓后續(xù)每個條件都能以AND開頭拼接不用判斷是不是第一個條件。分頁用OFFSET ... FETCH NEXTSQLSERVER 2012 以后都支持比ROW_NUMBER()套子查詢干凈。日期范圍這里有個細節(jié)CreateTime DATEADD(DAY, 1, EndDate)而不是 EndDate因為CreateTime如果是DATETIME類型帶時分秒會漏掉當天最后那部分數(shù)據(jù)。這個坑我在做報表時踩過數(shù)據(jù)對不上查了半天。4.3 臨時表與表變量的選擇中間結果集放臨時表還是表變量也是常被問的點。我的經驗判斷對比項臨時表#tmp表變量tmp統(tǒng)計信息有優(yōu)化器能估行數(shù)無固定估 1 行事務參與參與可回滾不參與回滾數(shù)據(jù)量適合大結果集適合小結果集幾百行內索引可建索引只能建主鍵/唯一約束數(shù)據(jù)量上千行、需要參與事務回滾、需要建索引加速關聯(lián)的用臨時表。幾百行以內的小結果集、不需要回滾的用表變量減少 tempdb 競爭。資源里兩種都有例子改的時候按數(shù)據(jù)量選別一律用表變量圖省事——優(yōu)化器估錯行數(shù)導致的執(zhí)行計劃翻車排查起來很費勁。4.4 批量更新與 MERGE 的適用邊界批量同步兩張表的數(shù)據(jù)MERGE語句看著很優(yōu)雅一條搞定插入、更新、刪除MERGE dbo.ProductStock AS tgt USING (SELECT ProductId, Qty FROM dbo.StockStaging) AS src ON tgt.ProductId src.ProductId WHEN MATCHED AND tgt.Qty src.Qty THEN UPDATE SET Qty src.Qty, UpdateTime GETDATE() WHEN NOT MATCHED BY TARGET THEN INSERT (ProductId, Qty, UpdateTime) VALUES (src.ProductId, src.Qty, GETDATE());但MERGE有幾個已知的坑并發(fā)場景下可能死鎖OUTPUT子句在帶觸發(fā)器時行為不一致執(zhí)行計劃有時不如分開寫UPDATE加INSERT穩(wěn)定。我的做法是數(shù)據(jù)量不大、并發(fā)不高、邏輯簡單的場景用 MERGE生產環(huán)境高并發(fā)批量同步還是拆成 UPDATE INSERT 兩條語句各自加好索引。資源里的 MERGE 例子適合學習語法真上生產前建議做并發(fā)壓測。5. 避坑與排查存儲過程上線前最容易翻車的五個點5.1 參數(shù)嗅探導致執(zhí)行計劃跑偏現(xiàn)象同一個存儲過程傳某些參數(shù)值執(zhí)行很快換一組參數(shù)慢幾十倍執(zhí)行計劃里估算行數(shù)和實際行數(shù)差幾個數(shù)量級。原因SQLSERVER 第一次執(zhí)行時根據(jù)傳入參數(shù)生成計劃并緩存后續(xù)復用這個計劃。如果第一次傳的是選擇性很高的值計劃按小結果集優(yōu)化后來傳低選擇性值就用了不合適的計劃這就是參數(shù)嗅探。解決對數(shù)據(jù)分布傾斜嚴重的查詢在過程內把參數(shù)賦給局部變量再使用阻斷嗅探或者用OPTION (RECOMPILE)讓每次執(zhí)行重新編譯代價是編譯開銷SQLSERVER 2016 以后還可以用OPTION (OPTIMIZE FOR UNKNOWN)。我一般先看執(zhí)行計劃確認是嗅探問題再選局部變量方案RECOMPILE留給執(zhí)行頻率低但參數(shù)變化大的過程。5.2 嵌套事務回滾不徹底現(xiàn)象過程 A 調用過程 BB 里出錯回滾了A 繼續(xù)執(zhí)行并提交結果數(shù)據(jù)只回滾了一半。原因SQLSERVER 不支持真正的嵌套事務內層BEGIN TRAN只是把TRANCOUNT加一內層ROLLBACK會把整個事務回滾并把計數(shù)清零外層再COMMIT就報“沒有對應 BEGIN TRAN”的錯或者外層沒判斷計數(shù)直接繼續(xù)跑。解決內層過程用SAVE TRANSACTION 保存點名出錯時ROLLBACK TRANSACTION 保存點名只回滾到保存點外層統(tǒng)一控制最終提交或回滾。調用鏈上的每個過程都要判斷TRANCOUNT別假設自己是事務的起點。5.3 游標未釋放導致連接資源泄漏現(xiàn)象跑批跑一段時間后連接池耗盡或者臨時表空間持續(xù)增長。原因游標OPEN后中途RETURN或異常跳出沒走到CLOSE和DEALLOCATE游標占用的資源不釋放。解決游標操作放進 TRY/CATCHCATCH 里補CLOSE和DEALLOCATE或者用LOCAL游標過程結束時自動釋放。更徹底的做法是能改集合操作就改掉從源頭減少游標使用。5.4 動態(tài) SQL 拼接漏了 schema 前綴現(xiàn)象本地測試正常部署到另一套環(huán)境報“對象名無效”。原因動態(tài) SQL 里的表名沒帶 schema 前綴依賴調用者的默認 schema不同賬號默認 schema 不同就找不到表。解決動態(tài) SQL 里所有對象名統(tǒng)一寫dbo.表名全限定形式別依賴默認 schema。這個習慣看著啰嗦但能省掉大量環(huán)境差異導致的排查時間。5.5 權限授予過寬現(xiàn)象應用賬號能直接改表、刪數(shù)據(jù)一次誤操作把生產數(shù)據(jù)清了。原因圖省事直接給賬號db_owner或者對表給了UPDATE、DELETE權限。解決應用賬號只給存儲過程的EXECUTE權限表級權限收回。授權腳本單獨維護部署時執(zhí)行。資源里的授權例子就是按這個思路寫的GRANT EXECUTE ON dbo.usp_xxx TO 應用賬號表權限一律不給。6. 驗收與答辯怎么證明這套存儲過程真的能用驗收這件事別等到最后一天才想。我一般從三個維度準備材料答辯時按這個順序講評委基本挑不出漏洞。第一維度是功能正確性。每個存儲過程配一組測試用例覆蓋正常路徑、邊界值、異常路徑。比如結算過程正常單、金額為零的單、訂單不存在的單三種輸入各跑一遍把執(zhí)行前后的數(shù)據(jù)快照對比貼出來。測試腳本用BEGIN TRAN ... ROLLBACK包住跑完不留臟數(shù)據(jù)這個技巧答辯時講出來很加分。第二維度是性能數(shù)據(jù)。挑數(shù)據(jù)量最大的那個過程造十萬行測試數(shù)據(jù)對比存儲過程版本和逐條 SQL 版本的耗時。用SET STATISTICS TIME ON和SET STATISTICS IO ON抓邏輯讀和 CPU 時間把執(zhí)行計劃截圖附上。有對比數(shù)據(jù)性能優(yōu)化就不是空話。第三維度是異常與回滾驗證。故意在事務中間制造錯誤比如插入違反唯一約束的數(shù)據(jù)驗證 CATCH 是否記了日志、事務是否完整回滾、連接是否正常歸還。這一步很多人跳過但恰恰是生產環(huán)境最看重的。答辯陳述我習慣按“需求背景 → 分層設計 → 關鍵實現(xiàn) → 測試數(shù)據(jù) → 踩坑與改進”五段走每段控制在兩分鐘內。評委提問集中在參數(shù)嗅探、事務嵌套、游標性能這三個點提前把這三塊的回答準備好基本穩(wěn)了。提示答辯演示時提前把 SSMS 連好、測試腳本打開現(xiàn)場敲代碼容易緊張出錯演示腳本跑通比現(xiàn)場發(fā)揮可靠。從那以后我每次交付存儲過程方案都強制走一遍“測試用例 性能對比 回滾驗證”這三件套哪怕時間緊也至少把異常回滾那條跑通——數(shù)據(jù)這東西沒有后悔藥。希望這套結構和例子能幫到你照著改改就能交出一份站得住腳的方案。本文還有配套的精品資源點擊獲取