はじめに #
数 KB の Parquet を 5 ファイル読むだけのクエリが、100ms かかっていた。
サーバーレスで GraphQL API を組んでいたときの話です。常時起動の DB は持たず、S3 に置いた Parquet を DuckDB で直接クエリして GraphQL で返す——「アイドルゼロでコストを抑える」構成です。動いた。でも、遅い。しかもデータは笑えるほど小さい。
「S3 が遅いんだろう」と当たりをつけて手を打った結果、見事に空振りしました。そこから測り方を変えて、ようやく真犯人にたどり着く。最終的にウォームのレイテンシは 100ms → 10ms(約10倍) になりましたが、効いたのは最初に疑った場所ではありませんでした。
この記事は、その回り道も含めた記録です。コードはこちら → https://github.com/kiitosu/graphql-duckdb-s3
やりたかったこと #
スタックは GraphQL Yoga + DuckDB(@duckdb/node-api) + S3。これを AWS Lambda(コンテナ + Lambda Web Adapter) に載せます。
GraphQL クライアント
↓
GraphQL Yoga(Lambda 上、LWA 経由で既存 HTTP サーバをそのまま起動)
↓ 薄いリゾルバ(GraphQL → DuckDB SQL)
DuckDB(同じ Lambda 内) read_parquet('s3://...')
↓
S3(Parquet, dt パーティション)
LWA(Lambda Web Adapter)のおかげで、既存の Node HTTP サーバをほぼ無改造で Lambda に載せられます。サーバーは「ただの HTTP サーバ」のまま、Lambda Runtime API との通訳は LWA が肩代わりしてくれる。Function URL は IAM 認証にして、署名なしリクエストは起動前に弾く(匿名アクセスも invoke 課金も発生しない)。
…という構成図を描いている分には気分がよかったのですが、現実はそう甘くありませんでした。
第一の敵:そもそも動かない、そして15秒のコールド #
DuckDB を Lambda コンテナに入れると、S3 読み取りがことごとく INTERNAL_SERVER_ERROR。GraphQL 層がエラーを丸めるので、CloudWatch の stderr を覗くまで原因が見えません。覗いて分かった「動かす前提」は3つでした。
HOME=/tmp:Lambda の既定$HOME(/home/sbx_userNNNN)は存在せず書き込めない。DuckDB の拡張 autoinstall がここでCan't find the home directoryで死ぬ。ca-certificatesを入れる:node:slimは CA 証明書を持たず、拡張(httpfs/aws)の HTTPS 取得が TLS 検証で落ちる。- glibc ベース + コンテナ内
npm ci:DuckDB は glibc 必須。linux/arm64でビルドしコンテナ内でnpm ciすれば、対応バイナリが解決される。
ようやく動いた。が、コールドが ~15 秒。tsx の起動時トランスパイルと、拡張の実行時ダウンロードが重い。ここは素直に潰せました。
- esbuild でビルド時にバンドルして
node実行(tsx を runtime から外す) - 拡張をビルド時にイメージへ焼き込み(runtime は
LOADのみ、DL しない) - DuckDB 接続を Init フェーズで事前確立(重い初期化を Init に前倒し。Init は CPU が一時的にブーストされるので速い)
これでコールドは ~2.1 秒まで落ちました。ここまでは順調だったんです。
違和感:データは数KBなのに、ウォームが100ms #
問題はウォーム(暖まった状態)でした。users { orders } を引くと 約100ms。ユーザー1人と、その注文が入った Parquet が数ファイル、合計たかだか 10KB 程度。これに 100ms は、どう考えても多い。
「まあ S3 への往復があるしな」と最初は流しかけました。でも引っかかる。10KB を読むのに 100ms は、いくらなんでも。
寄り道(そして空振り):S3 が遅いに違いない #
最初の仮説は単純でした。「S3 の読み取りが遅いんだろう。なら速い S3 を使えばいい」。ちょうど S3 Express One Zone(単一 AZ・低レイテンシ)がある。これだ、と。
ディレクトリバケットを CDK で立て、同じデータを置き、DuckDB をそちら向きに設定して、標準 S3 と A/B 計測しました。結果は——
ほぼ同じ。 誤差。むしろ僅かに遅い場面すらある。
完全な空振りでした。「速い S3」にしても1ミリも改善しないということは、そもそもボトルネックは S3 のデータ読み取りではない。当たりの付け方が間違っていた。Express は撤去しました。
ここで方針を変えます。推測で殴るのをやめて、ちゃんと分解して測る。
測り直す:犯人は「読む」ではなく「探す」だった #
ローカルからの壁時計はクライアント→東京の RTT が乗ってブレるので、サーバー側の Duration(CloudWatch の REPORT 行)を各30サンプル取り、クエリを種類別に分解しました。
| クエリ | サーバー側 中央値 |
|---|---|
{ __typename }(DuckDB も S3 も触らない) |
3ms |
users(1ファイル・直接パス) |
6ms |
users + orders(glob orders/*/*.parquet) |
101ms |
これを見た瞬間、像が反転しました。
- Yoga + LWA のオーバーヘッドは 3ms。
- users を1ファイル読むのは 6ms。
- なのに orders を足した途端 101ms。約95ms が orders だけで消えている。
users と orders の違いは、データ量ではありません(どちらも極小)。読み方です。users は users/users.parquet を直接パスで読む。orders は orders/*/*.parquet という glob。glob はファイルを見つけるために、まず S3 に LIST を投げる。
1行の実験で確信に変わる #
同じ5ファイルを、glob と「明示的にパスを並べた読み方」で比べてみました。
read_parquet('orders/*/*.parquet') (glob, LIST 必要) : 55ms
read_parquet([... 明示5パス ...]) (LIST 不要) : 1ms
決まりです。真犯人は「毎クエリ走る glob の S3 LIST(ファイル探索)」。データを読む処理は 1ms で終わっていた。重かったのは「読む」ことではなく「どのファイルがあるかを毎回 S3 に問い合わせる」ことだったのです。
ここで、さっきの Express の空振りもようやく腑に落ちました。ボトルネックがデータ転送ではなく LIST だったなら、S3 を速くしても効くわけがない。measure せずに当たりを付けた報いでした。
10倍:直し方はシンプルだった #
犯人が分かれば対策は素直です。ファイル一覧を一度だけ取って TTL でキャッシュし、各クエリは明示パスで読む。LIST が「毎クエリ」から「コンテナ1回 / TTL」に変わります。
let cache: { expr: string; at: number } | null = null;
const TTL_MS = 60_000;
export async function ordersFrom(): Promise<string> {
const now = Date.now();
if (cache && now - cache.at < TTL_MS) return cache.expr;
const conn = await getConnection();
const reader = await conn.runAndReadAll(
`SELECT file FROM glob('s3://${BUCKET}/orders/*/*.parquet')`,
);
const files = reader.getRowObjectsJS().map((r) => String(r.file));
const expr = files.length === 0
? `read_parquet('s3://${BUCKET}/orders/*/*.parquet', hive_partitioning=true)`
: `read_parquet([${files.map((f) => `'${f}'`).join(', ')}], hive_partitioning=true)`;
cache = { expr, at: now };
return expr;
}
結果、ここまでの道のりはこうなりました。
| 施策 | ウォーム中央値 |
|---|---|
| 初期 | 131ms |
メタデータキャッシュ(enable_object_cache 等) |
103ms |
| glob LIST 排除(明示パス + TTL) | 10ms |
101ms → 10ms、約10倍。 データはあくまでライブ取得(結果キャッシュではない)で、新しいパーティションも最大60秒で見えるので、バッチ追記型のワークロードと相性がいい。
しかし、これはスケールしない #
気持ちよく終わりたかったのですが、もう一段あります。明示パス化は速いけれど、読むファイル数自体は減っていない。user_id で引くと全 dt パーティションを走査する「疎なアクセス」は、ファイル数に線形に効いてきます。実 S3 でファイル数を振ると:
ファイル数 | glob(S3 LIST) | explicit(LIST無)
100 | 74.5ms | 9.9ms
1000 | 195.4ms | 94.5ms
4000 | 1002.4ms | 418.2ms
explicit は「定数項が良い」だけで、傾き(線形悪化)は変わらない。これを断つには「読むファイル数を減らすレイアウト」しかありません。例えば user_id % N でバケット化(固定数のグループに畳んで分割)すれば、ユーザー引きは1ファイル読みで規模非依存になります(合成データで 365日分:dt 分割 22ms/365ファイル vs バケット 2.6ms/1ファイル)。ただし日付クエリとはトレードオフで、どのアクセスパターンに最適化するかはビジネス判断。ここから先は「速さ」ではなく「設計」の話になります。
学び #
振り返ると、この回り道から得たものは技術の小ネタより、進め方そのものでした。
- 「遅い=○○のせい」と決めつけて殴ると外す。 S3 Express は典型的な空振りで、ボトルネックを取り違えていた。
- 分解して測ると像が反転する。
__typename3ms / users 6ms / orders 101ms と並べた瞬間に犯人が見えた。1行の比較実験(glob 55ms vs 明示 1ms)が確信に変えた。 - 副作用なく効く「透過的な」最適化はすぐ尽きる。 メタキャッシュと glob LIST 排除でほぼ打ち止め。その先(結果キャッシュ=鮮度、Provisioned Concurrency=コスト、レイアウト=設計)は必ずトレードオフを伴う。
そして構成自体の向き不向きも腹落ちしました。S3 直読みは「アクセスパターンが既知で限定的」なときに強い。任意の JOIN・任意の点引きを低レイテンシでとなると、それは RDB やウェアハウスの土俵です。
数 KB のクエリに 100ms かかっていた謎の正体が「データを読む時間」ではなく「ファイルを探す時間」だった、というのが、今回いちばんの収穫でした。
Reply by Email