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

相关推荐
不像程序员的程序媛8 小时前
系统cpu内存负载资源分析
运维·服务器·数据库
Jane Chiu9 小时前
Oracle DBMS_REDEFINITION(在线重定义)
数据库·oracle·分区表
全栈前端老曹9 小时前
【MongoDB】安全与权限管理 —— 用户认证、角色权限、SSL 加密
前端·javascript·数据库·安全·mongodb·nosql·ssl
怕孤单的草丛9 小时前
缓存管理面临的主要问题
java·数据库·缓存
我会尽全力 乐观而坚强10 小时前
MySQL零基础入门(二)
数据库·mysql·adb
kali-Myon11 小时前
某校园门禁系统高危 SQL 注入漏洞挖掘复盘
数据库·sql·安全·web安全
程序员在囧途12 小时前
likeadmin-api API 中转站怎么做统一报价?从 /pricing、/user/balance 到 task_id 回执的落地方法
运维·服务器·数据库·likeadmin-api·api中转站·token计费
龙石数据13 小时前
MySQL 全量同步到 Hive 怎么做?三步配置教程
数据库·hive·mysql·数据治理·数据中台
程序员在囧途14 小时前
likeadmin-api API 算力超市怎么做供应商切换?统一鉴权、task_id 和 callback_url 才能稳交付
java·服务器·数据库·开放api·likeadmin-api·api算力超市