gormのUpsertがわからないからって全削除&全挿入は止めよう

趣味で Go/Gorm v2/MySQL/DDD を使いゲームAPIを開発し運用しています。ゲームAPIは100%趣味で個人開発なので自分以外はAIの[CodeRabbit](https://coderabbit.ai/)くんぐらいしかレビューしておらず、かなりいい加減です。**"いい加減でも、とりあえず動けばいいだろう、ガハハ!"** と実装を進めたのですが、いい加減に作った結果、半年後ひどい目にあったので教訓として共有します。

## 問題となったコード
ユーザーのアイテムインベントリモデル、ユーザーモデル、およびそれらモデルをまとめてすべて永続化する処理があるとします。

![random-temp-board.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/84960597-de3e-8399-db84-089fe00cba6d.png)

```golang:user_item_summary_model.go
type ItemSummary struct {
	ItemSummaryId uint `gorm:"primarykey"`
	Id            uint32
    Amount        uint32
	UserId        uint
}
```

```golang:user_model.go
type User struct {
	Id          uint `gorm:"primarykey"`
	CreatedAt   time.Time
	UpdatedAt   time.Time
	DeletedAt   *time.Time
	Name        string
	ItemSummary []ItemSummary             `gorm:"PRELOAD:false"`
}
```

```golang:user_persistence.go
type userRepositoryImpl struct {
	Conn *gorm.DB
}

func NewUserRepositoryImpl(conn *gorm.DB) repository.UserRepository {
	return &userRepositoryImpl{Conn: conn}
}

// 渡されたユーザーの情報を使い全リレーションをDBに保存する
func (ur *userRepositoryImpl) UpdateUser(ctx context.Context, user *model_user.User) (*model_user.User, error) {
    // トランザクションを貼る
	tx := ur.Conn.Begin()
    // 全リレーションを吹き飛ばす
	if deleteErr := tx.Unscoped().WithContext(ctx).Delete(value, "user_id = ?", user.Id).Error; deleteErr != nil {
        tx.Rollback()
        return nil, deleteErr
    }
    // 全リレーションを保存する
	if err := tx.WithContext(ctx).Save(&user).Error; err != nil {
		return nil, err
	}
    // トランザクションを終えて確定させる
	tx.Commit()
	return user, nil
}
```

gormを使うのは初めてだったため、gormでupsertをする方法が調べてもよくわからず、一旦全消去をかけて全挿入するといういい加減な荒業を採用しました。何かしら問題が起こるように思いますが、趣味範囲ではこれで動いてしまいました。もちろんパフォーマンスが悪く数100ms程度の時間はかかってしまいますが... **動いているならヨシ!** としていました。

## 問題の発生
**約半年後、運用しているAPIを置くVPSサーバーがなぜかストレージ空き容量不足に陥ります。**

きっとnginxのログが原因だと思い、ログを消して2GB程空き容量を確保しました。しかし、1週間後またストレージ不足になります。そこでDockerコンテナのサイズを見てみると...

**なんじゃこりゃあああああ！**
![image (6).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254984/470c5158-9458-d5b9-d5ff-3897c0dd1ea4.png)


## 問題の原因
この原因は、先程のソースコードです。全消去&全挿入の処理には重大な問題を含んでいました。

**DELETE処理は自動的にストレージを開放しないのです!!**

https://dba.stackexchange.com/questions/284536/mysql-delete-didnt-shrink-db-size-nor-table-size

これが、コンテナが24GBに膨らんだ原因です。DB自体のサイズは1GBだったため、残り23GBはDELETEの残骸として溜まってしまっていたのでした。

記事によると、`OPTIMIZE TABLE (テーブル名);` で 最適化を行うことで削除分のストレージを開放できます。しかし、あまりにもDELETEの残骸が溜まりすぎていたため、OPTIMIZEを行い3時間待っても全く終わりません。結局のところ、`mysqldump`を行い出力して、完全にデータベースを作り直す羽目になりました...

## 問題の解決
DELETEの残骸が貯まらないよう、毎日バッチ処理で `OPTIMIZE TABLE`を打つのも応急処置としては有りです。しかし、根本を治さないと気がすまないため、修正しました。調査して(恐らく)正しくUPSERTを使うよう修正したところ、下記のようになりました。

```golang:user_persistence.go
// 渡されたユーザーの情報を使い全リレーションをDBに保存する
func (ur *userRepositoryImpl) UpdateUser(ctx context.Context, user *model_user.User) (*model_user.User, error) {
    // トランザクションを貼る
	tx := ur.Conn.Begin()
    // ItemSummaryをUPSERTする
    if err := conn.Clauses(clause.OnConflict{
        Columns:   []clause.Column{{Name: "item_summary_id"}},
	    DoUpdates: clause.AssignmentColumns([]string{"id", "amount"}),
	}).Create(user.ItemSummary).Error; err != nil {
	    tx.Rollback()
	    return nil, err
	}
    // ユーザー情報のみを更新する
	if err := conn.Model(&user).Omit("ItemSummary").Updates(user).Error; err != nil {
		return nil, err
	}
    // ItemSummaryから削除された行だけDELETEする
    if deleteErr := conn.Where("amount = 0").Where("user_id = ?", user.Id).Delete(
	  &model_user.ItemSummary{},
	).Error; deleteErr != nil {
	    tx.Rollback()
	    return nil, deleteErr
	}
    // トランザクションを終えて確定させる
	tx.Commit()
	return user, nil
}
```

これで無事、無茶なDELETEは走らず、必要な分だけがDELETEされるようになりました。

## 今後やりたいこと
DBが意図せずストレージを圧迫するという問題は解決しました。
しかし、まだこのコードは少しおかしいです。

gormとDDDに初めて挑戦したという背景があり、パフォーマンスを完全度外視して、永続化層で関連する全モデルを取得・変換しました。これ自体は勉強として有りだったものの、あまりにもレコードが多いため、相当なオーバーヘッドを発生させています。

今回の要件では`item_summary`は大量に作られることが予め想定されていました。パフォーマンスを考えるとユーザー情報とユーザーイベントリ情報は分けた永続化層を作り、全取得・全保存クエリがそもそも発生させないような処理にするのがベターでした。

このAPIは現状、`user_persistence.go`で、リレーション含めてすべてのユーザー情報を読み書きするという実装になってしまっています。今から治すのは難しいですが、地道にパフォーマンス重視な実装に直していきたいと思います。

## まとめ
- `DELETE` 構文はDB上の行を消すが、ストレージを開放しない。
- GORMのUPSERTは 下記のような書き方で書ける。
```golang
    if err := conn.Clauses(clause.OnConflict{
        // 主キーとなるID
        Columns:   []clause.Column{{Name: "item_summary_id"}},
        // コンフリクトした場合に何を更新するか
	    DoUpdates: clause.AssignmentColumns([]string{"id", "amount"}),
	}).Create(user.ItemSummary).Error; err != nil {
	    tx.Rollback()
	    return nil, err
```
- 永続化層の作り方は、パフォーマンスも意識してよく考えよう