PostgreSQL 设置唯一主键的生产事故复盘与最佳实践

一、事故背景

某 PostgreSQL 生产实例突然出现异常:

  • API 超时增加

  • 数据库连接数持续上涨

  • 新请求开始失败

  • 最终接近 max_connections

怀疑数据库内部存在阻塞。

登录数据库准备查看对象状态:

复制代码
\d+

结果发现:

\d+ 本身也卡住了。

这说明问题已经不仅是业务 SQL。

可能涉及:

  • DDL锁

  • catalog阻塞

  • 长事务

  • 系统表等待

开始排查。


二、第一步:查看数据库活动会话

首先查看当前数据库所有连接。

复制代码
SELECT
    pid,
    usename,
    wait_event_type,
    wait_event,
    state,
    query
FROM pg_stat_activity
WHERE datname=current_database();

发现大量会话处于:

复制代码
wait_event_type = Lock
wait_event = relation
state = active

例如:

复制代码
SELECT ...
FROM biz_table
WHERE ...

UPDATE biz_table
SET ...
WHERE ...

这说明:

业务 SQL 不是慢,而是在等待表锁。


三、第二步:确认是否存在锁阻塞

使用 blocker / blocked 排查 SQL。

复制代码
SELECT
    blocked.pid AS blocked_pid,
    blocked.usename AS blocked_user,
    blocked.wait_event_type,
    blocked.wait_event,
    now()-blocked.query_start AS blocked_duration,
    blocked.query AS blocked_query,

    blocker.pid AS blocker_pid,
    blocker.usename AS blocker_user,
    blocker.state AS blocker_state,
    now()-blocker.query_start AS blocker_duration,
    blocker.query AS blocker_query

FROM pg_stat_activity blocked
JOIN pg_locks bl
    ON bl.pid=blocked.pid

JOIN pg_locks kl
    ON kl.locktype=bl.locktype
    AND kl.database IS NOT DISTINCT FROM bl.database
    AND kl.relation IS NOT DISTINCT FROM bl.relation
    AND kl.page IS NOT DISTINCT FROM bl.page
    AND kl.tuple IS NOT DISTINCT FROM bl.tuple
    AND kl.virtualxid IS NOT DISTINCT FROM bl.virtualxid
    AND kl.transactionid IS NOT DISTINCT FROM bl.transactionid
    AND kl.classid IS NOT DISTINCT FROM bl.classid
    AND kl.objid IS NOT DISTINCT FROM bl.objid
    AND kl.objsubid IS NOT DISTINCT FROM bl.objsubid
    AND kl.pid<>bl.pid

JOIN pg_stat_activity blocker
    ON blocker.pid=kl.pid

WHERE
    NOT bl.granted
    AND kl.granted

ORDER BY blocked.query_start;

结果:

大量 blocked session 指向同一个 blocker。


四、第三步:找到真正的阻塞源

最终定位到唯一 blocker:

复制代码
ALTER TABLE biz_table
ALTER COLUMN id_col SET NOT NULL,
ALTER COLUMN id_col
ADD GENERATED BY DEFAULT AS IDENTITY
(
    MINVALUE 0
    RESTART WITH 39252882
    CACHE 1
),
ADD PRIMARY KEY(id_col,status_col);

至此基本确认:

生产环境执行了一次重型 DDL。


五、第四步:查看锁详情

进一步确认锁类型。

复制代码
SELECT
    a.pid,
    a.usename,
    l.mode,
    l.granted,
    a.state,
    now()-a.query_start AS duration,
    a.query
FROM pg_locks l
JOIN pg_stat_activity a
    ON a.pid=l.pid
WHERE l.relation='biz_table'::regclass
ORDER BY l.granted,a.query_start;

重点关注:

复制代码
AccessExclusiveLock

该锁会阻塞:

复制代码
SELECT
INSERT
UPDATE
DELETE

也就是说:

整张表不可访问。


六、第五步:分析为什么 \d+ 会卡住

为了确认 \d+ 做了什么,打开 psql 隐藏 SQL 输出。

复制代码
\set ECHO_HIDDEN on

然后再次执行:

复制代码
\d+

可以看到其内部执行的是 catalog 查询:

复制代码
SELECT ...
FROM pg_catalog.pg_class
JOIN pg_catalog.pg_namespace ...
LEFT JOIN pg_catalog.pg_description ...

由于存在 DDL 强锁竞争。

最终导致:

复制代码
\d+ 也被锁等待

七、根因分析

看似简单的 DDL:

复制代码
ALTER TABLE
SET NOT NULL
ADD IDENTITY
ADD PRIMARY KEY

实际上包含三个重操作。


1)SET NOT NULL

可能触发:

复制代码
全表扫描验证

2)ADD GENERATED AS IDENTITY

修改:

复制代码
sequence metadata
catalog metadata

3)ADD PRIMARY KEY

核心问题。

如果不存在现成索引。

PostgreSQL 会隐式执行:

复制代码
CREATE UNIQUE INDEX ...

而且:

不是 CONCURRENTLY。

意味着:

复制代码
ACCESS EXCLUSIVE LOCK
+
全表扫描
+
排序
+
索引构建

最终导致:

复制代码
业务 SQL 堆积
连接数上涨
max_connections 接近耗尽
\d+ 卡死

八、现场恢复

首先取消 DDL。

优先尝试:

复制代码
SELECT pg_cancel_backend(<pid>);

无法退出再使用:

复制代码
SELECT pg_terminate_backend(<pid>);

阻塞解除后。

连接逐渐恢复。


九、正确的大表 DDL 最佳实践

原始写法:

复制代码
ALTER TABLE ...
SET NOT NULL
ADD IDENTITY
ADD PRIMARY KEY

不适合生产大表。

推荐拆分。


Step1:在线验证 NOT NULL

添加未验证约束

复制代码
ALTER TABLE biz_table
ADD CONSTRAINT biz_table_id_nn
CHECK(id_col IS NOT NULL)
NOT VALID;

特点:

项目 影响
在线 YES
全表扫描 NO
短暂 ACCESS EXCLUSIVE

后台验证

复制代码
ALTER TABLE biz_table
VALIDATE CONSTRAINT biz_table_id_nn;

特点:

项目 影响
在线 YES
全表扫描 YES
阻塞DML NO

检查状态:

复制代码
SELECT
    conname,
    convalidated
FROM pg_constraint
WHERE conname='biz_table_id_nn';

必须为:

复制代码
t

Step2:在线创建唯一索引

不要直接 ADD PRIMARY KEY。

先执行:

复制代码
CREATE UNIQUE INDEX CONCURRENTLY idx_biz_pk
ON biz_table(id_col,status_col);

特点:

项目 影响
在线 YES
阻塞DML NO
事务内执行 NO

检查索引状态:

复制代码
SELECT
    indexrelid::regclass,
    indisvalid,
    indisready
FROM pg_index
WHERE indexrelid='idx_biz_pk'::regclass;

Step3:短窗口切换

低峰执行。

复制代码
ALTER TABLE biz_table
ALTER COLUMN id_col SET NOT NULL;

ALTER TABLE biz_table
ADD CONSTRAINT biz_table_pkey
PRIMARY KEY USING INDEX idx_biz_pk;

ALTER TABLE biz_table
ALTER COLUMN id_col
ADD GENERATED BY DEFAULT AS IDENTITY(...);

特点:

项目 影响
在线 部分
短暂 ACCESS EXCLUSIVE
推荐 低峰执行

十、总结

这次事故链路:

复制代码
重型 ALTER TABLE
        ↓
ACCESS EXCLUSIVE LOCK
        ↓
relation wait 堆积
        ↓
业务查询阻塞
        ↓
连接数打满
        ↓
\d+ 卡死

经验总结:

不要直接:

复制代码
ALTER TABLE ADD PRIMARY KEY

大表主键迁移:

先:

复制代码
CREATE UNIQUE INDEX CONCURRENTLY

再:

复制代码
PRIMARY KEY USING INDEX

大表 NOT NULL 迁移:

推荐:

复制代码
CHECK NOT VALID
    ↓
VALIDATE
    ↓
SET NOT NULL

生产DDL原则

长耗时操作在线完成。

最终 schema 切换放入短窗口。

相关推荐
码不停蹄的玄黓1 小时前
MySQL索引类型
数据库·mysql
或与且与或非1 小时前
postgresql+rabbitmq集群搭建方案
数据库·postgresql·rabbitmq
AllData公司负责人1 小时前
亲测丝滑,体验跃迁|AllData通过集成开源项目Cube-Studio,降低机器学习落地门槛
java·大数据·数据库·人工智能·机器学习·开源·cube-studio
KaMeidebaby1 小时前
卡梅德生物技术快报|抗体的制备与纯化:分子实验实操:番茄 sHSP 重组表达与抗体的制备与纯化工艺
前端·数据库·人工智能·其他·算法·百度·新浪微博
幻灭行度1 小时前
Redis ACL 实现多账号权限隔离
数据库·redis·oracle
Kurisu5752 小时前
深度解析:Go 语言 GMP 调度器模型与内核线程探测
java·数据库·golang
Lao A(zhou liang)的菜园2 小时前
作为Oracle DBA,如何快速处理HANG类故障?
数据库·oracle·dba
Lao A(zhou liang)的菜园2 小时前
作为DBA,如何快速处理Oracle连接类故障?
数据库·oracle·dba
效能革命笔记2 小时前
Gitee Team:以数据驱动与精细化管理,支撑关键领域 DevSecOps 落地
数据库·gitee