这里真正的问题不是如何实现锁,而是如何伪造可串行化。教你在前或非数据库世界中实现可序列化的方法是通过锁和信号量。基本思想是这样的:
lock()
modify a bunch of shared memory
unlock()
这样,当您有两个并发用户时,您可以确定他们中的任何一个都不会产生无效状态。因此,您的示例场景是两个玩家同时互相攻击并得出关于谁获胜的矛盾概念。所以你担心这种交错:
User A User B
| |
V |
attack! |
| V
| attack!
V |
read "wins" |
| V
| read "wins"
| |
V |
write "wins" |
V
write "wins"
问题是像这样交错读取和写入会导致用户 A 的写入被覆盖,或者其他一些问题。这类问题通常称为竞争条件,因为两个线程有效地竞争相同的资源,其中一个会“赢”,另一个会“输”,并且行为不是您想要的。
使用锁、信号量或临界区的解决方案是制造一种瓶颈:一次只有一个任务在临界区或瓶颈中,因此这组问题不会发生。每个试图通过瓶颈的人都在等待第一个通过的人——他们正在阻塞:
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 正在杀死玩家 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
在发生任何意外情况时发出 a 。您甚至可以将这些东西捆绑到触发检查约束中,并进一步捆绑到存储过程中以变得非常细致。
到第二个例子:能量限制演习。最简单的做法是添加一个检查约束:
CREATE TABLE player (
login VARCHAR PRIMARY KEY, -- etc.
energy INTEGER,
CONSTRAINT ensure_energy_is_positive CHECK(energy >= 0)
);
现在,如果玩家尝试使用他们的能量两次,您将在其中一个序列中遇到约束违规:
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
整天运行,而且一切都是单独运行的。如果您使用 InnoDB 作为引擎来创建表,它或多或少地按预期工作。如果可以的话,我建议您尝试使用 PostgreSQL,它对此类开箱即用的东西更加一致。当然,商业数据库也很强大。另一方面,SQLite 对整个数据库都有一个写锁,所以如果那是你的后端,那么前面的整个过程都没有实际意义。我不建议将它用于并发写入场景。
总之,问题在于数据库从根本上试图以非常高级的方式为您处理这个问题。99% 的时间,你根本不用担心;在正确的时间发生两个请求的几率并不高。您愿意采取的所有行动都会发生得如此之快,产生实际比赛条件的可能性微乎其微。但担心它是件好事。毕竟,你可能有一天会编写银行应用程序,知道如何正确地做事很重要。不幸的是,在这种特殊情况下,与关系数据库尝试执行的操作相比,您习惯使用的锁定原语非常原始。但数据库正试图优化速度和完整性,而不是简单熟悉的推理。
如果这对您来说是一个有趣的弯路,我希望您可以查看 Joe Celko 的一本书或数据库教科书以获取更多信息。