CARTA HOLDINGSのSunriseインターンに参加しました

ぺりー
35 min readNov 27, 2021

--

『大規模リクエストを捌きつつ、安定して価値を出し続ける広告プラットフォームを構築せよ!』

1秒間に数万リクエストを捌き、広告配信システムの安定運用に携わるエンジニアから、大規模リクエストを捌くために必要な設計・構築について学べます。

手を動かしながらWebサービスを作りはじめ、スケールしながら安定して価値を出せるシステムを設計していきます。

GolangのアプリケーションサーバーをAWSにデプロイし、サーバー強化により大量のリクエストを捌けるよう改修していただく実践的なインターンです。

会員数700万人以上の大規模サービスや、 月間数百億インプレッションを誇る広告配信プラットフォームを運営する

VOYAGE GROUPならではのスキルやノウハウを余すことなく伝授します。

期間

10月23日(土) ・24日(日)・30日(土)・31日(日):オンライン
11月6日(土):VOYAGE GROUP 渋谷オフィス

環境

AWSのAdminアカウント、シンプルな計測用サーバ、負荷計測ツール、Redash

目標

30000rpsを捌く

条件

コストはあまり考えなくて良い
インスタンス自体のスケールアップは禁止
(スペックを上げて高負荷に耐えているようでは、すぐに限界が来てしまうため)

流れ

開発は4人1チームで行い、一定時間(1.5h程度)ごとに全体のチームを通して、どのようなアプローチを行なったか、そのアプローチによる想定インパクト、モニタリング項目、ネクストアクションを発表、FBという流れでした。
1日1回サポーター(人事1人+エンジニア1人)と面談する時間も設けられていました。

構成

インフラ構成図

Goによるアプリケーションサーバ
実装されていたのは、以下のリクエストを受け付け、MySQL(Amazon RDS)に保存するシンプルな計測用アプリケーションサーバでした

curl -XGET example.com?name=hoge&value=1

なぜパフォーマンス改善をするのか

  • UXの向上
  • 一般的に3秒以下でwebページは表示されることが期待される
  • サービスの継続
  • Google: ページ反応が0.5秒遅くなるとアクセス数は20%減少
  • Amazon: ページ表示速度が0.1秒遅くなると売り上げが1%低下
  • 単位時間あたりに処理できるリクエスト数の増加はCPの改善に繋がる

パフォーマンス改善に絶対解はなく、技術者は最も重要と思われる観点での最適化のために妥協点を探る必要がある。
さらにソフトウェアを完全に最適化するのに要する労力はその最適化されたシステムを利用することで得られる利益よりも大きい。
幸い、効果の大きい改善は最適化工程の初期に現れれることが多い。

  • SLA: Service Level Agreement
    サービス品質保証。サービスを顧客に提供する際の契約
    達成できない場合に補償等が発生する場合もある
    例: ダウンタイムの年間上限
    99.999%(ファイブナイン)年間5分以上のダウンタイムは不可で月間なら25秒以上
  • SLO: Service Level Objective
    サービス品質目標。許容された停止時間・停止頻度・回復までの時間
    MTBF(Mean Time Between Failure)やMTTR(Mean Time To Recovery)をエラーバジェットの内に抑えること
    どの程度のダウンタイムであれば許容されるのかがわからなければ、問題が重大かどうかわからないし、ユーザの期待値もコントロールできない

必要とされるだけ可用性が高くなるようにシステムを設計せよ。ただし、それ以上のシステムは必要ない。

ポイント

  • システムのパフォーマンスを上げる
  • 可観測性を高める施策
  • チームのパフォーマンスを上げる
  • カナリアリリース

インターンで行った施策

too many connectionsの改善

まずは目標の1/3である10000rpsを5minで負荷をかけてみると、too many connectionsが多発して3secで負荷試験が中断されてしまいました。
アプリケーションとDBとの接続が多すぎて新たに接続を張れないエラーだったので、リクエストごとにsql.Open(driverName, dataSourceName string)(*DB, error)をhandlerの外に出し、一度だけ呼び出すように修正しました。(一度呼び出されればいいので)
これによって10000rpsの5minの負荷試験は中断されることは無くなったのですが、28%はTOを起こし、connection reset by peerというエラーが散見されるようになりました。

Open may just validate its arguments without creating a connection to the database. To verify that the data source name is valid, call Ping.

The returned DB is safe for concurrent use by multiple goroutines and maintains its own pool of idle connections.
Thus, the Open function should be called just once. It is rarely necessary to close a DB.
ref: https://pkg.go.dev/database/sql#Open

connection reset by peerの改善

too many connectionsは見られなくなったものの、sql.Openではコネクションを張っているわけではないので、クリティカルに効く改善にはなっていませんでした。
そのため、ピアにより接続が強制的にクローズされるエラーconnection reset by peerが発生しました。
通常、TOや再起動による接続の切断が原因なので、コネクションを張りすぎて待ちが発生し、TOされてしまったと考えられます。

Amazon RDS側でコネクションの最大値を引き上げても、一時的にしか引き上がらない上に、RDSが不安定になる恐れがあるため(インスタンスタイプに応じたデフォルトの設定が推奨されている)、アプリケーション側で対処する必要がありました。(そもそもコネクションをプールしない限り根本的にこの問題は解決できないが)

そもそも接続を張ること自体が時間・メモリを消費するコストの高い操作です。
そこでオーバーヘッドを減らすためにコネクションプールを実装し、以下の3つのパラメータを調整しました。
(Goのsql/databaseパッケージではsql.DBが接続の保持・管理を行なっており、DBへの操作が行われる時にまずidleな接続を使い、なければ新規に接続するという挙動。)

func (db *DB) SetConnMaxLifetime(d time.Duration)
func (db *DB) SetMaxIdleConns(n int)
func (db *DB) SetMaxOpenConns(n int)

設定前はSetMaxOpenConns(n int)がデフォルトで0=unlimitedなので最大接続数が無制限で、SetMaxIdleConnsで設定されるデフォルトの保持接続数が2でした。そのため、同時に2以上の接続が必要になると新たに接続を作り続けてしまっていました。

そこでSetMaxIdleConnsである程度の接続数を保持し、最大接続数を設定することでtoo many connections & connection reset by peerの根本的な解決ができると考えました。

中でもSetMaxIdleConnsSetMaxOpenConns以上の値に設定することで、idleな接続の開放をSetConnMaxLifetimeに任せることができるため、MaxOpenConns=<MaxIdleConnsになるように設定しました。
また、idleが長時間続く接続は、メモリを消費する以外にも問題を引き起こすのでSetConnMaxLifetimeで接続に寿命を持たせるようにして対処しました。
ここはSetConnMaxLifetimeSetMaxIdleTimeのどちらかでいいです。

さらに、MySQLへのコネクションは計測サーバだけでなく、redashからも接続されていたことに注意し、それを加味して数値を設定しました。
(計測サーバでMySQLのコネクションを上限まで張ってしまうとredashからアクセスできなくなる)

まずは最大コネクション数を
$ SHOW GLOBAL VARIABLES LIKE ‘max_connections';
で把握したのち下記の文献を参考にパラメータを変更していきました。

func (db *DB) Stats() DBStatsを使って、DBの統計情報をログに出力して進めていきました。

実際の接続数はCloudWatchのRDSのメトリクスからも、サーバー上からも確認できます。

ss -tnp | grep 3306

一般論として、より多くの接続をプールしたり長期間使い回したりする方がスループットは向上する一方、あまりに増やしすぎるとメモリ使用量が増加したり、一部のセッションのレスポンスタイムが極端に劣化したりする恐れがある。
ref: https://please-sleep.cou929.nu/go-sql-db-connection-pool.html

また、ConnMaxLifetime()を設定する場合、接続が期限切れになると再作成されるため、その頻度を念頭に置くことが重要です。例えば、総接続数が 100 で ConnMaxLifetime が 1 分の場合、アプリケーションは 1 秒ごとに最大 1.67 個の接続を kill して再作成する可能性があります (平均して)。この頻度は、パフォーマンスを向上させるどころか、最終的にはパフォーマンスの妨げになるほど大きくなることは避けたいものです。
ConnMaxLifetime を比較的短く設定するとよいでしょう。しかし、接続が不必要に頻繁に殺されたり再作成されたりするような短い設定にしないようにしましょう。
ref: https://tutuz-tech.hatenablog.com/entry/2020/03/24/170159

As a rule of thumb, you should explicitly set a MaxOpenConns value. This should be comfortably below any hard limits on the number of connections imposed by your database and infrastructure.

In general, higher MaxOpenConns and MaxIdleConns values will lead to better performance. But the returns are diminishing, and you should be aware that having a too-large idle connection pool (with connections that are not re-used and eventually go bad) can actually lead to reduced performance.

To mitigate the risk from point 2 above, you may want to set a relatively short ConnMaxLifetime. But you don't want this to be so short that leads to connections being killed and recreated unnecessarily often.

MaxIdleConns should always be less than or equal to MaxOpenConns.

For small-to-medium web applications I typically use the following settings as a starting point, and then optimize from there depending on the results of load-testing with real-life levels of throughput.
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5*time.Minute)

ref: https://www.alexedwards.net/blog/configuring-sqldb

TimeOutを解消する

上の施策でコネクションを再利用するようになった結果、一定の時間が経つと、TimeOutが一気に増える傾向が見えてきました。
そこで出力したDBの統計情報のログを見てみると、WaitCountが溜まり、WaitDurationが長くなっているのがわかりました。

[DEBUG] db.Stats(): {MaxOpenConnections:30 OpenConnections:30 InUse:30 Idle:0 WaitCount:115713 WaitDuration:124h35m46.969863007s MaxIdleClosed:0 MaxIdleTimeClosed:0 MaxLifetimeClosed:31}

MySQLにInsertする際に、
func (db *DB) Exec(query string, args …interface{}) (Result, error)
でTimeOutが発生しているようでした。
(func (db *DB) Prepare(query string) (*Stmt, error)はコネクションにバインドされたPrepared Statementを作成しているだけで、実行する際にコネクションが空いていなければ、再度Prepareし、実行する)

otherwise we make a prepared statement that’s bound
to a connection, and to execute this prepared statement
we either need to use this connection (if it’s free), else
get a new connection + re-prepare + execute on that one.
ref: https://cs.opensource.google/go/go/+/refs/tags/go1.17.3:src/database/sql/sql.go;l=1551;drc=refs%2Ftags%2Fgo1.17.3

そこでInsert待ちを解消するために、非同期のBulk Insertを実装しました。

今まではリクエストを受けるたびに都度Insertしていましたが、インメモリにある程度貯めてから非同期でBulk Insertするようにしました。
そうすることで一度にまとめてクエリを投げるので大幅にコネクション数、クエリ発行数をともに減らし、DBの負荷を下げることができる上に、リクエストからレスポンスの間にDBとの通信がなくなるため、処理時間を短縮できて、RPSが上がると考えました。
以下がサンプルの実装コードです。

type Cache struct { 
Mux sync.RWMutex
Items []*Item
}
type Item struct {
Now time.Time
Name string
Value int64
}
ticker := time.NewTicker(Duration)
// TODO: goroutineの数を制御する
go func() {
for {
<-ticker.Ccache.Mux.Lock()
itemsLen := len(cache.Items)
copiedItems := make([]*Item, 0, itemsLen)
copiedItems = append(copiedItems, cache.Items...)
cache.Items = cache.Items[:0]
cache.Mux.Unlock()
for offset := 0; offset < itemsLen; offset += Limit {
end := offset + Limit
if itemsLen < end {
end = itemsLen
}
query := "INSERT INTO TABLE(name, value) VALUES" + strings.TrimRight(
strings.Repeat("(?, ?, ?),", end-offset), ",",
) // 最後にスライスに残った余り
stmt, _:= db.Prepare(query)
defer stmt.Close()
args := make([]interface{}, 0, itemsLen)
for _, v := range copiedItems[offset:end] {
v := v
args = append(args, v.Now.String(), v.Name, v.Value)
}
_, _ = stmt.Exec(args...);
if end == itemsLen {
break
}
}
}
}()
handler := func(w http.ResponseWriter, r *http.Request) {
cache.Mux.RLock()
cache.Items = append(cache.Items, &Item{
Now: time.Now(),
Name: name,
Value: value,
})
cache.Mux.RUnlock()
}

結果は、同条件でTOがなくなり、リクエスト数とDBのレコード数の整合が取れていました。

ここではリクエスト数のばらつきによってレイテンシが高くならないようにtime.Ticker を用いて時間の制御によるInsertを行なっています。
しかし、Prepared Statementはクエリの実行プランの一部をキャッシュするため、固定数でInsertできる方がパフォーマンスが良かったかもしれないと思いました。

エラーハンドリングのやり方は、以下のようにエラーをchannelに詰めて、リトライすることもできますが、
バックオフなど考えることが多いので、Amazon SQSなどのキューイングサービス使ってリトライさせるのが良いと思います。

type Cache struct {
Mux sync.RWMutex
Items []*Item
RetryCh chan *Failure
}
type Failure struct {
Item *Item
Err error
}
func main() {go func() {
for failure := range <-cache.RetryCh {
if errors.As(failure.Err, &TemporaryErr) && TemporaryErr.IsTemporary() {
cache.Mux.Lock()
cache.Items = append(cache.Items, failure.Item)
cache.Mux.Unlock()
} else {
// ログを出したりコンテキストでキャンセルしたり
}
}
}()

非同期でBulk Insertする実装のデメリットはリクエスト即レポートではなくなることやサーバが状態を保ってしまうことで、デーモンが異常終了したり、EC2インスタンスが終了すること(インスタンスの入れ替えとか)でエラー時のケアが大事になります。

Insert中やキューにデータがある状態の時に終了すると、インメモリに溜まったリクエストが永続化されないため、終了させないようにする必要があり、同時にリクエストを受けられる状況にあるとキューがいつまでも解消されなくなってしまいます。
今回ここまで手は回らなかったのですがgraceful shutdown/restartを行う必要があります。

[Service]
# 終了
ExecStop=/bin/kill -HUP $MAINPID

終了時はsystemdがHUPシグナルをプロセスに送信するように設定されていたので、os/signalパッケージのNotifyでシグナルを受け取ることができます。
正常終了の実装のサンプル

go func() {
log.Fatal(srv.ListenAndServe())
}()
// シグナルを待つ
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGHUP)
<-sigChan
// TODO: Queue解消// 5秒以内(ALBと相談)にHTTPサーバを終了
ctx, _:= context.WithTimeout(context.Background(), 5*time.Second)
_ = srv.Shutdown(ctx)
os.Exit(0)

ただし、シグナルを受けて終了してもALBのヘルスチェック失敗の閾値を超えないとリクエストが飛んできてしまうので、一定時間待ってからALBから外れることを期待して終了するなど工夫が必要です。
異常終了のケアとしてはプロセスの再起動はsystemdに任せプロセスを入れ替える処理を実装してreload処理ができるようにするなどがいいらしいです(講評から)。

[Service]
Restart=always

30000rps 5minだとTOが再発してしまう

非同期のBulk Insertを実装したことで30000rps 30sの負荷試験をパスすることができましたが、5minで行うとTimeOutが再発してしまいました。
また、レスポンスを返す最速値と最遅値がそれぞれ142ms, 3264msでかなり差が開いていました。
そこで一度にBulk Insertする数を増やす対応と、キャッシュ保持時間を伸ばす対応を行ってみました。

プレースホルダーの数を限界(65535)まで引き上げましたが、結果は改善されず、キャッシュをコピーする時間にMutexLockしているのがボトルネックとなっていることを改めて確認しました。

channelを使用してリクエストをインメモリに貯める

handlerがレスポンスを返す際にロック待ちでTimeOutになっていると予想されたため、リクエストからキャッシュを直接操作するのを改め、リクエストをchannelで送信して、キャッシュに貯めることで、handlerではロック待ちをすることがなくなるように修正しました。
channelのバッファを十分にとれば、送信の待ちが発生しないはずです。
以下がサンプルの実装です。

items := make([]item, 0, CacheCap)
cache := Cache{
Items: items,
}
ch := make(chan item, CacheCap)
go func() {
for {
select {
case item := <-ch:
cache.Mux.RLock()
cache.Items = append(cache.Items, item)
cache.Mux.RUnlock()
case <-ticker.C:
// 略
}()
// handler内
ch <- Item{
Now: time.Now(),
Name: name,
Value: value,
}

too many open filesの解消

channelを用いた実装後から、以下のエラーが新たに出るようになりました。

http: Accept error: accept tcp [::]:8081: accept4: too many open files; retrying in 320ms

Linuxでプロセスが開けるファイルディスクリプタ(プロセス作成時にfd:0(標準入力)fd:1(標準出力)fd:2(標準エラー出力)は必ず作られ、3以降は汎用としてプログラムが任意に利用できる)の上限に達してしまうと発生するエラーでした。
Goのchannelは型付きのコンジット(=名前付きパイプ?)で単方向通信されているためファイルディスクリプタの上限に達したようです。
(Linuxでは全てがファイルとして表現され、通信もファイルなのでファイルディスクリプタを使います。なので以前より通信が多くなりファイルディスクリプタの数が多くなりすぎてしまうようです。)

Channels are a typed conduit through which you can send and receive values with the channel operator, <-.
ref: https://tour.golang.org/concurrency/2
ref: https://www.youtube.com/watch?v=uqjujzH-XLE&t=4499s

そこでプロセスがオープンできるファイルディスクリプタの上限をカーネルの上限値($ sysctl fs.nr_open)に変更しました。(/proc/sys/fs/file-maxでも確認できる)

ここでハマったのが$ ulimit -nで現在の上限数が把握し、それを変更しても、ログインユーザのセッション内でのみ変更が適用されていて、サーバが起動しているセッションでは変更されていないということでした。
正しく実行するにはプロセスIDを取得した後、プロセス毎に上限を引き上げる必要がありました。

// カーネルの上限値を表示
$ sysctl fs.nr_open
// サービスのプロセスIDを検索
$ ps aux | grep {$name}
root 12356 4.7 4.7 2586112 181632 ? Ssl 11:37 0:47 /opt/name/bin/name
ssm-user 12512 0.0 0.0 119420 904 pts/3 S+ 11:54 0:00 grep name
// 現在の設定を確認
$ cat /proc/{$processID}/limits
Limit Soft Limit Hard Limit Units
Max cpu time unlimited unlimited seconds
Max file size unlimited unlimited bytes
Max data size unlimited unlimited bytes
Max stack size 10485760 10485760 bytes
Max core file size 0 unlimited bytes
Max resident set unlimited unlimited bytes
Max processes unlimited unlimited processes
Max open files 65535 65535 files
Max locked memory unlimited unlimited bytes
Max address space unlimited unlimited bytes
Max file locks unlimited unlimited locks
Max pending signals 30446 30446 signals
Max msgqueue size 819200 819200 bytes
Max nice priority 0 0
Max realtime priority 0 0
Max realtime timeout unlimited unlimited us

systemdのサービスとして起動したプロセスのリソース制限値には”/etc/security/limits.conf"内で設定している制限値が適用されないので(Linuxの認証を通る際に適用されるものだから)/etc/systemd/system/name.serviceでディレクティブを設定する必要がありました。

[Service]
LimitNOFILE=1048576

ref: https://alis.to/haruki3/articles/3NjYE6VEzWvM

デーモンを再起動した後に別セッションで入ると、設定が更新されているのが確認できたため、再度負荷試験を行ったところ、too many open filesエラーは出なくなりました。
しかし、30000rps 30sでも以前のchannelを使用せずにインメモリにキャッシュする方がパフォーマンスが良かったため、残り時間と相談したところ30000rps 5minだと改善は見込めないと考え、切り戻す対応を行ないました。

net/httpでは接続があるたびにgoroutineとしてhandlerが上限なく起動するため、同時に受け付ける接続が増えるとファイルディスクリプタを食い尽くしていきます。

上のパッケージを使用することで同時接続数を制限することができ、ファイルディスクリプタを食い潰すのを制御できます。(ただし同時に扱える接続数を超えたリクエストが来た場合は待ちが発生してしまうため、パフォーマンスが劣化してしまいます(TimeOutに間に合えば200で返せる))

今回、ファイルディスクリプタ数に対して支配的な接続はALBとEC2間の接続になるので、この間のHTTP接続はTCP KeepAlive(通信しているお互いが生きているかをチェックするやつ)によって使いまわすことができるらしいです。
net/httpではデフォルトで有効であり、HTTP1.1でやりとりしていると、1つのTCP接続を使いまわし複数のHTTPのやりとりを行えます。

net/httpのデフォルトでは以下のようにTCP接続をタイムアウトしないです。

type Client struct {
// 中略
// Timeout specifies a time limit for requests made by this
// Client. The timeout includes connection time, any
// redirects, and reading the response body. The timer remains
// running after Get, Head, Post, or Do return and will
// interrupt reading of the Response.Body.
//
// A Timeout of zero means no timeout.
//
// The Client cancels requests to the underlying Transport
// as if the Request's Context ended.
//
// For compatibility, the Client will also use the deprecated
// CancelRequest method on Transport if found. New
// RoundTripper implementations should use the Request's Context
// for cancellation instead of implementing CancelRequest.
Timeout time.Duration
}

そのため、以下のようにServerに対してタイムアウトを設定する必要があります。

srv := &http.Server{
ReadTimeout: 5*time.Second,
WriteTimeout: 10*time.Second,
IdleTimeout: 120*time.Second,
Handler: mux,
}

タイムアウトを設定したい場合はALBのアイドルタイムアウトよりも長くなるように設定しなければなりません。(推奨は20秒)
Client <-> ALB <-> EC2の間はALBのタイムアウトの設定によりKeepAliveされます。(デフォルトは60秒)
これよりも短くするとTCP接続が都度発生して無駄になったり、ALBがEC2との接続に問題があるとき、504エラーが発生するようになるみたいです。

使い終わったTCP接続はTIME WAITの状態で積み上がり、一定時間(通常2分、デファクト1分)の後に解放されると同時に、ファイルディスクリプタから消されます。
このTIME WAIT状態のソケット数には上限があり、Linuxではカーネルパラメータで設定されていて、それを超えるとソケットを安全に閉じれなくなり、不具合が生じるらしいです。

// メモリと相談して引き上げられる
$ sysctl net.ipv4.tcp_max_tw_buckets
net.ipv4.tcp_max_tw_buckets = 8192

TIME WAIT状態のソケットを使いまわすにはtcp_tw_reuseオプションを使えばいいらしく、これを有効にすると、TIME WAIT状態のソケットが1秒以上後で再利用されるようになるらしい。

$ sysctl -w ipv4.tcp_timestamps="1"
$ sysctl -w net.ipv4.tcp_tw_reuse="1"

TimeOutを0にする

EC2をスケールアウトさせ10台構成でリクエストを捌くようにしました。30000rpsはスパイクアクセスではないという想定だったため、初期から10台の構成にした結果、30000rpsは達成することができました(当たり前)

レイテンシを計測する

ここでリクエストから永続化されるまでどの程度の遅延が生じているのかを正確に把握するため(今までは肌感だったので)、リクエストを受けてからInsert完了までの時間の最大値と最小値をインメモリに溜めてログ出力するようにしました。

type LatencyMeasure struct {
Mux sync.Mutex
Score map[string]time.Duration
}
ltc := LatencyMeasure{
Score: map[string]time.Duration{
"min": LatencyDefaultMin,
"max": LatencyDefaultMax,
},
}
go func() {
// 略
execTime := time.Now()
min, max := LatencyDefaultMin, LatencyDefaultMax
for _, v := range copiedCache[offset:end] {
sub := execTime.Sub(v.Now)
if min > sub {
min = sub
}
if max < sub{
max = sub
}
}
ltc.Mux.Lock()
if ltc.Score["min"] > min {
ltc.Score["min"] = min
}
if ltc.Score["max"] < max {
ltc.Score["max"] = max
}
ltc.Mux.Unlock()
// 略
}()
latencyTicker := time.NewTicker(LatencyTickerInterval)
go func() {
for {
<-ticker.C
ltc.Mux.Lock()
// TODO: logging
ltc.Score["min"], ltc.Score["max"] = LatencyDefaultMin, LatencyDefaultMax
ltc.Mux.Unlock()
}()

その結果、30000rps 5minの負荷試験の場合、DBの反映までに最大7分かかることがわかりました。

レイテンシの改善

背景として、CPU使用率が100%でロードアベレージが高いため、RDSのスペックを骨の髄まで吸い尽くしていました。
(Bulk InsertのおかげかIOPSには余裕がありました。)

アプリケーション側ではプレースホルダーを限界値である65535まで張っていました(プレースホルダーが3つあるため65535/3)。

WriteIOPSが高くてメモリの使用率に余裕があるのでBulk Insertでディスク書き込みが多くレイテンシが高いのではないかという仮説を立てました。(コネクションは一つで済むが、Single Insertするのと同じ単位でauto commitが走っているのではないか=INSERT INTO table VALUES (?, ?, ?), (?, ?, ?)...;(COMMIT;)INSERT INTO table VALUES (?, ?, ?); COMMIT;...として展開され、ディスク書き込みはSingle Insertと変わらないのでは?ということ)

Use the multiple-row INSERT syntax to reduce communication overhead between the client and the server if you need to insert many rows:

結果、この仮説は全くの誤りで、Bulk Insertはディスクアクセスを最低限に抑えるようになっていて、実際にSET AUTOCOMMIT=0;で試してもパフォーマンスの改善はありませんでした。
(MySQLはデフォルトでAUTOCOMMITモードで動作していて、トランザクションを明示的に開始しない限り、クエリはそれぞれ別のトランザクションで行われる。上記の設定だと、COMMITまたはROLLBACKするまでずっとトランザクションの中にいることになる。)

Instead of writing each key value to B-tree (that is, to the key cache, although the bulk insert code doesn’t know about the key cache), we store keys in a balanced binary (red-black) tree, in memory. When this tree reaches its memory limit, we write all keys to disk (to key cache, that is). But since the key stream coming from the binary tree is already sorted, inserting goes much faster, all the necessary pages are already in cache, disk access is minimized, and so forth.

そのほかにやったこと

  • CIを整備し、コードの品質の担保を行なった
  • ログにIssue番号のプレフィックスがつくようにして、CloudWatchのログからトレースしやすいようにした
  • System Managerで全インスタンスに1つの操作でデプロイが完了するようにした
  • handlerでpanic時にリカバリーできるようなミドルウェアを噛ませた

やらなかったこと

  • SQSやKinesisなどのキューイングサービスとAWS LambdaやAWS Batchを用いてリクエストを捌くこと
  • Amazon ElastiCacheを使って実装すること(バッチ用インスタンスを立てればスケールアウトできる)
    以上は実務インターンで実装したことがあったので今回は実装しませんでした。

最後に

このインターンに参加した目的の死なないインフラの知識を身につけることができて本当によかったです。また、MySQLの内部実装に興味が湧いたので、もっともっと勉強したいと思うようになりました。
RDS Proxyを使うとLambdaのような都度起動するものでもコネクションプールしてくれるらしいのでそれも使ってみたいと思います。
あと、焼肉弁当が美味しかった!!

焼肉弁当

--

--

ぺりー
ぺりー

Written by ぺりー

Satoru Kitaguchi. Backend Engineer at eureka, Inc.

No responses yet