PostgreSQL 行锁如何拖垮连接池
文章通过多个工作线程争抢同一账户行的场景,复现 SELECT ... FOR UPDATE 阻塞如何耗尽连接池并引发服务故障。内容覆盖复现流程、测试依赖问题及生产环境缓解思路。
AI 深度解读
PostgreSQL 的行锁竞争,为什么会拖垮整个 API?关键在于,等待行锁的请求也占着数据库连接。热点记录前面的事务迟迟不结束,后面的请求就可能把连接池占满,连访问其他记录的业务也跟着排队。
原文用 SQLAlchemy 脚本解释这条故障链,并提出锁等待超时和序列化依赖回退两项改进。不过,它的验证在导入依赖时就失败了。机制分析有参考价值,实验究竟证明了多少,需要分开看。
为什么一行数据被锁,会让整个服务缺连接
作者设计的场景很直接:多个 Worker 同时访问账户表 test_accounts 中 id = 1 的记录,通过 SELECT ... FOR UPDATE 获取排他行锁。
Worker 1 先拿到锁,在事务内停留 3.0s,然后提交。Worker 2 此时执行同样的查询,只能等待前者释放锁。问题在于,Worker 2 已经从连接池借出了连接,等待期间也不会归还。
连接池管理的是连接能否借出,不会因为查询正在等锁,就自动把连接交给其他请求。
原文的 SQLAlchemy 配置是 pool_size=5、max_overflow=2、pool_timeout=10。在这套配置下,池内常规连接加临时溢出连接,最多能提供 7 个连接。如果持锁事务和等待事务持续占用它们,新请求就只能等连接归还;超过借连接的等待期限,还会遭遇连接池超时。
这里有两种等待,不能混为一谈:
pool_timeout=10管的是应用等待借出连接的时间。lock_timeout管的是 PostgreSQL 执行语句时等待获取锁的时间。
给连接池设置超时,并不能替已经借出连接的查询终止锁等待。
原文把这条链路延伸到 Worker 饥饿和 API 故障,方向合理。但示例实际使用的是 Python threading.Thread,没有实现异步任务系统,也没有展示整个 API 的故障过程。
这个脚本展示了机制,却没有完成耗尽验证
脚本使用 SQLAlchemy、Pydantic v2、PostgreSQL 和 psycopg,通过 postgresql+psycopg:// 连接数据库。初始化时,它先删除并重建表,再插入一条 balance=1000、version=1 的账户记录。
随后,两个线程相隔 0.5s 启动。每个线程获取行锁后执行 time.sleep(3),再提交事务。这足以构造一次清晰的行锁等待,却不足以证明容量为 7 的连接池已经耗尽。
而且,main() 只启动了 use_timeout=False 的场景。虽然 worker_transaction() 内提供了超时开关,原文没有展示开启超时后的运行结果,也没有提供并发压测、连接占用曲线或请求失败比例。
更关键的是,作者报告的自动化 QA 和沙箱验证,在 import orjson 时就停止了:
ModuleNotFoundError: No module named 'orjson'
之后,依赖检查将它转换为 RuntimeError,提示执行 pip install sqlalchemy pydantic orjson psycopg。数据库逻辑根本没有运行。
因此,原文实际记录到的是「缺少依赖时立即失败」,并非「连接池耗尽后成功恢复」。作者也没有给出依赖安装完成后重新验证的结果。
初始化逻辑同样需要留意:Base.metadata.drop_all(bind=engine) 会删除模型对应的表。这段脚本适合放在隔离的实验数据库里,不能直接指向业务库。
锁等待要有限,诊断脚本也要能跑起来
作者最有价值的建议,是在事务中明确设置锁等待期限。原文指出,PostgreSQL 的 lock_timeout 默认值为 0,即该参数不限制锁等待时间。
下面这句 SQL 用来为当前事务设置锁等待超时:
-- Apply immediately upon opening the transaction context
SET LOCAL lock_timeout = '2s';
要看的是 SET LOCAL:设置作用于当前事务,应当在需要获取锁的语句之前执行。
这里的 2s 限制的是获取锁时的等待,并不限制事务的全部执行时间,也不会让已经拿到锁的 Worker 自动停止那段 3s 的处理。原文所说的「确定性恢复」因此偏强:超时可以切断一种持续等待,但服务能否恢复,还取决于事务是否回滚、连接是否归还,以及后续请求如何处理。
原文提到 QueryCanceled 和 LockNotAvailable,脚本则统一捕获 OperationalError、DBAPIError,调用 session.rollback(),最后通过 session.close() 释放会话资源。实际接入时,需要核对驱动和 ORM 的异常映射,不能把这几个异常名当成可直接互换的接口。
另一项改进针对依赖:诊断工具只是为了打印账户数据,却强制依赖 orjson,导致数据库实验无法开始。作者提出保留优化路径,同时允许标准库接管:
try:
import orjson
def serialize_data(obj) -> bytes:
return orjson.dumps(obj)
def deserialize_data(payload: bytes):
return orjson.loads(payload)
except ImportError:
import json
def serialize_data(obj) -> bytes:
return json.dumps(obj).encode("utf-8")
def deserialize_data(payload: bytes):
return json.loads(payload.decode("utf-8"))
注意回退分支中的 .encode("utf-8"):它把标准库输出转换成 bytes,保持了这里的返回类型约定。但返回类型一致,不代表两种库处理所有数据的行为都一致;超出示例中的简单账户字段,仍需检查兼容性。
依赖回退也不能代替环境声明。哪些包必须安装、采用什么版本,仍应通过锁文件、容器镜像或流水线配置写清楚。原文没有提供完整的版本锁定信息。
我的判断
我认同作者对故障扩散路径的解释:局部热点行上的等待,可以通过共享连接池影响其他业务。排查时只看数据库 CPU 或连接数上限,容易漏掉「连接已经借出,但都在等同一把锁」的问题。
相比直接扩大连接池,锁等待超时更贴近这里的故障机制。扩池可能暂时容纳更多请求,却不会让同一条记录上的排他操作并行完成。另一方面,超时只决定等多久,缩短持锁事务才是在减少等待来源,两者需要一起考虑。
这篇更适合作为排障假设和实验起点,不能当作经过压测验证的生产方案。
作者没有提供改造前后的对照数据,2s 也只是示例值,无法据此判断它适合支付、库存还是普通后台任务。原文建议的指数退避和熔断同样停留在方向层面,没有给出实现和验证。超时后立即重试,甚至可能让热点记录继续承受集中访问。
orjson 回退对诊断工具很实用,但它与连接池耗尽属于两个层面的问题:前者决定实验能不能启动,后者决定运行时如何控制竞争。把两者都称为架构约束可以,把它们并列视为服务稳定性的充分保证,就超出了原文证据。
能拿来做什么
- 排查 Node 或全栈服务的数据库超时时,把等待借连接和等待数据库锁分开记录;即使最终都表现为接口超时,它们需要调整的位置也不同。
- 为热点记录的事务检查锁等待期限,并确认超时后的回滚和连接释放路径;采用
SET LOCAL lock_timeout = '2s'前,应结合业务期限选择数值。 - 复现原文时,先在隔离数据库补齐依赖,再提高并发量,并分别运行
use_timeout=False和use_timeout=True,观察连接占用、异常和释放情况,补上原文缺失的验证。 - 编写数据库诊断脚本时,让可选的序列化优化具备回退能力,同时声明必需依赖,避免工具还没执行目标 SQL,就被外围组件挡住。