読み取り専用APIをRustとSQLiteで早く安く
こんにちはyutaです。
SQLiteを圧縮できるsqlite-zstdを紹介したいと思います。
なぜSQLite
弊社ではレコメンデーション用のデータベースを構築し、BoostDraftからのAPIを経由してオススメを取得できるようにしています。
当初はDynamoDB上にデータを登録していましたが、推論はバッチで実行/登録されるため、APIからのアクセスは読み取りだけになります。
そこで、データをSQLiteに丸っと投入し、APIサーバーと同梱してコンテナと起動するようにすれば、DynamoDBのコストを抑えながらついでにレイテンシも改善するのではないかと考えたわけです。
このワークロードの特徴をまとめるとこんな感じです。
- バッチ処理でデータを作成、APIは読み取りのみ
- データの中身は、特定のアイテムのIDをキーとして、キーと相関の高いアイテムのIDのリスト
- 検索クエリはアイテムのIDによる完全一致のみ
なぜsqlite-zstd
最初にLambdaにコンテナとしてデプロイすることを試しました。
しかしLambdaのコンテナサイズの上限は10GBです。わたしたちのSQLiteファイルのサイズは12GBです。惜しくも足りません。
圧縮できたらいいのになと検索してみたところ[sqlite-zstd] SQLiteのextensionをつかって透過的なデータの圧縮というジャストミートな記事が一番上に出てきました。クラスメソッドさんいつも助かっております。しかもこれRustで書かれています。Rustに愛されていることを再確認しました。
圧縮だ!
早速記事を参考に圧縮してみました。
$ ls -lh this-is.db
-rw-r--r-- 1 alu staff 12G Oct 30 09:41 this-is.db
$ sqlite3 this-is.db
sqlite> .load /tmp/libsqlite_zstd.dylib
sqlite> sqlite> SELECT zstd_enable_transparent('{"table": "this_is_table", "column": "this_is_column", "compression_level": 19, "dict_chooser": "SUBSTR(this_is_column, 1, 1)"}')
sqlite> SELECT zstd_incremental_maintenance(null, 1)
sqlite> VACUUM
$ ls -lh this-is.db
-rw-r--r-- 1 alu staff 1.7G Oct 30 18:58 this-is.db
すごい!元の大きさに対し14%くらいになりました。しかし時間はすごくかかりました。9時間くらいかかりました。
圧縮するってことは伸長するってことだから、結局元のサイズのストレージを確保する必要があるのでは?と思いますよね。安心してください、行レベル圧縮ですよ。
アプリケーションからは透過的に使えてSQLに小細工は必要ないですし、パフォーマンスも良好(ランダムリードなら素のデータベースより早い場合もある)です。
Lambdaにデプロイ
気を取り直してLambdaにデプロイしてみました。
ところが最初に届いたリクエストのレスポンスが遅く、調査してみるとコネクションの確立に時間がかかっているようでした。2回目以降のリクエストは問題ありません。
ローカルでは再現せず、Lambda特有の挙動なのか、あるいはローカルではディスクキャッシュのようなものが効いているのか、特定するには至りませんでした。
Lambdaは特性上、実行環境が初期化される機会が多くなるので、1回目から素早く応答できないとサービス全体のパフォーマンスが低下してしまいます。
AppRunnerにデプロイ
次に目をつけたのがAppRunnerです。AppRunnerはコンテナを起動しっぱなしにできるので、同様の問題があっとしても全体としてはパフォーマンスに問題は出にくいです。
ちなみにLambdaのコンテナサイズの上限が10GBであるのに対し、AppRunnerは3GBです。そこのところをお気をつけください。
検証したところAppRunnerもLambdaと同様にコネクションの確立に時間がかかっているようでした。対応策として、サーバーがリッスンする前にコネクションを確立させることで、最初のリクエストからキビキビ応答させることができるようになりました。
やだ。。。メモリリークしてない。。。?
AppRunnerでの挙動は理想的なものでした。しかしべつのエンジニアからAPIが時々エラーを返すと報告を受けます。
メトリクスを見てみると、時間の経過とともにメモリ使用量が増えていって、最終的にOOMKillされているようです。
メモリリークです。目を背けたいです。
しばらく目を向けた後ローカルで再現できないか試してみました。幸運なことに再現できました。これは本当に幸運なことです!
SQLiteの操作にはsqlxを使用しています。sqlxはコネクションプーリング機能があり、一定期間使用されていなかったり、作成して一定期間経ったコネクションを破棄して新しいコネクションを作成してくれます。昔は繋ぎっぱなしだとサーバー側でメモリリークが起きたりしていたので、時々破棄してあげる文化が残っています。
メモリ使用量の増加のタイミングをみていると、コネクションが破棄され、新たなコネクションが作成されたタイミングで増加しています。言い換えると、破棄されたコネクションの掴んでいるメモリを解放していないような挙動です。
sqlxは内部的にlibsqlite3-sysを使用しています。そうなってくるともうどこに問題があるのか追いかけるのには大変な労力が必要なります。なので追いかけないことにしました。
というわけで 1コネクションが消費するメモリ * プールサイズ
からメモリの割り当てサイズを計算して、コネクションが永久に破棄されないようにして運用しています。sqlxはサーバーはありませんので、これで問題ないのです。
おしまい
データベースサイズとの格闘が長くなってしまいましたが、めでたく当初の目標を達成することができました。
具体的な金額は秘密ですが、DynamoDBのコストがマルっと無くなったのは大きいです。
レイテンシについては1/10くらいまで低くなりました。「十分の一」というワード、たまんないですねぇ!
レイテンシ
読み取り専用のデータベースとしてSQLiteは非常に有用です。おっきすぎたらsqlite-zstdがちっさくしてくれます。コンテナに同梱すればスケーラビリティも抜群です。通信も発生しないので外部の障害に影響されにくいです。
有効なシーンは多くはないと思いますが、だいたい一社に一個くらいこういうのありますよね。そういう時にお試しあれ。
Discussion