ここでの本当の問題は、ロックを実装する方法ではなく、直列化可能性を偽造する方法です。データベース前またはデータベース以外の世界で直列化可能性を実現するように教えられる方法は、ロックとセマフォを使用することです。基本的な考え方は次のとおりです。
lock()
modify a bunch of shared memory
unlock()
そうすれば、2人の同時ユーザーがいる場合、どちらも無効な状態を生成しないことを確信できます。つまり、シナリオの例は、両方のプレーヤーが同時に攻撃し、どちらが勝ったかという矛盾した概念に到達するシナリオです。したがって、この種のインターリーブについて心配しています。
User A User B
| |
V |
attack! |
| V
| attack!
V |
read "wins" |
| V
| read "wins"
| |
V |
write "wins" |
V
write "wins"
問題は、このように読み取りと書き込みをインターリーブすると、ユーザーAの書き込みが上書きされるか、その他の問題が発生することです。この種の問題は一般に競合状態と呼ばれます。これは、2つのスレッドが同じリソースを効果的に競合し、一方が「勝ち」、もう一方が「負け」、動作が意図したものではないためです。
ロック、セマフォ、またはクリティカルセクションの解決策は、一種のボトルネックを作成することです。一度に1つのタスクのみがクリティカルセクションまたはボトルネックにあるため、この一連の問題は発生しません。ボトルネックを乗り越えようとしている人は誰でも、最初に乗り越えた人を待っています。彼らはブロックしています。
User A User B
| |
V |
attack! |
| attack!
V |
lock V
| blocking
V .
read "wins" .
| .
V .
write "wins" .
| .
V .
unlock V
lock
|
V
...
これを見る別の方法は、読み取り/書き込みの組み合わせを、中断できない単一のコヒーレントユニットとして扱う必要があるということです。言い換えれば、それらは原子単位として、原子的に扱われる必要があります。これは、データベースが「ACID準拠」であると人々が言うとき、まさにACIDのAが表すものです。データベースでは、次のようにアトミックユニットを表すトランザクションを代わりに使用するため、ロックはありません(または、少なくとも、ロックを持たないふりをする必要があります)。
BEGIN;
SELECT ...
UPDATE ...
COMMIT;
BEGIN
との間のすべてがCOMMIT
アトミックユニットとして扱われることが期待されているため、すべてが実行されるか、実行されないかのどちらかです。Aに依存するだけでは、特定のユースケースには不十分であることがわかります。これは、トランザクションが相互に失敗する可能性がないためです。
User A User B
| |
V |
BEGIN V
| BEGIN
V |
SELECT ... V
| SELECT ...
V |
UPDATE V
| UPDATE
V |
COMMIT V
COMMIT
特に、これを適切に記述した場合、特にそれらが異なる行で動作してUPDATE players SET wins = 37
いるUPDATE players SET wins = wins + 1
場合、データベースはこれらの更新を並行して実行できないと疑う理由はありません。その結果、より多くのデータベースfooを使用する必要があります。一貫性、つまりACIDのCについて心配する必要があります。
何か無効なことが発生したかどうかをデータベース自体が識別できるようにスキーマを設計したいと思います。可能であれば、データベースがそれを防ぐからです。注:現在、私たちは設計領域にいるので、問題に取り組むためのさまざまな方法が必然的にあります。ここで紹介するのは、最高のものではなく、良いものでもないかもしれませんが、リレーショナルデータベースの問題を解決するために必要な思考プロセスを説明するものになることを願っています。
つまり、整合性、つまり、すべてのトランザクションの前後でデータベースが有効な状態にあることが重要になります。データベースがこのようにデータの有効性を処理している場合は、トランザクションを単純な方法で記述でき、同時実行性のために不合理なことを行おうとすると、データベース自体がトランザクションを中止します。これは、私たちに新しい責任があることを意味します。検証を処理できるように、データベースにセマンティクスを認識させる方法を見つける必要があります。一般に、有効性を保証する最も簡単な方法は、主キーと外部キーの制約を使用することです。つまり、行が一意であること、または行が他のテーブルの行を確実に参照していることを確認します。そこから一般化できることを期待して、ゲームの2つのシナリオの思考プロセスといくつかの代替案を紹介します。
最初のシナリオはキルです。プレーヤー2がプレーヤー1を殺している最中の場合、プレーヤー1がプレーヤー2を殺すことができないようにしたいとします。これは、キルをアトミックにすることを意味します。私はこれを次のようにモデル化します:
CREATE TABLE players (
login VARCHAR,
-- password hashes, etc.
);
CREATE TABLE lives (
login VARCHAR REFERENCES players,
life INTEGER
);
CREATE TABLE alive (
login VARCHAR,
life INTEGER,
PRIMARY KEY (login, life),
FOREIGN KEY (login, life) REFERENCES lives
);
CREATE TABLE deaths (
login VARCHAR REFERENCES players,
life INTEGER,
killed_by VARCHAR,
killed_by_life INTEGER,
PRIMARY KEY (login, life),
FOREIGN KEY (killed_by, killed_by_life) REFERENCES lives
);
これで、アトミックに新しい生活を作成できます。
BEGIN;
SELECT login, MAX(life)+1 FROM lives WHERE login = 'login';
INSERT INTO lives (login, life) VALUES ('login', 'new life #');
INSERT INTO alive (login, life) VALUES ('login', 'new life #');
COMMIT;
そして、あなたは原子的に殺すことができます:
BEGIN;
SELECT name, life FROM alive
WHERE name = 'killer_name' AND life = 'life #';
SELECT name, life FROM alive
WHERE name = 'victim_name' AND life = 'life #';
-- if either of those SELECTs returned NULL, the victim
-- or killer died in another transaction
INSERT INTO deaths (name, life, killed_by, killed_by_life)
VALUES ('victim', 'life #', 'killer', 'killer life #');
DELETE FROM alive WHERE name = 'victim' AND life = 'life #';
COMMIT;
これで、これらのことがアトミックに発生していることを確信できます。これは、UNIQUE
によって暗示される制約によりPRIMARY KEY
、殺人者が誰であるかに関係なく、同じユーザーと同じ人生で新しい死亡記録が作成されるのを防ぐためです。ステップ間でカウントステートメントを発行したりROLLBACK
、予期しないことが発生した場合に発行したりするなどして、制約が満たされていることを手動で確認することもできます。これらをトリガーされたチェック制約にバンドルし、さらにストアドプロシージャにバンドルして、細心の注意を払うこともできます。
2番目の例に移ります:エネルギー制限操作。最も簡単な方法は、チェック制約を追加することです。
CREATE TABLE player (
login VARCHAR PRIMARY KEY, -- etc.
energy INTEGER,
CONSTRAINT ensure_energy_is_positive CHECK(energy >= 0)
);
これで、プレイヤーがエネルギーを2回使用しようとすると、シーケンスの1つで制約違反が発生します。
Player A #1 Player A #2
| |
V |
spell |
| V
V spell
BEGIN |
| |
| V
| BEGIN
V |
UPDATE SET energy = energy - 5;
| |
| |
| V
| UPDATE SET energy = energy - 5;
V |
[implied CHECK: pass]
| |
V |
COMMIT V
[implied CHECK: fail!]
|
V
ROLLBACK
これをチェック制約ではなくリレーショナル整合性の問題に変換するためにできることは他にもあります。たとえば、識別されたユニットにエネルギーを割り当て、それらを特定のスペル呼び出しインスタンスにリンクするなどですが、おそらくあなたにとっては大変な作業です。やり直します。これは問題なく動作します。
さて、すべてをレイアウトした後でこれを言わなければならないのは嫌ですが、データベースをチェックして、すべてが実際のACID準拠に設定されていることを確認する必要があります。デフォルトでは、MySQLにはテーブル用のMyISAMが同梱されていました。つまり、BEGIN
1日中使用でき、とにかくすべてが個別に実行されていました。代わりにエンジンとしてInnoDBを使用してテーブルを作成すると、ほぼ期待どおりに機能します。可能であればPostgreSQLを試してみることをお勧めします。これは、箱から出してすぐにこのようなことについてもう少し一貫性があります。そしてもちろん、商用データベースも強力です。一方、SQLiteにはデータベース全体の書き込みロックがあるため、それがバックエンドである場合、前述のすべてはかなり意味がありません。同時書き込みシナリオにはお勧めしません。
要約すると、問題は、データベースが基本的にこの問題を非常に高レベルの方法で処理しようとしていることです。99%の場合、心配する必要はありません。正確に適切なタイミングで2つのリクエストが発生する確率は、それほど高くありません。実行したいすべてのアクションは非常に高速に実行されるため、実際の競合状態が発生する可能性はほとんどありません。しかし、それについて心配するのは良いことです。結局のところ、あなたはいつか銀行のアプリケーションを書いているかもしれません、そしてそれは物事を正しく行う方法を知ることが重要です。残念ながら、この特定のケースでは、使用しているロックプリミティブは、リレーショナルデータベースが実行しようとしているものと比較して非常にプリミティブです。しかし、データベースは、単純な馴染みのある推論ではなく、速度と整合性を最適化しようとしています。
これがあなたにとって興味深い回り道であったなら、私はあなたがジョー・セルコの本の1つまたはデータベースの教科書をチェックして詳細を確認することを望みます。