72 lines
5.5 KiB
Markdown
72 lines
5.5 KiB
Markdown
---
|
||
id: backend.postgresql.interview
|
||
title: PostgreSQL 面试题
|
||
type: interview
|
||
domain: backend
|
||
stack: [postgresql]
|
||
tags: [PostgreSQL, SQL, 事务, 面试]
|
||
level: intermediate
|
||
related: [backend.postgresql.schema-sql, backend.postgresql.index-tx-mvcc, backend.mysql.interview]
|
||
week: [2, 16]
|
||
---
|
||
|
||
# PostgreSQL 面试题
|
||
|
||
> 在本计划里的体现:`todo-api` / `task-board` 的数据层(第 2~3 周),pgvector 对照(第 6 周)。MySQL 相关题目见 [MySQL 面试题](../mysql/interview.md)。
|
||
|
||
## 基础
|
||
|
||
### 【基础】`WHERE` 和 `HAVING` 的区别?
|
||
|
||
**参考答案**:`WHERE` 在分组前过滤行,不能使用聚合函数;`HAVING` 在 `GROUP BY` 之后过滤分组,可以使用聚合函数。能用 `WHERE` 过滤的尽量用 `WHERE`,减少参与分组的数据量。
|
||
|
||
### 【基础】`INNER JOIN` 和 `LEFT JOIN` 的区别?LEFT JOIN 的条件写在 ON 和 WHERE 里有什么不同?
|
||
|
||
**参考答案**:内连接只保留两边都匹配的行;左连接保留左表所有行,右表没匹配的列为 NULL。右表的过滤条件写在 `ON` 里时,不匹配的左表行仍然保留;写在 `WHERE` 里会把右表为 NULL 的行过滤掉,效果等同内连接。
|
||
|
||
### 【基础】为什么 `NOT IN` 遇到 NULL 会出问题?
|
||
|
||
**参考答案**:`x NOT IN (a, b, NULL)` 等价于 `x <> a AND x <> b AND x <> NULL`,而 `x <> NULL` 的结果是 UNKNOWN,整个条件永远不为真,查询返回空。用 `NOT EXISTS` 或先过滤掉 NULL。
|
||
|
||
### 【基础】金额应该用什么类型?时间呢?
|
||
|
||
**参考答案**:金额用 `NUMERIC(p, s)` 或以“分”为单位的整数,不用浮点数(有精度误差)。时间用 `TIMESTAMPTZ`,统一存 UTC,展示时按用户时区转换。
|
||
|
||
### 【基础】什么是 UPSERT?为什么比“先查再插”安全?
|
||
|
||
**参考答案**:`INSERT ... ON CONFLICT (唯一键) DO UPDATE / DO NOTHING`,一条语句完成“有则更新、无则插入”。它依赖唯一约束在数据库内部原子判断;“先查再插”在并发下两个请求都可能查不到然后都插入。
|
||
|
||
## 进阶
|
||
|
||
### 【进阶】PostgreSQL 的 MVCC 是怎么实现的?VACUUM 是干什么的?
|
||
|
||
**参考答案**:更新时写入一个新版本的行,旧版本标记为对新事务不可见但保留给仍在使用旧快照的事务;每个事务根据快照判断哪些版本可见,所以读写互不阻塞。旧版本(死元组)需要 VACUUM 回收空间并更新可见性信息,autovacuum 会自动执行。长事务会阻止死元组被回收,导致表膨胀和性能下降。
|
||
|
||
**追问**:和 MySQL InnoDB 的 MVCC 有什么不同?→ InnoDB 原地更新、旧版本放在 undo log 里由 purge 线程清理;PostgreSQL 新旧版本都在表里,需要 VACUUM。
|
||
|
||
### 【进阶】PostgreSQL 默认的隔离级别是什么?可重复读下修改冲突会怎样?
|
||
|
||
**参考答案**:默认读已提交。可重复读下,事务看到的是开始时的快照;如果要更新的行在快照之后被其他事务修改并提交了,会报 `could not serialize access due to concurrent update`(SQLSTATE 40001),应用需要重试整个事务。
|
||
|
||
### 【进阶】一条 SQL 很慢,你怎么排查?
|
||
|
||
**参考答案**:先用 `EXPLAIN (ANALYZE, BUFFERS)` 看执行计划:是不是全表扫描、预估行数和实际行数是否差距大(统计信息过时就 `ANALYZE`)、哪个节点耗时最多。常见优化:按查询条件建联合索引(注意最左前缀和排序方向)、避免在索引列上用函数、用覆盖索引、改写子查询、分页改成游标(按上一页最后一个 ID 继续)。线上用 `pg_stat_statements` 找出总耗时最高的语句。
|
||
|
||
### 【进阶】`FOR UPDATE SKIP LOCKED` 有什么用?
|
||
|
||
**参考答案**:选中并锁定行,遇到已被其他事务锁住的行直接跳过。适合“多个 worker 并发从任务表取任务”:每个 worker 拿到不同的任务,互不阻塞,不需要额外的消息队列。
|
||
|
||
## 场景题
|
||
|
||
### 【场景题】订单表有几千万行,按用户查最近订单的分页越往后越慢,怎么优化?
|
||
|
||
**参考答案**:`OFFSET` 越大,数据库要扫描并丢弃的行越多。改成游标分页(keyset):`WHERE user_id = ? AND (created_at, id) < (上一页最后一条的值) ORDER BY created_at DESC, id DESC LIMIT 20`,配合 `(user_id, created_at DESC, id DESC)` 联合索引,每页都是索引范围扫描。代价是不能直接跳到第 N 页,通常用“加载更多”交互。更大规模时按时间分区,历史数据归档。
|
||
|
||
### 【场景题】线上要给一张大表加索引和加字段,怎么做才不影响业务?
|
||
|
||
**参考答案**:索引用 `CREATE INDEX CONCURRENTLY`(不阻塞写,不能在事务里执行,失败要删掉无效索引重建)。加字段:PostgreSQL 11 起加带常量默认值的列不需要重写表,很快;但要注意 DDL 需要短暂的排他锁,如果前面有长事务会排队并阻塞后续查询,所以设置 `lock_timeout`,在低峰期执行。迁移脚本要向后兼容:先加字段、发新代码,再清理旧字段。
|
||
|
||
### 【场景题】用户和支付回调同时修改订单状态,怎么保证状态正确?
|
||
|
||
**参考答案**:状态流转规则集中定义,修改用带版本号(或旧状态)的条件 UPDATE 做乐观锁,影响行数为 0 时视为冲突,重新读取最新状态再决定;审计日志与状态修改放在同一事务。冲突频繁的场景可以用 `SELECT ... FOR UPDATE` 排队。详见本技术栈的[订单状态机场景](scenarios/order-state-audit.md)。
|