4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Golangはじめて物語(第10話: RDS Proxy+Aurora(PostgreSQL互換)とうまく付き合う方法)

Posted at

はじめに

以前の記事では、GolangでAurora(MySQL互換)のエンドポイントに直接接続する方法を書いたが、今回はRDS Proxyを経由する方法を考察する。

GolangでRDS Proxy+Aurora(PostgreSQL)互換を使う場合、以下を考慮する必要がある。

なお、本考察においては、この記事の構成を元にして実験をしている。
Aurora(PostgreSQL互換)のエンジンバージョンは12.7だ。

何を気を付けなければいけないのか

色々動作を検証していると、RDS Proxyの仕様とGolangの言語仕様の合わせ技でかなりややこしいことになった。
詳細は↓このような感じだ。

  • RDS Proxyは特性上、Prepared Statementのように接続時に設定を行うようなことをするとピン留めが発生する(参考:公式ドキュメントクラスメソッド先生の記事)
  • RDS Proxyには初期化クエリという、ProxyがAuroraとセッションを張ったときに実行するクエリがあり、そこでPrepared Statementも実行できるが、プログラム言語側からそのクエリを指定して実行することができない
  • Golangはプレースホルダを使ったSQLを発行すると、バックエンドのdatabase/sqlライブラリが勝手にPrepared Statementを実行してしまう仕様がある

もうかなり詰んだ状態である。

database/sqlのチュートリアルの「プリペアドステートメントを避ける」の項では、「都合が悪ければプレースホルダを使わないでリテラルに書いてね」と言っている。

ん?Oracleの世界ではリテラルなSQLはライブラリキャッシュが共有されなかったりハードパースがいちいち走ったりでイケてないが、PostgreSQLではそんなに問題にならないのか?

Aurora(PostgreSQL互換)におけるSQLのパース

以下のテーブルを定義した状態で実験する。

COMPANY=> \d employee
                         Table "public.employee"
  Column   |            Type             | Collation | Nullable | Default 
-----------+-----------------------------+-----------+----------+---------
 id        | character(5)                |           | not null | 
 name      | character(20)               |           | not null | 
 age       | integer                     |           |          | 
 update_dt | timestamp without time zone |           |          | 
Indexes:
    "employee_pkey" PRIMARY KEY, btree (id)

psqlで以下のSQLを実行してみる。

SQL1
select /*+ IndexScan (employee employee_pkey) */ id, name, age from employee where id = '00001';
SQL2
select /*+ IndexScan (employee employee_pkey) */ id, name, age from employee where id = '00002';

これを、pg_stat_statementsのEXTENSIONで確認してみよう。
DB名でpg_stat_statementsを絞り込む場合は以下のような感じで書けばよい。

select * from pg_stat_statements pgstmt, pg_database pgdb where pgstmt.dbid = pgdb.oid and pgdb.datname = '<DB名>';

すると、

select id, name, age from employee where id = $1         

のCallsが2件分カウントされている。どうやら、変数は良い感じにプレースホルダ化してまとめてくれるようだ。賢い。
explain analyzeでリテラル値を変更して実験してみても、1件目と2件目の検索では2件目の方がパースの時間が短く、キャッシュヒットしているであろうことが分かった。

つまり、PostgreSQLにおいては、リテラルなSQLはそれほど問題にならないようだ。
※非常にシンプルなSQLで実験しているので、実際に問題にならないかは、処理パターンを元にしっかりと検証機関に分析をしておこう。

Golangでの書き方

ここまで分かってしまえば、怖がることはあまりない。

ソース構成は以下のようにする。

.
├── db
│   ├── go.mod
│   └── main.go
├── go.mod
└── main.go

DBパッケージは以下のようにする。

db/main.goとdb/go.mod
db/main.go
package db

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

	"github.com/lib/pq"
)

const (
	ErrNoRows = "no rows in result set"
)

var db *sql.DB

func DbInit() (*sql.DB, error) {
	var (
		err error
	)

	dsn := fmt.Sprintf(
		"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
		os.Getenv("DB_HOST"),
		os.Getenv("DB_PORT"),
		os.Getenv("DB_USER"),
		os.Getenv("DB_PASSWORD"),
		os.Getenv("DB_NAME"),
	)
	db, err = sql.Open("postgres", dsn)
	if err != nil {
		log.Println("sql.Open Error.")
		panic(err)
	}
	err = db.Ping()
	if err != nil {
		log.Println("db.Ping Error.")
		panic(err)
	}

	db.SetMaxIdleConns(100)
	db.SetMaxOpenConns(100)
	db.SetConnMaxLifetime(0)

	return db, err
}

func DbClose() {
	if db != nil {
		db.Close()
	}
	log.Println("DB Closed.")
}

func DbConn() *sql.DB {
	return db
}
db/go.mod
module db

go 1.14

require (
	github.com/go-sql-driver/mysql v1.6.0
	github.com/lib/pq v1.10.4
)

このモジュールを以下のように呼び出す。
実際には、request.QueryStringParameters["id"]の検証等はこのブロックまでにしておく。

package main

import (
	"context"
	"errors"
	"fmt"

    "github.com/lib/pq"
	"github.com/aws/aws-lambda-go/events"

	"local.packages/db"
)

type employee struct {
	ID      string
	Name    string
	Age     int
}

func init() {
	_, err := db.DbInit()
	if err != nil {
		panic(err)
	}
}

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    // (中略)

	var employee employee 
	dbconn := db.DbConn()
	queryID := pq.QuoteLiteral(request.QueryStringParameters["id"])  
	err := dbconn.QueryRow(fmt.Sprintf("select /*+ IndexScan (employee employee_pkey) */ id, name, age from employee where id = %s", queryID)).Scan(&employee.ID, &employee.Name, &employee.Age)

    // (以下略)

}

QuoteLiteralしているのは、SQLインジェクション対策である。渡した文字列をいい感じにエスケープしてくれる。
適切に入力のバリデーションチェックをするのであれば、この対策は不要である。

このためにせっかくDBモジュールを作って、DBアクセスライブラリ関連を隠蔽しているのに、QuoteLiteralを使うがためにpqをimportするのは非常にイケていないので、こんな感じで切り出して、db.MakeQuery(……)で呼び出すのもアリだが、フォーマットと引数の数が合わなかったときにlinterがチェックしてくれなくなるので、一長一短だ。

func MakeQuery(format string, vars ...string) string {
	var quotedVars []interface{}

	for _, v := range vars {
		quotedVars = append(quotedVars, pq.QuoteLiteral(v))
	}

	return fmt.Sprintf(format, quotedVars...)
}

これで、Golangのdatabase/sqlモジュールはPrepared Statementを使わなくなるので、心置きなくRDS Proxyを通常通り使うことが可能になる。
※試しに、RDSプロキシの数を減らしたうえで、LambdaがAPI Gatewayにreturnする前にsleepを2秒噛ませて複数回実行したところ、プレースホルダを使う場合は接続上限を超えた実行回数のところでエラーになったが、この書き方であれば特に問題なく処理を続けることができた。もちろん、クエリパラメータのidを変更しても、pg_stat_statementsの統計上で同じSQLになるということも確認済みだ。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?