先決定要站在哪裡看

一個 PUT 進來,handler 讀出商品、改欄位、存回去——三行 Go。但這是三層框架疊起來的服務:Gin 的 handler、GORM 的 ORM、database/sql 的連線池、MySQL driver。每一層都可能自己多做一點事,而每一層都不會說。

站在自己的 handler 裡最省事,但那裡看不到重點:handler 只知道自己呼叫了 Save(),不知道那一次 Save() 變成幾句話離開行程。所以掛法是「兩端夾」——上端三個位置(改一個商品、先讀出商品、存回商品),下端直接站到 driver 的門口:mysqlConn.QueryContext 與 ExecContext。那是語句離開行程的最後一道關卡,過了那裡就是網路。

流量刻意切成兩個相位,每相位 240 個請求、每秒一發,只換一個變因:前四分鐘把 GET 回來的整包商品原樣送回去(category 和 supplier 兩個巢狀物件都在裡面),後四分鐘只送 {"quantity":N},關聯物件一個字都不提。

事前推得出來的:480 次 PUT、480 次 Save。推不出來的:driver 門口會收到幾句,以及第二個相位會不會比較少。

八分鐘看到的

比例先對上了:1440 ÷ 480 = 3,1443 ÷ 481 = 3。一次改動,讀三句、寫三句——六句話。

寫的那三句是這樣的(逐字抄自面板):

UPDATE `products` SET `name`=?,`description`=?,`price`=?,`quantity`=?,`category_id`=?,`supplier_id`=?,`created_at`=?,`updated_at`=? WHERE `id` = ?
INSERT INTO `categories` (`name`,`created_at`,`updated_at`,`id`) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `suppliers` (`name`,`contact`,`created_at`,`updated_at`,`id`) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE `id`=`id`

我要改的只有第一句裡的 quantity。另外兩張表沒人打算動,卻各被寫了 480 次。

而寫入那一側的堆疊,把「為什麼」直接印了出來。這是它最深的那條鏈,出現 169 次(拿掉套件前綴,以及最上面那五格觀測框架):

UpdateProduct · UpdateProduct · Save · DB.Save · processor.Execute
  · SaveBeforeAssociations · saveAssociations · DB.Create · processor.Execute · Create.func1
  · Tx.ExecContext · execDC · withLock · execDC.func2 · ctxDriverExec · ExecContext

SaveBeforeAssociations → saveAssociations → Create。那兩句 INSERT 不是我寫的,是 Save() 在寫本體之前先去把關聯存了一遍——中間那三格就是整篇的答案,而它們不在我的程式碼裡,也不在我的 log 裡。

我以為那是前端送回來的

這個解釋很順:前端拿 GET 回來的巢狀 JSON,改個數字就整包 PUT 回去,所以 GORM 收到的物件上掛著 category 和 supplier,它當然會把它們一起存。照這個說法,body 裡不放關聯,那兩句就該消失。

第二個相位就是為了這件事跑的。同樣每秒一發、同樣 240 次,body 換成只有一個欄位。

它沒有動。

相位                      PUT    UPDATE products   INSERT categories   INSERT suppliers   p50
A 整包巢狀 JSON           240          240               240                240        18.011ms
B 只送 {"quantity":N}     240          240               240                240        17.441ms

六句話一句沒少,每一句的次數一個不差。 body 從一整包縮到一個欄位,資料庫那頭完全感覺不到。

原因在讀那一側的堆疊裡:FindByID 那一行是 Preload("Category").Preload("Supplier").First(...),所以 handler 拿到的那個物件在收到 body 之前就已經掛好兩個關聯了。ShouldBindJSON 只是往上面蓋幾個欄位,蓋不掉沒提到的東西。前端送不送,從來就不是這件事的變因。

誠實說

「Preload 是那兩個物件的來源」這一步是讀出來的,不是量出來的。 我量到的是「body 不影響結果」,而堆疊上有 Preload → preloadEntryPoint → preload → Find。真正的對照組要把那個 Preload 拿掉再跑一輪,而那需要改原始碼——這一輪的規矩是不改,所以這一步停在「堆疊這樣寫」。

driver 門口看得到形狀,看不到值。 上面那三句寫入裡的 ? 就是 ?,我看得到它更新了哪些欄位,看不到更新成什麼。那句 UPDATE 連 created_at 都一起寫回去了——這個形狀本身有話說,但我沒有數字可以說它寫了什麼。

入口那兩條邊是 479,不是 480。 兩個下游各命中 480 次,但只有 479 次的堆疊上看得到入口那一格。圖照著量到的畫,不補成整數。

讀那一側是 481,不是 480。 多的那一次是窗口開始前 1.3 秒——流量腳本要先 GET 一次商品,才有整包 JSON 可以送。它在圖上,因為它真的發生了。

最深那條鏈的最上面五格是我自己。 面板上那個「25 層深」裡,有 5 格是 trap.Handler → HarvestPoint → GetOriginalPC 這種觀測框架,我在上面那個 code block 裡把它們拿掉了。觀測不是免費的,而它連在堆疊上都藏不住。

結果

我現在知道,那兩句 INSERT 跟前端送什麼沒有關係——它們是那一行 Preload 的帳,在兩行之後才結。 我原本會把它寫成前端的問題,因為那個解釋既合理又不用改我自己的程式碼。

而這件事在面板上是一句話都不會說的:兩個相位的曲線疊在一起,p50 差 0.57 毫秒,八分鐘裡沒有任何一格看起來不對。把 body 砍掉一半再跑四分鐘,花不到五分鐘,而它把一個「應該是這樣」換成了一個「就是這樣」。

📊 本文的遠端觀測面板(實測數據,可線上檢視):https://codebull-service-b7lha2dtrq-uc.a.run.app/public/inventory-fanout-20260908/ Behavior Graph 分頁是文章開頭那張圖的互動版,時間軸上寫著 events in span 4,804 與 1,443 ÷ 481 = ×3.00;Logs 分頁可以把六句 SQL 逐字對帳;Flamegraph 分頁就是上面那條 25 層深的框鏈。(面板時間軸是本地時間 UTC+8,對應內文的 UTC 窗口。)

這一輪對被觀測服務做了什麼:main.go 加了一行 blank import 把 SDK 帶進二進位檔,並用 -gcflags="all=-dwarflocationlists=true -N -l" 編譯。業務邏輯一個字沒動,沒有加任何 log 或計數器。六個觀測點全部是行程跑起來之後才掛上去的,掛在五個函式上——其中兩個在 go-sql-driver/mysql 裡面,那是我沒有寫過、也沒有改過的套件。