GORM の Save はアソシエーションを Upsert で上書きする──削除した関連が復活するバグの原因と対処

8分で読めるテック

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ページで公開することがあります。

このサイトについて

井上 周(Amane Inoue)の個人ブログです。技術・読書・ドラマ・旅・大学生活のことを書いています。