【Go 1.21】ループ変数が共有される問題への対策(プレビュー)が公開されました

TL;DR

  • Goのfor文で宣言されるループ変数は、ループ(イテレーション)毎に同じアドレスに値が格納される仕様になっている
  • この仕様のよってループ内で並行処理を実行するなど特定のケースで、どのループでも毎回同じ値が取得されてしまうといった意図しないバグが発生することがあった
  • Go 1.21でこの問題への対策のプレビューが公開され、問題無ければ1.22で正式リリースされる予定です
  • 既存プログラムがこの変更によって正常に動作しなくなる可能性は殆ど無いとされています
  • 影響を受けるコードを検出するビルドのオプションが追加されたり、変更によって失敗するテストケースを特定するツールが提供されているので、それらで検証が可能です

※社内のテックブログに投稿した内容と同じです

【Go 1.21】ループ変数が共有される問題への対策(プレビュー)が公開されました - Money Forward Developers Blog

はじめに

先日Go 1.21がリリースされましたが、以前から多くのGoエンジニアを悩ませてきた「ループ変数が共有される問題」への対策が実験的に盛り込まれたので、今回はそちらについて紹介したいと思います。

Go 1.21 is released! - The Go Programming Language

※本記事で紹介するコードの実行結果は全てGo 1.21での挙動です ※本記事は主に以下ドキュメントの情報を基に作成しています(8月15日時点)

従来の仕様とその問題点について

ループ変数は同じアドレスに格納される

そもそも「ループ変数が共有される」とはどういったことなのかをサンプルコードを交えて紹介します。以下のコードはslice内の全要素をfor文で順次処理したものです。ループごとに各要素がループ変数vに格納されて、その値とアドレス(ポインタ)を標準出力します。

func main() {
    values := []int{0, 2, 4, 6}
    for i, v := range values {
        fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
    }
}
index: 0, value: 0, address: 0xc000012028
index: 1, value: 2, address: 0xc000012028
index: 2, value: 4, address: 0xc000012028
index: 3, value: 6, address: 0xc000012028

go.dev

ループごとに異なる値が出力されるのは想定通りだと思いますが、注目すべき点はaddressが全て同じであるところです。このようにGoではループごとに値が変わっていても、その値の格納場所に同じアドレスを使い回すようになっています。

この仕様自体はバグではなく想定されたもので、ループごとに新たなメモリ割り当てが発生しないのでパフォーマンス面だけでみると合理的な仕様だと言えます。しかし後述する特定のケースではこの仕様が影響して意図しない動作を引き起こしていました。

ケース1:goroutineでの並行処理

先程と同様にループ処理を組みますが、今度はgoroutineに渡したクロージャ内でループ変数を直接使って標準出力します。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        wg.Add(1)
        go func() {
            // 実行されるごとに index/value/address が変更されていることを期待するが……
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }()
    }

    wg.Wait() // 全ての goroutine が終了するまで待機
}
index: 3, value: 6, address: 0xc000012050
index: 3, value: 6, address: 0xc000012050
index: 3, value: 6, address: 0xc000012050
index: 3, value: 6, address: 0xc000012050

go.dev

先程と異なり、全てのループで同じ結果(最後のループの結果)が出力されてしまいました。goroutineが起動してクロージャ内で標準出力の処理が行われる前に、後続のループ処理によってループ変数の値が上書きされたため、それぞれのループ開始時点の値が反映されなくなっています。

なので以下のように直前のgoroutineの処理が完了してから次のループに移るように書き換えれば、想定通りの値が出力されます。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        wg.Add(1)
        go func() {
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }()
        wg.Wait() // 直前の goroutine の処理が終了してから次のループに移る
    }
}
index: 0, value: 0, address: 0xc0000a8018
index: 1, value: 2, address: 0xc0000a8018
index: 2, value: 4, address: 0xc0000a8018
index: 3, value: 6, address: 0xc0000a8018

go.dev

但し上記の方法だと別でgoroutineを起動する意味が無い(並行処理にならない)のでお勧め出来ません。

今回のケースであれば、ループ変数を直接使用せずクロージャの引数を経由すれば、同じアドレスを共有する問題を回避できるので想定通りに動きます。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        wg.Add(1)
        go func(i, v int) {
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }(i, v) // クロージャの引数にループ変数を指定
    }

    wg.Wait()
}

go.dev

その他のアプローチで最もシンプルな方法は、ループ処理内で新たに変数を宣言する方法です。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        i, v := i, v // 新たに変数を宣言
        wg.Add(1)
        go func() {
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }()
    }

    wg.Wait()
}

go.dev

ケース2:テストの並行実行

以下は奇数を判定する関数のテストです。t.Parallel()でテストを並行実行しています。

func IsOdd(num int) bool {
    return num%2 != 0
}

func TestIsOdd(t *testing.T) {
    tests := []struct {
        num  int
        want bool
    }{
        {num: 1, want: true},
        {num: 2, want: true}, // 失敗する想定のテストケース
        {num: 3, want: true},
        {num: 4, want: false},
    }
    for _, tt := range tests {
        t.Run(fmt.Sprintf("num(%v)", tt.num), func(t *testing.T) {
            t.Parallel() // 並行実行
            if got := IsOdd(tt.num); got != tt.want {
                t.Errorf("got = %v, want %v", got, tt.want)
            }
        })

    }
}
=== RUN   TestIsOdd
(中略)
--- PASS: TestIsOdd (0.00s)
    --- PASS: TestIsOdd/num=1 (0.00s)
    --- PASS: TestIsOdd/num=2 (0.00s)
    --- PASS: TestIsOdd/num=4 (0.00s)
    --- PASS: TestIsOdd/num=3 (0.00s)
PASS

go.dev

1箇所、失敗する想定のテストケースがパスしてしまっています。こちらも先程と同様に、ループ変数が後続のループ処理で書き換えられてしまったことが原因です。

こちらもループ処理内で新たに変数を宣言すれば解消されます。

func TestIsOdd(t *testing.T) {
    tests := []struct {
        num  int
        want bool
    }{
        {num: 1, want: true},
        {num: 2, want: true}, // 失敗する想定のテストケース
        {num: 3, want: true},
        {num: 4, want: false},
    }
    for _, tt := range tests {
        tt := tt // 新たに変数を宣言
        t.Run(fmt.Sprintf("num=%v", tt.num), func(t *testing.T) {
            t.Parallel()
            if got := IsOdd(tt.num); got != tt.want {
                t.Errorf("got = %v, want %v", got, tt.want)
            }
        })

    }
}
=== RUN   TestIsOdd
(中略)
--- FAIL: TestIsOdd (0.00s)
    --- PASS: TestIsOdd/num=1 (0.00s)
    --- PASS: TestIsOdd/num=4 (0.00s)
    --- PASS: TestIsOdd/num=3 (0.00s)
    --- FAIL: TestIsOdd/num=2 (0.00s)
FAIL

go.dev

ケース3:ループ変数のポインタを利用した処理

最後はintのポインタを格納するslice(ids)に、0から9の連番をポインタで格納する処理です。

func main() {
    var ids []*int
    for i := 0; i < 10; i++ {
        ids = append(ids, &i)
    }
    for i, v := range ids {
        fmt.Printf("index: %d, id: %d, address: %p\n", i, *v, v)
    }
}
index: 0, id: 10, address: 0xc000012028
index: 1, id: 10, address: 0xc000012028
index: 2, id: 10, address: 0xc000012028
index: 3, id: 10, address: 0xc000012028
index: 4, id: 10, address: 0xc000012028
index: 5, id: 10, address: 0xc000012028
index: 6, id: 10, address: 0xc000012028
index: 7, id: 10, address: 0xc000012028
index: 8, id: 10, address: 0xc000012028
index: 9, id: 10, address: 0xc000012028

go.dev

こちらも先程までのケースと同様、格納されたアドレスが全て同じなので、ループ最後の値で出力されてしまっています。

ループ処理内で新たに変数を宣言すれば解消されます。

func main() {
    var ids []*int
    for i := 0; i < 10; i++ {
        i := i // 新たに変数を宣言
        ids = append(ids, &i)
    }
    for i, v := range ids {
        fmt.Printf("index: %d, id: %d, address: %p\n", i, *v, v)
    }
}
index: 0, id: 0, address: 0xc0000a2000
index: 1, id: 1, address: 0xc0000a2008
index: 2, id: 2, address: 0xc0000a2010
index: 3, id: 3, address: 0xc0000a2018
index: 4, id: 4, address: 0xc0000a2020
index: 5, id: 5, address: 0xc0000a2028
index: 6, id: 6, address: 0xc0000a2030
index: 7, id: 7, address: 0xc0000a2038
index: 8, id: 8, address: 0xc0000a2040
index: 9, id: 9, address: 0xc0000a2048

go.dev

従来の解決策

上記のサンプルコードでも例示しましたが、この問題への最もシンプルな解決策は、新たな変数を宣言する方法になります。この方法は以下の公式のFAQにもサンプルコードとともに紹介されています。

Even easier is just to create a new variable, using a declaration style that may seem odd but works fine in Go:

for _, v := range values {
    v := v // create a new 'v'.
    go func() {
        fmt.Println(v)
        done <- true
    }()
}

What happens with closures running as goroutines?

Goエンジニアはこの仕様を考慮してループ処理を実装することが求められていましたが、見落としがちな仕様なので、新たにGoを利用する人はもちろん、使い慣れた人でも対策が漏れてしまい、バグを引き起こしてしまったという話はいくつも耳にしたことがあります。

その中でも特に大きな問題になったのが、Let's Encryptでのインシデントです。

Let's Encryptでのインシデント

無償でSSL証明書を発行しているLet's Encryptでは、ユーザーやドメインの検証に使用していたソフトウェアをGoで開発していますが、2020年にその検証ロジックにバグがあることが報告されました。

そのバグの原因となったのが、これまでに紹介したループ変数が共有されてしまう仕様によって引き起こされたものでした。

このバグは公式のwikiでも実際のコードと併せて紹介されています。 https://github.com/golang/go/wiki/LoopvarExperiment#what-is-the-problem-this-solves

これが原因で300万件近くの証明書が取り消されかけて(最終的に取り消さない判断になった)、複数のネットメディアで取り上げられたほど大きな問題に発展しました。

問題の大小はありますが同様の事例は様々報告されており、何年も前からこの仕様を変えるべきだという議論が行われていましたが、ついに今回対策の提案が承認されてGo1.21にプレビューとして組み込まれました。

今後の仕様変更と懸念事項

採用された提案内容

今回の提案内容では特にこれまでの記述方法を変えなくても、コンパイラが適切に判断して対策が適用されます。

先程紹介した「ケース1」「ケース2」のようなループ変数がクロージャやgoroutineに捕捉された場合や、「ケース3」のようにループを抜けた先でループ変数が参照される場合は、ループごとに変数が作成されるようになります。これによってループごとに保持された値が出力できます。

以前まで記述していた新たな変数の宣言(x := x)は不要になります。

⁠Go 1.21での検証方法

Go 1.21はプレビューなのでデフォルトでは従来通りの挙動ですが、ビルド時にGOEXPERIMENT=loopvarを指定すると変更内容を反映してビルドが行われます。

GOEXPERIMENT=loopvar go install my/program
GOEXPERIMENT=loopvar go build my/program
GOEXPERIMENT=loopvar go test my/program
GOEXPERIMENT=loopvar go test my/program -bench=....

How do I try the change?

The Go Playground上でも先頭に// GOEXPERIMENT=loopvarとコメントを付けると、このオプションが有効になります。\ go.dev

変更によって影響を受けるコードの場所を確認したい場合、-gcflags=all=-d=loopvar=2を指定してビルドすると警告メッセージが表示されます。

$ go build -gcflags=all=-d=loopvar=2 cmd/go
...
modload/import.go:676:7: loop variable d now per-iteration, stack-allocated
modload/query.go:742:10: loop variable r now per-iteration, heap-allocated

Can I see a list of places in my code affected by the change?

またbisectというツールを使えば、この変更でテストが失敗を引き起こすループを特定できます。

go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=loopvar go test

My test fails with the change. How can I debug it?

懸念1:既存プログラムへの破壊的な影響

これまでの仕組みが大きく変わるので、既存プログラムが正常に動作しなくなるなどの悪影響がまず気になります。

以下リンクにこれまで通り動かなくなるサンプルコードが紹介されています。従来の仕様に依存したコードは今回の変更で正常に動作しなくなる可能性があります。 Can this change break programs?

func main() {
    print(sum([]int{0, 2, 4, 6}))
}

func sum(list []int) int {
    m := make(map[*int]int)
    for _, x := range list {
        m[&x] += x // xがループ内で1つしか無いことに依存している
    }
    for _, sum := range m {
        return sum
    }
    return 0
}
Output(適用前): 12
Output(適用後): 0

go.dev

func main() {
    var f func()
    for i := 0; i < 10; i++ {
        if i == 0 {
            f = func() { print(i) } // i=0しか有り得ない筈が、関数呼び出し時点の`i`を使用されるので、ループごとに値が異なる
        }
        f()
    }
}
Output(適用前): 0123456789
Output(適用後): 0000000000

go.dev

但し上記サンプルはGoの一般的なループ処理の記述方法とは大きくかけ離れた書き方で、考慮すべきケースは極めて稀だと思われます(issues上の議論の中でも実際に存在を確認できないという意見が多数)

Googleでは2023年5月初旬から本番利用しているツールに変更を反映しているようですが、現時点で問題は一度も報告されていないようです。

なおC#でも同様の変更を加えた経緯があるそうですが、特に変更によって大きな問題は発生しなかったようです

How often does the change break real programs?

懸念2:⁠パフォーマンス低下

新たにメモリ割り当てされることになるのでパフォーマンス低下の懸念がありますが、通常のループ処理は従来通りコンパイルされる(変数共有)ので、それらのパフォーマンスに影響は出ません。影響を受けるループの場合でも、コンパイラエスケープ解析で必要無いと判断されれば新しい割り当てが行われないようです。

Googleの内部環境で使用した際もパフォーマンスの問題は観測されなかったということなので、大抵のプログラムは影響を受けないと考えられます。

Will the change make programs slower by causing more allocations?

まとめ

今回調査してみて、少なくとも私の携わっているプロジェクトでは影響が殆ど無さそうなのが分かって安心しました。

新しい変数を都度宣言する従来の対策方法は、この仕様を知らない人からすると意図が読めませんし、うっかり忘れてしまう可能性もあるので、実装時に気にする必要がなくなるのは非常にありがたいです。

私自身、Goを使い始めて暫くした時にこの事象に遭遇して、かなり苦い思いをしたので、正式リリースを心待ちにしたいと思います。

【Go 1.21】愚直なループ処理から脱却できるslices/mapsパッケージ

TL;DR

  • slices・mapsパッケージによって、これまでfor文で実装するしかなかったsliceやmapなどに対する汎用処理が簡単に実装できるようになった
  • これまでGoには継承などの共通の振る舞いを実装する仕組みが存在しなかったが、Go 1.18で導入されたジェネリクスによって今回の汎用処理が実現できている
  • 今後さらに関数の追加や、イテレータ用のパッケージの導入が検討されており、今後更に便利になりそう

slicesパッケージ

pkg.go.dev

従来のsliceに対する汎用処理の実装方法

これまでGoではsliceに対する汎用処理が言語側でサポートされていませんでした。そのためsliceに対する処理は、for文によるループ処理を都度記述する必要がありました。以下はslice内に対象の数値が含まれているかチェックしたい場合のコード例です。

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    target := 3

    // ここから判定ロジック
    hasTarget := false
    for _, v := range numbers {
        if v == target {
            hasTarget = true
            break
        }
    }
    // ここまで7行

    fmt.Println("Has a target:", hasTarget)
}
Has a target: true

こういった汎用的な処理が言語側でサポートされている他言語と比べると、実装の手間や可読性などの観点でかなり見落とりします。以下はRubyで同じ処理を実装した場合のサンプルです。

numbers = [1, 2, 3, 4, 5]
target = 3
hasTarget = numbers.include?(target) # 1行で判定可能
p "Has a target: #{hasTarget}"
Has a target: true

slicesパッケージでの実装例

Go1.21からsliceに対する汎用処理を提供するslicesパッケージが導入されています。以下は先ほどのコードを追加されたslicesパッケージの関数で書き換えたものです。

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    target := 3
    hasTarget := slices.Contains(numbers, target) // 1行で判定可能
    fmt.Println("Has a target:", hasTarget)
}
Has a target: true

ループ処理を自前で実装する必要がなくなったので、格段に見やすくなりましたし、実装しやすくなりました。

ジェネリクスによって様々な型に対応可能

先ほどのサンプルコードではintのsliceを使いましたが、slicesパッケージの関数はジェネリクスを活用して実装されているため、様々な型に対応しています。先ほど使用したContains関数の定義を見てみると以下のようになっています。

func Contains[S ~[]E, E comparable](s S, v E) bool pkg.go.dev

型パラメータが[S ~[]E, E comparable]と定義されています。comparableは言語側で事前定義された型制約で、intstringなどの比較可能な型が受入可能です。なので以下のようにstringfloat64など様々な型の配列を同じ関数で処理できます。

func main() {
    strings := []string{"a", "b", "c", "d", "e"} // stringの場合
    targetStr := "f"
    hasStr := slices.Contains(strings, targetStr)
    fmt.Println("Has a target:", hasStr) // Has a target: false

    numbers := []float64{0.1, 0.5, 1.2, 1.5, 2.0} // float64の場合
    targetNum := 2.0
    hasNum := slices.Contains(numbers, targetNum)
    fmt.Println("Has a target:", hasNum) // Has a target: true
}

コールバック関数によって複雑な仕様にも対応可能

ジェネリクスによって様々な型を受け入れることが可能になりましたが、構造体(sturct)などcomparableを満たしていない型では先ほどの関数を利用できません。そういったケースでも柔軟に対応できるように、コールバック関数を渡せる関数も用意されています。

func ContainsFunc[S ~[]E, E any](s S, f func(E) bool) bool pkg.go.dev

これによって構造体(sturct)の比較も可能になっています。以下はslice内の構造体の一部が対象文字列と一致するかチェックするコードです。

func main() {
    type person struct {
        name string
        age  int
    }
    persons := []person{
        {name: "Tom", age: 20},
        {name: "Bob", age: 30},
    }
    target := "Bob"

    hasTarget := slices.ContainsFunc(persons, func(p person) bool {
        return p.name == target
    })
    fmt.Println("Has a target:", hasTarget) // Has a target: true
}

その他にも単純一致ではなく複雑な条件を指定することも可能です。以下はslice内に奇数が含まれるかチェックするコードです。

func main() {
    numbers := []int{2, 4, 6, 8}

    callbackFunc := func(n int) bool {
        return n%2 != 0
    }
    hasOdd := slices.ContainsFunc(numbers, callbackFunc)
    fmt.Println("Has a odd:", hasOdd) // Has a target: false
}

slicesパッケージの関数一覧

上記で例にあげたContains関数以外にも様々なユースケースごとの関数が用意されています。

  • 検索
    • BinarySearch/BinarySearchFunc:対象要素を二分探索して存在有無(bool)とインデックス(int)を返す
    • Contains/ContainsFunc:対象要素が含まれているか(bool)を返す
    • Equal/EqualFunc:sliceを比較して全ての要素が同じか(bool)を返す
    • Index/IndexFunc:対象要素のインデックス(int)を返す
    • IsSorted/IsSortedFunc:ソート済みか(bool)を返す
    • Max/MaxFunc:最大の要素を返す
    • Min/MinFunc:最小の要素を返す
  • データ操作 ※いずれも元のsliceを破壊的に変更せず、新たなsliceを返却します
    • Clip:未使用容量を削除する
    • Clone:コピーを返す(Shallow Copy
    • Compare/CompareFunc:重複する要素を削除する
    • Delete/DeleteFunc:指定した範囲を削除する
    • Grow:容量を指定した分だけ増やす
    • Insert:指定した箇所に要素を挿入する
    • Replace:指定した範囲を別で指定したsliceに置き換える
    • Sort:昇順で並び替える
    • SortFunc:指定した関数のロジックで並び替える
    • SortStableFunc:指定した関数のロジックで並び替える(安定ソート)
    • Reverse:逆順に並び替える

まだ他言語に比べると少なく感じますが、他にも新たな関数の追加が検討されています。 github.com

mapsパッケージ

pkg.go.dev

mapに対する汎用的な処理については、mapsパッケージが提供しています。以下は削除の実装例です。

func main() {
    origin := map[int]string{
        1:    "one",
        10:   "Ten",
        1000: "THOUSAND",
    }

    // 削除
    maps.DeleteFunc(origin, func(k int, v string) bool {
        return v == "THOUSAND"
    })
    fmt.Println(origin) // map[1:one 10:Ten]
}
  • 検索
    • Equal/EqualFunc:同じkey/valueのペアが含まれているか
  • データ操作
    • DeleteFunc:条件を満たすkey/valueのペアを削除する
    • Clone:コピーを返す(Shallow Copy
    • Copy:対象mapを別に指定したmapへコピーする(keyが重複する場合は上書き)

こちらはslicesパッケージに比べるとボリューム少なめですが、他にも新たな関数の追加が検討されています。 github.com

検討中のイテレータ用のパッケージについて

今回の改修はsliceとmapのそれぞれに対して専用のパッケージを用意して実現していますが、こういったイテレータ全般に対する汎用処理を提供するパッケージの検討も進められています。

github.com

以下はexpパッケージ(実験的なパッケージ)への追加の提案内容ですが、他言語で良く見かけるようなイテレータに対する定番の処理が提案されています。

Package xiter implements basic adapters for composing iterator sequences:

[Concat] and [Concat2] concatenate sequences. [Equal], [Equal2], [EqualFunc], and [EqualFunc2] check whether two sequences contain equal values. [Filter] and [Filter2] filter a sequence according to a function f. [Limit] and [Limit2] truncate a sequence after n items. [Map] and [Map2] apply a function f to a sequence. [Merge], [Merge2], [MergeFunc], and [MergeFunc2] merge two ordered sequences. [Reduce] and [Reduce2] combine the values in a sequence. [Zip] and [Zip2] iterate over two sequences in parallel.

github.com

本ブログ投稿時点ではacceptされていませんが、順調に進めば次のバージョンに組み込まれるかもしれません。

まとめ

イテレータ関連の汎用処理が言語側でサポートされていないのは、Goの使いづらいポイントの一つでしたが、それが大きく改善のはとても嬉しいです(毎回for文を書く作業から解放される...)

今回追加されたslices・mapsパッケージや、新たに検討されているイテレータのパッケージの実装にはジェネリクスが非常に多く活用されていて、ジェネリクスの活用例や実装のお手本を知る良い機会になりました。ジェネリクスの具体的な活用方法を知りたい時は、これらの実装を参考にしてみるのも良いかもしれません。

複数セッションからINSERT ... ON DUPLICATE KEY UPDATEを実行した際にデッドロックが発生する問題(MySQL)

TL;DR

  • MySQLにおいて、複数セッションから同時にINSERT ... ON DUPLICATE KEY UPDATEで複数行の挿入を実行するとデッドロックが発生する場合がある
  • この現象はMySQLの5.7.26(8系なら8.0.16)でのバグ修正に起因すると考えられる
  • MySQL自体のバグでは無いため、サーバー側で対策する必要がある(以下対処例)
    • リトライする
    • クエリを分割する
      • 一行づつINSERTする
      • UPDATEとINSERTを分ける
      • 重複レコードをDELETE後にINSERTを行う
    • トランザクション分離レベルをREAD COMMITTEDに変更する
    • テーブルロックする
    • サーバー側で並列ではなく直列にクエリが実行されるように制御する
    • 諦める(デッドロックを許容)

目次

INSERT ... ON DUPLICATE KEY UPDATEデッドロックが発生する

INSERT ... ON DUPLICATE KEY UPDATEによる複数行のBULK INSERT/UPDATEを複数プロセスのバッチ処理で実行した際、デッドロックが度々発生するという問題が発生しました。

今回はその再現検証と原因・対策について調査した内容をまとめています。

再現検証

  • MySQLバージョン: 8.0.32
  • 検証用テーブル
CREATE TABLE `tests` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `foo` int(11) NOT NULL,

  PRIMARY KEY (id),
  UNIQUE KEY `index_unique` (`foo`)
) ;

以下のクエリを複数セッションから繰り返し実行したところ、重複レコードが存在するタイミングでデッドロックが発生しました。

INSERT INTO tests (`foo`) VALUES (699422), (699421) ON DUPLICATE KEY UPDATE foo = VALUES (foo);
INSERT INTO tests (`foo`) VALUES (699439), (699439) ON DUPLICATE KEY UPDATE foo = VALUES (foo);

エラーメッセージはこちらです。

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

ログ調査

デッドロックの詳細ログを表示した結果は以下の通りです。

mysql> SHOW ENGINE INNODB STATUS;

〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-07-03 05:46:41 281472373108672
*** (1) TRANSACTION:
TRANSACTION 41226, ACTIVE 29 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 4 row lock(s)
MySQL thread id 6154, OS thread handle 281472697032640, query id 21610 172.19.0.1 mysql_user update
INSERT INTO tests (`foo`) VALUES (699422), (699421) ON DUPLICATE KEY UPDATE foo = VALUES (foo)

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 2093 page no 4 n bits 80 index PRIMARY of table `app`.`tests` trx id 41226 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2093 page no 4 n bits 80 index PRIMARY of table `app`.`tests` trx id 41226 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (2) TRANSACTION:
TRANSACTION 41227, ACTIVE 15 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 6 lock struct(s), heap size 1128, 4 row lock(s)
MySQL thread id 6147, OS thread handle 281472700202944, query id 21625 172.19.0.1 mysql_user update
INSERT INTO tests (`foo`) VALUES (699422), (699421) ON DUPLICATE KEY UPDATE foo = VALUES (foo)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2093 page no 4 n bits 80 index PRIMARY of table `app`.`tests` trx id 41227 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2093 page no 4 n bits 80 index PRIMARY of table `app`.`tests` trx id 41227 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (1)
------------
TRANSACTIONS
------------
Trx id counter 41229
Purge done for trx's n:o < 41187 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 562947829962320, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562947829959896, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562947829960704, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562947829959088, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562947829958280, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 41228, ACTIVE 171 sec
1 lock struct(s), heap size 1128, 1 row lock(s)
MySQL thread id 6155, OS thread handle 281472364715968, query id 21619 172.19.0.1 mysql_user
---TRANSACTION 41227, ACTIVE 176 sec
6 lock struct(s), heap size 1128, 8 row lock(s)
MySQL thread id 6147, OS thread handle 281472700202944, query id 21625 172.19.0.1 mysql_user
--------
〜〜〜〜〜〜〜〜〜(略)〜〜〜〜〜〜〜〜〜〜〜

デッドロック発生直前にperformance_schema.data_locksで取得したロックのステータスは以下の通りです。

mysql> SELECT ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_STATUS,LOCK_DATA FROM performance_schema.data_locks;
ENGINE_TRANSACTION_ID INDEX_NAME LOCK_TYPE LOCK_MODE LOCK_STATUS LOCK_DATA
41226 PRIMARY RECORD X,REC_NOT_GAP GRANTED 1
41226 PRIMARY RECORD X GRANTED supremum pseudo-record
41226 PRIMARY RECORD X,INSERT_INTENTION WAITING supremum pseudo-record
41227 PRIMARY RECORD X,REC_NOT_GAP GRANTED 3
41227 PRIMARY RECORD X GRANTED supremum pseudo-record
41228 PRIMARY RECORD X,INSERT_INTENTION WAITING supremum pseudo-record

上記の表の見方は以下の通りです。

  • ENGINE_TRANSACTION_ID:
  • INDEX_NAME:
    • NULL - テーブル・ロック
    • PRIMARY - プライマリインデックス
    • index_unique - セカンダリインデックス
  • LOCK_MODE:
    • X,REC_NOT_GAP - レコード自体の排他ロックであり、ギャップの排他ロックではない(レコードロック)
    • X - レコードとその前のギャップに対する排他ロック(ネクスキーロック
    • X,INSERT_INTENTION - 行の挿入前に INSERT 操作によって設定されるギャップロック(インテンションロック)
  • LOCK_STATUS:
    • GRANTED - トランザクションがロックを所有している
    • WAITING - 競合するロックが解放されるまで待機している
  • LOCK_DATA:
    • supremum pseudo-record - インデックスの最大値を超える疑似値とその範囲に対してギャップロック
    • NULL - テーブルロック
    • その他
      • primary indexの場合 - プライマリ・キーのすべてのカラム
      • secondary indexの場合 - インデックスの定義で明示されているカラムの値の後に、残りのプライマリキーのカラムが続く

上記から待機状態のロックを抜き出すと以下の通りです。

ENGINE_TRANSACTION_ID INDEX_NAME LOCK_TYPE LOCK_MODE LOCK_STATUS LOCK_DATA
41226 PRIMARY RECORD X,INSERT_INTENTION WAITING supremum pseudo-record
41228 PRIMARY RECORD X,INSERT_INTENTION WAITING supremum pseudo-record

インテンションロック(排他ロック)で待機状態となっており、これが競合してデッドロックが発生したと考えられます。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.7.1 InnoDB ロック

原因分析

以下のバグ報告のやり取りから、MySQLの5.7.26(8系なら8.0.16)で別のバグ修正のために追加されたロックの影響によって発生した事象だと考えられます。

MySQL Bugs: #98324: Deadlocks more frequent since version 5.7.26

しかし上記の変更は他のバグに対する修正であり、以下の通り今回のデッドロックについてはバグでは無いというのが公式の見解のため、サーバー側での対処が必要となります。

"Deadlock happens" is not a bug - deadlocks should be handled by a properly written application, as they are one of possible outcomes of each transaction.

"Deadlocks happen more often than before" is also not a bug (Yes, it might be a symptom of a bug if the change was a surprising result of some unrelated change we haven't thought through. But in our case, the deadlocks happen more often because we lock more often, and this was an intended change)

"I'd like deadlocks to happen less often" is a feature request in my opinion, unless one can point out where we make a mistake by taking a lock.

対処方法

リトライ処理を追加する

デッドロック発生時の一般的な対処方法としてリトライ処理が上げられます。

シンプルな処理なのでデッドロックの発生頻度が低いのであれば有効ですが、高頻度で発生するようであればパフォーマンス面で難があると考えられます。

クエリを分割する

以下3通りありますが、クエリを分割することで対処可能です。

一行づつINSERTする

シンプルですが、大量にレコードが存在する場合にはパフォーマンスに難があると思われます。

BEGIN;
INSERT new row1;
INSERT new row2;
INSERT new row3;
COMMIT;
UPDATEとINSERTを分ける

前述のINSERTよりは若干処理が煩雑ですが、大量のレコードの挿入が発生するなら、こちらの方がパフォーマンスは優れていると考えられます。

BEGIN;
SELECT rows that conflict on secondary indexes;
UPDATE conflicting rows;
INSERT new rows;
COMMIT;
重複レコードをDELETE後にINSERTを行う

前述のUPDATEとINSERTを分ける方法よりは、サーバー側の処理がシンプルに実装できるかもしれません。

但しAUTO_INCREMENTだとIDがどんどん採番されるのと、削除によるデータ喪失のリスクを考慮すると、前述のUPDATEとINSERTを分ける方法の方が無難かもしれません。

またDELETE文でWHERE句を利用した絞り込みを行う場合、条件に該当するレコードが存在しない場合はテーブルのすべての行がロック(ギャップロック)されてしまい、今度はそれがデッドロックの原因となり得るので注意が必要です。

BEGIN;
DELETE conflicting rows;
INSERT new rows;
COMMIT;

トランザクション分離レベルをREAD COMMITTEDに変更する

インテンションロックはギャップロックの一種ですが、READ COMMITTEDにするとファントムリード(ファジーリード)を許容するため、ギャップロックは取得されずにデッドロックが発生しなくなります。

ファントムリード(ファジーリード)が許容できるのであれば、こちらの方法も良いかもしれません。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.7.4 ファントム行

テーブルロックする

アクセス頻度が低いテーブルなら良いかもしれないです。

但しアクセス頻度が高いテーブルであれば、待ち状態が頻繁に発生するので、パフォーマンス面で難があります。

今回のようなデッドロックが発生していて対策を要する状況であれば、既に一定のアクセス頻度が想定されるので、あまり良い選択肢では無いかもしれません。

サーバー側で並列ではなく直列にクエリが実行されるように制御する

無理に並列実行する必要が無いような時などは、単一プロセスで動かすようにして対処するのも方法かもしれません。

但しサーバー側で排他制御の仕組みを導入したりしてまで、サーバー側での制御に固執する必要は無いと思われます。

諦める(デッドロックを許容)

発生頻度が非常に少なく、ユーザー側で再実行が容易に出来たりするのであれば、あえて対策せず許容するのも選択肢として考慮しても良いと考えられます。

まとめ

  • リトライする
    • メリット:実装がシンプル
    • デメリット:根本的な解決にはならない。デッドロックの発生頻度が多いとパフォーマンスに悪影響を及ぼす
    • 採用シーン:デッドロックの発生頻度が少ない場合
  • 一行づつINSERTする
    • メリット:実装がシンプル
    • デメリット:大量のレコードをINSERTする場合はパフォーマンスに難がある
    • 採用シーン:INSERTするレコードが少数の場合
  • UPDATEとINSERTを分ける
    • メリット:DELETE後にINSERTを行うよりパフォーマンス・安全性ともに有利
    • デメリット:リトライや一行づつINSERTに比べると実装コストが高い。
    • 採用シーン:トランザクション分離レベルの変更が難しく、リトライで対処困難な場合
  • 重複レコードをDELETE後にINSERTを行う
    • メリット:UPDATE/INSERTに比べると実装がシンプルになりそう
    • デメリット:AUTO_INCREMENT使用中は連番が歯抜けになる。データ喪失のリスク。ギャップロックが発生する可能性あり。
    • 採用シーン:上記デメリットが許容できて、極力実装コストを下げたい場合(UPDATEとINSERTを分ける場合との比較)
  • トランザクション分離レベルをREAD COMMITTEDに変更する
    • メリット:設定変更だけで完了できる
    • デメリット:ファントムリード(ファジーリード)が発生する
    • 採用シーン:ファントムリード(ファジーリード)が許容できる場合
  • テーブルロックする
    • メリット:別要因で発生するデッドロックも抑制できそう
    • デメリット:アクセス頻度が多いテーブルだとパフォーマンスに悪影響を及ぼす
    • 採用シーン:テーブルへのアクセス頻度が低い場合
  • サーバー側で並列ではなく直列にクエリが実行されるように制御する
    • メリット:大きな改修が不要
    • デメリット:並列処理では無くなるので想定したパフォーマンスを出せなくなるかもしれない
    • 採用シーン:単一プロセスでの実行が許容できる場合
  • 諦める(デッドロックを許容)
    • メリット:改修不要
    • デメリット:何も解決しない
    • 採用シーン:解決する必要が無い場合

参考リンク

Best practices with Amazon Aurora MySQL - Amazon Aurora

Go1.20で追加された「unsafe.StringData、unsafe.String、unsafe.SliceData」による[]byteとstringの変換処理

TL;DR

  • Go1.20からメモリ効率を重視した[]bytestringの変換処理として以下が提供されている
    • string -> []byte:unsafe.Slice(unsafe.StringData(s), len(s))
    • []byte → string:unsafe.String(&b[0], len(b))

目次

従来の変換方法

Goにおいて[]bytestringで相互に変換を行う際、簡潔な方法として以下のように型キャストする実装方法があります。

// string -> []byte
[]byte(s)

// []byte → string
string(b)

しかし[]bytestringは内部のデータ構造が異なるため、上記の方法では無駄なメモリアロケーションが発生し、パフォーマンス面に難があります。

その問題に対処するため、公式のドキュメントなどで明示されていないですが、以下のような手法がこれまで広く使われていました。

// string -> []byte
*(*[]byte)(unsafe.Pointer(&s))

// []byte → string
*(*string)(unsafe.Pointer(&b))

但し上記の方法は、本来想定されていない無理矢理な変換方法なので、使用方法を誤ればバグを生み出すリスクがありました。

新しい変換方法

この問題に対処するため、Go1.20にて「unsafe.StringData、unsafe.String、unsafe.SliceData」が追加されたことで、以下の方法が推奨されるようになりました。

// string -> []byte
unsafe.Slice(unsafe.StringData(s), len(s))

// []byte → string
unsafe.String(&b[0], len(b))

ベンチマーク

import (
    "testing"
    "unsafe"
)

func BenchmarkTest1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        test1()
    }
}

func BenchmarkTest2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        test2()
    }
}

func BenchmarkTest3(b *testing.B) {
    for i := 0; i < b.N; i++ {
        test3()
    }
}

func test1() {
    origin := "test"

    // string -> []byte
    b := []byte(origin)

    // []byte → string
    _ = string(b)
}

func test2() {
    origin := "test"

    // string -> []byte
    b := *(*[]byte)(unsafe.Pointer(&origin))

    // []byte → string
    _ = *(*string)(unsafe.Pointer(&b))
}

func test3() {
    origin := "test"

    // string -> []byte
    b := unsafe.Slice(unsafe.StringData(origin), len(origin))

    // []byte → string
    _ = unsafe.String(&b[0], len(b))
}
$ go test -bench . -benchmem
goos: darwin
goarch: arm64
BenchmarkTest1-10       234380760            4.933 ns/op           0 B/op          0 allocs/op
BenchmarkTest2-10       1000000000           0.2888 ns/op          0 B/op          0 allocs/op
BenchmarkTest3-10       1000000000           0.4360 ns/op          0 B/op          0 allocs/op
PASS
ok      command-line-arguments  2.790s

単純な型キャストと比べると、新しい方法での変換も非常にパフォーマンスが良いことが分かります。

活用事例

既にGinやTinyGoなどのライブラリでは取り入れられているようで、今後どんどん置換が進んでいきそうです。

github.com

github.com

参考リンク

github.com

Google Chromeなどの一部ツールは、サブドメイン付きのlocalhostのアドレス(subdomain.localhost)を自動的に127.0.0.1で名前解決してくれます

TL;DR

※2023年6月時点

  • Google Chromeなどのツールでは、サブドメイン付きのlocalhostのURL(例:http://hoge.localhost:3000)を、自動で127.0.0.1に名前解決してリクエストを行います
  • 一方でSafariなどの一部ツールでは非対応なので、それらでも同様に名前解決したい場合は、別途実行環境のDNSの設定を変更する必要がある
  • 自動的な名前解決が主流のようだが、ツール毎の差分や今後の仕様変更の可能性を考慮するなら、自動的な名前解決に依存しない運用が望ましい

目次

ブラウザやツールによってサブドメイン付きlocalhostの名前変換の仕様が異なる

はてなブログのようなユーザー毎に任意のサブドメインを提供するようなサービス(APIを構築する際、ローカル環境(localhost)でも様々なサブドメインに切り替えた動作検証をしたいと思います。

その場合ホストマシンのDNSの設定などを変更する必要があると思いきや、Chromeなどの一部のツールは特にDNS関連の設定を変更しなくても、自動で127.0.0.1に名前解決してリクエストを行いました。

一方でSafariや各プログラミング言語のHTTPクライアントライブラリなどで実行すると、名前解決が行われずに、通信に失敗します。

以下は各ツールでのリクエスト結果です(2023年6月時点)

※実行環境

ブラウザ

  • Edeg → 成功

  • Postman(APIクライアント) → 成功

CLI

  • ping → エラー
$ ping hoge.localhost
ping: cannot resolve hoge.localhost: Unknown host
  • curl → 成功
$ curl 'http://hoge.localhost:3000/health'
"OK"

$ curl -V
curl 7.88.1 (x86_64-apple-darwin22.0) libcurl/7.88.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.11 nghttp2/1.51.0
Release-Date: 2023-02-20
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS GSS-API HSTS HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM NTLM_WB SPNEGO SSL threadsafe UnixSockets

HTTPクライアントライブラリ

  • Dio(Flutter) → エラー
flutter: *** DioError ***:
flutter: uri: http://hoge.localhost:3000
flutter: DioError [unknown]: null
Error: SocketException: Failed host lookup: 'hoge.localhost' (OS Error: nodename nor servname provided, or not known, errno = 8)
  • net/http(Go) → エラー
Get "http://hoge.localhost:3000": dial tcp: lookup hoge.localhost: no such host

非対応のツールでは実行環境にDNS設定を行う必要がある

Macであれば、/etc/hostsファイルにドメイン名を追記することでIPアドレスとのマッピングが可能です。

  • /etc/hosts
127.0.0.1 hoge.localhost
127.0.0.1 fuga.localhost

しかしホストPCに直接設定することになるので、チーム開発などをする際は、設定の差分に気を付ける必要があります。

lvh.meというドメインを使えば、非対応のツールでも対応可能だが。。。

実行環境のDNS設定を変更以外の方法を探すと、127.0.0.1DNSに登録されているlvh.meというドメインが存在するそうで、それを使えばサブドメインを付けた状態でもループバックアドレスに名前解決できました。

lvh.meというループバックドメイン:Technical tips:Media hub

ただこのドメインは公的機関など信頼性のある団体ではなく、どこかの個人が管理しているようなので、突然使えなくなったり、情報漏洩などセキュリティ上のリスクが懸念されるので、商用サービスなどでの利用は控えた方が良さそうです。

dns - It is safe to use lvh.me instead of localhost for testing? - Stack Overflow

RFC 6761での定義

なおRFCの「localhost」に関するドメイン名の定義には、以下のように記述されていました。

6.3. Domain Name Reservation Considerations for "localhost."

The domain "localhost." and any names falling within ".localhost." are special in the following ways:

  1. Users are free to use localhost names as they would any other domain names. Users may assume that IPv4 and IPv6 address queries for localhost names will always resolve to the respective IP loopback address.

  2. Application software MAY recognize localhost names as special, or MAY pass them to name resolution APIs as they would for other domain names.

  3. Name resolution APIs and libraries SHOULD recognize localhost names as special and SHOULD always return the IP loopback address for address queries and negative responses for all other query types. Name resolution APIs SHOULD NOT send queries for localhost names to their configured caching DNS server(s).

  4. Caching DNS servers SHOULD recognize localhost names as special and SHOULD NOT attempt to look up NS records for them, or otherwise query authoritative DNS servers in an attempt to resolve localhost names. Instead, caching DNS servers SHOULD, for all such address queries, generate an immediate positive response giving the IP loopback address, and for all other query types, generate an immediate negative response. This is to avoid unnecessary load on the root name servers and other name servers.

https://www.rfc-editor.org/rfc/rfc6761#section-6.3

.localhost.を含むドメイン名はループバックアドレス127.0.0.1)で解決することが推奨されているので、この仕様が現時点でのスタンダードのようです。

Google Chromeも以前は未対応だった

またGoogle Chromeに関しては、数年前の時点ではこの自動での名前解決に対応していなかったようで、途中で仕様変更の提案を受けて仕様が変わったようです。

dns - Why does Chrome resolve websitename.localhost as localhost? - Webmasters Stack Exchange

今後の仕様変更の可能性を考慮するなら、自動での名前解決に依存しない設計が望ましい

前述の通り、自動的に名前解決する方針が主流のようですが、各ツールごとに仕様が揃っていなかったり、仕様が変わったりしているのが現状です。

ローカル環境での話なので、そこまで神経質に取り扱う必要は無いかもしれないですが、今後の仕様変更などを考慮するなら、自動での名前解決に依存しない方針で開発を進めた方が良いかもしれません。

参考リンク

Echo(Golang web framework)のディレクトリ構成の参考情報 - OSS「echo-sample」のコードリーディング

TL;DR

echo-sampleは、こんな構成だった スクリーンショット 2020-01-13 18.34.23.png (199.7 kB)

きっかけ

業務で、GolangとそのWebフレームワークのEchoで、APIサーバーを作る機会があったのですが、初めて触るのでディレクトリ構成で悩みました。
必要なのは、Clean Architectureなどでガッツリ分離する必要も無いくらいのシンプルな機能のみなので、OSSで程よい構成のお手本を探しました。

いくつかEchoを利用したリポジトリがある中で、echo-sampleが簡潔な構成で参考になりそうでした。
そこで、それぞれのファイルがどういった役割を担っているのか調査したので、結果をまとめました。

echo-sampleの概要

名前の通り、Echoを用いたアプリケーションのサンプルです。
POSTで新しいメンバーの登録、GETでメンバーのデータを取得(個別or全件)出来るという非常に簡素なAPIです。

github.com

ディレクトリ構成

echo-sample 
    ├── .gitignore
    ├── LICENSE
    ├── glide.lock
    ├── glide.yaml
    │ 
    ├── main.go ・ ・ ・ 「①サーバー起動」
    │ 
    ├── api
    │    └── member.go ・ ・ ・ 「③レスポンスの制御」
    │ 
    ├── conf
    │    └── config.go  ・ ・ ・ 「④'DBの認証情報を定義」
    │ 
    ├── db
    │    ├── ddl.sql ・ ・ ・ 「⓪DBの作成」
    │    └── mysql.go ・ ・ ・ 「③'DBの設定」
    │ 
    ├── handler
    │    └── handler.go ・ ・ ・ 「③'''エラーハンドリング」
    │ 
    ├── middleware
    │    └── transaction.go ・ ・ ・ 「③’'DBの起動・トランザクション制御」
    │ 
    ├── model
    │    └── member.go ・ ・ ・ 「④ビジネスロジックの実装」
    │ 
    ├── route
    │    └── router.go ・ ・ ・ 「②ルーティング設定」
    │ 
    └── vendor
         ├── github.com
         │   └── ...
         └── golang.org/x
             └── ...

個々の構成毎の役割

パッケージ管理のglideや外部ライブラリの置き場所のvendor.gitignoreLICENSEは自明なので飛ばします

main.go

echo-sample/main.go at master · eurie-inc/echo-sample · GitHub

役割
  • サーバー起動
    • logrusの読み込み及び設定
詳細

全ての起点となる/main.goですが、最低限の内容に留めていました。
サーバー起動を行っていますが、echoインスタンスの生成は、後述するrouteで行っています。

func main() {

    router := route.Init()
    router.Run(fasthttp.New(":8888"))
}

また、ログ出力に標準logではなく、外部パッケージのlogrusを利用しており、それの読み込み/設定も行っています。
標準logより高機能で良いらしいです。

func init() {

    logrus.SetLevel(logrus.DebugLevel)
    logrus.SetFormatter(&logrus.JSONFormatter{})
}

qiita.com

route

echo-sample/route/router.go at master · eurie-inc/echo-sample · GitHub

役割
詳細

先ほど説明した通り、ここではechoインスタンスの生成を行っています。

func Init() *echo.Echo {

    e := echo.New()

        // 省略

}

インスタンス生成の中で、各種ミドルウェアの読み込みと、ルーティングの設定を行っています。

ミドルウェアについては、echo標準のものをいくつか読み込んでいます。

   // Set Bundle MiddleWare
    e.Use(echoMw.Logger())
    e.Use(echoMw.Gzip())
    e.Use(echoMw.CORSWithConfig(echoMw.CORSConfig{
        AllowOrigins: []string{"*"},
        AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAcceptEncoding},
    }))

echo.labstack.com

echo.labstack.com

また後述するmiddlewareで定義したミドルウェアも読み込んでいます。
その中で、後述するdbを呼び出して、DB接続も行っています。

   // Set Custom MiddleWare
    e.Use(myMw.TransactionHandler(db.Init()))

また、エラーハンドリングを行う為に、後述するhandlerの設定も行っています。

   e.SetHTTPErrorHandler(handler.JSONHTTPErrorHandler)

ルーティングでは、バージョン管理が容易に出来るように、Groupでパスを束ねていました。
なお、実際の処理は、後述するapiで実装しているようです。

   v1 := e.Group("/api/v1")
    {
        v1.POST("/members", api.PostMember())
        v1.GET("/members", api.GetMembers())
        v1.GET("/members/:id", api.GetMember())
    }

middleware

echo-sample/middleware/transaction.go at master · eurie-inc/echo-sample · GitHub

役割
詳細

transaction.goという名前の通り、DBのトランザクション 制御を請負っているようです。

const (
    TxKey = "Tx"
)

func TransactionHandler(db *dbr.Session) echo.MiddlewareFunc {

    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return echo.HandlerFunc(func(c echo.Context) error {

            tx, _ := db.Begin()

            c.Set(TxKey, tx)

            if err := next(c); err != nil {
                tx.Rollback()
                logrus.Debug("Transction Rollback: ", err)
                return err
            }
            logrus.Debug("Transaction Commit")
            tx.Commit()

            return nil
        })
    }
}

DBの操作にはdbrというパッケージを利用しています。

godoc.org

handler

echo-sample/handler/handler.go at master · eurie-inc/echo-sample · GitHub

役割
  • HTTPのエラーハンドリング
詳細

こちらでHTTPのエラーハンドリングを行っている様です。
なお、echo.Contextは、現在のHTTPリクエストのコンテキストを表します。

func JSONHTTPErrorHandler(err error, c echo.Context) {
    code := fasthttp.StatusInternalServerError
    msg := "Internal Server Error"
    if he, ok := err.(*echo.HTTPError); ok {
        code = he.Code
        msg = he.Message
    }
    if !c.Response().Committed() {
        c.JSON(code, map[string]interface{}{
            "statusCode": code,
            "message":    msg,
        })
    }
}

echo.labstack.com

db

echo-sample/db/mysql.go at master · eurie-inc/echo-sample · GitHub

役割
  • DBへの接続
詳細

ここではデータベースへの接続を行っています。

func Init() *dbr.Session {

    session := getSession()

    return session
}

func getSession() *dbr.Session {

    db, err := dbr.Open("mysql",
        conf.USER+":"+conf.PASSWORD+"@tcp("+conf.HOST+":"+conf.PORT+")/"+conf.DB,
        nil)

    if err != nil {
        logrus.Error(err)
    } else {
        session := db.NewSession(nil)
        return session
    }
    return nil
}

ここでもdbrというパッケージを利用しています。 なお、認証情報については後述するconfから読み出しています。

conf

echo-sample/conf/config.go at master · eurie-inc/echo-sample · GitHub

役割
  • DBの認証情報やサーバーの接続情報の定義
詳細

ここでは DBの認証情報やサーバーの接続情報の定義しています。
定数は全てここで管理している様です。

const (
    USER     string = "root"
    PASSWORD string = "mysql01"
    DB       string = "sample"
    HOST     string = "192.168.99.100"
    PORT     string = "32769"
)

api

echo-sample/api/member.go at master · eurie-inc/echo-sample · GitHub

役割
  • レスポンスのフォーマットを指定
  • modelのエラーハンドリング
詳細

apiでは、modelの処理が正常に完了すれば、その処理結果をJSONとして返します。
また、処理がエラーになった場合は、HTTPエラーを返します。
実際のデータの取得や更新についてはmodelで行っているようです。

func PostMember() echo.HandlerFunc {
    return func(c echo.Context) (err error) {

        m := new(model.Member)
        c.Bind(&m)

        tx := c.Get("Tx").(*dbr.Tx)

        member := model.NewMember(m.Number, m.Name, m.Position)

        if err := member.Save(tx); err != nil {
            logrus.Debug(err)
            return echo.NewHTTPError(fasthttp.StatusInternalServerError)
        }
        return c.JSON(fasthttp.StatusCreated, member)
    }
}

func GetMember() echo.HandlerFunc {
    return func(c echo.Context) (err error) {

        number, _ := strconv.ParseInt(c.Param("id"), 0, 64)

        tx := c.Get("Tx").(*dbr.Tx)

        member := new(model.Member)
        if err := member.Load(tx, number); err != nil {
            logrus.Debug(err)
            return echo.NewHTTPError(fasthttp.StatusNotFound, "Member does not exists.")
        }
        return c.JSON(fasthttp.StatusOK, member)
    }
}

func GetMembers() echo.HandlerFunc {
    return func(c echo.Context) (err error) {
        tx := c.Get("Tx").(*dbr.Tx)

        position := c.QueryParam("position")
        members := new(model.Members)
        if err = members.Load(tx, position); err != nil {
            logrus.Debug(err)
            return echo.NewHTTPError(fasthttp.StatusNotFound, "Member does not exists.")
        }

        return c.JSON(fasthttp.StatusOK, members)
    }

model

echo-sample/model/member.go at master · eurie-inc/echo-sample · GitHub

役割
  • DBの操作
詳細

/modelは大きく分けると、以下の3つで構成されています。

  • JSONでデータを取り扱う為の構造体
// メンバー(個別)
type Member struct {
    Number    int64  `json:"number"`
    Name      string `json:"name"`
    Position  string `json:"position"`
    CreatedAt int64  `json:"createdAt"`
}
................................................
// メンバー(全件)
type Members []Member
// メンバー保存
func NewMember(member int64, name, position string) *Member {
    return &Member{
        Number:    member,
        Name:      name,
        Position:  position,
        CreatedAt: time.Now().Unix(),
    }
}
  • メンバー取得・保存用の関数
// メンバー保存
func (m *Member) Save(tx *dbr.Tx) error {

    _, err := tx.InsertInto("member").
        Columns("number", "name", "position", "created_at").
        Record(m).
        Exec()

    return err
}

// メンバー取得(個別)
func (m *Member) Load(tx *dbr.Tx, number int64) error {

    return tx.Select("*").
        From("member").
        Where("number = ?", number).
        LoadStruct(m)
}

// メンバー取得(全件)
func (m *Members) Load(tx *dbr.Tx, position string) error {

    var condition dbr.Condition
    if position != "" {
        condition = dbr.Eq("position", position)
    }

    return tx.Select("*").
        From("member").
        Where(condition).
        LoadStruct(m)
}

メンバー取得・保存用の関数では、dbrという外部ライブラリを用いて、dbへアクセスしています。

なお、完全に余談ですが、JSON形式のテキストを、Goの構造体の書式に変換してくれるツールがありました。 mholt.github.io

/db/ddl.sql

echo-sample/db/ddl.sql at master · eurie-inc/echo-sample · GitHub

役割
  • DBの作成
詳細

最後に残ったddl.sqlは、データベース作成用のSQLが記述されています。

CREATE DATABASE IF NOT EXISTS sample;
USE sample;
DROP TABLE IF EXISTS member;
CREATE TABLE IF NOT EXISTS member (
  number    INT PRIMARY KEY,
  name      VARCHAR(255) NOT NULL,
  position  CHAR(2) NOT NULL,
  created_at BIGINT       NOT NULL
);

参考リンク

github.com

echo.labstack.com

AtCoder Beginner Contest 144「D - Water Bottle」(Ruby)

図を描いて三角関数を使えば、実装は比較的簡単 (図をキレイに書くスキルが欲しい・・・)

問題

問題文 高橋君は、底面が 1 辺 a

c m の正方形であり、高さが b

c m であるような直方体型の水筒を持っています。(水筒の厚みは無視できます。)

この水筒の中に体積 x

c m 3 の水を入れ、底面の正方形の 1 辺を軸として、この水筒を徐々に傾けます。

水を溢れさせずに水筒を傾けることができる最大の角度を求めてください。

制約 入力は全て整数 1 ≤ a ≤ 100 1 ≤ b ≤ 100 1 ≤ x ≤ a 2 b

atcoder.jp

考えたこと

こういった図形絡みの問題は、実際に図を描いた方が分かりやすいと思ったので、図を描いてみました。

f:id:ryoutaku_jo:20191027235758j:plain

水の体積と底面の正方形の 1 辺の長さから、水面までの高さを割り出せます。

底面の正方形の 1 辺を軸として水筒を徐々に傾けるので、傾きを考える時は面で考えて良さそう。

傾きを考える時、二つのパターンがありそう。

一つは、最大まで傾けても、水面が底面に達しない場合。

これはコップを傾けたことで水に接している左辺の長さが伸びると、右辺はそれに反比例して縮むことに着目して、各辺の長さを割り出しています。

f:id:ryoutaku_jo:20191027235811j:plain

もう一つは、最大まで傾けてると、水面が底面に達する場合。

これはコップを傾けて水が接している面の形が変わっても、面積は変わらないことに着目して各辺の長さを割り出しています。

f:id:ryoutaku_jo:20191028023141j:plain

それぞれ計算方法が異なるので、条件分岐が必要。

この二つのパターンを三角関数の公式に当てはめれば良さそう。

detail.chiebukuro.yahoo.co.jp

docs.ruby-lang.org

コード

a,b,x = gets.split(' ').map(&:to_i)
 
if b-2*x.to_f/(a*a) <= 0
  puts Math.atan2((b-x.to_f/(a*a))*2, a) * 180 / Math::PI
else
  puts Math.atan2(b, 2*x.to_f/(a*b)) * 180 / Math::PI
end

コード長 244 Byte
実行時間 7 ms
メモリ 1788 KB

AtCoder Beginner Contest 144「C - Walk on Multiplication Table」(Ruby)

平方根の活用がポイント

問題

問題文 高橋君は無限に広い掛け算表の上にいます。

掛け算表のマス ( i , j ) には整数 i × j が書かれており、高橋君は最初 ( 1 , 1 ) にいます。

高橋君は 1 回の移動で ( i , j ) から ( i + 1 , j ) か ( i , j + 1 ) のどちらかにのみ移ることができます。

整数 N が与えられるので、 N が書かれているマスに到達するまでに必要な移動回数の最小値を求めてください。

制約 2 ≤ N ≤ 10 12 N は整数である。

atcoder.jp

考えたこと

1回の移動で (i,j) から (i+1,j) か(i,j+1)とあることから、縦方向か横方向に、それぞれ1マスづつしか動けないことが分かります。

現在地が(1, 1)なので、移動先が1であれば、移動する必要はありません。

つまり移動回数は、(i - 1) + (j - 1)で求めることが出来ます。

そして整数 i×jは、複数の組み合わせが想定されます。

移動回数がiとjの足し算なので、移動回数の最小値を求めるには、出来るだけ互いの値を小さく抑える必要があります。

マスの位置は掛け算で表されるので、どちらかの値を1にして、一方を最大の値にすれば、移動回数も最大になります。

互いの値の差が最大の時に移動回数が最大になるので、互いの値の差が最小になるケースを考える必要があります。

互いの値の差が最小になるので、両方の値が同じ場合です。

平方根{\sqrt{N}}を用いれば、互いの値の差が無い数値を求めることが出来ます。

しかしNは整数なので、平方根の結果が整数にならない場合は、そこから最も違い数値を探す必要があります。

{\sqrt{N}}通りなので、全探索でも対応出来そうです。

コード

n = gets.to_i
a = Math.sqrt(n).to_i

while n % a != 0
  a -= 1
end

b = n / a
puts a + b - 2

コード長 100 Byte
実行時間 45 ms
メモリ 3836 KB

モデルのバリデーションでBoolean型の入力チェックを行う方法(Rails)

結論

  • Boolean型の入力チェックはpresence: trueだと出来ない

  • inclusion:{in: [true, false]}であれば、チェック可能

基本的な入力必須のバリデーション

Railsのモデルのバリデーションにおいて、入力必須のチェックを行う場合、presence: trueが良く用いられます。

class User < ApplicationRecord
  validates :name, presence: true
end

これはテキスト(string型、text型など)や数値(integer型など)などであれば有効です。

しかし、Boolean型(真偽値、true/false)では、正常に機能しません。

Boolean型ではpresence: trueは正常に機能しない

Boolean型のバリデーションにpresence: trueを利用すると、falseが入った時にも、エラーで弾かれてしまいます。

これはpresence: trueが、内部でblank?メソッドを使って、属性の有無を判別しているのが原因です。

blank?メソッドは、オブジェクトがnil, 空文字列('', ' '), 空配列([]), 空ハッシュ ({}), falseのときtrueを返し、1つ以上の要素があればfalseを返すメソッドです。

つまり、falseという値が入っていたとしても、入力されていないと判定されてしまうのです。

inclusionを利用する

Boolean型に対して入力有無のチェックを行いたい場合は、inclusionを利用します。

inclusionは、指定した複数の値の中に、値が含まれているかどうかを検証します。

inclusionで、trueもしくわfalseを指定すれば、falseであってもtrueが返ります。

class User < ApplicationRecord
  validates :is_admin, inclusion: { in: [true, false] }
end

参考リンク

https://railsguides.jp/active_record_validations.html

AtCoder Beginner Contest 142「D - Disjoint Set of Common Divisors」(Ruby)

Rubyって標準添付ライブラリで素因数分解できるんですね

問題

D - Disjoint Set of Common Divisors

実行時間制限: 2 sec / メモリ制限: 1024 MB

配点 : 400 点

問題文 正整数 A , B が与えられます。

A と B の正の公約数の中からいくつかを選びます。

ただし、選んだ整数の中のどの異なる 2 つの整数についても互いに素でなければなりません。

最大でいくつ選べるでしょうか。

考えたこと

まずAとBの公約数を求める必要があります。

調べたところ、Rubyには標準添付ライブラリに素因数分解の処理が用意されています。

require 'prime'
12.prime_division
#=> [[2, 2], [3, 1]]
#2の2乗、3の1乗

18.prime_division
#=> [[2, 1], [3, 2]]
#2の1乗、3の2乗

このように、配列で素因数分解された結果が出力できるので、これで公約数を求めることが出来ます。

あとは二つの数字の素因数分解した結果を見比べて、一致する数を集計するだけです。

なお、今回は「互いに素」ということなので、各配列の先頭部分の素数だけ見れば良いです。

提出コード

思いつくままに書いたので汚い・・・

require 'prime'
 
a,b = gets.split.map(&:to_i)
 
a_f = a.prime_division
b_f = b.prime_division
 
arr = []
delete_f = true
a_f.each do |aa|
  while delete_f do
    b_f.delete_if do |bb|
      if aa[0] == bb[0]
        arr << aa[0]
        delete_f = false
        true
      elsif aa[0] < bb[0]
        false
      else
        true
      end
    end
    delete_f = false
  end
  delete_f = true
end
 
puts arr.size + 1

f:id:ryoutaku_jo:20190929002936p:plain

補足

素因数分解には、Linuxコマンドのfactorコマンドを使用する方法もあります。

RubyLinuxコマンドを呼び出す場合は、バッククォート(`)で括れば良いです

x = 12
`factor #{x}`
#=> "12: 2 2 3\n"

これを使って、もっとシンプルにしたコードがこちらです。

a, b = gets.split.map(&:to_i)
g = a.gcd(b)
s = `factor #{g}`.split[1..-1].map(&:to_i)
p s.uniq.size+1