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 切换放入短窗口。

相关推荐
yanwumuxi1 小时前
【数据库系列】opensearch数据备份和恢复
数据库
anxiao_m1 小时前
2026企业AI数字孪生选型攻略,不同场景对应不同解决方案
大数据·网络·数据库
SelectDB1 小时前
从 ClickHouse 迁移到 Doris:SQL 兼容、同步与验证清单(实战笔记)
大数据·数据库·数据分析
SelectDB1 小时前
Doris 还是 ClickHouse?一张表说清 6 个关键差异(实战笔记)
大数据·数据库·数据分析
oradh2 小时前
Oracle Undo问题总结(两类Undo表空间不足和单个会话占用大量Undo的性能问题)
数据库·oracle
企业数字化笔记2 小时前
固定资产财务账与实物账怎么对账?折旧快照、差异检测与SQL核对
android·数据库·sql
DBA_G2 小时前
GBase 8a表空间多路径存储数据迁移链路适配解析
数据库·oracle
java1234_小锋2 小时前
Tornado 6.5,全面支持 Python 3.14
数据库·python·tornado
jyOverQ2 小时前
MySQL 事务到底是什么?ACID 四大特性与隔离级别怎么理解?
数据库·mysql
SelectDB3 小时前
Doris 直查 Paimon 索引:快手湖上向量检索的共建与落地
大数据·数据库·数据分析