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

相关推荐
ClouGence7 小时前
Oracle CDC 架构优化:从主库直连到 DataGuard 备库同步
数据库·后端·oracle
无响应de神9 小时前
三、用户与权限管理
数据库·mysql
麦聪聊数据1 天前
数据服务化时代:企业数据能力输出的核心路径
数据库
shushangyun_1 天前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化
DARLING Zero two♡1 天前
【MySQL数据库】数据类型与表约束
数据库·mysql
曹牧1 天前
Oracle EXPLAIN PLAN
数据库·oracle
BD_Marathon1 天前
SQL学习指南——视图
数据库·sql
活宝小娜1 天前
mysql详细安装教程
数据库·mysql·adb
贤时间1 天前
codex 助力oracle ebs 开发
数据库·oracle
秉承初心1 天前
PostgreSQL 数据性能瓶颈突破实战
数据库·postgresql·oracle