死锁听起来像数据库内部的小插曲:两个事务互相等锁,其中一个被数据库杀掉,应用重试一下就完了。现实里,它经常是停机事故的前奏。查询写得不稳、事务持锁太久、应用无限并发冲击同一批行,都会把一次死锁扩大成连接池耗尽、请求排队、数据库 CPU 飙升,最后变成用户可见的不可用。
死锁为什么会升级成停机
典型死锁并不复杂:事务 A 锁住了 orders:1,等待 payments:1;事务 B 锁住了 payments:1,等待 orders:1。数据库会检测环路,然后选择一个事务回滚。
问题在于应用通常不会只跑两个事务。高峰期里,同一段业务代码可能被几百个请求同时执行。如果每个请求都以不同顺序访问相同资源,死锁会频繁出现。数据库开始反复检测、回滚、清理事务;应用层看到异常后立刻重试;重试又制造更多并发写入。这个反馈环会把局部锁竞争变成系统级拥塞。
停机往往不是“数据库不会处理死锁”,而是“应用没有给数据库喘息空间”。
降低死锁:从查询顺序和事务长度下手
减少死锁的第一条工程规则是:让事务以一致顺序获取锁。比如更新账户转账时,不要按请求里的 from_account_id、to_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、连接池等待、慢查询、锁等待时间,再决定是否收紧写入并发。
落地检查清单
处理死锁,别只盯着报错堆栈。可以按这个顺序推进:
- 找出最常发生死锁的事务路径,记录涉及的表、索引和访问顺序。
- 让同类事务按一致顺序锁定资源,尤其是多行更新、多表更新、余额和库存类逻辑。
- 缩短事务时间,避免在事务里调用外部 HTTP、做大批量计算或等待用户输入。
- 为关键查询补齐合适索引,减少不必要的扫描和锁范围扩大。
- 只对可恢复数据库错误做有限重试,并使用退避和抖动。
- 给热点写路径加 Traffic Control:并发上限、队列、超时、熔断都比无限堆请求更可控。
死锁本身不是灾难。真正危险的是应用把死锁当成普通异常,立刻用更多请求和更多重试回应它。数据库负责发现环路,应用负责控制节奏;两边都做好,死锁才会停留在日志里,而不是升级成停机。