先決定要站在哪裡看
ORM 的黑盒性質永遠是同一個形狀:你寫的那一行 Go,和真正離開行程的那句 SQL,中間隔了一層。鏈式呼叫在執行期才展開,展開成幾句、展開成什麼形狀,沒有東西會告訴你。
站在自己的程式碼裡數呼叫次數很省事,但那只回答了一半——我知道我叫了 Delete(),不知道它變成什麼。所以掛法是「上下兩端一起夾」:上端五個呼叫端各一個計數(Create、First、Update、Count、Delete),下端是所有語句的收斂點 (*processor).Execute,在同一個函式上放三種訊號——計數、計時,以及把 stmt.SQL.buf 逐字抓出來的那個 log 點。
上端數「我叫了幾次」,下端數「實際跑了幾次、跑出什麼」。中間的差額就是這一層自己加上去的東西。
事前推得出來的是五個呼叫端各自的次數。推不出來的有兩件:它們加起來會不會等於下端,以及那八種語句到底長什麼樣。
十一分鐘看到的
呼叫那一側先對上了。236 輪,每輪兩次 Create、兩次 First、各一次 Update / Count / Delete:
新增一筆 472 = 236 × 2
查第一筆 472 = 236 × 2
更新一個欄位 236
數使用者 236
刪掉最舊的 236
————
呼叫合計 1,652
而收斂點被進入了 1,888 次。1888 − 1652 = 236——正好一輪一次。
那 236 次多在哪裡,圖上直接標著:查第一筆 → GORM 執行一次 那條邊的倍率是 ×1.5(708 ÷ 472),其餘四條全是 ×1。邊上還列出中間那幾層的名字:Preload → preloadEntryPoint → preload → Find。
一行 db.Preload("User").First(&found, order.ID),在執行期展開成兩次執行。 這個比例不寫在任何一行程式碼裡。
我以為 Delete 送出去的是 DELETE
八種語句,去重之後每一種的次數是這樣的:
INSERT INTO "users" (…) 236
INSERT INTO "orders" (…) 236
SELECT * FROM "orders" WHERE "orders"."id" = $1 AND … ORDER BY … LIMIT $2 236
SELECT * FROM "users" WHERE "users"."id" = $1 AND "users"."deleted_at" IS NULL 236
SELECT * FROM "users" WHERE "users"."deleted_at" IS NULL ORDER BY created_at asc … 236
SELECT count(*) FROM "users" WHERE "users"."deleted_at" IS NULL 236
UPDATE "users" SET "age"=$1,"updated_at"=$2 WHERE … AND "id" = $3 236
UPDATE "users" SET "deleted_at"=$1 WHERE "users"."id" = $2 AND … 236
8 × 236 = 1,888,跟收斂點的命中數一分不差。
兩句 INSERT、四句 SELECT、兩句 UPDATE。沒有 DELETE。
我盯著那張清單看了一會兒才反應過來:最後那一句就是 db.Delete(&oldest)。它不是把那一列拿掉,它是往那一列寫了一個時間戳。
UPDATE "users" SET "deleted_at"=$1 WHERE "users"."id" = $2 AND "users"."deleted_at" IS NULL
這件事寫在型別裡——User 內嵌了 gorm.Model,那裡面有一個 DeletedAt。讀 struct 讀得出來。但我不是讀出來的,我是在一張沒有 DELETE 的清單上發現的。 而同一件事的影子其實鋪滿了整張清單:六句語句都拖著一個 deleted_at IS NULL,連 count(*) 都是。那個 WHERE 也不是我寫的。
而這件事會回頭咬人
User 的 Email 欄位上有 uniqueIndex。而上面那句 UPDATE 沒有動 email——它只寫了 deleted_at。
所以那 236 個「被刪掉」的 user,email 一個都沒有還回來。 對這支 demo 來說這是致命的:它每一輪用 user{n}@demo.com 造使用者,計數器隨行程重啟歸零,而上一輪的 email 還躺在唯一索引裡。下一次啟動,Create 會從第一輪就開始撞。
這一段是推的,不是量的:這十一分鐘裡行程沒有重啟過,所以我沒有看到那次碰撞。我量到的是那句 UPDATE 只寫了 deleted_at,其餘是它加上 uniqueIndex 的必然結果。
這不是 GORM 做錯了什麼——軟刪除就是這個語意,而且它把語意寫在型別上。但「刪掉」這兩個字在你的程式碼裡和在你的資料庫裡不是同一件事,而中間那一層預設不會提醒你。
誠實說
我只看到 GORM 送出什麼,沒看到 PostgreSQL 收到什麼。 收斂點在 ORM 這一側,database/sql 與驅動那一段這一輪沒有掛點。要確認那八句真的原樣離開行程,得再往下站一層。
表變多大,我沒有量。 上面那段是從語句的形狀讀出來的:UPDATE 只寫 deleted_at,所以列還在。我沒有掛任何點去數表的列數,所以全篇不出現任何一個表大小的數字。
收斂點的下面是空的。 圖上 GORM 執行一次 → 送出 SQL 那條邊是 ×1、零層未觀測,因為兩個點在同一個函式裡;真正深的那一段——callback 鏈、連線池、驅動——在這張圖上完全不存在。一張不會說自己在哪裡停止觀測的圖,會被當成完整的圖來讀。
那條邊是 1,887,不是 1,888。 收斂點命中 1,888 次,但只有 1,887 次在下游的堆疊裡看得到它。圖照著量到的畫,不補成整數。
結果
我現在知道,Delete() 這個名字描述的是我的意圖,不是資料庫收到的東西——而我是靠一張「少了一種語句」的清單才知道的,不是靠讀原始碼。
八種語句、每種 236 次、跟 1,888 次執行一分不差。這種整齊本身沒什麼了不起,它只是讓那個唯一的異常無處可躲:當每個數字都對得起來的時候,對不起來的那一個就是主角。
這一輪對被觀測服務做了什麼:main.go 加了一行 blank import 把 SDK 帶進二進位檔,並用 -gcflags="all=-dwarflocationlists=true -N -l" 編譯。業務邏輯一個字沒動,沒有加任何 log 或計數器。八個觀測點掛在五個函式上,全部是行程跑起來之後才掛上去的。