4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【DynamoDB Streams】イベント駆動アプリに負荷をかけ続けたらどうなるか確かめてみる

4
Posted at
                                            
DynamoDBStreams.png
Amazon DynamoDB Streams

はじめに

前回は、Kiro Roasters の EC 注文処理パイプライン(DynamoDB Streams + Lambda)を Amplify Gen 2 で建てて、通常時にきれいに動くことを確認しました。

今回は、バズで注文が殺到したときを想定して負荷を流します。注文が数百〜数千件/分で降ってきたとき、この構成はどこで詰まるのか。実際に負荷をかけて確かめてみました。

まずは結論から

  • DynamoDB Streams でさばける量には上限があり、それ以上は伸びない
  • Lambda の同時実行数は増えない。枠は 1,000 あるのに、実際に動いたのは40くらい
  • 上限を超えて詰まってもエラーにはならない。異常を示すのは IteratorAge だけ

順に見ていきます。

構成のおさらい

前回作ったパイプラインを再掲します。注文の受付(order-accept)が orders テーブルに書き込み、その変更を Streams が拾って order-processor が後続処理(決済 → 引当 → 通知 → ポイント付与)を直列に回す、という流れです。注文照会(order-query)は orders を読むだけの別経路です。

DynamoDB_Stream2_Outline.drawio.png

利用者から見ると、EC サイトで注文するだけのよくある画面です。

DynamoDB_Stream2_EC_Screen.png

この構成に負荷をかけると、どこまでさばけるのか。次から実際に流してみます。

負荷テスト

高負荷を叩き込む

まず、検証用の負荷テスト画面から、注文を大量に投入します。1分あたりの投入件数を指定して、バズ状態を再現します。

DynamoDB_Streams2_LoadTool_Buzz.png

この殺到している注文のうちの1件を追跡して、受付から完了までを見てみます。

注文の受付(書き込み)は一瞬で返ります。ところがその後の後続処理が動かず、ステータスは 未完了 のまま、いつまでも先に進みません。

その後もしばらく待ち続けたところ、結局この1件が全段階を終えるまで、1時間以上かかりました。エラーにはなりませんでした。

段階ごとの経過を見ると、原因がはっきりします。決済・引当・通知・ポイント付与の各段階の所要そのものは通常どおり(引当38ms、通知516ms など)なのに、そもそも決済が始まるまでに延々と待たされている。処理が重いのではなく、順番待ち(滞留)で待たされているのです。

書き込みは間に合っているのに、後続処理が追いつかない。これが「詰まり」の正体です。

詰まりどころの特定

詰まりの犯人を、実測で絞り込んでいきます。

頭打ちになる処理量

投入レートを段階的に上げてみます。投入した量と、実際に処理された量(1分あたり)を並べます。

投入した量 実際に処理された量
1,000 件/分 約 550 件/分
2,000 件/分 約 570 件/分

投入を倍にしたのに、処理量は 550 → 570 でほとんど動きません。 さばける量には天井があって、いくら注文を積んでもそこから伸びない。処理が追いつかない分は失われるわけではなく、すべて処理待ちとして溜まっていきます。これが「詰まり」の実体です。溜まった分だけ、後ろの注文はどんどん待たされていきます。実際、記事冒頭で追跡した1件は、受付から完了まで70分以上かかっていました。

では、この天井は何が決めているのか。犯人を探します。

リソース使用率からの絞り込み

まず疑うのは Lambda の同時実行枠(既定 1,000)です。ここを使い切って詰まったのなら、話は分かりやすい。詰まっている最中の各リソースの使用率を見ます。

要素 上限 実測最大 使用率
Lambda 同時実行枠 1,000 43 4.3%
DynamoDB 書き込み 4,000 WCU/s 76.8 WCU/s 1.92%

どちらも殆ど使っていないことが分かります。 Lambda 枠は 4.3%、DynamoDB も 2% 弱しか使っていない。枠不足で詰まったのではありません。

唯一、天井に張り付いていた値があります。同時に動いていた Lambda の数が、約 40 で頭打ちでした。枠は 1,000 あるのに、40 までしか起動されない。これが犯人です。

詰まっていたのは、以下ブロック図の赤枠の部分です。

DynamoDB_Streams2_Bottleneck.png

書き込み(受付)は間に合っているのに、Streams → order-processor が同時実行 40 で頭打ちになり、処理を待つ注文が Streams にどんどん溜まっていきます。

では、同時実行数が 40 になる根拠を次の計算式で解き明かします。

消費能力の計算

Streams + Lambda が1分間にさばける件数(消費能力)は、たった3つの値で決まります。

消費能力 = S × P ÷ D
  • S(オープンシャード数) … Streams は項目への各操作(追加・更新・削除)をレコードとして流します。このレコードはパーティション単位で「シャード」に振り分けられ、シャード数はテーブルのパーティション数と1対1です。パーティションは DynamoDB がデータ量や負荷に応じて内部で決めるもので、利用者が直接指定できません。
  • P(ParallelizationFactor、並列化係数) … Streams を Lambda のトリガーにするときの設定で、1シャードあたりの Lambda 並列数を決めます。既定は 1、最大 10。この構成で唯一、設定で動かせる値です。注文の順序を厳密に守りたいなら 1 のままですが、処理能力を優先するなら上げます。ちなみに、上述の頭打ちの検証は P=10(最大)で回した結果です。
  • D(1レコードあたりの処理時間) … Lambda が注文1件を処理し終えるまでの時間(Duration)。今回は決済3秒+通知0.5秒などで約3.6秒。

なぜ同時実行が 40 で止まったのか。式の前半 S × P がその答えです。

  • 1つのシャードは、既定では1つの Lambda 実行環境が処理します。P を上げると1シャードあたり最大 P 並列まで増やせるので、同時に動ける Lambda の数の上限 = S × P になります。
  • 今回は S = 4(テーブルのパーティションが4つ)、P = 10。だから S × P = 40。実測で張り付いていた 40 は、この値でした。

つまり、天井を決めていたのは Lambda の同時実行数の枠ではなく、自分で設定した覚えのないパーティション数だったのです。P を最大の 10 にしても、S が 4 なら 40 までしか広がりません。

さらに、同時実行する 40 個の Lambda がそれぞれ D 秒に1件を処理するので、1秒あたりに片付く件数は S × P ÷ D。これが消費能力です。式に S=4、D≒3.6秒 を入れると、限界は次のようになるはずです(60倍して件/分で表記)。

P(並列化係数) 消費能力(理論値)
1(既定) 約 66 件/分
10(最大) 約 666 件/分

実測値との比較

この予測が実測と合うか確かめます。能力を超える負荷をかけてわざと詰まらせ、投入を止めたあとの「消化速度」を実測値として測定します。

条件 理論値(S × P ÷ D) 実測値 実測値 ÷ 理論値
S=4、P=1、D=3,613ms 66.4/分 66.30/分 99.8%
S=4、P=10、D=3,602ms 666.3/分 557.5/分 83.7%

P=1 ではほぼ理論値通りになりました(99.8%)。 ところが P=10 にすると理論値の 83.7% しか出ません。 先ほど「2,000件/分でも処理は約570件/分」だったのは、この P=10 の実測能力(約557件/分)に張り付いていたからです。

以上のことから、この構成(Streams + Lambda)が遅延なくさばける上限は S × P ÷ D でほぼ決まる固定値だということが分かりました。Lambda のおおよその処理時間(D)が分かれば、現在のシャード数(S)と並列化係数(P)から自分の構成のおおよその上限を把握できます。

ただし、実測の通りで P=10 にすると式の 83.7% しか出ていません。この「伸びきらない」理由を含めて続けて考察に入ります。

考察

ではここからは考察パートに入ります。

並列化係数のスケール損失

一般には「P を上げれば同時実行が P 倍になる」と説明されます(AWS 公式も、理論値としては P 倍としつつ「実際には異なる値になることもある」と含みを持たせています)。今回の実測では、P=1 では式がぴったり当たったのに、P=10 では理論値の 83.7% までしか伸びませんでした。 つまり 10 倍にしたつもりが、実効では約 8.4 倍。原因は ParallelizationFactor 内部のチェックポイント同期にあると考えられます。

P 本の並行処理は、一巡ごとに進捗を揃える(チェックポイントを取る)必要があります。P 本のうち最も遅い1本が、次の一巡の開始を律速します。P=1 には揃える相手がいないので損失が出ませんが、P=10 では毎回この待ち合わせが入り、1レコードあたり約 703ms のオーバーヘッドになりました。

この損失が「待ち行列が足りないだけ」ではなく構造的なものだ、という切り分けもできました。滞留を 6,697 件 → 約 43,000 件(6.4倍)に増やしても、稼働率は 83.7% → 85.8% と2ポイントしか動きません。待ち行列の長さの問題ではないのです。

さらに、この 0.84 という係数は 処理時間 D を変えても動きませんでした。

D(処理時間) 理論値 実測値 実測値 ÷ 理論値
3,602ms 666.3/分 557.5/分 83.7%
697.7ms 3,439.9/分 2,889.6/分 84.0%

D を5分の1程度に縮めても比は 0.84 のまま。オーバーヘッドが固定の値(例えば 600〜700ms)なら比はもっと変わるはずですが、そうならなかった。つまりオーバーヘッドは D に比例する per-record のコストで、S × P ÷ D × 0.84 は D の広いレンジで使えるということです。処理を軽くすれば能力は素直に伸びます。

0.84 はあくまで今回の検証で得られた実測値です。しかも P=1 と P=10 の2点でしか測っておらず、中間の P(2 や 5)は未検証です。環境や処理内容が変われば値も変わり得るので、あくまで目安として使ってください。

監視に現れない詰まり

Streams での詰まりが発生していたときの各メトリクスを確認しておきます。
投入負荷2,000件/分を45分間流し続けたときの状態です。

観測 値
API Gateway 4XXError / 5XXError 0 / 0
order-processor の Errors / Throttles 0 / 0(全 45 分)
DLQ のメッセージ数 0(全区間)
WriteThrottleEvents(基表 / GSI) 0
滞留(投入停止(45分経過)時点) 42,814 件
IteratorAge(投入停止時点) 21.09 分

※ IteratorAge は滞留が始まってからの経過時間なので、投入を止めても滞留を消化し切るまで伸び続けます。

42,814 件の注文が「決済されないまま」滞留しているのに、Errors も Throttles もゼロ、そして異常検知のために追加していた DLQ も空でした。

DLQ(デッドレターキュー)は、処理に失敗したレコードの退避先です。今回の構成では「後続処理で失敗した注文は DLQ に落ちる → DLQ を監視すれば異常に気づける」という想定でした。ところが詰まり(滞留)は「失敗」ではありません。レコードはまだ処理されていないだけで、エラーになったわけではない。だから DLQ では詰まりを検知できませんでした。

結局、エラーもDLQも動かず、この詰まりを唯一映していたのは、IteratorAge だけでした。この指標の使い方を、もう少し掘り下げます。

IteratorAge からの逆算

この値は正確には「先頭の未処理レコードが書かれてからの経過時間」で、ここから滞留件数と回復時間を計算できます。

滞留件数   = IteratorAge × 投入レート
回復時間   = 滞留件数 ÷ 消費能力

掛けるのは消費能力ではなく投入レートである点に注意してください。IteratorAge は経過時間なので、実時間1秒あたり1秒より速く増えることは原理的にありえません。実測でも、IteratorAge × 投入レート から出した滞留件数は 42,091 件で、直接数えた 42,814 件と 1.7% で一致しました。

この式が使えるのは、滞留が一定のペースで増えていくからです。実測でも、滞留の増え方はずっと一直線でした。ペースが変わらないので、数分だけ観測して増加の傾きを掴めば、その先(1時間後、数時間後)まで計算で見通せます。

きれいな直線になったのは、今回が検証用で 1件あたりの処理時間(D)を固定しているからです。実運用で、たとえば購入アイテムのカテゴリによって決済や引当のフローが変わり D がばらつくと、処理能力が揺らいで増え方は直線になりません。その場合は単純な外挿は効かないので、傾きは「おおよその目安」として扱ってください。

滞留・回復時間の見積もり

指標 値 計算式
滞留増加率 1,427 件/分 投入レート − 消費能力
回復時間(30分投入した場合) 約 76 分 滞留件数 ÷ 消費能力
データロスまでの猶予 33.6 時間 保持期限 ÷ 増加スピード

滞留増加率は、1分あたり何件ずつ列が伸びるか(投入レート − 消費能力)。回復時間は、投入を止めてから列を消化し切るまで(滞留件数 ÷ 消費能力)。冒頭で追跡した1件が「受付から完了まで70分以上」かかっていたのも、この回復時間の想定範囲に収まります。

3つ目のデータロスまでの猶予が、この節でいちばん重要です。DynamoDB Streams のレコードには保持期限(24時間)があり、処理されないまま24時間を超えると消えます(=そのレコードのデータロス)。滞留が延々と伸び続ければ、いつか先頭のレコードがこの期限に到達する。そこまでの時間が「猶予」です。

「猶予」の下限値は24時間ですが、現実的には滞留と消化の追いかけっこになるため実際には24時間を超えます。今回の検証ではIteratorAge は実時間1秒あたり約0.71秒のペースでしか伸びないので、データロスが始まるまでの猶予は 33.6時間と計算できました。

データロス(保持期限切れによる消滅)は、DLQ に入りません。 期限切れは「処理の失敗」ではないので OnFailure が発火しないのです。DLQ が空でも「レコードを失っていない」とは言えません。検知は IteratorAge の監視だけが頼りです。

仕組みの整理

実測を踏まえて、ここまで出てきた S × P と IteratorAge を改めて整理しておきます。

DynamoDB Streams はシャードという単位に分かれています。そして重要なのが、シャード数はテーブルのパーティション数と1対1で対応するという点です。パーティションはテーブルのデータ量や書き込み量に応じて DynamoDB が内部で分割するもので、利用者が直接指定できません。今回 S=4 だったのは、テーブルのパーティションが4つだったということです。

Lambda はこのストリームを Event Source Mapping(ESM) で読みます。ESM のスケーリングの基本則がこれです。

Streams を読む Lambda の最大同時実行数 = シャード数(S) × 並列化係数(P)
  • 1シャードは、標準では1つの Lambda が順番に処理します(順序保証のため)
  • ParallelizationFactor(P、最大10) を上げると、1シャードあたり最大 P 個まで並行処理できます。ただし同じパーティションキーのレコードは順序が守られます

だから同時に動ける Lambda は最大でも S × P。Lambda のアカウント同時実行枠(1,000)とは別の、もっと手前にある上限です。枠が空いていても S × P を超えて起動されないのは、この仕様が理由です。ここが「Lambda はスケールするはずなのに詰まる」のからくりでした。

そして遅延を測る IteratorAge の定義も押さえておきます。

  • IteratorAge = レコードがストリームに書かれてから、Lambda がそれを処理するまでの時間。ストリームの先頭で待っている一番古いレコードの待ち時間です
  • 正常時は数百ms〜数秒。消費が追いつかず行列が伸びると、分〜時間の単位に膨らみます

この記事で見てきた滞留・遅延・回復は、すべてこの2つ(S × P の上限と IteratorAge の定義)から説明できます。

より正確には、シャードには親子関係があり、パーティション分割にともなって「閉じたシャード(クローズ)」と「開いているシャード(オープン)」が生まれます。同時実行に効くのはオープンシャードの数です。ここは次の記事(シャードを増やす回)で詳しく扱います。

サイジングの考え方

この S × P を使えば、自分の構成が必要な負荷をさばけるか、事前に見積もれます。ちょっと乱暴に見積もると、次のように考えられます。今回は、目標とする消費能力を 2,000件/分 とします。

必要な並列数 ≒ 投入レート(件/秒) × 1件あたりの処理時間(秒)

これはリトルの法則そのものです。目標 2,000件/分= 33件/秒、1件あたりの処理時間が3秒なら 33 × 3 ≒ 100並列あれば滞留しない、という直感です。この直感自体は正しいです。

問題は、その100並列を Streams が起動してくれるとは限らないことです。Lambda の同時実行枠は 1,000 もあるので100並列は楽勝のはずですが、Streams 経由で実際に起動される数は枠ではなく S × P で頭打ちになります。今回は S=4 なので S × P = 40。100必要なのに40しか動かないから詰まったわけです。

なので実務での目安は2段構えになります。

  1. 必要並列を出す: 投入レート(件/秒)× 処理時間(秒)。ピーク投入で見積もる
  2. S × P が賄えるか確かめる: シャード数 S は DescribeStream(ストリームの構成を返す API)で確認できます。S × P(P は最大10)が必要並列に届くかを見て、届かなければ、いくら Lambda 枠が空いていても滞留します

さらに2つ補正を入れると精度が上がります。

  • × 0.84 を見込む(P=10 のとき)。チェックポイント同期の損失があるので、S × P の生の値より1〜2割手前で頭打ちになります
  • 消費能力の1/3を上限の目安にする。投入も処理もゆらぐため、能力ギリギリまで攻めると一時的な待ち行列で完了が遅れます(実測では能力の9割で負荷を掛けると半数が10秒を超えました)

たとえば「100並列必要」なら、P を最大の10にしても、S × 10 × 0.84 ≥ 100 からシャードは最低12必要です。では、どうすれば S を増やせるのか。これは次の記事で確かめます。

まとめ

DynamoDB Streams + Lambda の限界を実測して分かったことです。

  • 消費能力の限界は S × P ÷ D × 0.84(P=10 のとき)。Lambda の同時実行枠は関係ない。
  • 限界を決めるのはテーブルのパーティション数(S)。自分で設定できない値が上限になる
  • 詰まってもエラーやDLQでは検知できない。 唯一 IteratorAge だけが教えてくれる
  • 滞留件数は IteratorAge × 投入レート、回復時間は 滞留件数 ÷ 消費能力 で計算できる

次回は、この限界そのものを広げられるのかを確かめてみます。

参考リンク

4
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?