死锁不是小概率异常:它如何拖垮数据库,以及应用该怎么收手

2026-07-08 41 预计阅读时间: 1 分钟
来源: planetscale.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

死锁听起来像数据库内部的小插曲:两个事务互相等锁,其中一个被数据库杀掉,应用重试一下就完了。现实里,它经常是停机事故的前奏。查询写得不稳、事务持锁太久、应用无限并发冲击同一批行,都会把一次死锁扩大成连接池耗尽、请求排队、数据库 CPU 飙升,最后变成用户可见的不可用。

死锁为什么会升级成停机

典型死锁并不复杂:事务 A 锁住了 orders:1,等待 payments:1;事务 B 锁住了 payments:1,等待 orders:1。数据库会检测环路,然后选择一个事务回滚。

问题在于应用通常不会只跑两个事务。高峰期里,同一段业务代码可能被几百个请求同时执行。如果每个请求都以不同顺序访问相同资源,死锁会频繁出现。数据库开始反复检测、回滚、清理事务;应用层看到异常后立刻重试;重试又制造更多并发写入。这个反馈环会把局部锁竞争变成系统级拥塞。

停机往往不是“数据库不会处理死锁”,而是“应用没有给数据库喘息空间”。

降低死锁:从查询顺序和事务长度下手

减少死锁的第一条工程规则是:让事务以一致顺序获取锁。比如更新账户转账时,不要按请求里的 from_account_idto_account_id 顺序随意锁行,而是按主键排序后锁定。

可以这样实践。下面示例使用 PostgreSQL,演示如何固定锁顺序,并在死锁或锁等待超时时做有限重试。

运行前准备:需要本机有 PostgreSQL,并把 DATABASE_URL 改成你的连接串。

python -m venv .venv
. .venv/bin/activate
pip install psycopg[binary]
export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"

创建表和测试数据:

psql "$DATABASE_URL" <<'SQL'
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
  id BIGINT PRIMARY KEY,
  balance NUMERIC NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts (id, balance) VALUES (1, 1000), (2, 1000)
ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance;
SQL

保存为 transfer.py

import os
import random
import time

import psycopg
from psycopg import errors

DATABASE_URL = os.environ["DATABASE_URL"]


def transfer(from_id: int, to_id: int, amount: int) -> None:
    # 固定锁顺序:无论转账方向如何,都先锁较小 id。
    lock_ids = sorted([from_id, to_id])

    for attempt in range(5):
        try:
            with psycopg.connect(DATABASE_URL) as conn:
                with conn.transaction():
                    with conn.cursor() as cur:
                        cur.execute("SET LOCAL lock_timeout = '2s'")
                        cur.execute(
                            "SELECT id FROM accounts WHERE id = ANY(%s) ORDER BY id FOR UPDATE",
                            (lock_ids,),
                        )
                        cur.execute(
                            "UPDATE accounts SET balance = balance - %s WHERE id = %s",
                            (amount, from_id),
                        )
                        cur.execute(
                            "UPDATE accounts SET balance = balance + %s WHERE id = %s",
                            (amount, to_id),
                        )
            return
        except (errors.DeadlockDetected, errors.LockNotAvailable) as exc:
            if attempt == 4:
                raise
            sleep = min(0.5, 0.05 * (2 ** attempt)) + random.uniform(0, 0.05)
            print(f"retry after {type(exc).__name__}, attempt={attempt + 1}, sleep={sleep:.3f}s")
            time.sleep(sleep)


if __name__ == "__main__":
    transfer(1, 2, 10)
    transfer(2, 1, 5)
    print("done")

运行:

python transfer.py
psql "$DATABASE_URL" -c "SELECT * FROM accounts ORDER BY id;"

这段代码里有三个关键点:

  • ORDER BY id FOR UPDATE 让事务按稳定顺序拿锁。
  • SET LOCAL lock_timeout = '2s' 避免请求无限等待锁。
  • 重试次数有限,并带指数退避和随机抖动,避免所有请求同时重试。

重试不是“再来一次”这么简单

死锁受害事务会被回滚,重试是合理的。但重试必须有边界。

危险的重试通常长这样:捕获所有数据库异常,立即 while true 重跑事务。这会在数据库最拥挤的时候继续加压。更稳的做法是只重试可恢复错误,比如 deadlock、serialization failure、lock timeout;设置最大次数;把最终失败返回给上游,让限流、队列或用户提示接管。

另外,重试要求操作具备幂等性。转账、扣库存、发放优惠券这类操作,最好带业务幂等键,例如 request_id,避免应用在网络超时后重复提交,数据库虽然成功提交一次,调用方却误以为失败再发一次。

一个常见表结构可以这样设计:

CREATE TABLE transfer_requests (
  request_id TEXT PRIMARY KEY,
  from_account_id BIGINT NOT NULL,
  to_account_id BIGINT NOT NULL,
  amount NUMERIC NOT NULL,
  status TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

应用在处理前先插入 request_id。如果主键冲突,就返回之前的处理结果,而不是重新扣款。

Traffic Control:让应用别把数据库压到失控

来源摘要提到 Traffic Control 的作用:保护数据库免受应用流量冲击。这个点很重要,因为死锁治理不只在 SQL 层。即使查询顺序修好了,高峰流量、批处理任务、突发重试仍可能让数据库进入锁竞争和排队。

如果没有现成的 Traffic Control 产品或中间层,可以先在应用侧实现两个控制面:

  • 对高风险写操作设置并发上限。
  • 对数据库错误率、锁等待时间、连接池排队时间设置熔断或降级策略。

下面是一个可以改造到 Python 服务里的最小示例,用信号量限制同一进程内的并发数据库写入。它不是完整的分布式限流,但能表达基本思路:当数据库已经紧张时,应用要排队或拒绝,而不是继续放大压力。

import asyncio

DB_WRITE_LIMIT = 20
write_gate = asyncio.Semaphore(DB_WRITE_LIMIT)


async def handle_write_request(payload):
    try:
        await asyncio.wait_for(write_gate.acquire(), timeout=0.2)
    except asyncio.TimeoutError:
        return {"status": 503, "body": "database is busy, retry later"}

    try:
        # 在这里执行数据库事务:扣库存、转账、写订单等。
        await run_database_transaction(payload)
        return {"status": 200, "body": "ok"}
    finally:
        write_gate.release()


async def run_database_transaction(payload):
    await asyncio.sleep(0.05)

生产环境里,这类控制通常需要结合指标动态调整,而不是写死一个数字。比如观察 PostgreSQL 的 deadlocks、连接池等待、慢查询、锁等待时间,再决定是否收紧写入并发。

落地检查清单

处理死锁,别只盯着报错堆栈。可以按这个顺序推进:

  1. 找出最常发生死锁的事务路径,记录涉及的表、索引和访问顺序。
  2. 让同类事务按一致顺序锁定资源,尤其是多行更新、多表更新、余额和库存类逻辑。
  3. 缩短事务时间,避免在事务里调用外部 HTTP、做大批量计算或等待用户输入。
  4. 为关键查询补齐合适索引,减少不必要的扫描和锁范围扩大。
  5. 只对可恢复数据库错误做有限重试,并使用退避和抖动。
  6. 给热点写路径加 Traffic Control:并发上限、队列、超时、熔断都比无限堆请求更可控。

死锁本身不是灾难。真正危险的是应用把死锁当成普通异常,立刻用更多请求和更多重试回应它。数据库负责发现环路,应用负责控制节奏;两边都做好,死锁才会停留在日志里,而不是升级成停机。


相关推荐