SELECT current_setting('server_version') AS server_version,
current_setting('block_size') AS block_size,
current_setting('max_connections') AS max_connections,
current_setting('transaction_isolation') AS default_isolation;
server_version
block_size
max_connections
default_isolation
18.6
8192
100
read committed
(1 行)
默认的 100 个连接里,有 3 个留给超级用户救急。普通角色把其余的用完,就会看到上面那条报错;如果应用用的就是超级用户,则要等 100 个全满,报的是 FATAL: sorry, too many clients already。两条的错误码都是 53300。
SELECT id, created_at FROM orders WHERE id = 42;
SELECT count(*) AS orders, sum(total_cents) AS revenue_cents
FROM orders WHERE created_at::date = date '2026-03-01';
id
created_at
42
2025-01-02 10:54:00+08
(1 行)
orders
revenue_cents
38
635993
(1 行)
把会话时区换成 UTC,再执行完全相同的两条:
SET TIME ZONE 'UTC';
SELECT id, created_at FROM orders WHERE id = 42;
SELECT count(*) AS orders, sum(total_cents) AS revenue_cents
FROM orders WHERE created_at::date = date '2026-03-01';
SET TIME ZONE 'Asia/Shanghai';
SELECT count(*) AS orders, sum(total_cents) AS revenue_cents
FROM orders
WHERE created_at >= timestamptz '2026-03-01 00:00+08'
AND created_at < timestamptz '2026-03-02 00:00+08';
SELECT count(*) AS all_orders,
count(*) FILTER (WHERE store_id <> 3) AS not_equal_3,
count(*) FILTER (WHERE store_id IS DISTINCT FROM 3) AS is_distinct_from_3,
count(*) FILTER (WHERE store_id IS NULL) AS online_orders
FROM orders;
SELECT (SELECT count(*) FROM members m
WHERE m.id NOT IN (SELECT member_id FROM orders)) AS not_in,
(SELECT count(*) FROM members m
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id)) AS not_exists;
not_in
not_exists
0
500
(1 行)
子查询的结果里只要有一个 NULL(这里是游客订单),NOT IN 对每一行都得不出“真”,结果是 0 行,而且不报错。NOT EXISTS 给出的 500 才是对的。凡是“不在某个集合里”的查询,都用 NOT EXISTS。
UPDATE inventory SET qty = qty - 5 WHERE store_id = 1 AND book_id = 1;
INSERT INTO payment_events (provider_event_id, order_id, amount_cents)
VALUES ('evt_typo', 999999, 4098);
ERROR: new row for relation "inventory" violates check constraint "inventory_qty_check"SQLSTATE 23514DETAIL: Failing row contains (1, 1, -4).
ERROR: insert or update on table "payment_events" violates foreign key constraint "payment_events_order_id_fkey"SQLSTATE 23503DETAIL: Key (order_id)=(999999) is not present in table "orders".
SELECT relname, relpages, reltuples::bigint AS reltuples, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class WHERE relname IN ('orders', 'orders_pkey') ORDER BY relname;
SELECT lower, upper, upper - lower AS free_bytes FROM page_header(get_raw_page('orders', 0));
SELECT lp, lp_off, lp_len FROM heap_page_items(get_raw_page('orders', 0)) WHERE lp BETWEEN 41 AND 43;
SELECT count(*) AS tuples_on_page_0 FROM heap_page_items(get_raw_page('orders', 0));
CREATE INDEX orders_status_idx ON orders (status);
SELECT most_common_vals, most_common_freqs
FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
random_page_cost 默认是 4。这个数已经假设大部分随机读能命中缓存,机械硬盘真实的随机读比它贵得多。数据在固态硬盘上或者基本都在内存里时,很多团队会把它调到 1.1 左右,这是经验做法,不是定论。至于 SET enable_seqscan = off 这类开关,是调试用的,不要用它在生产环境“强制走索引”。
估错了会怎样:一张没有统计信息的新表
规划器的选择只和估计一样好。把 3 月的订单另存成一张新表,马上拿它做报表:
CREATE TABLE march_orders AS
SELECT * FROM orders
WHERE created_at >= timestamptz '2026-03-01 00:00+08' AND created_at < timestamptz '2026-04-01 00:00+08';
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT m.tier, count(*) FROM march_orders o JOIN members m ON m.id = o.member_id
WHERE o.status = 'completed' GROUP BY m.tier;
ANALYZE march_orders;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT m.tier, count(*) FROM march_orders o JOIN members m ON m.id = o.member_id
WHERE o.status = 'completed' GROUP BY m.tier;
DROP TABLE march_orders;
CREATE INDEX orders_member_created_idx ON orders (member_id, created_at);
DROP INDEX orders_member_idx;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT id, status, total_cents FROM orders
WHERE member_id = 2001 ORDER BY created_at DESC LIMIT 20;
CREATE INDEX
DROP INDEX
Limit (cost=0.29..24.90 rows=20 width=29) (actual rows=20.00 loops=1)
-> Index Scan Backward using orders_member_created_idx on orders (cost=0.29..848.23 rows=689 width=29) (actual rows=20.00 loops=1)
Index Cond: (member_id = 2001)
Index Searches: 1
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT count(*) FROM orders
WHERE created_at >= timestamptz '2026-03-01 00:00+08' AND created_at < timestamptz '2026-03-02 00:00+08';
CREATE INDEX orders_store_created_idx ON orders (store_id, created_at);
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT count(*) FROM orders
WHERE created_at >= timestamptz '2026-03-01 00:00+08' AND created_at < timestamptz '2026-03-02 00:00+08';
Aggregate (cost=506.10..506.11 rows=1 width=8) (actual rows=1.00 loops=1)
-> Seq Scan on orders (cost=0.00..506.00 rows=39 width=0) (actual rows=38.00 loops=1)
Filter: ((created_at >= '2026-03-01 00:00:00+08'::timestamp with time zone) AND (created_at < '2026-03-02 00:00:00+08'::timestamp with time zone))
Rows Removed by Filter: 19962
CREATE INDEX
Aggregate (cost=56.61..56.62 rows=1 width=8) (actual rows=1.00 loops=1)
-> Index Only Scan using orders_store_created_idx on orders (cost=0.29..56.52 rows=39 width=0) (actual rows=38.00 loops=1)
Index Cond: ((created_at >= '2026-03-01 00:00:00+08'::timestamp with time zone) AND (created_at < '2026-03-02 00:00:00+08'::timestamp with time zone))
Heap Fetches: 0
Index Searches: 15
用上了,而且是 Index Only Scan,连表都没碰。关键在最后一行 Index Searches: 15:store_id 的取值很少,只有 12 家店外加 NULL。PostgreSQL 对每一个取值分别进一次索引,跳着查;比 13 多出来的 2 次,是在探还有没有别的取值。这叫跳跃扫描(skip scan) 18 新增,17 及更早的版本做不到,旧教程里“不带第一列就用不上复合索引”的说法因此不再绝对。
EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders WHERE store_id = 3 AND created_at::date = date '2026-03-01';
EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders
WHERE store_id = 3
AND created_at >= timestamptz '2026-03-01 00:00+08' AND created_at < timestamptz '2026-03-02 00:00+08';
Aggregate
-> Index Only Scan using orders_store_created_idx on orders
Index Cond: (store_id = 3)
Filter: ((created_at)::date = '2026-03-01'::date)
Aggregate
-> Index Only Scan using orders_store_created_idx on orders
Index Cond: ((store_id = 3) AND (created_at >= '2026-03-01 00:00:00+08'::timestamp with time zone) AND (created_at < '2026-03-02 00:00:00+08'::timestamp with time zone))
第一种写法里,日期条件落在 Filter 上:索引只帮忙定位了 3 号店,然后把这家店的每一条都拿出来算一遍 ::date 再比。第二种写法里,三个条件全在 Index Cond 上,索引直接跳到那一天。列一旦被包进函数或类型转换,索引里存的那个顺序就对不上了。
深翻页:记住上一页的最后一行
后台的订单列表按编号倒序,翻到第 751 页:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT id, status FROM orders ORDER BY id DESC LIMIT 20 OFFSET 15000;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT id, status FROM orders WHERE id < 5001 ORDER BY id DESC LIMIT 20;
Limit (actual rows=20.00 loops=1)
-> Index Scan Backward using orders_pkey on orders (actual rows=15020.00 loops=1)
Index Searches: 1
Limit (actual rows=20.00 loops=1)
-> Index Scan Backward using orders_pkey on orders (actual rows=20.00 loops=1)
Index Cond: (id < 5001)
Index Searches: 1
还有三种形状值得知道名字:部分索引(partial index)只收录满足条件的行,比如 ON orders (created_at) WHERE status = 'paid' 就是一份又小又准的待发货队列;表达式索引(expression index)为 lower(email) 这类写法服务;INCLUDE 可以让索引多带几列,凑成上面那种不回表的 Index Only Scan。反过来,查询里只要多要一列索引里没有的,就得回表,所以 SELECT * 会让这类优化落空。
SELECT ctid, xmin, xmax, id, status, total_cents, version
FROM orders WHERE id BETWEEN 42 AND 46 ORDER BY id;
ctid
xmin
xmax
id
status
total_cents
version
(0,42)
763
0
42
paid
4098
1
(0,43)
763
0
43
paid
12017
1
(0,44)
763
0
44
paid
19936
1
(0,45)
763
0
45
paid
27855
1
(0,46)
763
0
46
completed
5774
1
(5 行)
订单 42 发货
用状态机那一篇的写法,把“必须是已付款”写进 WHERE,同时让版本号加一。RETURNING 里的 old 和 new18 新增 可以把改之前和改之后的值一起拿回来:
UPDATE orders SET status = 'shipped', version = version + 1
WHERE id = 42 AND status = 'paid'
RETURNING old.ctid AS old_ctid, new.ctid AS new_ctid, old.xmin AS old_xmin, new.xmin AS new_xmin,
new.status, new.total_cents, new.version;
-- leaf page 1 of the primary-key index; '2a' is 42 in hexadecimal
SELECT htid AS points_to FROM bt_page_items('orders_pkey', 1) WHERE data = '2a 00 00 00 00 00 00 00';
UPDATE orders SET total_cents = total_cents + 100
WHERE id = 43
RETURNING old.ctid AS old_ctid, new.ctid AS new_ctid, new.xmin AS new_xmin, new.status, new.total_cents, new.version;
SELECT * FROM orders_page0 WHERE lp IN (43, 100);
这给了“要不要给某一列建索引”一个新的考量:给 status 建索引,意味着状态机每流转一次,就要多写 4 处索引。另外两个和 ORM 有关的事实:把所有字段原样写回去并不妨碍 HOT,看的是被索引列的值有没有变;但一次什么都没改的保存照样会写出一个新版本,所以保存之前先判断有没有变化。
订单 44:改了又回滚
BEGIN;
UPDATE orders SET total_cents = total_cents + 500
WHERE id = 44
RETURNING old.ctid AS old_ctid, new.ctid AS new_ctid, new.xmin AS new_xmin, new.status, new.total_cents, new.version;
ROLLBACK;
SELECT lp, slot, xmin, xmax, pg_xact_status(xmax::text::xid8) AS xmax_status
FROM orders_page0 WHERE lp = 44;
BEGIN;
UPDATE orders SET total_cents = total_cents + 1000
WHERE id = 45
RETURNING old.ctid AS old_ctid, new.ctid AS new_ctid, new.xmin AS new_xmin, new.status, new.total_cents, new.version;
PREPARE TRANSACTION 'session_b';
BEGIN
old_ctid
new_ctid
new_xmin
status
total_cents
version
(0,45)
(0,101)
825
paid
28855
1
UPDATE 1
PREPARE TRANSACTION
我们(会话 A)这时去读订单 45:
SELECT ctid, xmin, xmax, total_cents FROM orders WHERE id = 45;
UPDATE orders SET total_cents = total_cents + 100
WHERE id = 46
RETURNING old.ctid AS old_ctid, new.ctid AS new_ctid, new.xmin AS new_xmin, new.status, new.total_cents, new.version;
old_ctid
new_ctid
new_xmin
status
total_cents
version
(0,46)
(0,102)
826
completed
5874
1
UPDATE 1
再看一眼此刻的快照长什么样:
SELECT pg_current_snapshot();
pg_current_snapshot
825:827:825
(1 行)
三段用冒号隔开。第一段 825:比它小的事务都结束了。第二段 827:从它开始的事务,在拍快照时都还没结束,一律当作看不见。第三段是夹在两者之间、当时还在进行的事务,这里正是会话 B 的 825。只要 B 不结束,此后每个人拍的每张快照里都会有它。
VACUUM 做什么
清理旧版本的命令是 VACUUM。它扫描表,把对谁都不可见的版本删掉,把索引里指向它们的条目也删掉,然后把腾出的空间登记为可复用。在此之前先看一个和它有关的现象。查会员 295 的下单时间,要的两列都在索引里,所以是不回表的 Index Only Scan:
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT member_id, created_at FROM orders WHERE member_id = 295;
Index Only Scan using orders_member_created_idx on orders (cost=0.29..4.46 rows=10 width=12) (actual rows=10.00 loops=1)
Index Cond: (member_id = 295)
Heap Fetches: 2
Index Searches: 1
-- INDEX_CLEANUP ON: with so few dead items the default (AUTO) would skip the index pass
VACUUM (INDEX_CLEANUP ON) orders;
SELECT * FROM orders_page0 WHERE lp BETWEEN 42 AND 46 OR lp >= 99 ORDER BY lp;
VACUUM
lp
slot
redirect_to
xmin
xmax
next_version
flags
42
unused
NULL
NULL
NULL
NULL
{}
43
redirect
100
NULL
NULL
NULL
{}
44
normal
NULL
763
824
(0,101)
{XMIN_COMMITTED,XMAX_INVALID,HOT_UPDATED}
45
normal
NULL
763
825
(0,101)
{XMIN_COMMITTED,HOT_UPDATED}
46
normal
NULL
763
826
(0,102)
{XMIN_COMMITTED,XMAX_COMMITTED,HOT_UPDATED}
99
normal
NULL
822
0
(0,99)
{XMIN_COMMITTED,XMAX_INVALID}
100
normal
NULL
823
0
(0,100)
{XMIN_COMMITTED,XMAX_INVALID,ONLY_TUPLE}
101
normal
NULL
825
0
(0,101)
{XMAX_INVALID,ONLY_TUPLE}
102
normal
NULL
826
0
(0,102)
{XMIN_COMMITTED,XMAX_INVALID,ONLY_TUPLE}
(9 行)
对照上一节的结果,有的收了,有的没收:
槽位 42 从 dead 变成了 unused。订单 42 的旧版本是被事务 822 取代的,索引里指向它的条目已经删掉,槽位可以给新行用了。
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT member_id, created_at FROM orders WHERE member_id = 295;
Index Only Scan using orders_member_created_idx on orders (cost=0.29..4.46 rows=10 width=12) (actual rows=10.00 loops=1)
Index Cond: (member_id = 295)
Heap Fetches: 0
Index Searches: 1
CREATE TABLE bloat_demo AS SELECT * FROM orders;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS freshly_loaded;
UPDATE bloat_demo SET version = version + 1;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS after_updating_every_row;
VACUUM bloat_demo;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS after_vacuum;
UPDATE bloat_demo SET version = version + 1;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS after_second_update;
VACUUM FULL bloat_demo;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS after_vacuum_full;
DROP TABLE bloat_demo;
UPDATE orders SET status = 'completed', version = version + 1
WHERE id = 42 AND status = 'shipped';
UPDATE orders SET status = 'completed', version = version + 1
WHERE id = 42 AND status = 'shipped';
UPDATE 1
UPDATE 0
第一次 UPDATE 1,第二次 UPDATE 0。并发时后到的那个请求得到的就是这个 UPDATE 0。它不是报错,所以应用必须检查影响行数,为 0 就告诉调用方“状态已经变了”。DDD 那一篇里的乐观锁 WHERE id = … AND version = … 是同一个套路。
卖最后一本书也一样,把“还有货”写进条件:
UPDATE inventory SET qty = qty - 1
WHERE store_id = 1 AND book_id = 1 AND qty >= 1
RETURNING old.qty AS qty_before, new.qty AS qty_after;
UPDATE inventory SET qty = qty - 1
WHERE store_id = 1 AND book_id = 1 AND qty >= 1
RETURNING old.qty AS qty_before, new.qty AS qty_after;
qty_before
qty_after
1
0
UPDATE 1
qty_before
qty_after
UPDATE 0
第一个请求把库存从 1 扣到 0,第二个请求一行也没改到,应用据此回答“已售罄”。如果有人忘了写 AND qty >= 1,第 2 节那条约束还会再拦一道:
UPDATE inventory SET qty = qty - 1 WHERE store_id = 1 AND book_id = 1;
ERROR: new row for relation "inventory" violates check constraint "inventory_qty_check"SQLSTATE 23514DETAIL: Failing row contains (1, 1, -1).
ERROR: insert or update on table "payment_events" violates foreign key constraint "payment_events_order_id_fkey"SQLSTATE 23503DETAIL: Key (order_id)=(999999) is not present in table "orders".
ERROR: current transaction is aborted, commands ignored until end of transaction blockSQLSTATE 25P02
支付平台的回调会重复投递。把平台给的事件编号设为主键,重复的那次自然插不进去,ON CONFLICT DO NOTHING 让它安静地变成“0 行”而不是报错:
INSERT INTO payment_events (provider_event_id, order_id, amount_cents)
VALUES ('evt_0001', 20002, 5900)
ON CONFLICT (provider_event_id) DO NOTHING;
INSERT INTO payment_events (provider_event_id, order_id, amount_cents)
VALUES ('evt_0001', 20002, 5900)
ON CONFLICT (provider_event_id) DO NOTHING;
在 Node 里最容易写错的是这一行:await pool.query('BEGIN')。连接池每次 pool.query 都可能换一个连接,于是 BEGIN 落在一个连接上,后面的语句落在别的连接上各自提交,那个执行了 BEGIN 的连接则带着一个不结束的事务回到池里。事务必须从头到尾用同一个连接。下面的写法同时处理了上表里“整个事务重做”的要求:
// The parts of node-postgres' Pool and PoolClient this helper relies on.
interface Queryable {
query(text: string, values?: unknown[]): Promise<{ rows: any[]; rowCount: number | null; command: string }>;
}
interface PooledClient extends Queryable {
release(destroy?: boolean): void;
}
interface ConnectionPool {
connect(): Promise<PooledClient>;
}
// serialization_failure and deadlock_detected: the transaction did nothing wrong, run it again
const RETRYABLE = new Set(['40001', '40P01']);
async function inTransaction<T>(
pool: ConnectionPool,
work: (tx: Queryable) => Promise<T>,
maxAttempts = 3,
): Promise<T> {
for (let attempt = 1; ; attempt += 1) {
// BEGIN, the work and COMMIT must all travel on this one connection
const client = await pool.connect();
let broken = false;
try {
await client.query('BEGIN');
const result = await work(client);
const committed = await client.query('COMMIT');
// COMMIT in a transaction that already failed answers ROLLBACK instead of raising an error
if (committed.command === 'ROLLBACK') throw new Error('a statement inside work() failed; nothing was committed');
return result;
} catch (error) {
// a connection that cannot even roll back must not return to the pool
await client.query('ROLLBACK').catch(() => { broken = true; });
const code = (error as { code?: string }).code;
if (code === undefined || !RETRYABLE.has(code) || attempt >= maxAttempts) throw error;
} finally {
client.release(broken);
}
// back off only after the connection is back in the pool
await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 25 + Math.random() * 25));
}
}
// Ship order 42: the guard lives in WHERE, the row count says whether we won.
async function shipOrder(pool: ConnectionPool, orderId: number): Promise<boolean> {
return inTransaction(pool, async tx => {
const updated = await tx.query(
"UPDATE orders SET status = 'shipped', version = version + 1 WHERE id = $1 AND status = 'paid'",
[orderId],
);
return updated.rowCount === 1;
});
}
习惯判断条件写进 UPDATE … WHERE 并检查影响行数;跨行的规则交给约束;40001 和 40P01 重试整个事务;事务从头到尾用同一个连接。
9 守:锁,和不锁表的迁移
行锁记在行上,表锁排成队。迁移出事故,多半出在排队上。
周二 20:00 发布脚本给订单表加一列。这条语句本身只要几毫秒,全站却卡了 80 秒。
行锁写在行里
想先读后改又不想被人插队,可以用 SELECT … FOR UPDATE 把行锁住。锁住之后看看锁记在哪里:
BEGIN;
SELECT id, status FROM orders WHERE id = 43 FOR UPDATE;
SELECT locktype, relation::regclass AS relation, mode
FROM pg_locks
WHERE pid = pg_backend_pid() AND (relation = 'orders'::regclass OR locktype = 'transactionid');
SELECT xmax, pg_current_xact_id() AS my_xid FROM orders WHERE id = 43;
COMMIT;
名字里带 ROW 的也是表级锁,名字是历史遗留。读和写拿的锁互不冲突,所以平时感觉不到它们的存在。会话 C 只是读过 orders,这时如果有人想独占这张表:
BEGIN;
LOCK TABLE orders IN ACCESS EXCLUSIVE MODE NOWAIT;
ROLLBACK;
BEGIN
ERROR: could not obtain lock on relation "orders"SQLSTATE 55P03
ROLLBACK
拿不到。看看 C 手里握着什么:
SELECT relation::regclass AS relation, mode
FROM pg_locks
WHERE pid IS NULL AND locktype = 'relation' AND relation IN ('orders'::regclass, 'inventory'::regclass)
ORDER BY 1, 2;
relation
mode
inventory
RowExclusiveLock
orders
AccessShareLock
(2 行)
orders 上那把 AccessShareLock,是 C 在事务里执行过一条 SELECT 留下的。查询早就跑完了,但事务没结束,锁就不放。一个只读不写、发着呆的会话,足以挡住所有要 ACCESS EXCLUSIVE 的语句,大多数 ALTER TABLE 都在其中。让 C 退场:
ROLLBACK PREPARED 'session_c';
ROLLBACK PREPARED
锁队列:一条等锁的 DDL 会挡住所有人
被挡住的 ALTER TABLE 自己等一会儿倒也罢了,麻烦在于等锁是排队的。它排进队里之后,后来的普通查询要的锁和它冲突,只能排在它后面。于是一个发呆的会话加一条几毫秒的 DDL,就能让整张表停摆:
先看一条 ALTER TABLE 到底做了多重的事。pg_relation_filenode 返回表在磁盘上的文件编号,编号变了就说明整张表被重写了一遍:
BEGIN;
SELECT pg_relation_filenode('orders') AS file_before;
ALTER TABLE orders ADD COLUMN source text NOT NULL DEFAULT 'web';
SELECT pg_relation_filenode('orders') AS file_after_constant_default;
SELECT mode FROM pg_locks WHERE relation = 'orders'::regclass AND pid = pg_backend_pid();
ALTER TABLE orders ADD COLUMN public_id uuid NOT NULL DEFAULT uuidv7();
SELECT pg_relation_filenode('orders') AS file_after_volatile_default;
ROLLBACK;
SELECT count(*) AS new_columns_left FROM information_schema.columns
WHERE table_name = 'orders' AND column_name IN ('source', 'public_id');
-- demo only: a checkpoint here keeps the list of WAL records short
CHECKPOINT;
UPDATE orders SET total_cents = total_cents + 1 WHERE id = 47;
UPDATE orders SET total_cents = total_cents + 1 WHERE id = 48;
SELECT resource_manager, record_type, record_length, fpi_length
FROM pg_get_wal_records_info((pg_control_checkpoint()).redo_lsn, pg_current_wal_lsn())
WHERE resource_manager IN ('Heap', 'Heap2', 'Btree', 'Transaction')
ORDER BY start_lsn;
-- 1. who has had a transaction open the longest?
SELECT pid, state, xact_start, wait_event_type, left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_type = 'client backend' AND xact_start IS NOT NULL
ORDER BY xact_start;
-- 2. who is waiting, and which sessions are in the way?
SELECT pid, pg_blocking_pids(pid) AS blocked_by, left(query, 60) AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
-- 3. any forgotten prepared transactions?
SELECT gid, prepared FROM pg_prepared_xacts ORDER BY prepared;
这三条在这里都返回 0 行,所以没有贴输出:没有会话在等锁,预备事务也都结束了;第一条在真实的服务器上至少会列出你自己这个会话,而本页的引擎把自己报告成 standalone backend,被 WHERE 筛掉了。语句本身在构建页面时执行过。
第一条的 state 列里,idle in transaction 就是“开着事务在发呆”。第二条的 pg_blocking_pids 既包括握着冲突锁的会话,也包括排在前面等锁的会话,所以顺着它能从卡住的请求找到 ALTER,再从 ALTER 找到那个发呆的会话。
四个默认都是 0,也就是不限时。给应用用的数据库角色设上,用 ALTER ROLE app SET statement_timeout = '5s' 这种写法;人手工连库用的角色至少设上 idle_in_transaction_session_timeout,19:40 那个会话就是这样连进来的。不要写进全局配置:全局的值对所有会话生效,管理员手动建索引、跑 VACUUM、修数据时也会被掐掉。具体取值看业务,这里的 5 秒只是示例。
SET TIME ZONE 'Asia/Shanghai';
CREATE TABLE stores (
id integer PRIMARY KEY,
code text NOT NULL UNIQUE,
city text NOT NULL
);
CREATE TABLE members (
id integer PRIMARY KEY,
name text NOT NULL,
tier text NOT NULL CHECK (tier IN ('basic', 'gold')),
joined_on date NOT NULL
);
CREATE TABLE books (
id integer PRIMARY KEY,
isbn text NOT NULL UNIQUE,
title text NOT NULL,
price_cents integer NOT NULL CHECK (price_cents > 0)
);
CREATE TABLE inventory (
store_id integer NOT NULL REFERENCES stores,
book_id integer NOT NULL REFERENCES books,
qty integer NOT NULL CHECK (qty >= 0),
PRIMARY KEY (store_id, book_id)
);
-- fillfactor 90 keeps a tenth of every page free for later row versions
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id integer REFERENCES members, -- NULL: guest checkout
store_id integer REFERENCES stores, -- NULL: online shop
status text NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'completed', 'cancelled',
'refunding', 'refunded')),
total_cents integer NOT NULL CHECK (total_cents >= 0),
created_at timestamptz NOT NULL,
version integer NOT NULL DEFAULT 1
) WITH (fillfactor = 90);
CREATE TABLE payment_events (
provider_event_id text PRIMARY KEY, -- idempotency key chosen by the payment provider
order_id bigint NOT NULL REFERENCES orders,
amount_cents integer NOT NULL
);
INSERT INTO stores (id, code, city)
SELECT i, 'S' || lpad(i::text, 2, '0'), (ARRAY['杭州', '上海', '苏州', '南京'])[1 + (i - 1) % 4]
FROM generate_series(1, 12) AS i;
INSERT INTO books (id, isbn, title, price_cents)
SELECT i, '9787' || lpad(i::text, 9, '0'),
CASE i WHEN 1 THEN '三体' WHEN 2 THEN '活着' WHEN 3 THEN '额尔古纳河右岸' ELSE '书目 ' || i END,
2900 + (i * 37) % 9000
FROM generate_series(1, 500) AS i;
INSERT INTO members (id, name, tier, joined_on)
SELECT i, '书友 ' || i, CASE WHEN i % 10 = 0 THEN 'gold' ELSE 'basic' END, date '2024-01-01' + (i * 7) % 600
FROM generate_series(1, 2000) AS i;
-- one account that orders far more than anyone else
INSERT INTO members (id, name, tier, joined_on) VALUES (2001, '市图书馆采购', 'gold', date '2024-03-01');
-- store S01 holds the last copy of book 1
INSERT INTO inventory (store_id, book_id, qty)
SELECT s, b, CASE WHEN s = 1 AND b = 1 THEN 1 ELSE (s * 13 + b * 7) % 20 END
FROM generate_series(1, 12) AS s, generate_series(1, 500) AS b;
INSERT INTO orders (member_id, store_id, status, total_cents, created_at)
SELECT CASE WHEN i % 29 = 0 THEN 2001 WHEN i % 4 = 0 THEN NULL ELSE 1 + (i * 7) % 2000 END,
CASE WHEN i % 3 = 0 THEN NULL ELSE 1 + (i / 3) % 12 END,
CASE WHEN i % 200 = 0 THEN 'cancelled'
WHEN i % 200 BETWEEN 1 AND 3 THEN 'pending'
WHEN i % 200 BETWEEN 40 AND 45 THEN 'paid'
WHEN i % 200 BETWEEN 100 AND 109 THEN 'shipped'
ELSE 'completed' END,
1500 + (i * 7919) % 30000,
timestamptz '2025-01-01 09:00+08' + i * interval '37 minutes'
FROM generate_series(1, 20000) AS i;
CREATE EXTENSION pageinspect;
CREATE EXTENSION pg_walinspect;
-- a readable window onto page 0 of the orders table
CREATE VIEW orders_page0 AS
SELECT lp,
(ARRAY['unused', 'normal', 'redirect', 'dead'])[lp_flags + 1] AS slot,
CASE WHEN lp_flags = 2 THEN lp_off END AS redirect_to,
t_xmin AS xmin, t_xmax AS xmax, t_ctid AS next_version,
ARRAY(SELECT replace(flag, 'HEAP_', '')
FROM unnest((heap_tuple_infomask_flags(t_infomask, t_infomask2)).raw_flags) AS flag
WHERE flag IN ('HEAP_HOT_UPDATED', 'HEAP_ONLY_TUPLE', 'HEAP_XMAX_LOCK_ONLY',
'HEAP_XMIN_COMMITTED', 'HEAP_XMIN_INVALID', 'HEAP_XMAX_COMMITTED', 'HEAP_XMAX_INVALID')) AS flags
FROM heap_page_items(get_raw_page('orders', 0));
-- VACUUM cannot run inside a transaction block, so it is sent on its own
VACUUM ANALYZE;