📌

どのレイヤー(層)でトランザクションを実装すべきか

2024/02/28に公開

はじめに

こんにちは。クラウドエース株式会社で主にアプリケーション開発を担当している水野です。

今回は、どのレイヤー(層)でトランザクション実装すべきかについてご紹介します。

結論から言うと、usecase 層と infrastructure 層で実装します。
各層で実装することは以下です。実装内容は抽象化(カプセル化)させ、他の層では意識させないような構成にします。

実装内容
usecase どの処理に対して整合性を保つかと処理をどの順番で実施するか
infrastructure DB 固有のトランザクション操作(コミットやロールバック等)とトランザクションの分離性

背景

DDD のアーキテクチャにおいて、以下のような構成があります。
※ 図の矢印は依存関係を示しています。(依存元→依存先)
alt text

役割 関心事
domain ドメイン知識(ビジネスルール)を表現 ビジネス知識
usecase domain 層で定義しているオブジェクトやメソッドを用いてユースケースを実現 機能の処理順序
presentation クライアントとの入出力を定義 技術知識
infrastructure DB や外部APIとの入出力を定義 技術知識

このように、層ごとに関心事の分離を行うことで、保守性の高い(変更容易性や再利用性等)アプリケーションを実現できます。
しかし、「トランザクション」においてはどうでしょうか。
トランザクションはビジネス領域においても、技術領域においても関心事がある内容です。
そういう曖昧なものは「ひとまず usecase 層に入れてしまえ」という方針になりがちです。
ですが、DB 固有の知識を usecase 層の関心事にしてしまっては、関心事の分離をするメリットが得られません。
そのため、関心事の分離を実現しつつトランザクション実装をする方法を模索してみました。

前提

1. クリーンアーキテクチャを採用している(オニオンアーキテクチャやレイヤードアーキテクチャも含む)

そもそもビジネス知識と技術知識を分離していないアーキテクチャを採用している場合、メリットは得られません。
そのため、オニオンアーキテクチャやレイヤードアーキテクチャを含むクリーンアーキテクチャを前提とします。

2. MySQL か PostgreSQL である

今回のご紹介では、MySQL と PostgreSQL のみを対象とします。
とくに、トランザクション分離レベルは DB エンジンの種類によって仕様が決まるため、他の DB エンジンは各自で調査してください。(もし調査した方はコメントに記載いただけると嬉しいです)

トランザクションにおける各レイヤーの関心事

各レイヤーには関心事において違いがあり、トランザクションにおいても同様のことが言えます。

infrastructure 層の関心事

infrastructure 層は、DB や外部 API との入出力を定義しており、技術知識に関心事を持ちます。逆にビジネス知識の関心事はできるだけ持たないようにすべきです。

トランザクションにおける infrastructure 層の関心事は以下です。

  • トランザクション開始、コミット、ロールバックの操作
  • トランザクションの分離性

usecase 層の関心事

usecase 層は、domain 層で定義しているオブジェクトやメソッドを用い、「ファイルを登録する」などのユースケースを実現しています。そのため、機能を実現するための処理順序に関心事を持ちます。逆に技術知識の関心事はできるだけ持たないようにすべきです。

トランザクションのおける usecase 層の関心事は以下です。

  • どの処理に対して、整合性を保つか
  • 処理をどの順番で実施するか

方針

1. Unit of Work というデザインパターンを採用

設計として Unit of Work というデザインパターンを採用します。
Unit of Work とは、ビジネストランザクションとDB通信が1対1の関係になる設計方針です。
ドメインオブジェクトの取得や更新が発生する度にDB通信が発生するには、DB パフォーマンス上非効率です。
しかし、DB 処理をひとつの SQL クエリにまとめてしまうと、ビジネス知識が infrastructure 層に漏れてしまい、関心事の分離ができなくなります。
それを解決するのが Unit of Work です。
https://learn.microsoft.com/ja-jp/archive/msdn-magazine/2009/june/the-unit-of-work-pattern-and-persistence-ignorance
https://martinfowler.com/eaaCatalog/unitOfWork.html

2. トランザクション分離レベルは、REPEATABLE READまたはSERIALIZABLEとする

データの整合性を保つためには、トランザクション分離レベルを考える必要があります。

詳しい内容は割愛しますが、標準SQLトランザクション分離レベルをまとめると以下です。(PostgreSQLの公式ドキュメントをもとに作成)

分離レベル ダーティリード ファジーリード ファントムリード
READ UNCOMMITTED 可能性あり※1 可能性あり 可能性あり
READ COMMITTED 安全 可能性あり 可能性あり
REPEATABLE READ 安全 安全 可能性あり※1
SERIALIZABLE 安全 安全 安全

※1 PostgreSQLの場合は発生しない

トランザクション分離レベルは、パフォーマンスと厳密性それぞれの要件を踏まえて判断することをオススメします。
ファジーリードは更新処理に対して、ファントムリードは追加処理に対しての現象です。

また、デフォルト分離レベルは、PostgreSQLがREAD COMMITTED、MySQL InnoDB は REPEATABLE READです。

https://www.postgresql.jp/document/16/html/transaction-iso.html
https://dev.mysql.com/doc/refman/8.0/ja/innodb-transaction-isolation-levels.html

実装方法

上記の各レイヤーの関心事の分離を実現した実装方法をご紹介します。

infrastructure 層

トランザクション実装

Uptrace が提供している ORM ライブラリの Bun を参考にしました。
引数に関数を持たせ、その関数が成功したか失敗したかを元にトランザクション制御する実装方法です。
https://github.com/uptrace/bun/blob/master/db.go#L389-L413

トランザクション分離レベルの設定

TxOptionsIsolationLevel で設定ができます。

リトライ実装

DBのダウンタイムを考慮し、トランザクションにリトライ処理を組み込みます。
例えば、Google Cloud の Cloud SQL を使用する場合、メンテナンスダウンタイムが発生してしまいます。Cloud SQL のメンテナンスでは、進行中のトランザクションが commit され、既存の接続からのリクエストが終了されるまで数秒待機してからシャットダウンされます。その後、オープンまたは長時間実行されているトランザクションはロールバックされます。
https://cloud.google.com/sql/docs/postgres/maintenance?hl=ja#step-2
そのため、トランザクションが予期しないタイミングでロールバックされる可能性があり,
アプリケーションへの影響を最小限に抑えるため、指数バックオフのリトライ等の対策が必要です。

以下が実装例です。

client.go
type Client struct {
	*sql.DB
}

// DBクライアント作成
func NewClient() *Client {
    dbURI := "root:root@tcp(127.0.0.1:3306/dbName)?parseTime=true"
    dbPool, err := sql.Open("mysql", dbURI)
    if err != nil {
        log.Fatal(fmt.Errorf("sql.Open: %w", err))
    }
    ...(中略)...
    return &Client{dbPool}
}

// リトライとトランザクション
func (c *Client) RunInTx(ctx context.Context, fn func(ctx context.Context) error) error {
	if err := c.retry(ctx, func(ctx context.Context) error {
		return c.runInTx(ctx, fn)
	}); err != nil {
		return err
	}
	return nil
}

// リトライ
// cf. https://github.com/avast/retry-go
func (c *Client) retry(ctx context.Context, fn func(context.Context) error) error {
	return retry.Do(
        // 引数に格納されたfn関数を実行
		func() error {
			if err := fn(ctx); err != nil {
				return err
			}
			return nil
		},
		// 最大4回試行する
		retry.Attempts(4),
		// リトライをスキップする条件
		retry.RetryIf(func(err error) bool {
			return !sql.ErrNoRow && !errs.IsNotFound(err)
		}),
		// 最後の処理のみのエラーを出力する
		retry.LastErrorOnly(true),
		// 指数バックオフ形式で再試行間隔を調節
		retry.Delay(200*time.Millisecond),
		retry.DelayType(retry.BackOffDelay),
	)
}

// トランザクション
// cf. https://github.com/uptrace/bun/blob/master/db.go#L391-L413f
func (c *Client) runInTx(ctx context.Context, fn func(ctx context.Context) error) error {
    // トランザクション分離レベルの設定をここで行う
    tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    if err != nil {
        return err
    }
    // context にトランザクション追加
    ctx = context.WithValue(ctx, c.CtxTxKey(), tx)

    var done bool
    // runInTx()終了時に実行
    defer func() {
        if !done {
            tx.Rollback()
        }
    }()

    // 引数に格納されたfn関数を実行
    if err = fn(ctx); err != nil {
    	return err
    }

    // 引数に格納されたfn関数が成功したら、doneをtrueにする
    done = true
    if err = tx.Commit(); err != nil {
    	return err
    }

    return nil
}

// トランザクション用のcontext キー
type TxKey string

func (c *Client) CtxTxKey() TxKey {
    return "tx"
}

func (c *Client) TxFromCtx(ctx context.Context) *sql.Tx {
    tx, ok := ctx.Value(c.CtxTxKey()).(*sql.Tx)
    if !ok {
    	return nil
    }
    return tx
}

SQL クエリの実装

context によって、この処理がトランザクション対象なのかを判断します。

// context からトランザクションを取得
if tx := TxFromCtx(ctx); tx != nil {
    // context にトランザクションがある場合は、トランザクションの DB 処理を実施
} else {
    // context にトランザクションが無い場合は、通常の DB 処理を実施
}

usecase 層

RunInTx() による DB 実装の隠蔽化により、usecase 層では「どの処理に対して、整合性を保つか」と「処理をどの順番で実施するか」にのみ関心を持たせることができます。

// func(ctx context.Context) error {} で記述した処理はトランザクション対象となる
if err = RunInTx(ctx, func(ctx context.Context) error {
    newSample, err := NewSample(ctx)
    if err != nil {
        return err
    }

    if exist, err := ExistSample(ctx, newSample); err != nil {
        return err
    } else if exist {
        return fmt.Errorf("sample already exists")
    }

    if err = AddSample(ctx, newSample); err != nil {
        return err
    }

    return nil
}); err != nil {
    return err
}

さいごに

今回は、どのレイヤー(層)でトランザクション実装すべきかについてご紹介しました。
結論は、usecase 層と infrastructure 層での実装です。
各層で実装することは以下です。実装内容は抽象化(カプセル化)させ、他の層では意識させないような構成にします。

実装内容
usecase どの処理に対して整合性を保つかと処理をどの順番で実施するか
infrastructure DB固有のトランザクション操作(コミットやロールバック等)とトランザクションの分離性

関心事の分離は、複雑なシステムであるほど実装レベルでの適用が難しくなると思います。
そんなお悩みの方に少しでも役に立つ情報であると願っています。

Discussion