7.1 なぜ必要なのか
ACID のすべてのプロパティ、その目的、使用例について詳しく説明しました。ご覧のとおり、すべてのデータベースが ACID 保証を提供しているわけではなく、パフォーマンス向上のために ACID 保証が犠牲になっています。したがって、ACID を提供しないデータベースがプロジェクトで選択される可能性があり、必要な ACID 機能の一部をアプリケーション側で実装する必要がある場合があります。また、システムがマイクロサービスやその他の種類の分散アプリケーションとして設計されている場合、1 つのサービスにおける通常のローカル トランザクションは分散トランザクションになり、当然のことながら、たとえデータベースが個々のマイクロサービスは ACID になります。
トランザクション マネージャーの作成方法について完全なガイドを提供するつもりはありません。トランザクション マネージャーは大きすぎて複雑すぎるためです。ここでは、いくつかの基本的なテクニックのみを説明したいと思います。分散アプリケーションについて話しているわけではない場合、ACID の保証が必要な場合にアプリケーション側で ACID を完全に実装しようとする理由は見当たりません。結局のところ、既製のソリューションを採用する方があらゆる意味で簡単で安価です (つまり、ACID を持つデータベースです)。
ただし、アプリケーション側でトランザクションを行う際に役立つテクニックをいくつか紹介したいと思います。結局のところ、これらのテクニックを知っていれば、必ずしもトランザクションが関与しないシナリオであっても、さまざまなシナリオで役立ち、より優れた開発者になれるでしょう (私はそう願っています)。
7.2 トランザクション愛好家のための基本ツール
楽観的なブロックと悲観的なブロック。これらは、同時にアクセスできる一部のデータに対する 2 種類のロックです。
楽観主義者は、同時アクセスの可能性がそれほど高くないと想定しているため、次の処理を実行します: 目的の行を読み取り、そのバージョン番号 (またはタイムスタンプ、またはチェックサム/ハッシュ - データ スキームを変更してバージョンの列を追加できない場合) を記憶します。またはタイムスタンプ)、このデータの変更をデータベースに書き込む前に、このデータのバージョンが変更されたかどうかを確認します。バージョンが変更されている場合は、発生した競合を何らかの方法で解決してデータを更新する (「コミット」) か、トランザクションをロールバックする (「ロールバック」) 必要があります。この方法の欠点は、「チェック時から使用時まで」という長い名前 (TOCTOU と略される) を持つバグにとって有利な条件が生じることです。つまり、チェックと書き込みの間の期間で状態が変化する可能性があります。楽観的ロックの経験はありませんが、
一例として、開発者の日常生活から、オプティミスティック ロックのようなものを使用するテクノロジを見つけました。これは HTTP プロトコルです。最初の HTTP GET リクエストに対する応答には、クライアントからの後続の PUT リクエスト用の ETag ヘッダーが含まれてもよく、クライアントはこれを If-Match ヘッダーで使用してもよいです (MAY)。GET メソッドと HEAD メソッドの場合、サーバーは、既知の ETag の 1 つと一致する場合にのみ、要求されたリソースを送り返します。PUT およびその他の安全でないメソッドの場合、この場合もリソースのみがロードされます。ETag の仕組みがわからない場合は、「feedparser」ライブラリ (RSS やその他のフィードの解析に役立ちます) を使用した良い例を次に示します。
>>> import feedparser
>>> d = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml')
>>> d.etag
'"6c132-941-ad7e3080"'
>>> d2 = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml', etag=d.etag)
>>> d2.feed
{}
>>> d2.debug_message
'The feed has not changed since you last checked, so the server sent no data. This is a feature, not a bug!'
一方、悲観主義者は、トランザクションが同じデータで「一致」することが多いという事実から出発し、作業を簡素化し、不必要な競合状態を回避するために、必要なデータを単純にブロックします。ロック メカニズムを実装するには、(プールから接続をプルするのではなく) セッションのデータベース接続を維持するか、トランザクションの ID を使用する必要があります。 、接続に関係なく使用できます。ペシミスティック ロックの欠点は、これを使用するとトランザクションの処理が全般的に遅くなることですが、データについては落ち着いて、真の分離を得ることができます。
ただし、いくつかのプロセスが相互にロックされたリソースを待機するデッドロックの可能性には、さらなる危険が潜んでいます。たとえば、トランザクションにはリソース A と B が必要です。プロセス 1 はリソース A を占有し、プロセス 2 はリソース B を占有しています。2 つのプロセスはどちらも実行を続行できません。この問題を解決するにはさまざまな方法があります。詳しくは説明しませんので、まず Wikipedia を読んでください。簡単に言うと、ロック階層を作成する可能性があります。この概念をさらに詳しく知りたい場合は、「食事の哲学者問題」 (「食事の哲学者問題」) について頭を悩ませてみてください。
以下は、同じシナリオで両方のロックがどのように動作するかを示す良い例です。
ロックの実装について。詳細には触れたくないが、分散システム用のロック マネージャー (例: ZooKeeper、Redis、etcd、Consul) があります。
7.3 演算のべき等性
一般に冪等コードは良い習慣であり、開発者がトランザクションを使用するかどうかに関係なく、これを実行できることが望ましい場合はまさにこれに当てはまります。冪等性は、その操作がオブジェクトに再度適用されたときに同じ結果を生成する操作の特性です。関数が呼び出され、結果が返されました。2 秒か 5 秒後に再度呼び出すと、同じ結果が得られました。もちろん、データベース内のデータが変更されている場合は、結果は異なります。3 番目のシステムのデータは関数に依存しない可能性がありますが、関数に依存するものはすべて予測可能でなければなりません。
冪等性にはいくつかの症状が現れる可能性があります。そのうちの 1 つは、コードの記述方法に関する単なる推奨事項です。最良の関数とは、1 つのことを実行する関数であることを覚えていますか? そして、この関数の単体テストを作成すると何が良いでしょうか? これら 2 つのルールに従えば、関数が冪等になる可能性が高くなります。混乱を避けるために、冪等関数は必ずしも「純粋」(「関数の純粋性」という意味で)ではないことを明確にします。純粋関数とは、入力時に受け取ったデータのみを操作し、データを一切変更せず、処理された結果を返す関数です。これらは、関数プログラミング手法を使用してアプリケーションを拡張できるようにする関数です。私たちは一般的なデータとデータベースについて話しているので、私たちの関数は純粋である可能性が低く、
これは純粋な関数です。
def square(num: int) -> int:
return num * num
ただし、この関数は純粋ではなく、冪等です (これらの部分からコードをどのように作成するかについて結論を導き出さないでください)。
def insert_data(insert_query: str, db_connection: DbConnectionType) -> int:
db_connection.execute(insert_query)
return True
たくさんの言葉を使う代わりに、私がどのように冪等プログラムの書き方を学ばなければならなかったのかについてだけ話します。ご覧のとおり、私は AWS で多くの仕事をしています。AWS Lambda と呼ばれるサービスがあります。Lambda を使用すると、サーバーを管理する必要がなく、一部のイベントに応答して、またはスケジュールに従って実行されるコードを読み込むだけで済みます。イベントは、メッセージ ブローカーによって配信されるメッセージである場合があります。AWS では、このブローカーは AWS SNS です。これは、AWS を使用していない人にとっても明らかであると思います。チャネル (「トピック」) を通じてメッセージを送信するブローカーがあり、これらのチャネルにサブスクライブしているマイクロサービスがメッセージを受信し、何らかの形で反応します。
問題は、SNS がメッセージを「少なくとも 1 回」配信する (「少なくとも 1 回配信」) ことです。どういう意味ですか?遅かれ早かれ、Lambda コードが 2 回呼び出されるでしょう。そしてそれは本当に起こります。関数が冪等である必要があるシナリオは数多くあります。たとえば、口座からお金が引き出されるとき、誰かが同じ金額を 2 回引き出すことが予想されますが、これらが本当に独立した 2 回であることを確認する必要があります。言い換えれば、これらは 2 つの異なるトランザクションであり、1 つのトランザクションの繰り返しではありません。
変更のために、API へのリクエストの頻度を制限する (「レート制限」) という別の例を示します。Lambda は、特定の user_id を持つイベントを受信します。このイベントについては、その ID を持つユーザーが一部の API に対して可能なリクエスト数を使い果たしたかどうかを確認する必要があります。AWS からの呼び出しの値を DynamoDB に保存し、関数を呼び出すたびに値を 1 ずつ増やすことができます。
しかし、この Lambda 関数が同じイベントによって 2 回呼び出された場合はどうなるでしょうか? ところで、lambda_handler() 関数の引数に注目しましたか。2 番目の引数である AWS Lambda のコンテキストはデフォルトで指定され、一意の呼び出しごとに生成される request_id などのさまざまなメタデータが含まれます。これは、テーブルに行われた呼び出しの数を保存する代わりに、request_id のリストを保存できることを意味します。呼び出しごとに、Lambda は指定されたリクエストがすでに処理されているかどうかを確認します。
import json
import os
from typing import Any, Dict
from aws_lambda_powertools.utilities.typing import LambdaContext # needed only for argument type annotation
import boto3
limit = os.getenv('LIMIT')
def handler_name(event: Dict[str: Any], context: LambdaContext):
request_id = context.aws_request_id
# We find user_id in incoming event
user_id = event["user_id"]
# Our table for DynamoDB
table = boto3.resource('dynamodb').Table('my_table')
# Doing update
table.update_item(
Key={'pkey': user_id},
UpdateExpression='ADD requests :request_id',
ConditionExpression='attribute_not_exists (requests) OR (size(requests) < :limit AND NOT contains(requests, :request_id))',
ExpressionAttributeValues={
':request_id': {'S': request_id},
':requests': {'SS': [request_id]},
':limit': {'N': limit}
}
)
# TODO: write further logic
return {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": json.dumps({
"status ": "success"
})
}
私の例は実際にインターネットから取得したものであるため、特にもう少し詳しい情報が得られるため、元のソースへの リンクを残しておきます。
前に、一意のトランザクション ID のようなものを使用して共有データをロックできると述べたことを覚えていますか? 操作を冪等にするためにも使用できることがわかりました。このような ID を自分で生成できる方法を見てみましょう。