読者です 読者をやめる 読者になる 読者になる

技術備忘記

日々調べたこととかとりとめなく

GoでORM利用の際のオーバーヘッドについて

はじめに

GoのORM的なライブラリには幾つか種類がありますが、例えばgorpで あるテーブルからレコードを取得するのは以下の流れとなります。

1)以下のようなテーブルと対になる構造体を定義

type TestStruct struct {
    ID    int            `db:"id"`
    Col1  sql.NullString `db:"col1"`
    Col2  sql.NullString `db:"col2"`
    Col3  sql.NullString `db:"col3"`
    Col4  sql.NullString `db:"col4"`
    Col5  sql.NullString `db:"col5"`
    Col6  sql.NullString `db:"col6"`
    Col7  sql.NullString `db:"col7"`
    Col8  sql.NullString `db:"col8"`
    Col9  sql.NullString `db:"col9"`
    Col10 sql.NullString `db:"col10"`
}

(2)テーブルとのマッピングをライブラリに知らせる

dbMap = &gorp.DbMap{
    Db:      db,
    Dialect: gorp.MySQLDialect{"InnoDB", "UTF8"},
}
dbMap.AddTableWithName(TestBinder{}, "test").SetKeys(true, "ID")

(3) 構造体(のポインタ)をinterface{}で受ける。そこにselectの結果をセット

tbl := TestStruct{}
dbMap.Get(&tbl, 1)

他のメジャーどころな物も、大枠一緒な印象です。 ところで、これらの一連の処理は、要件やテーブル構造によっては、パフォーマンスに問題を抱える可能性があります。 原因は、reflection の利用です。

gorpではreflectionを使って、以下の処理を行っています。

  • (2)でinterfaceから構造体の情報を取り出し、fieldのタグ情報からテーブルのカラムとのマッピング
  • (3)でinterfaceから構造体の情報を取り出し、その構造体field一つずつに値のセット

このreflectionの処理がどれくらいのオーバーヘッドになるのか、計測してみました。

また、gorpでは

  • (2)をselectを発行する前にあらかじめセットする方法 (以降 gorp-1)
  • (2)と(3)とを、select時に同時に行う方法 (gorp-2)

があります。後者の方法をとった場合、reflectionの回数が増えるため、よりオーバーヘッドが顕著になることが予想されます。 そちらも合わせてベンチマークを取ってみたいと思います。

ベンチマーク

テストコード

以下3つのテストを用意しました。

  1. BenchmarkFindByID がgorpを使わないテスト
  2. BenchmarkGorpFindByID がgorpを使ったテストで上記gorp-1に該当
  3. BenchmarkGorpFindByIDDynamicMappingがgorpを使ったテストで上記gorp-2に該当
package main

import (
    "database/sql"
    "fmt"
    "os"
    "testing"

    "github.com/go-gorp/gorp"
    _ "github.com/go-sql-driver/mysql"
)

var db *sql.DB
var dbMap *gorp.DbMap

type TestStruct struct {
    ID    int            `db:"id"`
    Col1  sql.NullString `db:"col1"`
    Col2  sql.NullString `db:"col2"`
    Col3  sql.NullString `db:"col3"`
    Col4  sql.NullString `db:"col4"`
    Col5  sql.NullString `db:"col5"`
    Col6  sql.NullString `db:"col6"`
    Col7  sql.NullString `db:"col7"`
    Col8  sql.NullString `db:"col8"`
    Col9  sql.NullString `db:"col9"`
    Col10 sql.NullString `db:"col10"`
}

func init() {
    var err error

    // initialize sql.DB
    host := os.Getenv("MYSQL_HOST")
    database := os.Getenv("MYSQL_DATABASE")
    usr := os.Getenv("MYSQL_USER")
    pass := os.Getenv("MYSQL_PASSWORD")

    if db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s", usr, pass, host, database)); err != nil {
        panic(err)
    }

    // initialize table
    db.Exec(`CREATE TABLE IF NOT EXISTS test(
              id   INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
              col1 VARCHAR(100),
              col2 VARCHAR(100),
              col3 VARCHAR(100),
              col4 VARCHAR(100),
              col5 VARCHAR(100),
              col6 VARCHAR(100),
              col7 VARCHAR(100),
              col8 VARCHAR(100),
              col9 VARCHAR(100),
              col10 VARCHAR(100)
  )`)
    db.Exec("TRUNCATE TABLE test")
    db.Exec(`INSERT INTO test VALUES(
              1,
              '1',
              '2',
              '3',
              '4',
              '5',
              '6',
              '7',
              '8',
              '9',
              '10'
  )`)

    // initialize gorp.DbMap
    dbMap = &gorp.DbMap{
        Db:      db,
        Dialect: gorp.MySQLDialect{"InnoDB", "UTF8"},
    }
    dbMap.AddTableWithName(TestStruct{}, "test").SetKeys(true, "ID")
}

func BenchmarkFindByID(b *testing.B) {
    tb := TestStruct{}
    for i := 0; i < b.N; i++ {
        rows, err := db.Query("SELECT * FROM test WHERE id = ?", 1)
        if err != nil {
            panic(err)
        }
        if !rows.Next() {
            panic(sql.ErrNoRows)
        }
        err = rows.Scan(&tb.ID,
            &tb.Col1,
            &tb.Col2,
            &tb.Col3,
            &tb.Col4,
            &tb.Col5,
            &tb.Col6,
            &tb.Col7,
            &tb.Col8,
            &tb.Col9,
            &tb.Col10)

        if err != nil {
            panic(err)
        }
        rows.Close()
    }
}

func BenchmarkGorpFindByID(b *testing.B) {
    tb := TestStruct{}
    for i := 0; i < b.N; i++ {
        if _, err := dbMap.Get(&tb, 1); err != nil {
            panic(err)
        }
    }
}

func BenchmarkGorpFindByIDDynamicMapping(b *testing.B) {
    tb := TestStruct{}
    for i := 0; i < b.N; i++ {
        if err := dbMap.SelectOne(&tb, "SELECT * FROM test WHERE id = ?", 1); err != nil {
            panic(err)
        }
    }
}

カラム数 11個で測定

go test -bench . -benchmem
PASS
BenchmarkFindByID-5                        10000        198918 ns/op        1776 B/op         46 allocs/op
BenchmarkGorpFindByID-5                    10000        206618 ns/op        2432 B/op         61 allocs/op
BenchmarkGorpFindByIDDynamicMapping-5       5000        276175 ns/op       13104 B/op        592 allocs/op

1 > 2 > 3 の結果になっています。予想通りですね。 カラム数を増やすとどうなるでしょうか。

カラム数 101個で測定

go test -bench . -benchmem
PASS
BenchmarkFindByID-5                 5000        355918 ns/op       14161 B/op        316 allocs/op
BenchmarkGorpFindByID-5                     3000        434415 ns/op       20049 B/op        421 allocs/op
BenchmarkGorpFindByIDDynamicMapping-5        100      11087651 ns/op      699789 B/op      41632 allocs/op

順番は一緒ですが、その差がより顕著になっています。 特に3の爆発っぷりがすごいですね。ここまで差が出るとは正直予想外でした。

終わりに

この結果を受けてORMなんて使っちゃダメだ!などと結論付ける気は毛頭ありません。 ただし、要件によってはこのオーバーヘッドが無視できないケースは確実にあります。

先日あるバッチプログラムの速度を10倍に改善しなくてはならないという 問題に直面しました。

その際に色々対応はしたのですがどうしても一定以上の速度が出ず悩み、 仕方がないので今回のテストコードのように自前でmappingするコードに変えたところ あっさり目標を達成することができました。 (今回の記事を書くきっかけとなりました)

とはいえ、カラム数が増えるとこれを手で書くのは辛いし、コピペミスなどバグの温床にもなるので、 構造体からmappingの処理をいい感じにgo generateしてくれるツールでもそのうち作ろうかなぁと思ってます。

2016.06.15 追記 ツール作りました。よかったら使ってみてください。

GitHub - Jun-Chang/gdbinder: gdbinder is light-weight data mapper for Golang

今回は以上です。