初创公司的PostgreSQL生存指南

Hacker News Top 工具

摘要

一份面向在生产环境中运行PostgreSQL的初创公司的全面指南,涵盖模式设计、查询优化、索引、迁移、连接管理,以及查询计划与分区等高级主题。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/22 17:22

# Hatchet 来源:https://hatchet.run/blog/postgres-survival-guide 在过去半年多的时间里,我一直在为我们的工程师编写一份内部文档,试图将两年来的 Postgres 实战经验归纳成一篇相对连贯的文章。虽然我很喜欢 Postgres 手册(https://www.postgresql.org/docs/18/index.html),但当问题真正爆发时,读手册往往没那么直接——因为它实在是太详尽了。我觉得这份内容可能对其他人也有用,欢迎大家反馈(或者分享你们在生产环境中运行 Postgres 时学到的其他小技巧)。 在开始 Hatchet 之前,虽然我对 SQL 还算熟悉,但我的知识基本上只停留在“如果查询慢,那就建索引”。这也是本文的起点:我假设你已经熟悉 SQL 基础、行、表,并且大致了解索引是什么。 *如果所有查询都是 Claude 写的,那这份指南可能就没啥用了。推荐使用 supabase/agent-skills(https://github.com/supabase/agent-skills)* --- #### 关于 ORM 的快速说明(https://hatchet.run/blog/postgres-survival-guide#a-quick-note-on-orms) 这份指南依然有用,但你可能需要将一些技巧翻译成你喜欢的 ORM。随着规模扩大,很多优化在 ORM 下是无法实现的,除非你能突破抽象层直接写 SQL。你可以优雅地做到这一点,也可以不那么优雅;Prisma 的 TypedSQL(https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/typedsql)或类似的东西看起来很有意思。我们在 Hatchet 使用 `sqlc`,它能提供非常类似的行为;如果你用的是 Go 技术栈,强烈推荐。 #### 目录(https://hatchet.run/blog/postgres-survival-guide#table-of-contents) - 简单基础:良好的读、写和模式(https://hatchet.run/blog/postgres-survival-guide#the-simple-stuff-good-reads-writes-and-schemas) - 编写良好的模式(https://hatchet.run/blog/postgres-survival-guide#writing-a-good-schema) - 编写良好的读查询(https://hatchet.run/blog/postgres-survival-guide#writing-good-read-queries) - 编写高性能的 JOIN(https://hatchet.run/blog/postgres-survival-guide#writing-performant-joins) - 复合索引及将 ORDER BY 与索引对齐(https://hatchet.run/blog/postgres-survival-guide#compound-indexes-and-aligning-order-by-to-your-indexes) - 编写良好的写查询(https://hatchet.run/blog/postgres-survival-guide#writing-good-write-queries) - 数据迁移(https://hatchet.run/blog/postgres-survival-guide#migrations) - 连接管理(https://hatchet.run/blog/postgres-survival-guide#connection-management) - 进阶:查询计划器、批量更新和自动清理(https://hatchet.run/blog/postgres-survival-guide#intermediate-the-query-planner-bulk-updates-and-autovacuum) - 认识最泄露的抽象:查询计划器(https://hatchet.run/blog/postgres-survival-guide#introducing-the-leakiest-of-abstractions-the-query-planner) - 有时候顺序扫描是合理的(https://hatchet.run/blog/postgres-survival-guide#sometimes-it-just-makes-sense-to-seq-scan) - 写入大量数据(https://hatchet.run/blog/postgres-survival-guide#writing-lots-of-data) - 默认的自动清理设置可能会搞垮你的数据库(https://hatchet.run/blog/postgres-survival-guide#default-autovacuum-settings-can-kill-your-database) - 其他类型的膨胀(https://hatchet.run/blog/postgres-survival-guide#other-types-of-bloat) - 一些高级内容(https://hatchet.run/blog/postgres-survival-guide#some-advanced-stuff) - FOR UPDATE SKIP LOCKED(https://hatchet.run/blog/postgres-survival-guide#for-update-skip-locked) - 分区(https://hatchet.run/blog/postgres-survival-guide#partitioning) - 大表迁移的技巧(https://hatchet.run/blog/postgres-survival-guide#tricks-for-large-table-migrations) ### 简单基础:良好的读、写和模式(https://hatchet.run/blog/postgres-survival-guide#the-simple-stuff-good-reads-writes-and-schemas) 让我们从基础开始:低流量下的查询和模式。 #### 编写良好的模式(https://hatchet.run/blog/postgres-survival-guide#writing-a-good-schema) 当应用部署后,模式是最难更改的部分,因此花些时间设计模式是值得的。我建议迭代式地构建模式:先为表和主键做一个粗略的设计,然后根据应用需求在这些表上编写一些查询。你可以通过以下问题来大致评估:*这是高读还是高写的表?读操作中最常见的过滤条件是什么?哪些列更新最频繁?* 如果想要更正规一些,可以参考数据库范式化(https://www.digitalocean.com/community/tutorials/database-normalization)到 1NF/2NF/3NF,但我发现范式有时与查询效率和易用性相矛盾,而这在快速迭代中至关重要——有时直接往 `jsonb` 列里塞数据反而更简单。 我对模式的几个经验法则: - 使用标识列(自增整数,性能略优于 `bigserial`)或内置 UUID 作为主键 - 始终使用 `timestamptz` - 始终使用主键 - 在低流量表上使用带级联删除的外键,尤其是当数据库一致性和正确性很重要时。高流量时需谨慎。 #### 编写良好的读查询(https://hatchet.run/blog/postgres-survival-guide#writing-good-read-queries) 从 `SELECT` 查询开始。一个有用但略不精确的心智模型是:在底层,Postgres 要么非常快速地找到表中的单行,要么通过*顺序扫描*读取表中的每一行 😞。 当使用以下条件过滤时,Postgres 会非常快速地找到单行: - 显式索引 - 唯一约束(只是索引的一种特殊形式) - 主键(在 Postgres 中,主键会自动索引) 默认情况下,索引使用 `btree` 实现。最好将索引视为 Postgres 中的另一个表,它以特定格式存储数据,从而优化查找(后面会详细介绍)。这些 btree 非常棒,因为查找单行的时间大约为 `log(n)`,其中 `n` 是表中的行数——也就是说,真的很快。 当 Postgres 无法使用索引时,它会使用顺序扫描,也叫 `seq scan`。顺序扫描比索引查找慢得多,但现代数据库加载行到内存的速度非常快,以至于一开始你可能根本注意不到:少于 20k 行的表上进行顺序扫描几乎是瞬间完成的。 #### 编写高性能的 JOIN(https://hatchet.run/blog/postgres-survival-guide#writing-performant-joins) 对于内连接,很少有不使用主键作为内连接的理由;否则通常意味着模式设计或范式化有问题。对待 `ON` 子句应该像对待 `WHERE` 子句一样尊重——同样的原则适用。使用索引。 #### 复合索引及将 ORDER BY 与索引对齐(https://hatchet.run/blog/postgres-survival-guide#compound-indexes-and-aligning-order-by-to-your-indexes) 通常,应用中第一个慢查询会是大表上的列表查询。类似于: 加载语法高亮... 这种情况下,你可以使用复合索引——一个合理的例子是: 加载语法高亮... 在更复杂的情况下,一个好的经验法则是:`ORDER BY` 列应该是索引中的最后一列,并且应将列与 `ORDER BY` 中的顺序对齐。注意,Postgres 可以双向扫描 btree,所以有时 `DESC` 无关紧要——但对于复合索引,这是好习惯。更多信息见这里(https://www.cybertec-postgresql.com/en/benefits-of-a-descending-index)。 #### 编写良好的写查询(https://hatchet.run/blog/postgres-survival-guide#writing-good-write-queries) 成功写入的前提是: 1. **保持事务短小。** 不要在事务中间去查询外部服务,除非你有充分理由。 2. **小心要锁定的行**;换句话说,只锁定你需要的行。每次更新一行时,你都会对该行加上一个短时间的锁,直到事务提交。 随着系统越来越忙,你会开始更多地感受到锁的影响。例如,将来你可能尝试用简单的 `CREATE INDEX` 命令创建索引——结果发现这会给表加锁,阻止插入和更新!在现有大表上创建索引时,始终使用 `CREATE INDEX CONCURRENTLY`。 #### 数据迁移(https://hatchet.run/blog/postgres-survival-guide#migrations) 精通编写迁移是一项重要的技术优势:它能帮助你更快迭代,并提高停机时间。作为起点,尽量保持迁移是累加式的(即不要删除列或修改列),并且尽可能在事务中运行迁移;这样回滚和部分迁移会更容易处理。随着你变得更熟练,可以开始研究扩展和收缩(expand and contract)迁移模式(https://martinfowler.com/bliki/ParallelChange.html)。 判断迁移好坏的最简单心智模型是:它是否会阻塞所有写入?不加 `CONCURRENTLY` 创建索引会阻塞所有写入,因此你可能会遇到停机。通常,需要调用 `ALTER TABLE` 的操作值得再三检查;例如,在非常大的表上添加新的检查约束也可能会阻塞写入(除非你使用 `NOT VALID` 关键字添加)。 #### 连接管理(https://hatchet.run/blog/postgres-survival-guide#connection-management) 每次对数据库执行事务或查询时,你都在使用一个连接。连接在多个维度(CPU 和内存)上都很昂贵,连接频繁切换可能导致大量不必要的资源浪费,因此连接应该是长生命周期的。连接风暴(同时启动大量新连接)也可能导致与内部 Postgres 锁相关的、非常难以调试的边缘情况。 由于所有这些连接隐患,外部连接池如 pgbouncer 非常棒!如果因某种原因无法添加,内存连接池是很好的第二选择。例如,因为 Hatchet 是开源的,我们并不假设所有用户数据库都使用连接池,所以我们使用 pgxpool(https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool)(Go 的内存连接池)来实现这一功能。 ### 进阶:查询计划器、批量更新和自动清理(https://hatchet.run/blog/postgres-survival-guide#intermediate-the-query-planner-bulk-updates-and-autovacuum) #### 认识最泄露的抽象:查询计划器(https://hatchet.run/blog/postgres-survival-guide#introducing-the-leakiest-of-abstractions-the-query-planner) 到了某个阶段,你的查询可能变得复杂到简单的索引不够用(而且你不应该无休止地向表添加索引——它们会带来开销)。这些查询可能涉及多个 `JOIN` 语句或不同类型的连接,查询数据的正确路径并不明确。 此时,你需要关注*查询计划器*。最好的情况下,查询计划器也是一个泄露的抽象(https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/)。它是一个内部实现,你几乎无法控制它,但你必须了解它自发且有时非理性的行为。就像和 LLM 合作一样! 查询计划器会查看你传入的查询,并找出如何将查询转换为数据库内部的一组操作。例如,它可能会查看你的查询,意识到需要使用索引。在理想世界中,查询计划器应该对于每个查询和参数集都知道最优的计划。但查询计划器基于有限的信息运行,有时它选不到最佳选项。 这个有限的信息就是表统计信息。你可以直接在 Postgres 中查询它: 加载语法高亮... 每次 `ANALYZE` 都会收集这些统计信息。自动清理运行时也会发生(见下文),因此更频繁的自动清理也意味着你的查询统计信息会更新。查询行为不正常的一个常见原因是 ANALYZE 不够频繁。 我认为将查询视为二进制(要么进行顺序扫描,要么不进行顺序扫描)有助于思考的原因是:你对查询进行越微观的优化,查询计划器出幺蛾子的风险就越大。如果你坚持通过主键和索引进行查询,查询计划器会轻松得多。 假设你的查询没有明显错误,但仍然很慢——如何调试呢?一些 Postgres 数据库提供商(如 Google CloudSQL)会对查询进行采样并保存慢查询——但很多不提供。这时,`EXPLAIN ANALYZE` 是你的好朋友。它会输出查询的查询计划并执行查询(在生产环境中执行要小心——你可以使用不带 `ANALYZE` 的 `EXPLAIN` 来仅获取计划),然后将其基于表统计信息的估算值与实际扫描的行数进行比较。我通常将 SQL 查询放在文件中,前面加上 `EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)` 然后运行: 加载语法高亮... 接着使用 explain.dalibo.com(https://explain.dalibo.com/)来可视化执行计划。 #### 有时候顺序扫描是合理的(https://hatchet.run/blog/postgres-survival-guide#sometimes-it-just-makes-sense-to-seq-scan) 有些情况下,你认为应该使用索引,但查询计划器仍然进行顺序扫描,即使表统计信息是最新的且索引有效。在这些情况下,Postgres 通常估算顺序扫描的成本小于索引扫描的成本。索引扫描确实有一些开销;索引与表中的实际数据(称为堆)分开存储——在堆中找到所有行可能很昂贵! 除非你能大幅重建查询,否则你可能不得不接受它会进行顺序扫描,或者考虑分区之类的方法(下面会详述)。 #### 写入大量数据(https://hatchet.run/blog/postgres-survival-guide#writing-lots-of-data) 假设你的应用正在扩展,需要快速写入大量数据。每个查询都有一些相关开销(除了我们之前讨论的连接开销外):包括到数据库的往返时间、内部应用连接池获取连接的时间,以及 Postgres 处理查询的时间(包括一组内部 Postgres 锁(https://github.com/postgres/postgres/blob/master/src/backend/storage/lmgr/README),这些锁在高吞吐场景下可能成为瓶颈)。 为了减少这种开销,我们可以将一批行打包到每个查询中。最简单的方法是在一个隐式事务中一次性将所有查询发送到 Postgres 服务器(在 Go 中,我们可以使用 `pgx` 的 `SendBatch` 来执行)。批处理非常强大:我们发现它可以将吞吐量提高约 10 倍。我在这里(https://hatchet.run/blog/fastest-postgres-inserts)写了更多关于这一点以及其他快速写入数据的技巧。 #### 默认的自动清理设置可能会搞垮你的数据库(https://hatchet.run/blog/postgres-survival-guide#default-autovacuum-settings-can-kill-your-database) 自动清理是 Postgres 数据库中的关键操作,有时需要调整,特别是在高写入场景下。自动清理

相似文章

Postgres by Example

Hacker News Top

一份使用带注释的SQL示例的PostgreSQL实践入门,涵盖从基础到高级主题。

你只需要PostgreSQL

Lobsters Hottest

一份详细指南,介绍如何使用PostgreSQL作为单一数据库来处理金融应用的方方面面,包括模式设计、状态机、触发器和性能优化。

PgBouncer 工作原理

Lobsters Hottest

一份详细的技术指南,解释 PgBouncer 作为 PostgreSQL 连接池的工作原理,涵盖其连接池模式、生产部署及常见陷阱。

PgDog 获得融资,即将登陆您的数据库

Hacker News Top

PgDog 是一个开源代理,使 Postgres 实现水平扩展,已从 Basis Set、YC 等机构获得 550 万美元融资。该工具已在生产环境中每秒处理超过 200 万次查询。

展望 Postgres 19

Hacker News Top

PostgreSQL 19 beta 引入了关键特性,如 REPACK CONCURRENTLY、分区拆分与合并以及增强的逻辑复制,为生产数据库管理提供了实用改进。