“Traffic Jam”导致数据库崩溃(以及如何修复)

发布: (2025年12月22日 GMT+8 15:54)
7 min read
原文: Dev.to

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 % —— 没有死锁、没有资金损失、没有愤怒的用户。

要点

  1. 死锁是时序错误,而不是逻辑错误。
  2. FOR UPDATE 功能强大,但如果锁的顺序不一致则很危险。
  3. 一行代码的解决方案: 对 ID(或任何可比较的资源标识符)进行排序,并始终按该顺序加锁。
  4. 测试你的并发! 使用类似 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 进行排序。拯救你的数据库。

Back to Blog

相关文章

阅读更多 »