🔄

deferで「エラーが出ていたらロールバック」をするのをやめよう

に公開6

導入

Go ではerrという名前の変数を使ってエラーハンドリングをするのが一般的です。
一般的であるがためにerrを不用意に使ってしまうと意図しない挙動になりかねません。

例: errでトランザクションを管理する実装

DB アクセスのある関数で、以下の要件を満たす実装を行いました。

  • 関数のどこかでエラーになったら トランザクションをロールバックする
  • エラーにならなければトランザクションをコミットする

※実装は簡略化しています

var err error

tx, err := db.Begin()
if err != nil {
    return err
}

defer func() {
    if err != nil {
        tx.Rollback()
    } else {
        tx.Commit()
    }
}()

value, err := anyFunc()
if err != nil {
    return err
}

return nil

トランザクション中に実行される anyFunc の結果によって トランザクションをコミットするか決定しています。

anyFunc から返ったエラーは最初に定義したerrに代入されます。
それがnilでなければロールバックが行われ、そうでなければコミットされます。

この時点では要件通りで問題ありませんが、保守性を考えると望ましい実装ではありません。

変更して壊れる例

以下は簡単な修正を加えたことで意図しないコミットが行われるようになってしまう例です。

var err error

tx, err := db.Begin()
if err != nil {
    return err
}

defer func() {
    if err != nil {
        tx.Rollback()
    } else {
        tx.Commit()
    }
}()

if anyCondition { // anyFuncをある条件下でのみ実行するようにした
    value, err := anyFunc()
    if err != nil {
        return err
    }
}

return nil

最初の例と違う部分は anyFunc とそのエラー処理を if ブロックの中で実行するようにした点だけですが、anyFunc からエラーが返った場合にロールバックではなくコミットが行われるようになりました。

一見問題なく見える単純な変更で、重大なバグが発生してしまいました。

この問題には Go の代入の仕様が関係しています。

:=を使った代入とシャドーイング

Go では:=を使うことで新しい変数を宣言しながら代入することが出来ます。

単一の変数に対する代入

同じスコープに同名の変数が存在したとしても、同じブロックに別の変数が作成されます。(シャドーイング)

既に同じブロックで同名の変数が宣言されている場合はエラーになります。

複数の変数に対する代入

代入される変数のうち、未宣言ものは変数の作成と代入が行われ、宣言済みのものには代入だけが行われます。

ただし、代入される変数が全て同じブロックで宣言済みの場合はエラーになります。

なぜ壊れたのか

anyFuncのエラーが最初に定義したerrに代入されず、新しく宣言された if の中のerrに代入されることになります。
defer の中でアクセスされるerrはデフォルト値のnilのままで、anyFuncがエラーであるかにかかわらず常にコミットするようになってしまいました。

改善策

1. ロールバック用のフラグを新しく定義する

var err error
needsRollback := true

tx, err := db.Begin()
if err != nil {
    return err
}

defer func() {
    if needsRollback {
        tx.Rollback()
    } else {
        tx.Commit()
    }
}()

if anyCondition {
    value, err := anyFunc()
    if err != nil {
        return err
    }
}

needsRollback = false
return nil

概要

  • ロールバックが必要であることを示すフラグ(needsRollback)を追加する
  • trueであればロールバックする

メリット

  • err以外にコミット/ロールバックの条件がある場合に便利
  • フラグを変更しない場合ロールバックなので、異常系の実装漏れがおこりづらい

デメリット

  • 全ての正常系にフラグをfalseにする処理を追加する必要がある

2. トランザクション管理を外に出す

// トランザクション管理以外の処理
func process(tx Tx) error {
    if anyCondition {
        value, err := anyFunc()
        if err != nil {
            return err
        }
    }

    return nil
}

var err error

tx, err := db.Begin()
if err != nil {
    return err
}

if err := process(tx) {
    tx.Rollback()
} else {
    tx.Commit()
}

概要

  • トランザクションの開始と終了を関数外に切り分ける

メリット

  • トランザクションの状態を気にせず実装できる
  • コミットするフローが複数あっても追加の記述がない
  • テストや再利用がしやすい

デメリット

  • 既存の実装によっては設計の変更が難しい場合がある

3. 常にロールバックする

var err error

tx, err := db.Begin()
if err != nil {
    return err
}

defer tx.Rollback()  // コミットしていたら何も起こらない

if anyCondition {
    value, err := anyFunc()
    if err != nil {
        return err
    }
}

tx.Commit()  // 処理が成功したらコミットする
return nil

概要

  • 処理が成功したかにかかわらずロールバックする
    • 多くのライブラリはコミット後のロールバックで何も起こらないため

メリット

  • 最も単純
  • フラグを変更しない場合ロールバックなので、異常系の実装漏れがおこりづらい

デメリット

  • 処理が失敗したときにロールバック以外にすることがある場合採用できない
  • コミット後のロールバックがエラーになるライブラリでないか確認する必要がある

終わりに

errを代入直後以外で使うのを止めよう!

Discussion

ngicksngicks

goplsのanalysesのshadowを有効にするとshadowingがすべてwarningにできますのでそっちでやる+CIでチェックするという方針でもありかと思います。

ねちょねちょ

コメントありがとうございます。
公式のツールでチェック出来るのは取り入れやすくて良いですね。

ngicksngicks

書いといてなんですが、shadowingがwarningになるのはかなりverboseなので私は切ってます。

そして確かにNoboNoboさんのおっしゃっている通り、named return valueを使ったほうがいいですね!
named return valueを使うと、returnした値がnamed return valueに代入されたみたいにふるまいますので、かなりこの事故は防げます。

適当なサンプルを作ってobjdump -dして中身を確認してみたところ上記の説明であってる挙動をしていましたが、NoboNoboさんの示されたサンプルで過不足なさそうなのといい例が思いつかなかったから変になってしまったのでdetialsに隠しておきます。

objdumpで見たnamed return valueの挙動
package main

import "fmt"

//go:noinline
func foo(arg int) (named int) {
	defer func() {
		fmt.Println(named)
	}()

	if arg < 0 {
		return arg
	}
	named = 3
	if arg > 5 {
		named := named + 3
		return named
	}
	return 1
}

func main() {
	n := foo(1)
	fmt.Println(n)
}
» go version
go version go1.25.1 linux/amd64
go build -trimpath .
objdump -d namedreturn > objdump-d.txt

4981c0, 4981c4arg <= 5の比較、4981c6で6をdeferで呼び出してる無名関数のキャプチャーしてる変数に代入, 4981dcでdeferされた関数実行。4981deでキャプチャーされた値を%rax(引数と返り値はどちらもレジスタ渡しをするので返り値)に再度格納、今回は特に値を変更していないが場合によっては数値が変わっている。4981e8でreturn。

上で書いたことの認識は当たってそうです。

0000000000498160 <main.foo>:
  498160:	49 3b 66 10          	cmp    0x10(%r14),%rsp
  498164:	0f 86 cf 00 00 00    	jbe    498239 <main.foo+0xd9>
  49816a:	55                   	push   %rbp
  49816b:	48 89 e5             	mov    %rsp,%rbp
  49816e:	48 83 ec 28          	sub    $0x28,%rsp
  498172:	66 44 0f d6 7c 24 20 	movq   %xmm15,0x20(%rsp)
  498179:	c6 44 24 07 00       	movb   $0x0,0x7(%rsp)
  49817e:	48 c7 44 24 08 00 00 	movq   $0x0,0x8(%rsp)
  498185:	00 00 
  498187:	48 8d 0d d2 00 00 00 	lea    0xd2(%rip),%rcx        # 498260 <main.foo.func1>
  49818e:	48 89 4c 24 10       	mov    %rcx,0x10(%rsp)
  498193:	48 8d 4c 24 08       	lea    0x8(%rsp),%rcx
  498198:	48 89 4c 24 18       	mov    %rcx,0x18(%rsp)
  49819d:	48 8d 54 24 10       	lea    0x10(%rsp),%rdx
  4981a2:	48 89 54 24 20       	mov    %rdx,0x20(%rsp)
  4981a7:	c6 44 24 07 01       	movb   $0x1,0x7(%rsp)
  4981ac:	48 85 c0             	test   %rax,%rax
  4981af:	7c 5c                	jl     49820d <main.foo+0xad>
  4981b1:	48 c7 44 24 08 03 00 	movq   $0x3,0x8(%rsp)
  4981b8:	00 00 
  4981ba:	66 0f 1f 44 00 00    	nopw   0x0(%rax,%rax,1)
  4981c0:	48 83 f8 05          	cmp    $0x5,%rax
  4981c4:	7e 23                	jle    4981e9 <main.foo+0x89>
  4981c6:	48 c7 44 24 08 06 00 	movq   $0x6,0x8(%rsp)
  4981cd:	00 00 
  4981cf:	c6 44 24 07 00       	movb   $0x0,0x7(%rsp)
  4981d4:	48 8b 54 24 20       	mov    0x20(%rsp),%rdx
  4981d9:	48 8b 02             	mov    (%rdx),%rax
  4981dc:	ff d0                	call   *%rax
  4981de:	48 8b 44 24 08       	mov    0x8(%rsp),%rax
  4981e3:	48 83 c4 28          	add    $0x28,%rsp
  4981e7:	5d                   	pop    %rbp
  4981e8:	c3                   	ret
  4981e9:	48 c7 44 24 08 01 00 	movq   $0x1,0x8(%rsp)
  4981f0:	00 00 
  4981f2:	c6 44 24 07 00       	movb   $0x0,0x7(%rsp)
  4981f7:	48 8b 54 24 20       	mov    0x20(%rsp),%rdx
  4981fc:	48 8b 02             	mov    (%rdx),%rax
  4981ff:	90                   	nop
  498200:	ff d0                	call   *%rax
  498202:	48 8b 44 24 08       	mov    0x8(%rsp),%rax
  498207:	48 83 c4 28          	add    $0x28,%rsp
  49820b:	5d                   	pop    %rbp
  49820c:	c3                   	ret
  49820d:	48 89 44 24 08       	mov    %rax,0x8(%rsp)
  498212:	c6 44 24 07 00       	movb   $0x0,0x7(%rsp)
  498217:	48 8b 44 24 10       	mov    0x10(%rsp),%rax
  49821c:	ff d0                	call   *%rax
  49821e:	48 8b 44 24 08       	mov    0x8(%rsp),%rax
  498223:	48 83 c4 28          	add    $0x28,%rsp
  498227:	5d                   	pop    %rbp
  498228:	c3                   	ret
  498229:	e8 72 20 fa ff       	call   43a2a0 <runtime.deferreturn>
  49822e:	48 8b 44 24 08       	mov    0x8(%rsp),%rax
  498233:	48 83 c4 28          	add    $0x28,%rsp
  498237:	5d                   	pop    %rbp
  498238:	c3                   	ret
  498239:	48 89 44 24 08       	mov    %rax,0x8(%rsp)
  49823e:	66 90                	xchg   %ax,%ax
  498240:	e8 5b a0 fd ff       	call   4722a0 <runtime.morestack_noctxt.abi0>
  498245:	48 8b 44 24 08       	mov    0x8(%rsp),%rax
  49824a:	e9 11 ff ff ff       	jmp    498160 <main.foo>
  49824f:	cc                   	int3
  498250:	cc                   	int3
  498251:	cc                   	int3
  498252:	cc                   	int3
  498253:	cc                   	int3
  498254:	cc                   	int3
  498255:	cc                   	int3
  498256:	cc                   	int3
  498257:	cc                   	int3
  498258:	cc                   	int3
  498259:	cc                   	int3
  49825a:	cc                   	int3
  49825b:	cc                   	int3
  49825c:	cc                   	int3
  49825d:	cc                   	int3
  49825e:	cc                   	int3
  49825f:	cc                   	int3
ねちょねちょ

shadowingがwarningになるのはかなりverbose

やっぱり使いどころは難しいですよね。でも曖昧な部分が減るのは代えがたいメリットだと思います。

挙動の確認も勉強になりました。ありがとうございます!

NoboNoboNoboNobo

冒頭の書き方のvar err errorと返値のerrorは別の物を指しているので、
err変数と実際に返されるerrorの値が異なることが起きるので推奨されません。

こういう時は名前付き返値を使うと良いです。
この場合、関数の冒頭でシャドウイング前に参照するerrシンボルがreturnに渡されたものであることが保証されます。
(その後シャドウイングが発生しても大丈夫)
https://go.dev/play/p/AE2zVZQQJjg

ねちょねちょ

これはかなり綺麗に書けて良いですね!
記事を書く前にシャドーイングについて調べたのですが全く気づきませんでした…