“Traffic Jam”导致数据库崩溃(以及如何修复)
Source: Dev.to
请提供您希望翻译的具体文本内容,我将按照要求保留源链接并进行简体中文翻译。
问题
你编写了一个完美的银行 API。逻辑严密,数学无误,单元测试始终通过。
然后你上线了。
流量激增。两个用户尝试 同时 向对方转账。突然,你的日志里出现了大量红色文字:
Deadlock detected.
数据库并不是因为你的数学错误而崩溃,而是因为 时序 的错误导致崩溃。
数据库死锁的样子
死锁本质上就像西部电影中的僵持局面。
想象两个朋友,Alice 和 Bob。
他们决定在同一毫秒向对方各发送 $10。
数据库内部发生了什么
| 事务 | 操作 | 锁 |
|---|---|---|
| A(Alice → Bob) | SELECT … FOR UPDATE on Alice’s row | 锁定 Alice |
| 需要锁定 Bob 的行以存款 | 等待 Bob | |
| B(Bob → Alice) | SELECT … FOR UPDATE on Bob’s row | 锁定 Bob |
| 需要锁定 Alice 的行以存款 | 等待 Alice |
- 事务 A 等待 Bob。
- 事务 B 等待 Alice。
两者都无法继续 → 循环等待 → 死锁。
现代数据库引擎会检测到这个循环并 终止其中一个事务,向用户返回通用的 500 Internal Server Error。
“不安全”的转账端点
我们使用 FastAPI + psycopg。在 不安全 版本中,我们先锁定 发送方,再锁定 接收方——这看起来合乎逻辑,但实际上很危险。
# unsafe_transfer.py
@app.post("/transfer/unsafe")
def transfer_unsafe(req: TransferRequest):
with pool.connection() as conn:
with conn.cursor() as cur:
# 1️⃣ Lock the sender's account
cur.execute(
"SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
(req.from_account,)
)
current_balance = cur.fetchone()[0]
if current_balance Bob
[3] ❌ DEADLOCK: Bob -> Alice
[2] ✅ Success: Alice -> Bob
[5] ❌ DEADLOCK: Bob -> Alice
[4] ✅ Success: Alice -> Bob
...
Success: 6
Deadlocks: 14
数据库抛出 DeadlockDetected,回滚事务,导致资金未能转移。用户非常生气。
你无法通过更快的硬件来解决这个问题。
你无法通过“优化”SQL 来解决这个问题。
你需要通过几何方式来解决——即保持一致的锁定顺序。
Source: …
修复方案:强制全局锁顺序
为了防止循环,每个事务必须以完全相同的顺序获取锁。
最简单的规则:始终先锁定 ID 较小的账户。
为什么有效
| 场景 | 锁顺序(安全) |
|---|---|
| Alice(ID 1)→ Bob(ID 2) | 锁 1 → 锁 2 |
| Bob(ID 2)→ Alice(ID 1) | 锁 1 → 锁 2 |
两个事务现在争夺相同的第一个锁(ID 1)。一个获得锁,另一个等待。没有循环等待 → 没有死锁。
安全的转账端点
# safe_transfer.py
@app.post("/transfer/safe")
def transfer_safe(req: TransferRequest):
# 确定锁顺序:先锁低 ID,再锁高 ID
first_lock_id = min(req.from_account, req.to_account)
second_lock_id = max(req.from_account, req.to_account)
with pool.connection() as conn:
with conn.cursor() as cur:
# 1️⃣ 按固定且一致的顺序锁定账户
cur.execute(
"SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
(first_lock_id,)
)
# 即使有延迟,第二个事务也只是在等待第一个锁。
time.sleep(0.1)
cur.execute(
"SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
(second_lock_id,)
)
# 2️⃣ 现在我们拥有了两个锁,可以安全地检查余额并进行转账。
# (插入与之前相同的余额检查和 UPDATE 逻辑)
# …
conn.commit()
唯一的改动:在任何 SELECT … FOR UPDATE 之前对两个 ID 进行排序。其他保持不变。
修复后的结果
运行 完全相同 的 attack.py 脚本针对 /transfer/safe,得到:
--- Starting Attack ---
Mode: SAFE (Fixed)
Users: 8
[1] ✅ Success: Alice -> Bob
[2] ✅ Success: Bob -> Alice
[3] ✅ Success: Alice -> Bob
[4] ✅ Success: Bob -> Alice
...
Success: 16
Deadlocks: 0
延迟略有增加(因为第二个事务必须等待第一个锁),但 可靠性为 100 % —— 没有死锁、没有资金损失、没有愤怒的用户。
要点
- 死锁是时序错误,而不是逻辑错误。
FOR UPDATE功能强大,但如果锁的顺序不一致则很危险。- 一行代码的解决方案: 对 ID(或任何可比较的资源标识符)进行排序,并始终按该顺序加锁。
- 测试你的并发! 使用类似
attack.py的脚本来验证你的接口能否在并发请求下存活。
通过强制使用确定性的锁顺序,你可以消除导致死锁的循环依赖,使你的银行 API 在真实流量高峰下也能保持稳健。 🚀
8
- ✅ Success: Bob → Alice
- ✅ Success: Alice → Bob
- ✅ Success: Bob → Alice
- ✅ Success: Alice → Bob
- …
**Success:** 20
**Deadlocks:** 0
Zero crashes. Perfect consistency.
Deadlocks aren’t random bad luck. They are predictable consequences of inconsistent locking.
If you touch multiple rows in a single transaction, always touch them in the same order.
Sort your IDs. Save your database.
零崩溃。完美的一致性。
死锁不是随机的不幸。它们是锁定不一致的可预见后果。
如果在单个事务中操作多行数据,始终以相同的顺序操作它们。
对你的 ID 进行排序。拯救你的数据库。