午前2時の呼び出し
スマートフォンが震えます。時刻は午前2時、モニタリングダッシュボードは真っ赤に染まっています。急激なトラフィックの急増により、注文処理サービスで500エラーが続発しました。CloudWatchログを確認すると、特定のメッセージが数千回も記録されていました。
ClientError: An error occurred (TransactionConflictException) when calling the TransactWriteItems operation: Transaction is ongoing for the item
これは、TransactWriteItems と TransactGetItems が操作中に対象アイテムへの排他的アクセスを必要とするために発生します。トランザクションAが処理中であるときに、トランザクションBが同じパーティションキーにアクセスしようとすると、AWSは即座にトランザクションBを拒否します。リクエストをキューイング(待ち行列)に入れることはせず、単に強制終了させるのです。
なぜトランザクションが失敗するのか
最近発生した本番環境のインシデントでは、「在庫(Stock)」アイテムの更新と「注文(Order)」レコードの作成を、1つのアトミックなステップで実行していました。タイムセール中、800件以上のLambda実行が同時に、まったく同じミリ秒で同じ「在庫」アイテムを減算しようとしたのです。
DynamoDBのトランザクションはACID準拠ですが、単一の行に対する高い競合(コンテンション)が発生するようには設計されていません。狭い廊下を想像してください。一人が通り抜けている間、他の全員は外で待たなければなりません。100人が一度に押し通ろうとすると、DynamoDBは渋滞を防ぐために人々を追い返し始めます。
解決策:3つのアプローチ
プロビジョニングされたスループットを増やしても、この問題は解決しません。「トラフィックの渋滞」をアプリケーションがどう処理するかを変更する必要があります。
1. ジッターを組み合わせたスマートなリトライ
最も迅速な修正方法は、例外をキャッチしてリトライすることです。しかし、失敗した100個のリクエストがすべて正確に100ミリ秒後にリトライすると、再び衝突してしまいます。これが「サンダリングハード(thundering herd)」問題です。指数バックオフ(exponential backoff)にジッター(ランダムな遅延)を組み合わせて使用する必要があります。
以下は、PythonとBoto3を使用した堅牢な実装例です。
import boto3
import time
import random
from botocore.exceptions import ClientError
dynamodb = boto3.client('dynamodb')
def update_stock_with_retry(item_id, quantity, max_retries=5):
for attempt in range(max_retries):
try:
return dynamodb.transact_write_items(
TransactItems=[
{
'Update': {
'TableName': 'Inventory',
'Key': {'ID': {'S': item_id}},
'UpdateExpression': 'SET stock = stock - :q',
'ExpressionAttributeValues': {':q': {'N': str(quantity)}},
'ConditionExpression': 'stock >= :q'
}
}
]
)
except ClientError as e:
if e.response['Error']['Code'] == 'TransactionConflictException':
# 指数バックオフ: 50ms, 100ms, 200ms...
# 負荷を分散させるためにランダムなジッターを追加する
wait_time = ((2 ** attempt) * 0.05) + random.uniform(0, 0.02)
print(f"Collision on {item_id}. Retrying in {wait_time:.3f}s...")
time.sleep(wait_time)
else:
raise e
raise Exception("Transaction failed after maximum retry attempts.")
2. トランザクションのスコープを縮小する
TransactWriteItems の呼び出しに追加するアイテムが増えるほど、数学的に競合が発生する確率が高まります。「最終ログイン(LastLogin)」のタイムスタンプを、金銭的な「購入(Purchase)」と同じトランザクションで更新する必要は本当にあるでしょうか?おそらくありません。クリティカルではない更新は、別の UpdateItem 呼び出しに移動してください。標準の更新は楽観的ロック(optimistic locking)を使用するため、完全なトランザクションよりもはるかにスムーズに高い並行性を処理できます。
3. 分散カウンタの使用
単一のグローバルカウンタでボトルネックが発生している場合、トランザクションは適切なツールではありません。代わりにシャーディングパターンを使用してください。カウンタを10個または20個の異なる行(例:counter_shard_1, counter_shard_2)に分割します。アプリケーションは更新するシャードをランダムに選択します。合計カウントが必要なときは、すべてのシャードを合算するだけです。これにより、「負荷」が複数のパーティションキーに分散されます。
修正の検証
バックオフのロジックをデプロイしたら、メトリクスを注意深く監視してください。データの整合性を維持しながら、エラー率が低下することを確認します。
- **CloudWatch Metrics:** `TransactionCheckFailed` を追跡します。ジッター導入後も、この数値はゼロにならないかもしれませんが、リトライが成功することでアプリケーションレベルの500エラーは解消されるはずです。
- **Latency Monitoring:** Lambda の `Duration` を監視します。リトライは実行時間に数ミリ秒加算されます。レイテンシが500ミリ秒を超えて急上昇する場合は、シャード数を増やす必要があるかもしれません。
- **Stress Testing:** 簡単な負荷テストを実行します。1つのアイテムに対して50件の同時リクエストを送信するシンプルなスクリプトを使用したところ、ジッター付きリトライを追加した後は、成功率が12%から100%に向上しました。
重要なまとめ
DynamoDBのトランザクションは強力ですが、従来のSQLトランザクションを直接置き換えるものではありません。これらには厳格な並行性の制限が伴います。
- **ホットキーを制限する:** アイテムが1秒間に5〜10回以上更新される場合、トランザクションは苦戦する可能性が高いです。
- **SDKの設定を確認する:** 多くのAWS SDKにはデフォルトのリトライ設定がありますが、`TransactionConflictException` はリトライ対象に含まれていないことがよくあります。使用しているSDKの動作を必ず確認してください。
- **べき等性を強制する:** `ClientRequestToken` パラメータを使用してください。これにより、リトライが成功したもののネットワークがタイムアウトした場合でも、誤って顧客に二重に請求してしまうことを防げます。

