🍃

Amazon Aurora DSQL におけるリトライが性能に与える影響を軽く調べてみた

に公開

調べたことの概要

Amazon Aurora DSQL 競合が発生すると最初にコミットに成功したトランザクション以外は例外が投げられ、アプリケーション側でのリトライ処理が必要になります。では、競合が実際に発生してリトライが必要になるケースにおいて、どのくらいの性能影響があるのか軽く調べてみた、というのがこの記事になります。

仮定のシナリオ

具体的には複数のスレッドが同じレコードに対して排他的ロックを取得して値の更新を行うようなケースを想定しました。JavaのServlet APIベースのWebサーバーにおいては各HTTPリクエストは独立したスレッドとして実行されます。これらのHTTPリクエストが同じレコード、例えば同一のポイント口座の残高を更新する、同一商品の在庫数を更新する、などの処理が実行されるとき、このシナリオのようなケースが発生する可能性があります。

今回はサクッと調べたいのと、処理方式の差による処理時間の差を目立たせたいので、テスト用の実装は極めて簡素にしました。

  1. 最初に初期値0を指定して新規レコードをINSERTする。 ... ⓪の処理
  2. そのレコードに対してvalueに1加算するUPDATEを10回する。 ... ①~⑪の処理
  3. 初期値と10回の加算の結果を最初に挿入したレコードから取得する。 ... ⑫の処理

②~⑪の処理をマルチスレッドの並列処理とすることにより競合を発生させる、というシナリオになります。競合を発生させるSQLを発行するアプリケーションはAurora DSQLのエンドポイントと同じリージョンのCloudShell上で実行しています。

テスト時点では、Aurora DSQLのエンドポイントはVPCの外にありました

case0 - 競合が発生しないケース

競合が発生するケースを調べる前に、まずは比較対象となる競合が発生しないケースについての調査を実施しています。具体的には②~⑪の処理をシングルスレッドで順番に実行したケースです。

処理時間 (ミリ秒)
#01 289
#02 290
#03 290
#04 294
#05 295
#06 296
#07 298
#08 302
#09 305
#10 310

12回実施した結果を処理時間で昇順にソートして先頭と末尾の行を削除しています。平均値は約297ミリ秒でした。

case1 - 競合が発生するケース(工夫なし)

競合が発生するケースです。具体的には②~⑪の処理をマルチスレッドですべて同時に実行しています。競合の発生時に例外がスローされたスレッドは直ちにリトライ処理を実行します。

処理時間 (ミリ秒)
#01 324
#02 330
#03 332
#04 351
#05 364
#06 367
#07 390
#08 407
#09 413
#10 424

12回実施した結果を処理時間で昇順にソートして先頭と末尾の行を削除しています。平均値は約370ミリ秒でした。競合がないケースの約1.2倍程度の処理時間がかかっていますが、そこまで大きな処理の遅延は生じていません。ただし、リトライ回数を20回以上に設定しないと処理が成功する前にリトライ回数を超過してしまうケースが発生したので、相当な回数のリトライが発生していることが推測されます。トラフィック量が多くリトライが必要なケースが積み重なるような場合、システムにかなりの負荷がかかることが想像されます。

case2 - 競合が発生するケース(Backoff設定追加)

競合が発生するケースですが、リトライ処理の実行にインターバルを設け、かつ揺らぎが発生するように設定しています。

処理時間 (ミリ秒)
#01 360
#02 369
#03 375
#04 378
#05 383
#06 394
#07 406
#08 409
#09 433
#10 433

12回実施した結果を処理時間で昇順にソートして先頭と末尾の行を削除しています。平均値は約394ミリ秒でした。競合がないケースの約1.06倍の処理時間となっており、Backoff設定なしのケースと比較して処理時間はほぼ同じになっています。リトライ回数の上限を10回に設定しても殆どのケースで処理成功したため、Backoff設定なしのケースと比較して、リトライ回数を半減させることが出来ており、システム不可はかなり軽減されています。

case3 - 競合が発生するケース(データモデル変更)

「同一レコードの値を変更する」という競合の発生原因となっているロジックを排除するためにデータ構造も含めてアプリケーションを改修した案です。そのうえで、~⑪の処理をマルチスレッドですべて同時に実行しています。競合の発生時に例外がスローされたスレッドは直ちにリトライ処理を実行します。

  1. 最初に初期値0を指定して新規レコードをINSERTする。 ... ⓪の処理
  2. 加算する値と対象レコードを指定する新規レコードをINSERTする。 ... ①~⑪の処理
  3. 初期値と10回の加算の結果を最初に挿入したレコードから取得する。 ... ⑫の処理

    UPDATEが無くなって、更新処理はINSERTだけです
処理時間 (ミリ秒)
#01 67
#02 87
#03 88
#04 91
#05 91
#06 94
#07 99
#08 110
#09 115
#10 202

12回実施した結果を処理時間で昇順にソートして先頭と末尾の行を削除しています。平均値は約104ミリ秒でした。競合がないケースの約3倍の処理性能を達成しており、Aurora DSL の性能を引き出すには大胆なデータ構造の変更も有効であることが判明しました。

INSERT INTO でも競合による例外が発生する

新規レコードを挿入する際は排他ロック等の取得は行わず、更新対象となるレコードも他のトランザクションとは別のモノとなるため競合は発生しないように思われます。しかし、実際には以下のような例外が発生することがあります。(常に発生するわけではないです。)

Caused by: org.springframework.dao.CannotAcquireLockException: PreparedStatementCallback; SQL [INSERT INTO e_table (vid, value) VALUES (?, ?)]; ERROR: schema has been updated by another transaction, please retry: (OC001)
  Position: 13
        at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:128)
        at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:107)
        at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:116)
        at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1556)
        at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:677)
        at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:972)
        at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:1016)
        at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:1026)
        at ... 以降省略

同一レコードに対して排他ロックを取得しようとした場合の競合同じCannotAcquireLockExceptionがスローされてきていますが、実はSQLStateが違うなどの微妙な差異があります。

項目 case1, case2で発生する例外 case3で発生する例外
例外クラス CannotAcquireLockException CannotAcquireLockException
SQLState OC000 OC001
エラーの意味 変更内容が他のトランザクションと競合していた。 スキーマが他のトランザクションによって更新された。

どうやら、INSERT文が悪さをしているわけではなく、テストのために実行された CREATE TABLE 文による実行結果が並列実行されている各接続をハンドリングしているコンピューティングリソースに反映されていないことが検知されてエラーが発生しているようです。スキーマ変更してから何秒以上経過すれば大丈夫なのか不明なので、この手のエラーはいつでも発生する前提で実装しておく必要がありそうです。

OC000と並んでOC001もリトライにより問題解決する旨がユーザーガイドにも記載されているので、まずはリトライするのが無難でしょう。
https://docs.aws.amazon.com/aurora-dsql/latest/userguide/troubleshooting.html#troubleshooting-occ

まとめ

競合が実際に発生してリトライが必要になるケースにおいて、どのくらいの性能影響があるのか軽く調べてみた

結論は以下になります。

  • 排他制御が必要な場合、競合が発生しないようにシングルスレッドなどで直列化して逐次実行していくのが性能面で有利。
  • リトライが頻発するとシステムへの負荷が高まるため、Backoffを設定してリトライ回数の削減を図る必要がある。適切な値が設定できればBackoff設定なしの場合と比較しても大きな性能劣化は生じない。
  • データ構造やアプリケーションロジックを見直して排他制御なしで機能等が実現できるようにし、並列処理を可能にすると Auora DSQL の高い伸縮性を最大限に活用できる。
  • 想定外のSQLからリトライ可能な例外が発生する事態を想定し、リトライ機能を実装する範囲は広めにするのが安全。
ケース 平均処理時間(ミリ秒) 概要
Case0 297 競合が発生しないようにシングルスレッドで実行
Case1 370 競合が発生した場合は直ちにリトライ
Case2 394 競合が発生した場合は揺らぎのある間隔でリトライ
Case3 104 UPDATE文ではなくINSERT文を利用するよう改修

Discussion