GORM の Save はアソシエーションを Upsert で上書きする──削除した関連が復活するバグの原因と対処
GORM の Save(&model) はアソシエーションを Upsert で自動保存します。つまり、別の goroutine が Save を呼んだ瞬間に、自分が削除したアソシエーションのレコードが上書きで復活します。これがこの記事で扱うバグの正体です。
GORM(v1.9.16) を使っていて、アソシエーションの削除がうまくいかなかったときにハマりました。その調査と対処のメモを残しておきます。
再現した構成
Product モデルと、それに紐づく Shop モデルを 並行して更新する プログラムを書いていました。
package main
import (
"log"
"github.com/my-best/products.my-best.com/go/internal/models"
"golang.org/x/sync/errgroup"
)
func main() {
var product models.Product
eg := errgroup.Group{}
eg.Go(func() error { return updateProduct(&product) })
eg.Go(func() error { return updateProductShop(&product) })
if err := eg.Wait(); err != nil {
log.Fatal(err)
}
}
updateProductShop の内部では、不要になった Shops を削除する処理を書いていました。
err := db.GetDB().Transaction(func(tx *gorm.DB) error {
db.GetDB().Model(&product).Related(&product.Shops)
for _, shop := range product.Shops {
if err := tx.Delete(&shop).Error; err != nil {
return fmt.Errorf("既存のShopの削除中にエラーが発生しました: %v", err)
}
}
return nil
})
main を実行し終わっても、削除されているはずの Shop が残り続けました。
ログを読む:DELETE の直後に UPDATE が来ていた
GORMのクエリログを確認したところ、下記のような流れになっていました(一部抜粋)。
// updateProductShop の処理
[2022-07-12 10:51:16] [2.35ms] SELECT * FROM `shops` WHERE (`product_id` = 17821591)
[1 rows affected or returned ]
[2022-07-12 10:51:16] [1.40ms] DELETE FROM `shops` WHERE `shops`.`id` = 155648
[1 rows affected or returned ]
// updateProduct の処理
[2022-07-12 10:51:17] [1.85ms] SELECT * FROM `products` WHERE (product_id = 17821591)
[0 rows affected or returned ]
[2022-07-12 10:51:17] [1.59ms] UPDATE `products` SET (略) WHERE `products`.`id` = 17821591
[1 rows affected or returned ]
[2022-07-12 10:51:17] [1.51ms] UPDATE `shops` SET `product_id` = 17821591, (略) WHERE `shops`.`id` = 155648
[0 rows affected or returned ]
DELETE が成功しているのに、その直後に別 goroutine からの UPDATE で同じ id のレコードが書き戻されています。updateProduct の中で呼ばれていた Save が原因でした。
db.GetDB().Save(&product)
GORM のドキュメントには次のように書かれています。
https://gorm.io/ja_JP/docs/associations.html#関連を削除する
GORMはレコードの作成・更新時にUpsertを使用して自動的に関連データとその参照を保存します
つまり Save(&product) を呼んだ時点で、product に紐づいている Shops スライスの内容が丸ごと Upsert されます。Delete した後でも product.Shops に古い値が残っていれば、そのまま復活します。
暫定対応:Association().Delete で関連も削除する
レコードの削除に加えて、関連(Association)側の削除も明示的に実行する必要があります。
err := db.GetDB().Transaction(func(tx *gorm.DB) error {
db.GetDB().Model(&product).Related(&product.Shops)
for _, shop := range product.Shops {
if err := tx.Delete(&shop).Error; err != nil {
return fmt.Errorf("既存のShopの削除中にエラーが発生しました: %v", err)
}
+ if err := db.GetDB().Model(&product).Association("Shops").Delete(shop).Error; err != nil {
+ return fmt.Errorf("関連の削除に失敗しました: %v", err)
+ }
}
return nil
})
Association("Shops").Delete により、product.Shops スライスからも該当の shop が除去されます。これで後から Save が走っても Upsert の対象に含まれなくなります。
恒久対応:Omit でアソシエーションの自動保存をスキップする
根本的な解決は、Save 呼び出し側でアソシエーションの自動保存を無効にすることです。GORM は Omit オプションでこれを制御できます。
https://gorm.io/ja_JP/docs/associations.html#アソシエーションの自動作成-x2F-更新をスキップ
// 変更前
db.GetDB().Save(&product)
// 変更後
db.GetDB().Omit("Shops").Save(&product)
Save を呼ぶ箇所が複数ある場合は、アソシエーションを更新するつもりがない箇所にはすべて Omit を付けるのが安全です。
GORM v2(gorm.io/gorm)での変更点
この記事は v1.9.16 での挙動をもとにしています。v2 系では FullSaveAssociations という設定で同じ挙動を制御できます。
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
FullSaveAssociations: false, // デフォルトは true
})
false にするとアソシエーションの自動 Upsert が全体的に無効になります。プロジェクト全体で「Save 時にアソシエーションを自動保存しない」という方針を取るなら、v2 ではこの設定を使うのが最も明快です。
記事の更新をメールで受け取る
質問・リクエストを送る
記事についての質問や、取り上げてほしいテーマがあればお気軽にどうぞ。いただいた質問はブログ記事として回答し、Q&Aページで公開することがあります。