创业公司的 Postgres 生存指南
文章摘要
Hatchet 联合创始人 Alexander Belanger 把两年生产环境使用 Postgres 的经验浓缩成了这篇指南。核心论点是:创业公司遇到的绝大多数数据库问题,根源不是 Postgres 本身的能力上限,而是糟糕的表结构设计、没优化过的查询,以及配置不当的 autovacuum。
在基础层面,作者强调「schema 是往后最难改的东西」,因此值得在早期多花时间。具体建议包括:主键用 identity 列或 UUID;时间戳一律用 timestamptz;外键级联删除要有选择地使用(低写入量下更安全);定表结构之前先想清楚这张表是读密集还是写密集。
查询优化上,作者给出一个二元的心智模型:一条查询要么通过索引或主键高效定位到行,要么退化成顺序扫描(sequential scan)。他建议把索引理解为「另一张为查找而优化的表」,B-tree 结构提供对数级查找复杂度。实践细节上:两万行以下的表顺序扫描基本可以忽略;复合索引的列顺序应与 ORDER BY 对齐;JOIN 应该走主键,否则说明 schema 设计需要复查;生产表建索引一定要用 CREATE INDEX CONCURRENTLY。
写入侧的建议是:事务保持短小,绝不在事务中间调用外部服务;只更新真正需要更新的行以减少锁定范围;用批量写入(把多行打包进一次数据库往返,比如 SendBatch),作者称这带来了约 10 倍的吞吐提升。迁移策略上主张尽量做增量式(additive)迁移、不删列,任何会触发 ALTER TABLE 的操作都要审视是否阻塞写入——比如给大表加 check 约束,不带 NOT VALID 就会阻塞写。
进入中等规模后,作者点出三个坑。一是查询计划器依赖 ANALYZE 生成的统计信息,统计不准就会选错执行计划(好消息是 autovacuum 会顺带触发 ANALYZE)。二是连接管理:每个连接都消耗 CPU 和内存,建议用 pgbouncer 这类外部连接池,或 Go 的 pgxpool 这类进程内池,连接风暴会引发很隐蔽的内部锁问题。三是 autovacuum 调优,这是最关键的扩展瓶颈:死元组(dead tuple)在写入速度超过回收速度时不断堆积,危险信号包括 autovacuum 进程运行超过一小时、表体积快速膨胀,最坏情况是事务 ID 回卷(XID wraparound)导致长时间停机。
进阶技巧部分介绍了 FOR UPDATE SKIP LOCKED(用单条查询实现任务队列、以及跨实例的分布式租约)、表分区(按时间或 hash 切分,让 autovacuum 可以按分区独立运行,删除旧数据变成 drop partition 的瞬时操作,代价是读性能依赖分区裁剪),以及大表数据迁移的正确做法——不要放在单个大事务里(会阻塞 autovacuum 并阻止并发写),而是用触发器配合事务外的分批回填,靠唯一约束防止重复写入。
HN 评论精华
-
thundergolfer:这篇文章对监控和告警的重视程度远远不够。Postgres 有几种关键故障模式是你绝不该让它发生的,而告警能给你早期预警。比如 AWS 会在你接近 XID 回卷时发邮件,但创业公司很可能漏掉这封邮件——特别是它在节礼日发出来的时候。你应该把 AWS 监控的那个指标接到 on-call 传呼系统上。
-
theallan:整篇「生存指南」竟然完全没提备份和恢复方案,这不应该是拿到生产库后第一件要做的事吗?高可用可以算「锦上添花」,但备份恢复计划怎么能缺席?由此引出了本帖最热闹的讨论分支。rsyring 和 Tostino 力推 pgBackRest,称其支持时间点恢复(PITR),且做了文件内增量(1GB 文件里变了 8KB 就只备份差异),省了大量存储费用。k_bx 提醒它并不像 git 那样纯增量,仍需定期做全量备份——他没搞懂这点,用了 5 个月后发现 Backblaze 上堆了 40TB。CodesInChaos 补充说 pgBackRest 前段时间因缺乏资金差点停止维护,好在维护者已拿到资金。
-
ComputerGuru:不必搞得那么复杂、引入那么多依赖。对大多数人来说,一个 cron job 跑
pg_dumpall管道到 zstd 再传到 S3 就完全够用了,能带你走很远。Scarbutt 反驳「前提是你能接受丢掉两次备份之间的数据」,lobo_tuerto 补刀「总比没有备份丢掉全部数据好」。他还贡献了本帖技术密度最高的一条补充清单:一般该用 uuidv7 而非 v4;除了减少锁定行数,还要保证所有查询的加锁顺序确定(比如永远ORDER BY id ASC),否则会死锁;用explain (generic_plan)可以带占位符直接粘贴查询、看到 Postgres 在不知道具体参数值时会怎么优化;测试查询计划时用SET seqscan = off(实际是把顺序扫描的代价设成天文数字),尤其在表为空时验证索引是否会被用上;不要无脑默认 btree,只按 id 查、不需要排序和范围比较时可以考虑 hash 索引以减少索引膨胀;学一下 GIN 和 GIST 索引,它们能在不改语法的前提下加速LIKE '%foo%'这类查询,不必切到全文检索。 -
abelanger(原作者):回应说加锁顺序这条建议很好,应该写进指南。他补充了一个更隐蔽的死锁场景:即使你在每张表内都用
ORDER BY+FOR UPDATE保证了行的加锁顺序,如果事务 A 先锁table_a再锁table_b、事务 B 顺序相反,照样死锁。这在理论上显而易见,但实践中调试难度指数级上升,因为你需要对每次写入触及的所有表有全局认知——他们就被某些扩展在这上面坑过。他也提到团队正在测试用 GIN 加速 JSONB 的键值查找,性能提升非常显著,且发现 AND 与 OR 查询之间存在很大的性能偏斜。 -
mjr00:如果你本来不是 Postgres 专家,就直接用 RDS 之类的云数据库。自己托管省下的钱,跟你能白拿到的久经考验的 HA、备份恢复、时间点恢复、只读副本等基础设施相比根本不值一提。他还补充了两条运维纪律:早点把应用部署和数据库部署分开——你不可能事务性地同时部署 schema 变更和应用变更,两者版本必然有一段时间不一致,迟早会遇到库改成功但应用发布失败的情况,所以上生产后要养成只做向后兼容的 schema 变更的习惯(新列可空或带默认值、不重命名表和列)。以及尽早确立 schema 管理策略,别让部署流程变成「资深开发在自己机器上对生产库手跑 DDL」。dwedge 部分反对:他们公司抱着同样心态,结果养了一堆从来没人用的托管只读副本(不做报表、不做只读查询、备份也是云商管的),每月白烧钱,而且托管数据库会以很烦人的方式限制你能做的事。9dev 算了笔账:在 Hetzner 上跑一主一从 + pgBackRest 备份到其 S3,不到 50 欧元就能拿到 HA、PITR 和 3-2-1 备份,足够撑到 A 轮,还自带 GDPR 合规——「同等配置的 RDS 要多少钱?」OrangeDelonge 回敬:「如果你在 50 欧元这个量级上斤斤计较,你大概进错了创业公司。」vanviegen 则担心被 AWS 的出口流量费和数据库延迟锁死。
-
traceroute66:在全文搜索「function」零结果,「毫无亮点」——竟然连存储函数都不提一句?他认为存储函数能帮助防御 SQL 注入,还能防止开发者随便写查询。这引发了本帖最激烈的争论。raverbashing:创业公司最不该花时间做的就是存储函数,「如果他们真有这个时间,说明他们在服务与市场匹配上投入不够」。fabian2k:防 SQL 注入根本不需要存储过程,近一二十年的任何 Postgres 客户端库都支持参数化查询,而且大多数人反正会用 ORM;他更担心存储过程会把逻辑藏到主代码库之外。traceroute66 反驳「这就叫有文档的函数」,跟你用的编程语言库里的函数没区别。Tostino 给出了折中实践:他把所有数据库函数纳入版本控制,每次发布时由 liquibase 随其他迁移一起部署,当作普通代码对待——用对了场合,存储过程能救命。stackskipton 则警告存储函数容易把数据库变成一个所有人都在调的巨石 API,长大后任何 schema 变更都要花好几天。
-
frollogaston:给出了一份和文章不同的「组织层面低垂果实」清单,认为创业公司遇到的更多是组织问题而非扩展问题:别用 ORM;主键用 serial 而非有业务含义的字段;jsonb 慎用;把真相来源(source of truth)设计成只追加(只 INSERT,不 UPDATE / DELETE),需要时再加反规范化的派生表;用连接池但注意连接数,除非你搞坏了什么否则大概不需要 PgBouncer;代码里除非有明确理由否则避免显式事务,绝不在开着事务时做 RPC 之类的长耗时操作,也几乎永远不要用 SERIALIZABLE;如果你在用
SELECT FOR UPDATE,大概哪里出了问题;别用一张表加个「type int」枚举列来重新发明类型系统(manphone 指出这就是 EAV 模式,唯一优点是灵活,缺点是其他一切);也别用自引用的 node/edge 表重新发明图数据库。kentm 对「只追加」提出修正:假设 99% 只追加,但要为边缘情况留出更新和删除的余地——如果数据涉及 PII 且受 GDPR 约束,你必须有硬删除的手段;另外「只追加日志 + 可变视图」这套请务必用触发器或物化视图实现,他见过手动双写两张表最后数据不一致的案例。 -
sgarland(自称 DBRE):反对文章里「有时候直接往 jsonb 列里倒数据更省事」的说法——对创业公司来说,把所有东西塞进 JSONB 的性能代价会盖过反规范化带来的任何收益,只要 schema 设计得聪明,JOIN 真的没那么难;另外给状态之类的字段留自由文本列,最后一定会被
closed != CLOSED != Closed这种问题咬到。他还补充说,「以为该用索引但计划器仍然顺序扫描」通常是两个原因之一:忘了索引本质是 B+tree 而数据布局对该查询不友好,或者数据分布不均匀(比如用户地理分布高度集中在大城市),后者可以用直方图应对。他也提到文章没讲其他索引类型,特别是 BRIN——在数据形态合适(时序是最明显的例子,但任何有有用聚簇性的数据都值得考虑)时,它能以近乎零开销带来极高性能。 -
mrkaye97(Hatchet 的 Matt):补充了一个反直觉的实战经验——在少数非常特定的场景下,他们选择在内存中做 JOIN。常见建议是减少数据库往返次数(通常是好建议),但有时会走过头,催生出需要复杂 JOIN / UNION / CASE 逻辑的过度复杂查询。他们有几处代码改成独立跑两条以上的简单查询,再用 map 在应用层匹配行。传统认知会说这样更慢,但在这些场景下反而更好,因为查询计划行为更可预测。saltcured 提醒这非常依赖场景:如果 JOIN 产生的行数远大于源集合,本地生成排列组合确实能省数据库负载和网络流量;但很多内连接是高选择性的,输出远小于输入,此时把全部记录拉下来本地过滤会糟糕得多。Matt 澄清他们只在计划器因 JOIN 条件复杂(含 CASE、布尔 OR 之类)而难以做出好决策时才这么做,且用得很少。
-
lennoff:不同意
timestamptz那条建议——他倾向用不带时区的timestamp,强迫自己处处用 UTC,连用别的东西的念头都不会有;他在金融科技行业,凡是见过存带时区日期时间的,最后都以灾难收场。dan_sbl 指出这是命名误导:timestamptz其实根本不存储时区,所有值都以 UTC 存储,两种类型底层都是 8 字节完全相同;但用timestamptz能让你在需要时更容易按非 UTC 时区做按天、按小时分组,处理夏令时尤其有用。lennoff 查了文档后承认对方说得对,「这个类型的名字确实起得糟糕」。 -
ucarion 提了个实际难题:在一个大家不断随手加接口的代码库里,很难避免两个接口分别按 (a, b) 和 (b, a) 顺序更新,有没有什么「纪律」能在真实混乱的业务代码里给表强加一个顺序,来避免哲学家就餐问题?forgotmy_login 建议用 SERIALIZABLE 隔离级别让其中一个失败,并把事务失败和重试当作一等公民。ucarion 回应说他见过重试反而恶化局面——本质问题是两条热路径互相冲突,重试只会冲突得更厉害。mrkaye97 承认没有完美解法,他们是随着发现逐个修复的,并建议审视一下「为什么会有两段应用代码以不同顺序更新同样两张表的行」,这本身可能是代码坏味道,通常出现在两个割裂的子团队共用同一个数据库时。
-
zer00eyz:文章从规范化和设计讲起值得肯定,这一点的重要性应该被更多强调——他见过太多「让 ORM 帮我们建库」的糟糕案例,并推荐《Database Design for Mere Mortals》一书。他认为文章缺了一条:不要害怕把 Postgres 用在「蠢」用途上,比如缓存、队列,尤其是在冲刺上线的路上;也不要害怕跑多个 Postgres 实例,特别是当你把它当工作队列用的时候。此外 Postgres 的角色(role)系统蕴含着惊人的能力,官方手册没能体现出它的丰富程度。
-
thisismyswamp:数据库管理的第一条规则是——除非你愿意花钱雇一个全职的人来做,否则不要自己托管或管理数据库。