分類: 資料庫

  • 拔掉 RLS 的兩天:AI 時代規模化 Day One 為何從奢侈變預設

    重點摘要

    • 540 萬包裹壓測,警衛的社區包裹列表要 7330 毫秒。病根是 RLS 兩條 PERMISSIVE policy 用 OR 合併,讓查詢規劃器用不到 community_id 索引。
    • 全面拔除 173 條 policy、85 張表的 RLS,租戶隔離 100% 改由應用層帶 community_id。同一條查詢從 7330ms 降到 1.34ms,約 5500 倍。
    • 但拔掉 RLS 不是終點——第二波跨社區洩漏被「單租戶壓測」蓋住了:對照社區在多數表是 0 筆,洩漏判準(本社區數=全平台數)自動成立。要每張表都灌 ≥2 個社區才測得出;而且漏帶 community_id 不只洩漏,更是少了索引,同一個病根換地方又長。
    • AI 把規模準備的「建造成本」打掉了,但有兩個成本它打不掉:分區帶來的「複雜度稅」(維護照繳),與「取決於規模實測才知道對錯」的經驗決策(RLS 自己就是反例)。
    • 結論:AI 時代「規模化 Day One」從奢侈變預設,但「準備什麼」的判斷力更值錢——有把握的提前上,取決於壓測的,老實留給壓測。

    一個 V2 產品、兩天、一個 AI 協作者,把一套多租戶系統從「靠資料庫兜底」推進到「應用層自己負責」。這篇記錄的不是 commit log,是技術選型的隱性成本,怎麼在特定規模與特定哲學下才現形——以及 AI 怎麼悄悄改寫了「該不該提前準備規模化」這道老題目。

    先講結論:MVP 智慧是成本算出來的啟發法,不是定律

    傳統智慧說:MVP 先做,別過度工程,規模化以後再說。這句話我一直信,直到這兩天我意識到——它是一條成本驅動的啟發法,不是定律

    它成立的前提是「規模準備很貴」。以前要做分區、多租戶隔離、idempotency、離線佇列這整套規模機械,往往要多三到四倍的人力、好幾個月。那個成本,才讓「先別做、等需要再說」變成理性選擇。但兩件事同時改變了這個算式:

    1. AI 把建造成本打掉了。那套規模機械,AI 輔助下生成 + 維護的人力崩塌。
    2. 這是 V2,不是 try。前一版已經證明了領域——住戶、警衛、包裹是真需求,而且會有很多社區。規模不是猜測,是 near-certainty。「You Ain’t Gonna Need It」的前提「你可能不需要」,直接是假的。

    所以對一個 AI 輔助、領域已驗證的 V2,Day One 就準備規模化,比經典 MVP 智慧主張的更該做。但這篇真正有料的地方,是兩個 AI 也打不掉的成本。後面講。

    當初為什麼這樣選技術?

    這套系統的技術選型,是三股力量的合力:領域驅動 + 規模前瞻 + 不重蹈前版覆轍

    • Flutter 單一 codebase:住戶用手機、警衛用平板,功能大半重疊靠角色 gate。兩份原生 = 兩倍維護;一份 + responsive 是省力的對。加上離線優先是硬需求(警衛平板斷網也要能收件、拍照、印通知條)。代價老實說:CanvasKit 的 web 端很痛,e2e 自動化一路在跟它的無障礙語意樹搏鬥。
    • Go + GraphQL(型別契約):Go 單一 binary 好部署、並發強,對著「1000+ RPM / 1000 社區」的目標。GraphQL 用型別嚴謹的契約——這是對前版的反動:舊版用 category/method 字串路由 + PHP 的型別強制轉換怪招,踩過坑。型別世界 = runtime 少驚喜。
    • PostgreSQL + RLS(Row-Level Security):這個最值得講,因為我們親手拆了它

    RLS 的故事:為安全而選,為清亮而拆

    RLS 當初為什麼選?防禦縱深。理由聽起來無懈可擊:「把租戶隔離放在資料庫層,app 出 bug 也繞不過。」每個社區的資料用 policy 鎖死,誰都別想跨社區看到別人的包裹。

    然後規模化壓測來了。我們灌了 540 萬包裹、5.4 萬戶、202 個社區,模擬一個 V2 跑兩三年後的樣子。一量——警衛的社區包裹列表,7330 毫秒。七秒。

    病根不是索引沒建,是 RLS 的兩條 PERMISSIVE policy 用 OR 合併(是系統管理員) OR (社區=X)。community_id 只出現在 OR 的一邊,查詢規劃器就用不到 community_id 索引,只能 Seq Scan 掃完整張表的所有分區。這個病,設計時看不出來,要到 5.4M 才現形

    業主的反應很關鍵。他不要「在 DB 加更多限制」來繞,他要的是更乾淨:「我不要我的資料庫跟我的 AP 有這樣的依賴。本來就該做到零洩漏,怎麼會依賴 RLS?」

    於是我們全面拔除 RLS——173 條 policy、85 張表,全部 DROP。租戶隔離 100% 改由應用層:每一條碰租戶資料的查詢,自己帶上 community_id = current_setting('app.community_id')

    拆的過程,反過來證明「本來就該零洩漏」當時是假的

    審計時我抓到,授權中介層對「同社區範圍」的操作寫死了一行註解:「RLS 負責租戶隔離,這裡不另外檢查。」也就是說,有好幾個改別人資料的後端進入點(停用警衛、在別社區建戶、復活別社區的軟刪戶),它們的隔離正確性,本來就完全押在 RLS 上。RLS 一拔,這些就是跨社區提權漏洞。

    派去掃的 AI 子代理一開始判了 23 個缺口;我逐條人工驗證,收斂到 3 個真缺口——其餘有的早被別的守衛擋著、有的是子代理幻覺出根本不存在的函式。這也是個教訓:AI 的清單要逐條驗,不能盲信。

    拆完,效能呢?同一條查詢,帶上 community_id:

    查詢 RLS 開(OR 合併擋索引) RLS 拔除 + 應用層 community_id
    警衛社區包裹列表(5.4M 列) 7330 ms(Seq Scan 全分區) 1.34 ms(Index Scan)
    包裹改動歷程鑽取 0.2 ms

    從 7330ms 到 1.34ms,約 5500 倍。反諷的地方:當初選 RLS 是「為了安全」。它不但變成效能地雷,還讓 app 層偷懶——「反正 DB 會兜底」,於是 app 層的隔離就不嚴謹了。兜底機制養出了被兜底者的鬆懈。

    拔完 RLS 之後:洩漏沒結束,是換了個地方躲

    拔掉那天修了 3 個提權,我以為收工了。錯。RLS 一拔,等於把「DB 兜底」這層拿掉,所有原本偷懶的查詢全部裸奔——真正的第二波,要再跑一輪壓測才現形。

    一個包裹計數查詢,給某社區警衛回了 280 萬——那是整個平台的數字,不是他那個社區的。病根:這個 count 自己手寫 WHERE,繞過了會自動補 community_id 的中央 helper。同一類的還有一票:社區統計(*Stats)、各種計數(*Count)、住戶/包裹 picker、以及「先 load-by-id 再改」的 mutation——全是各自為政手寫查詢、漏帶租戶欄位的高風險地帶。

    但這篇最該記住的,是為什麼第一次壓測沒抓到。答案很反直覺:單租戶結構上測不出跨租戶洩漏。

    我第一次灌的 540 萬,只灌在 parcelshouseholds 兩張表,其他維度(使用者、訪客、公告、picklist)對照社區根本 0 筆。而洩漏的判準是「本社區查到的數字 == 全平台的數字」——當對照組是 0,這條恆等式自動成立,洩漏被 0 資料蓋得死死的。修法不只是補 WHERE,是補資料形狀:每一張要驗的表,都灌進 ≥2 個社區的資料,動態探針才有對照組去抓「你回給我的,有沒有混進別人的」。

    還有一層,業主一句話點破:漏帶 community_id 不只是洩漏,是效能。「為什麼還有功能沒戴上 ID,這不只會洩漏,重點是效能會變差,沒用到 index。」少了那個欄位,查詢就跟當初 RLS 的 OR 一樣用不到索引、掃全表。防洩漏和上索引,是同一個動作——community_id 既是隔離邊界,也是索引前綴。當初那個「OR 擋索引」的病根,拔了 RLS 之後,在每一條手寫查詢裡換個地方又長了出來。

    規模下另外兩個 Day One 就得拍的板

    同一輪還拍了兩個一旦上線就很難回頭的板,都是「規模 + 時間」逼出來的。

    • 快照 vs 正規化:歷史標籤不准 join。包裹上的物流公司、儲位、包裹類型、收件人姓名,全部存成當下的文字快照,不是外鍵。為什麼不正規化?因為這些是歷史事實。若做成外鍵去 join 現行字典,哪天某家物流公司改名或下架,過去的包裹紀錄會被回頭改寫——未來的公司出現在過去的資料上。原則一句話:身分用外鍵,歷史標籤用快照。這跟「證據鏈不可竄改」是同一個底層要求。
    • 控制面狀態用單例快取,不是每頁查 DB。社區可被系統管理員停用,每個請求都得確認「這社區還活著嗎」。最笨的做法是每頁打一次 DB——那又是另一種 RLS 式的過度防禦。做法是一個程序級單例快取(map + 鎖),10 分鐘 TTL,miss 才查 DB,停用/復活時主動逐出;隔離檢查收斂在一個 chokepoint(進交易時查一次),不撒在每條查詢。順手修了個誠實問題:社區停用後登入原本回「帳密錯誤」會誤導用戶,改成帳密對的人才告知「社區暫停服務中」——既不誤導本人,也不洩漏社區存在給攻擊者。

    兩個 AI 也打不掉的成本

    回到開頭的 thesis。AI 讓規模準備「便宜到值得 Day One 就做」——但有兩個成本它打不掉。

    成本 AI 打掉了什麼 AI 打不掉什麼
    複雜度稅 建造成本(生成分區/隔離/佇列) 維護稅:每次改動/debug 都要付。例:pg_total_relation_size 對分區母表回 0,最大的表都報 0,要展開分區層才量得到
    經驗決策 實作速度 壓測經驗:RLS 的「OR 擋索引」要 5.4M 才看得見,連 AI 都沒辦法 Day One 告訴你對的選擇

    成本一:AI 打掉「建造成本」,沒打掉「複雜度稅」。月分區讓程式更難推理。這兩天我們親自繳了這個稅:監測資料量時,pg_total_relation_size 對分區母表只算母表本身(回 0);還有 FK 要複合鍵、分區不繼承 RLS、清測試資料時被自己剛上的 append-only trigger 擋住……這個複雜度,是每一次未來改動、每一次 debug 都要付的稅,連 AI 也付。「AI 讓規模準備好建」是真的,「規模準備免費」是假的——稅照繳,只是從建造繳給了維護。

    成本二:有些規模決策是「經驗的」,Day One 就是準備不了——RLS 自己就是鐵證。RLS 本來就是 Day One 的規模準備(多租戶隔離),結果它是錯的 Day One 選擇。為什麼當初設計看不出來?因為「OR 擋索引」這個病要 5.4M 壓測才看得見。有些決策,正確答案取決於規模行為,而那行為你預測不了——那是壓出來的,不是設計出來的。而且如前面那一節的教訓:光有規模還不夠,要對的「資料形狀」——對照社區是 0 筆的那次壓測,把第二波洩漏整個蓋住了。經驗決策不只需要壓力,需要對的壓力

    AI 時代,「規模化 Day One」從奢侈變預設。但「準備什麼」的判斷力反而更值錢:有把握的結構提前上(便宜了);取決於規模實測的決策,老實留給壓測。

    有把握的(會有很多社區 → 按社區/時間分區;離線不可妥協 → 佇列),提前做。沒把握的(隔離機制、索引策略),別假裝設計階段就能拍板。這跟我之前寫過的 企業 AI 落地為什麼失敗 是同一個底層觀念:方法論不能照抄,要看你的前提條件還成不成立。

    方法:規格化、決策留證、以及「我自己打臉自己」的報告

    這兩天還做了第二件大事——把包裹做成可被法庭級檢驗的證據鏈。改動寫進不可竄改的 log(鑽一顆包裹的歷程,5.4M 下 0.2 毫秒)、簽名與照片在儲存層鎖死不可刪、系統管理員調查要「破窗」且每次都留審計。但比功能更值得分享的是方法

    • 規格先行。每個設計決定都先寫進規格、辯論清楚、鎖進文件,才動手。對話會被壓縮遺忘,規格不會。
    • 決策留證,不靠主觀評分。業主不信任 AI 加工過的「成本/風險」評分,他要看真實檔案、真實行數、schema 影響。所以需要他拍板的時刻,我生的是事實卡片,不是紅黃綠燈。
    • 三份報告的誠實檢驗。最後他要一份評分報告。但他要的不是一個數字——他要三份做比較:原始版、我「現在的預估」(鎖時間戳,不准事後改)、和「全部做完 + 全面測試後的真實量測」。核心是拿我的主觀預估去對真實量到的,差多少 = 我的評估可信度。

    結果:我預估綜合 7.5,真實約 7.8,誤差約 0.3 分;方向性預測(「dev 環境的測試污染會讓部分整合測試紅、但那不是回歸」「forensic 能力做完會升」「效能會守住」)全中。唯一校準:我對「系統穩定度」過度保守——實際零回歸。這種「逼 AI 先承諾預測、再用真實數據打臉」的設計,把「AI 的話可不可信」變成一個可量測的問題。

    人機協作:最好的部分不是分工,是辯論

    這兩天的分工大概是:業主出領域知識與方向決策,AI 出執行、驗證與反思。但最好的部分不是分工,是辯論。RLS 該不該拆、拆了 sysadmin 怎麼查日誌、照片資料夾要不要鏡射分區、孤兒清理會不會變成刪證據的後門——這些不是「下指令、執行」,是來回推。

    業主用領域常識把我拉回現實(「哪有警衛不分社區的,警衛 A 在四個社區工作就是四個警衛」),我用壓測數據和審計把假設證偽(「你說本來就零洩漏,但我們現有 code 就漏了 3 個」)。AI 不會累、能掃完每個呼叫點、能把 23 個候選逐條驗到剩 3 個真的;但判斷「準備什麼」「什麼時候該停下來問人」,還是要人。

    結語

    如果只能留一句:老的 RD 有老的包袱,所以才有「MVP、先別管規模」的智慧。但那個智慧是成本算出來的。當 AI 把建造成本打掉、當你做的是領域已驗證的 V2——規模化就是 Day One 的事。只是別忘了,AI 打不掉複雜度稅,也替不了你壓測;有把握的提前上,沒把握的,老實壓出來——而且要用對的資料形狀去壓,單租戶測不出跨租戶的洩漏。

    RLS 是我們「Day One 準備了錯的東西、規模化才發現」的活標本。它沒有讓這個決定變壞——它讓這個決定有了教材

    技術細節:Flutter + Go(gqlgen) + PostgreSQL 16;月分區、River 佇列、MinIO。壓測 540 萬包裹 / 202 社區 / RLS-off:熱查詢 1.34ms、改動 log 鑽歷程 0.2ms。

  • SQL Server 踩坑實錄:從 DELETE 不釋放空間到 NOT IN 效能炸彈

    SQL Server 踩坑實錄:從 DELETE 不釋放空間到 NOT IN 效能炸彈

    這篇文章是我從 DBA 到全端架構師這幾年,在 SQL Server 效能優化上踩過的坑的總整理。不是教科書式的理論,而是每一條都是我實際測試、實際踩雷後的血淚經驗。如果你正在處理 SQL Server 效能優化的問題——DELETE 後空間沒釋放、查詢莫名其妙變慢、鎖定機制搞不清楚——這篇應該能幫你少走不少冤枉路。

    TL;DR 重點摘要

    • DELETE 不會釋放磁碟空間,只是標記刪除。要真正回收空間,必須用 TRUNCATE 或 ALTER INDEX REBUILD。
    • NOT IN 是效能炸彈,改用 NOT EXISTS 可以讓查詢快數十倍,尤其在子查詢結果集大的時候。
    • 沒有 TABLOCKX 的交易不安全,並行交易會讀到未提交的資料(Dirty Read),高併發場景務必設定正確的隔離層級。
    • 暫存表不是都一樣,@ 表變數、# 本地暫存表、## 全域暫存表各有適用場景,選錯會嚴重影響效能。

    1. 儲存空間的真相 — DELETE 真的刪除了嗎?

    這大概是我當 DBA 第一年最震驚的發現:DELETE 不會釋放磁碟空間。它只是把資料列標記為「ghost record」,等待背景的 Ghost Cleanup 程序來處理。但即使 Ghost Cleanup 跑完了,那些頁面(Page)還是屬於該表的配置空間,不會歸還給作業系統。

    如果你想真正回收空間,只有兩條路:

    • TRUNCATE TABLE:直接釋放所有資料頁面,包含 7-byte 的列標頭(row header),速度極快,但會清除所有資料。
    • ALTER INDEX … REBUILD:重建索引時重新組織頁面,回收碎片化的空間。

    三種配置單元(Allocation Unit)

    SQL Server 在底層把資料分成三種配置單元儲存:

    配置單元 儲存內容 典型欄位類型
    IN_ROW_DATA固定長度 + 行內變動長度資料char, int, datetime, nvarchar(100)
    LOB_DATA大型物件資料nvarchar(max), text, image, xml
    ROW_OVERFLOW_DATA超過 8060 bytes 的變動長度資料nvarchar 超過行內限制時溢出

    CHAR vs VARCHAR 的儲存差異

    很多人以為「反正都是存字串」,但底層差異巨大。CHAR(100) 不管你存 1 個字還是 100 個字,永遠佔用 100 bytes。VARCHAR(100) 則只儲存實際資料長度加上 2 bytes 的長度前綴。

    -- Verify with DATALENGTH()
    DECLARE @fixed CHAR(100) = 'Hello';
    DECLARE @variable VARCHAR(100) = 'Hello';
    
    SELECT DATALENGTH(@fixed) AS CharLength,      -- Result: 100
           DATALENGTH(@variable) AS VarcharLength; -- Result: 5

    另外要注意:索引佔用的是真實空間(IN_ROW_DATA)。當你清空表後,索引也被清空。但只要 INSERT 新資料,索引會立即重新填充。而且 VARCHAR 欄位建索引時,仍然受到 900 bytes 索引鍵大小限制

    Azure SQL Database 的常見假警報

    在 Azure SQL Database 上,大量 DELETE 後看到儲存空間快滿了——這是假警報。空間根本沒被釋放。DELETE ... WITH (TABLOCK) 效果有限,必須搭配 TRUNCATEALTER INDEX ALL ON [TableName] REBUILD 才能真正回收。


    2. 鎖定機制 — 你的交易真的安全嗎?

    我曾經在生產環境遇過一個離奇的 Bug:兩筆交易同時更新同一張表,結果一筆交易讀到了另一筆還沒 COMMIT 的資料。這就是經典的 Dirty Read(髒讀)

    不使用適當鎖定機制時,會碰到三種資料異常:

    異常類型 說明 情境
    Dirty Read(髒讀)讀到其他交易未提交的資料T1 UPDATE 未 COMMIT,T2 SELECT 讀到修改後的值
    Non-repeatable Read(不可重複讀)同一交易內兩次讀取結果不同T1 SELECT → T2 UPDATE COMMIT → T1 再次 SELECT 結果變了
    Phantom Row(幻影列)同一交易內多出新的資料列T1 SELECT → T2 INSERT COMMIT → T1 再次 SELECT 多了一列

    正確的做法:TABLOCKX 排他鎖

    -- Problem: Without TABLOCKX, T2 can read T1's uncommitted changes
    -- Session 1
    BEGIN TRAN T1;
    UPDATE Orders SET Amount = 999 WHERE OrderID = 1;
    -- (not committed yet)
    
    -- Session 2 (runs concurrently, sees Amount = 999 → Dirty Read!)
    SELECT Amount FROM Orders WHERE OrderID = 1;
    
    -- Solution: Use TABLOCKX for exclusive access
    BEGIN TRAN T1;
    SELECT * FROM Orders WITH (TABLOCKX) WHERE OrderID = 1;
    -- Now T2 is BLOCKED until T1 commits or rolls back
    UPDATE Orders SET Amount = 999 WHERE OrderID = 1;
    COMMIT TRAN T1;

    在高併發場景中,如果不想鎖整張表,也可以考慮設定交易隔離層級為 SERIALIZABLE 或使用 ROWLOCK, UPDLOCK 組合,但 TABLOCKX 是最簡單粗暴且確定有效的方式。


    3. 查詢優化 — EXISTS vs IN 的效能陷阱

    這個坑我踩了不止一次。先講結論:

    • 正向查詢(EXISTS vs IN):執行計畫幾乎相同,效能差異不大。
    • 否定查詢(NOT EXISTS vs NOT IN)NOT EXISTS 遠遠快於 NOT IN,差距可達數十倍。
    -- NOT IN: Slow — performs O(n*m) comparison, NULL handling issues
    SELECT * FROM Products
    WHERE ProductID NOT IN (
        SELECT ProductID FROM OrderDetails
    );
    
    -- NOT EXISTS: Fast — uses semi-join, stops at first match
    SELECT * FROM Products p
    WHERE NOT EXISTS (
        SELECT 1 FROM OrderDetails od
        WHERE od.ProductID = p.ProductID
    );
    
    -- Additional trap: if OrderDetails.ProductID contains ANY NULL value,
    -- NOT IN returns ZERO rows! NOT EXISTS handles NULL correctly.

    NOT IN 之所以慢,是因為它必須對子查詢的每一筆結果做比對,而且還要處理 NULL 的三值邏輯。NOT EXISTS 則是用半連接(Semi-Join)策略,找到第一筆匹配就停止。

    排序與 TOP 的隱藏陷阱

    加上 TOP 之後,SQL Server 的排序演算法會完全改變。沒有 TOP 時用完整排序(Full Sort),有 TOP 時用 Top-N Sort,記憶體需求和執行路徑完全不同。

    SQL 執行順序(必背)

    很多查詢優化的問題,根源是不理解 SQL 的實際執行順序:

    FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → TOP/OFFSET

    注意 SELECT 在 WHERE 之後,所以你不能在 WHERE 中使用 SELECT 裡定義的別名。而 ORDER BY 在 SELECT 之後,所以可以用別名排序。理解這個順序,很多「為什麼這樣寫不行」的問題都迎刃而解。

    另外,索引不只消除全表掃描,還能跳過排序階段。如果 ORDER BY 的欄位剛好有索引,SQL Server 可以直接按索引順序讀取,省掉排序的 CPU 和記憶體開銷。


    4. 全文檢索 — 比 LIKE ‘%keyword%’ 快一百倍

    如果你的應用有「搜尋文章內容」的需求,還在用 LIKE '%keyword%',那你的查詢基本上每次都是全表掃描。全文檢索(Full-Text Search)透過反向索引(Inverted Index)來加速文字搜尋,效能差距是數量級的。

    建立全文檢索的前提與步驟

    前提:目標表必須有主鍵(Primary Key)。因為反向索引需要唯一識別碼來對應每筆資料。

    -- Step 1: Enable full-text search on the database (if not already)
    -- (SQL Server installs Full-Text Search as a feature)
    
    -- Step 2: Create a full-text catalog
    CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;
    
    -- Step 3: Create a full-text index on the table
    -- The table MUST have a primary key
    CREATE FULLTEXT INDEX ON Articles (
        Title LANGUAGE 1028,      -- 1028 = Traditional Chinese
        Content LANGUAGE 1028
    )
    KEY INDEX PK_Articles          -- Must reference the PK
    ON ftCatalog
    WITH CHANGE_TRACKING AUTO;     -- Auto-update when data changes
    
    -- Step 4: Query using CONTAINS or FREETEXT
    SELECT * FROM Articles
    WHERE CONTAINS(Content, N'效能優化');
    
    -- Compare with LIKE (full table scan every time)
    SELECT * FROM Articles
    WHERE Content LIKE N'%效能優化%';

    語言設定很重要:LANGUAGE 1028(繁體中文)會使用對應的斷詞器(Word Breaker),直接影響搜尋品質。英文斷詞用空格就行,但中文斷詞需要語意分析,設錯語言會導致搜不到結果。


    5. 監控與追蹤 — 沒有 Profiler 怎麼辦?

    SQL Server Profiler 在生產環境不一定能用(效能開銷太大,或者根本沒權限)。這時候 DMV(Dynamic Management Views)就是你的救星。

    追蹤特定時間範圍的查詢

    -- Find top queries by CPU time within a time range
    SELECT TOP 20
        qs.last_execution_time,
        qs.execution_count,
        qs.total_worker_time / 1000 AS total_cpu_ms,
        qs.total_elapsed_time / 1000 AS total_elapsed_ms,
        qs.total_logical_reads,
        SUBSTRING(st.text,
            (qs.statement_start_offset / 2) + 1,
            ((CASE qs.statement_end_offset
                WHEN -1 THEN DATALENGTH(st.text)
                ELSE qs.statement_end_offset
              END - qs.statement_start_offset) / 2) + 1
        ) AS query_text
    FROM sys.dm_exec_query_stats qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
    WHERE qs.last_execution_time >= '2026-04-04 09:00:00'
      AND qs.last_execution_time <= '2026-04-04 18:00:00'
    ORDER BY qs.total_worker_time DESC;

    查看當前執行中的程序

    -- Quick check: who's running what right now?
    EXEC sp_who2;
    
    -- Or with more detail via DMV
    SELECT
        r.session_id,
        r.status,
        r.command,
        r.wait_type,
        r.wait_time,
        t.text AS query_text,
        r.cpu_time,
        r.reads,
        r.writes
    FROM sys.dm_exec_requests r
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
    WHERE r.session_id > 50; -- Exclude system sessions

    全庫儲存空間盤點

    -- Iterate all user tables and check space usage
    CREATE TABLE #SpaceUsed (
        TableName NVARCHAR(128),
        Rows NVARCHAR(20),
        Reserved NVARCHAR(20),
        Data NVARCHAR(20),
        IndexSize NVARCHAR(20),
        Unused NVARCHAR(20)
    );
    
    DECLARE @tbl NVARCHAR(128);
    DECLARE tbl_cursor CURSOR FOR
        SELECT TABLE_SCHEMA + '.' + TABLE_NAME
        FROM INFORMATION_SCHEMA.TABLES
        WHERE TABLE_TYPE = 'BASE TABLE';
    
    OPEN tbl_cursor;
    FETCH NEXT FROM tbl_cursor INTO @tbl;
    
    WHILE @@FETCH_STATUS = 0
    BEGIN
        INSERT INTO #SpaceUsed
        EXEC sp_spaceused @tbl;
        FETCH NEXT FROM tbl_cursor INTO @tbl;
    END
    
    CLOSE tbl_cursor;
    DEALLOCATE tbl_cursor;
    
    SELECT * FROM #SpaceUsed ORDER BY CAST(REPLACE(Reserved, ' KB', '') AS BIGINT) DESC;
    DROP TABLE #SpaceUsed;

    6. 暫存表大全 — @、#、## 到底差在哪?

    SQL Server 有三種暫存表,看起來差一個符號,但行為天差地別:

    特性 @TableVar(表變數) #TempTable(本地暫存表) ##GlobalTemp(全域暫存表)
    儲存位置記憶體(小量時)/ tempdbtempdbtempdb
    統計資訊無(優化器假設 1 列)
    作用範圍當前批次/程序當前 Session所有 Session
    交易回滾不受 ROLLBACK 影響受 ROLLBACK 影響受 ROLLBACK 影響
    可建索引僅限宣告時的約束可隨時建立可隨時建立
    適用場景少量資料(< 100 列)大量資料、需要索引跨 Session 共享資料

    選擇指南

    • 資料量 < 100 列,且不需要索引 → 用 @TableVar
    • 資料量大,需要統計資訊讓優化器做正確決策 → 用 #TempTable
    • 需要跨 Session 共享(例如 ETL 中間結果) → 用 ##GlobalTemp(但要小心生命週期管理)
    • 在 ROLLBACK 時需要保留資料(例如錯誤日誌) → 用 @TableVar,因為它不受交易回滾影響
    -- Table variable: optimizer always estimates 1 row
    DECLARE @small TABLE (ID INT, Name NVARCHAR(50));
    INSERT INTO @small SELECT TOP 10 ID, Name FROM Products;
    
    -- Local temp table: has statistics, better for large datasets
    CREATE TABLE #bigtemp (ID INT, Name NVARCHAR(50));
    INSERT INTO #bigtemp SELECT ID, Name FROM Products;
    CREATE INDEX IX_bigtemp_ID ON #bigtemp(ID); -- Can add indexes
    
    -- Global temp table: visible to all sessions
    CREATE TABLE ##shared (ID INT, Name NVARCHAR(50));
    -- Other sessions can SELECT from ##shared

    最常見的錯誤是:把幾萬筆資料塞進 @TableVar,然後納悶為什麼 JOIN 超慢。原因是優化器認為裡面只有 1 列,選了 Nested Loop 而不是 Hash Join。換成 #TempTable 就正常了。


    結語

    SQL Server 的這些坑,有的文件上有寫但沒人看,有的要自己測過才會懂。我把這幾年的經驗整理在這裡,希望能幫到正在跟 SQL Server 搏鬥的你。如果只能記住一件事,請記住:NOT IN 是效能炸彈,永遠用 NOT EXISTS 取代它。這一個改動可能就能讓你的查詢從分鐘級變成秒級。


    常見問題 FAQ

    Q: DELETE 後空間沒有減少,是 Bug 嗎?

    不是 Bug,這是 SQL Server 的設計。DELETE 只標記刪除,頁面仍歸屬於表。使用 TRUNCATE TABLE 或 ALTER INDEX REBUILD 來真正回收空間。

    Q: NOT IN 和 NOT EXISTS 結果一樣嗎?

    不一定。如果子查詢包含 NULL 值,NOT IN 會返回空結果集(因為 NULL 的比較結果是 UNKNOWN)。NOT EXISTS 則能正確處理 NULL。除了效能差異,正確性也是選擇 NOT EXISTS 的原因。

    Q: 什麼時候該用表變數 @,什麼時候用暫存表 #?

    簡單判斷:資料量少於 100 列用 @,超過就用 #。關鍵差異在於統計資訊——表變數沒有統計資訊,優化器會做出錯誤的執行計畫。

    Q: 全文檢索和 LIKE 差多少?

    在百萬筆資料的文字欄位上,全文檢索可以比 LIKE '%keyword%' 快 100 倍以上。LIKE 前綴帶 % 時無法使用索引,只能全表掃描;全文檢索則使用反向索引,直接定位包含關鍵字的列。

    Q: 生產環境不能用 SQL Profiler 該怎麼監控?

    使用 DMV(動態管理檢視):sys.dm_exec_query_stats 搭配 sys.dm_exec_sql_text 可以按時間範圍追蹤查詢,sys.dm_exec_requests 可以看當前正在執行的查詢,效能衝擊遠小於 Profiler。

  • WordPress REST API 調試實戰:從 NNNN 字符到完整修復

     

    WordPress REST API 調試實戰:從 NNNN 字符到完整修復

     

    🎯 重點摘要

    • 問題根源:WordPress 資料庫中存在字面 ‘n’ 字符(0x6E),而非換行符(0x0A)
    • 表現症狀:REST API 返回 NNNN、n< 模式、表格損壞、內容亂碼
    • 根本原因:多層架構的信息轉換導致錯誤的故障假設和調試方向偏離
    • 解決方案:使用 od -c 檢查二進位數據、多層驗證、直接在資料庫層修復

    問題是如何出現的?

    WordPress REST API 調試中最常見的陷阱就是 症狀與原因的巨大落差。你在前端看到 NNNN 字符和 n< 模式,但實際問題可能在完全不同的地方。

    這篇文章根據真實的 WordPress 修復案例(超過 1,000 個 NNNN 字符、16 個表格損壞),詳細解析多層故障排除流程。

    第 1 層:表面症狀 vs 實際原因

    當 REST API 返回異常內容時,最危險的假設就是直接指責過濾器或編碼問題。實際上,以下三層都可能是問題來源:

    你看到的 期望的原因 實際原因 解決難度
    REST API 顯示 n< 過濾器損壞內容 資料庫中有字面 ‘n’ 字符 ⭐⭐⭐⭐
    NNNN 字符出現 轉義或編碼問題 ‘nn’ 模式(字面n + 換行) ⭐⭐⭐⭐⭐
    表格消失或亂碼 HTML 結構破壞 字面 ‘n’ 阻斷了 HTML 標籤解析 ⭐⭐⭐⭐

    表 1:WordPress 調試常見誤判清單 — 本表格列出 REST API 常見症狀、直觀的誤判原因,以及實際根本原因。這些誤判會導致調試花費 2-4 小時無果。

    第 2 層:多層架構的信息失真

    WordPress 資料從資料庫到瀏覽器經過多個轉換層,每一層都會改變你看到的表現形式:

    層級 你看到的 實際的字節 驗證方式
    MySQL 命令列 n(轉義序列) 0x0A(真實)或 0x6E(’n’ 字符) od -c
    PHP 讀取 實際換行或字面 ‘n’ 二進位正確表示 strpos($str, “n”)
    REST API JSON n 字符或 n JSON 正確轉義 jq + od -c
    瀏覽器顯示 NNNN、亂碼或正常 HTML 渲染結果 DevTools 檢查

    表 2:多層架構信息轉換對比 — 同一份資料在不同層級呈現出不同的表現。MySQL 命令列使用轉義表示,PHP 使用二進位,REST API 使用 JSON,瀏覽器進行 HTML 渲染。如果不理解這些轉換,很容易做出錯誤的根因判斷。

    第 3 層:正確的調試順序

    大多數 WordPress 調試問題都是因為調試順序錯誤。正確的調試順序應該是:

    1. 直接檢查二進位資料(od -c)— 這是源頭事實,必須第一步做
    2. 對比 DB ↔ Filter ↔ REST API 的三層輸出 — 縮小問題範圍
    3. 假設反轉 — 如果不是編碼問題,那是資料損壞嗎?
    4. 定位損壞位置 — 哪一層引入的?是資料庫本身還是更新時損壞?
    5. 追蹤操作歷史 — 之前做過什麼導致損壞?

    在真實案例中,調試花費了大量時間的原因是:第 1 次調查順序是 2 → 3 → 1 → 4 → 5,而正確順序應該是 1 → 2 → 3 → 4 → 5。

    第 4 層:實際的修復步驟

    步驟 1:使用 od -c 檢查資料庫的實際字節

    docker exec wordpress mysql -u wpuser -pwp_password wordpress -e 
      "SELECT SUBSTRING(post_content, POSITION('' IN post_content), 50) 
       FROM wp_posts WHERE ID = 984;" | tail -1 | od -c | head -20

    輸出應該顯示:

    !   -   -   >   n      n   <   !   -   -
                        ^   ^
                字面'n'  實際換行

    如果看到這個模式,你已經找到了根本原因:資料庫中有字面 ‘n’ 字符

    步驟 2:修復資料庫損壞

    docker exec wordpress mysql -u wpuser -pwp_password wordpress -e "
    UPDATE wp_posts
    SET post_content = REPLACE(post_content, CONCAT('n', CHAR(10)), CHAR(10))
    WHERE ID = 984;
    "

    這個 SQL 語句移除所有「字面 ‘n’ + 換行符」的組合,只保留實際的換行符。

    步驟 3:驗證修復

    curl -s http://localhost:8001/wp-json/wp/v2/posts/984 | jq -r '.content.rendered' | grep -o 'n<' | wc -l
    # 應該返回 0

    第 5 層:為什麼調試這麼困難?

    困難點 為什麼 解決方案
    信息不對稱 MySQL 顯示 n、PHP 顯示實際換行、REST API 顯示 n 字符 建立單一源頭(od -c),在那層定位問題
    問題來源不清 用戶說「做表格後出現 NNNN」,但不知道之前對資料做過什麼 追蹤操作歷史,理解損壞何時引入
    多層架構複雜 Database → Filter(6 個) → REST API → Browser 逐層檢查,縮小問題範圍到特定層級
    工具轉換多次 MySQL CLI → od -c → PHP → curl → jq → JSON 固定驗證工具,避免多次轉換導致的失真

    表 3:WordPress REST API 調試困難點分析 — 列出調試過程中的四個主要困難,以及每個困難對應的解決方案。這些都是基於真實的修復案例總結出來的。

    第 6 層:最佳實踐清單

    • 第一步永遠是 od -c — 不要猜測,直接看二進位數據
    • 建立多層驗證 — 不要只檢查一層,Database + Filter + REST API 都要查
    • 假設反轉 — 一個方向卡住了,立即反轉假設方向
    • 追蹤操作歷史 — 理解「之前發生了什麼」比「現在看起來怎樣」更重要
    • 表格要有邊框 — 使用 inline style: style="border: 1px solid #333; padding: 8px;"
    • 保存配置檔 — WordPress API 認證信息應該存在 ~/.claude/projects/project-name/wordpress-config.env

    常見問題(FAQ)

    總結

    WordPress REST API 調試的關鍵是理解 多層架構中的信息失真。症狀永遠不等於原因,你看到的 NNNN 字符只是冰山一角。

    記住這個優先順序:

    1. od -c 檢查二進位(源頭事實)
    2. 逐層驗證(Database → Filter → REST API)
    3. 假設反轉(卡住時反向思考)
    4. 追蹤歷史(理解根本原因)
    5. 修復並驗證(修完要驗證三層)

    下次遇到 WordPress REST API 問題時,不要急著改過濾器或重建資料庫。先用 od -c 看看真正的二進位數據,一切就清楚了。

     

  • Solr 實戰完全筆記:從基礎語法到效能調校與評分機制

    基本參數介紹

    • q:查詢的關鍵字,此參數最為重要,例如 q=id:1,默認為 q=*:*
    • fl:指定返回哪些字段,用逗號或空格分隔,注意字段區分大小寫,例如 fl=id,title,sort
    • start:返回結果的第幾條記錄開始,一般分頁用,默認 0 開始
    • rows:指定返回結果最多有多少條記錄,默認值為 10,配合 start 實現分頁
    • sort:排序方式,例如 id desc 表示按照 id 降序,多個字段:score desc, price asc
    • wt:(writer type) 指定輸出格式,有 xml, json, php 等
    • fq:(filter query) 過濾查詢,提供一個可選的篩選器查詢,例如:q=id:1&fq=sort:[1 TO 5]
    • df:默認的查詢字段,一般默認指定
    • qt:(query type) 指定哪個類型來處理查詢請求,一般不用指定,默認是 standard
    • indent:返回的結果是否縮進,默認關閉,用 indent=true 開啟

    查詢語法

    • : 指定字段查指定值,如返回所有值 *:*
    • ? 表示單個任意字符的通配
    • * 表示多個任意字符的通配(不能在檢索的項開始使用)
    • ~ 表示模糊檢索,如 roam~ 將找到 foam 和 roams;roam~0.8 檢索返回相似度在 0.8 以上的記錄
    • AND|| 布爾操作符
    • OR&& 布爾操作符
    • NOT!- 排除操作符
    • + 存在操作符,要求符號後的項必須在文檔中存在
    • ( ) 用於構成子查詢
    • [] 包含範圍檢索,如 date:[201507 TO 201510] 包含頭尾
    • {} 不包含範圍檢索,如 date:{201507 TO 201510} 不包含頭尾

    Solr 本質

    Solr 本質上還是搜尋引擎,因此優先還是 index 其後才是 store。
    Partial update 也是先把資料拉回來重新 index 後 store。
    順序:index 先,然後 store

    (閱讀全文…)

  • Kafka 實務坑筆記(九):Topic 分流設計的藝術

    問題背景

    我們的 Kafka Topic 設計是依照平台分類

    (閱讀全文…)