接手一个内部数据平台重构时,团队在选型上卡了整整两天。需求单摊开来什么都有:订单是标准的关系型结构,用户画像是一大坨嵌套 JSON,运营要按关键词搜工单正文,风控要算“三公里内”的门店,后来还追加了一个“相似文本推荐”。有人提议拆成三套存储各管一摊,有人提议先用文档库顶住再说。最后拍板的人只说了一句:先拿 PostgreSQL 试,数据库界的瑞士军刀,别急着拆家。这句话后来被证明是对的,但我们也实实在在交了学费——军刀好用,前提是你清楚哪把刀什么时候该伸出来。这篇是从零到一系列的第一篇,讲的就是在 0 到 1 这个阶段,为什么值得把 PostgreSQL 作为默认选项,以及这个“默认”背后的技术依据和边界在哪里。
适合读这篇的人大致分三类:正在做技术选型、手里只有一个模糊需求单的开发者;已经用上了 PostgreSQL 但只把它当“更严格的关系库”在用、没发挥出真正价值的人;还有一类是被“万能数据库”的宣传词坑过一次、想搞清楚它到底能扛多少事的人。我会把能力清单、底层机制、横向对比、落地配置和上线后的高频故障都摊开讲,参数给到可以直接抄的数值,踩坑点写清楚复现路径。
1. 先把“瑞士军刀”这个比喻拆开看:它到底能拧哪几颗螺丝
“数据库界的瑞士军刀”这个说法流传很广,但它其实是个偷懒的比喻。军刀的特点是功能多、体积小、随手掏出来就能用;而数据库选型真正要问的是:这些功能是原生实现还是外挂拼凑?组合使用时会不会互相拖累?我见过太多团队冲着“什么都能干”上了车,结果只用了关系型那一小块,剩下的能力一个没碰,反而背上了一个比轻量方案重得多的运维包袱。所以先把这个比喻拆解清楚,比直接背功能列表有用得多。
我的判断标准很朴素:一个数据库的“万能”是不是真的,看它新增一种数据类型或查询模式时,需不需要把数据搬到另一个系统里去。如果需要搬,那叫“集成”,不叫“原生”;如果不需要搬,数据还在同一张表、同一个事务里,那才叫真正的能力覆盖。PostgreSQL 在这件事上的表现,是它区别于其他方案的核心。
1.1 一把刀十八般兵器:能力清单与它的真实边界
先把家底亮出来。下面这张表是我给团队做内部分享时用的版本,把常见需求和对应能力对上号,省得每次都要翻文档。
| 业务需求 | PostgreSQL 的对应能力 | 成熟度判断 |
|---|---|---|
| 标准事务与强约束 | ACID、外键、CHECK、唯一约束、可延迟约束 | 非常成熟,主线能力 |
| 复杂报表查询 | CTE、窗口函数、GROUPING SETS、LATERAL、并行查询 | 非常成熟 |
| 半结构化字段 | JSONB 类型 + GIN 索引 + 路径查询 | 成熟,可替代轻量文档库 |
| 全文检索 | tsvector / tsquery + GIN + 排名函数 | 成熟,中文需配分词方案 |
| 地理空间计算 | 社区空间扩展,支持几何类型与空间索引 | 成熟,行业事实标准级 |
| 时序与指标 | 分区表 + BRIN 索引,或专用时序扩展 | 可用,超大规模需评估 |
| 向量相似检索 | 社区向量扩展,支持近邻索引 | 近几年快速成熟 |
| 模糊匹配 | 三元组相似度扩展,支持 LIKE 加速 | 成熟 |
| 消息与变更捕获 | 逻辑复制、逻辑解码 | 成熟 |
| 行级权限控制 | 行级安全策略(RLS) | 成熟,多租户场景好用 |
边界同样要说清楚,否则容易误判。它不是列式存储引擎,几亿行宽表的全表聚合扫描,性能比不上专门的列存方案;它的原生水平扩展能力偏弱,分库分表要靠中间件或应用层做;单个热点行的超高频自增更新(比如秒杀计数器)会因为行锁竞争而排队,这类场景更适合放到内存型存储里做;单机数据量到了一定规模后,索引维护和备份窗口都会变成实实在在的成本。
注意:判断一个需求该不该交给 PostgreSQL,问自己一句“这份数据愿不愿意跟主业务数据放在同一个事务里”。愿意,就用它;不愿意,才考虑引入第二个组件。这个问法能过滤掉八成的过度设计。
1.2 从“能存”到“能查”:扩展性才是真正的护城河
很多人对 PostgreSQL 的印象停留在“一个更严格的开源关系库”,这其实漏掉了它最值钱的部分。它真正稀缺的地方在于:类型系统、索引访问方法、操作符类这三件套是开放的。任何扩展只要实现了这三样,就能让自己的数据类型像原生类型一样参与索引、参与查询规划、参与事务。
举个具体例子解释这件事的含金量。假设你想加一种“向量”类型做相似度检索,在大多数数据库里,你得把数据导出去,用外部服务算完再导回来。而在 PostgreSQL 里,扩展做的事情是:注册一个vector类型、注册一个支持近似最近邻的索引访问方法、注册距离操作符<->。注册完之后,你可以直接写这样的查询:
SELECT id, title FROM docs ORDER BY embedding <-> '[0.12, 0.83, 0.44]'::vector LIMIT 10;这条语句会走索引,参与事务,能被EXPLAIN分析,能被pg_stat_statements统计,能和其他表做 JOIN。数据一步没挪。这就是“原生”和“集成”的差别。
同样的逻辑也适用于空间数据。几何类型之所以能高效做“附近搜索”,是因为它挂了专门的树形空间索引,而查询规划器知道怎么用这个索引。全文检索之所以快,是因为tsvector上能建倒排索引,@@操作符能命中它。你会发现,所有这些能力的实现路径是同一套:定义一个类型,给它配一个索引,教优化器怎么用。这套机制开放,才有了“军刀”的观感。
CREATE EXTENSION这个命令看起来平平无奇,但它是整个生态的入口。执行一条命令,几万行 C 代码编进你的数据库实例,新类型立刻可用。这件事在别的系统里通常是改内核级别的工程。
1.3 这个比喻最容易误导人的两处地方
第一处误导是“什么都能干,等于什么都最强”。军刀上的小锯子能锯树枝,但你不会拿它去伐木。JSONB 好用,但在深层嵌套、超大文档、频繁全文更新的场景下,它的写入放大和索引维护成本是真实存在的;分区表能撑起时序数据,但如果你每天要跑几十个大范围聚合,列存方案仍然是更优解。选型时的心态应该是:用它覆盖 80% 的常规需求,把剩下 20% 真正极端的负载识别出来单独处理,而不是指望它一招通吃。
第二处误导更隐蔽,也更贵:“能省掉一个组件”和“应该省掉一个组件”是两回事。我们在项目里省掉了搜索引擎,用全文检索顶住,这个决定是对的,因为工单量只有几百万级,查询 QPS 也不高。但如果当时数据量再大一个数量级、还需要复杂的分词打分和聚合统计,硬顶就会变成灾难。省组件的前提是负载画像清楚,而不是“反正它能干”。
我的经验是:每引入一个能力,就在设计文档里补一句“什么条件下这个能力会撑不住,届时怎么迁走”。这句话花五分钟写,能在半年后救你一命。
2. 支撑“万能”的底层设计:进程模型、MVCC 与可扩展类型系统
用了一段时间之后,我开始觉得光会用不够,得知道它为什么这么设计。因为一旦出了问题,你排查的方向完全取决于你对底层机制的认知。举个例子:为什么连接数一多就卡?为什么表用久了查询会莫名其妙变慢?为什么加了索引反而写入变慢?这三个问题分别指向进程模型、MVCC 和索引维护,理解清楚之后,排查就变成了对照检查,而不是瞎试。
这一章讲的都是“为什么”,不涉及太多操作,但这些认知会贯穿后面所有章节。
2.1 每个连接一个进程:红利与连接数陷阱
PostgreSQL 采用多进程架构,每个客户端连接对应一个后端进程,由主进程 fork 出来。这和线程模型有本质区别,带来的好处很实在:连接之间内存隔离好,一个连接跑挂了不至于带崩整个实例;扩展用 C 写的时候不需要考虑线程安全,这也是社区扩展数量庞大的原因之一。
代价也很直接:每个连接都有固定开销。一个空闲连接的私有内存通常在几 MB 量级,再加上连接本身的栈空间和会话状态。你可以用下面这条语句大致估算当前连接的内存占用:
SELECT sum(backend_memory) / 1024 / 1024 AS total_mb, count(*) AS conns FROM ( SELECT pg_backend_pid(), setting::bigint AS backend_memory FROM pg_settings WHERE name = 'shared_buffers' ) t;更实用的观察方式是看实际进程的常驻内存:
ps -o pid,rss,cmd -C postgres | sort -k2 -nr | head -20我实测下来,一个连接在空闲状态下常驻内存 5 到 10 MB 是常态,一旦跑起排序和哈希,work_mem会额外叠加。假设你把work_mem设成 64MB,同时有 20 个查询在做排序,光这一项就是 1.2 GB。这就是为什么“把max_connections调到 1000”这种做法几乎必然出问题——不是连接数本身撑不住,是并发查询的内存峰值撑不住。
正确的姿势是:数据库侧的连接数控制在核数的几倍以内,应用侧的并发用连接池挡掉。后面 4.3 节会给出具体配置。
2.2 MVCC 与表膨胀:为什么读写不打架,但需要人定期打扫
PostgreSQL 的多版本并发控制实现方式比较特别:更新一行,不是原地改,而是写入一个新版本的行,把旧版本标记为已删除。读操作根据自己的快照去判断哪个版本可见。好处是读永远不阻塞写,写也不阻塞读,没有脏读,也不需要读锁。
代价是死元组(dead tuple)会堆积。更新一百万行,表里就多了一百万个旧版本,它们还占着页面空间,直到被清理线程回收。更麻烦的是索引:索引项指向旧版本,也得跟着清理。这就是为什么一张频繁更新的表,逻辑数据量没变,物理体积却能翻好几倍,查询扫描的页面越来越多,越来越慢。
清理工作由 autovacuum 后台进程负责,但它默认的触发条件是“死元组超过表行数的 20% 且不少于 50 行”。对于千万级的大表,20% 意味着要堆到两百万死元组才动手,那时候表已经胖得不像话了。所以大表通常要单独调低触发阈值,这个在 5.1 节会给出具体做法。
还有一个隐藏杀手:长事务。清理线程只能回收“所有活跃快照都看不见”的旧版本。如果某个事务开了半小时没提交,这半小时内产生的所有死元组都动不了,表会一路膨胀下去。我遇到过一次线上表体积一周涨了六倍,最后定位到一个没设超时的批处理任务,事务开着去调外部接口了。
监控死元组用这条:
SELECT relname, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / greatest(n_live_tup, 1) * 100, 2) AS dead_pct, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20;2.3 类型系统与索引框架:让新能力长得像原生功能
PostgreSQL 内置了多种索引访问方法,每种对应不同的数据形态。理解它们的适用面,比记住“加索引会变快”有用得多。
| 索引类型 | 适用形态 | 典型场景 |
|---|---|---|
| B-tree | 有序、可比较的单值 | 主键、时间范围、等值与排序 |
| Hash | 仅等值匹配 | 超长字符串的精确匹配 |
| GIN | 一个值里包含多个元素 | JSONB、数组、全文检索 |
| GiST | 空间与区间关系 | 几何数据、范围类型、近邻检索 |
| BRIN | 物理顺序与值强相关 | 追加写入的时序大表 |
| SP-GiST | 非平衡空间划分 | 前缀树、四叉树类结构 |
GIN 是理解 JSONB 性能的关键。它的原理是把一个 JSONB 文档拆成一条条“键路径 + 值”的记录,建成倒排表。所以WHERE data @> '{"status":"paid"}'这种包含查询能走索引,而WHERE>docker run -d \ --name pg-main \ -e POSTGRES_PASSWORD='替换成你的密码' \ -e POSTGRES_INITDB_ARGS='--encoding=UTF8 --locale=C' \ -p 5432:5432 \ -v /data/pg:/var/lib/postgresql/data \ postgres:16
几个细节值得说。--locale=C是刻意的选择:用 C 排序规则,字符串比较和索引行为最稳定,跨环境迁移不会因为系统 locale 不同导致排序结果不一致。代价是中文按字节序排,如果业务需要按拼音排序,用ORDER BY ... COLLATE在查询里指定,或者在列级指定排序规则,比全局依赖系统 locale 更可控。
-v挂载数据目录时必须挂到宿主机路径,别用匿名卷。我吃过一次亏:容器重建,数据跟着没了,因为卷是匿名的。生产环境还要注意数据目录的权限和文件系统,避免用网络文件系统挂载 PostgreSQL 数据目录,它在文件锁和 fsync 语义上有坑。
本地安装的话,包管理器装完之后第一件事是确认数据目录、配置文件位置和版本:
psql --version psql -c "SHOW config_file;" psql -c "SHOW data_directory;"版本策略上,我的建议是新项目直接用当前稳定的大版本,别用太老的版本。较新的版本在并行查询、逻辑复制、分区表性能、JSON 路径查询上都有实质改进,而这些恰好是新项目会用到的。升级路径上也别拖,大版本跨度越大,升级成本越高。
4.2 核心参数:给你一台 16GB / 8 核机器的具体数值
参数配置最忌讳抄博客不看机器规格。下面给一套 16GB 内存、8 核、SSD 存储的具体值,并解释每一项的计算逻辑,你按比例换算到自己的机器上就行。
# 内存相关 shared_buffers = 4GB # 约物理内存的 25% effective_cache_size = 12GB # 约物理内存的 75%,只是给优化器的估算提示 work_mem = 16MB # 单次排序/哈希操作的上限 maintenance_work_mem = 512MB # 建索引、清理等维护操作 # 连接相关 max_connections = 200 # 配合连接池,不要盲目调大 # 写入相关 wal_level = replica max_wal_size = 4GB min_wal_size = 1GB checkpoint_timeout = 15min checkpoint_completion_target = 0.9 # 存储特性 random_page_cost = 1.1 # SSD 上用这个值,机械盘保持默认 4.0 effective_io_concurrency = 200 # SSD 可以调高,机械盘设 2 # 并行 max_parallel_workers_per_gather = 4 max_parallel_workers = 8shared_buffers为什么是 25% 而不是越多越好?因为数据库自己的缓存和操作系统的页缓存是两层缓存,shared_buffers太大会挤占系统缓存,反而降低整体命中率。这个比例在专用数据库服务器上是经验值,机器还跑别的服务时要往下调。
work_mem是最容易出事的一项。它不是全局上限,是每个排序或哈希操作的上限,一个复杂查询里可能同时存在多个这样的操作。所以真实内存峰值大致是:work_mem × 单查询内并发节点数 × 并发查询数。16MB 配 200 连接理论上能吃到 3.2GB 以上,这已经是保守估计。如果监控里经常看到临时文件写入(pg_stat_database.temp_bytes持续增长),可以适度调高,但要同步盯着内存水位。
max_connections定 200 的前提是前面有连接池。如果没有连接池,应用侧每个实例开 50 个连接,四个实例就满了,然后开始报连接错误。这个数字本质上是“数据库能同时处理多少个会话”,不是“能接受多少个客户端”。
random_page_cost = 1.1这个改动很多人忽略,但影响不小。SSD 上随机读和顺序读的差距远小于机械盘,保持默认的 4.0 会让优化器过度偏好全表扫描、不敢走索引。改完之后,一些原本该走索引却走了顺序扫描的查询会自己变好。
4.3 连接池:三种模式的选择与 transaction 模式的坑
连接池放在应用和数据库之间,核心作用是让大量应用连接复用少量数据库连接。主流方案支持三种模式:
| 模式 | 连接复用粒度 | 适用场景 | 限制 |
|---|---|---|---|
| session | 会话结束后归还 | 兼容性最好 | 复用率低,收益有限 |
| transaction | 事务结束后归还 | 绝大多数 Web 服务 | 会话级特性不可用 |
| statement | 单语句后归还 | 极简自动提交场景 | 多语句事务不可用 |
transaction 模式是性价比最高的选择,但有几个必须知道的限制,否则会踩坑:
- 会话级临时表在两个事务之间可能被分到不同后端,行为不可预期。
- 会话级 advisory lock、
SET命令设置的会话参数,在事务结束后不保证还生效。 - 服务端预处理语句在早期版本上有兼容问题,较新版本加了跟踪机制,但应用框架层仍需确认是否开启。
LISTEN/NOTIFY这类长连接语义在 transaction 模式下不可靠。
我的建议是:默认用 transaction 模式,然后在应用层做一次全量扫描,把所有依赖“连接粘性”的写法(临时表、会话变量、会话锁)挑出来改掉。这件事在项目早期做成本很低,等代码多了再改就是大工程。
连接池大小怎么定?一个粗略的起点是:数据库核数 × 2 + 有效磁盘数,8 核 SSD 场景大概 20 到 30 个池内连接。很多人第一反应是“太小了吧”,但只要每个事务足够短,这个数量能撑住的 QPS 远超预期。我实测过一个接口平均 3ms 的场景,30 个连接跑到几千 QPS 毫无压力。真正拖垮吞吐的从来不是连接数不够,是事务太长。
4.4 备份与恢复:从“能跑”到“敢上线”的最后一关
没有恢复演练的备份等于没有备份,这句话我说过很多次。具体做法分两层:
逻辑备份用于日常和小体量恢复:
# 备份(自定义格式,支持并行恢复和选择性恢复) pg_dump -h 127.0.0.1 -U app -Fc -f /backup/app_$(date +%F).dump appdb # 恢复 pg_restore -h 127.0.0.1 -U app -d appdb_new -j 4 /backup/app_2025-01-01.dump-Fc这个格式比纯 SQL 好在两点:压缩率高,恢复时可以并行(-j),还能只恢复某几张表。-j 4的并行度不要超过核数,超过反而因为 IO 争抢变慢。
物理备份用于整实例快速恢复和搭建从库:
pg_basebackup -h 127.0.0.1 -U replicator -D /data/pg_standby \ -Fp -Xs -P -R-R会自动生成复制配置,起实例就能作为从库跟上。
再往上一层是持续归档加时间点恢复。配置archive_mode = on和archive_command(或者用备份工具接管),把 WAL 持续推到独立存储。这套配置的验证方法是:定期真的做一次恢复到指定时间点,记录耗时,看看恢复出来的数据是否符合预期。我见过备份跑了两年没出过问题的团队,第一次真恢复时发现归档链路早就断了,因为归档命令写错了一个路径,日志里一直在报错但没人看。
提示:备份文件一定要落在和数据库不同的物理存储上。同一个磁盘上的备份,磁盘挂了就一起没了,这种情况在实际故障里占比不低。
5. 上线后真正会让你半夜爬起来的问题
前面都是准备阶段。真正考验人的是上线三个月之后,数据量上来了,查询模式变复杂了,一些早期看不出来的问题开始显形。这一章列的五个问题,是我在不同项目里反复遇到的,排查路径都写清楚,你照着走就行。
5.1 表膨胀与 autovacuum:从默认配置到按表调参
先说症状:一张几百万行的表,查询走的是索引,条件也不复杂,但响应时间从 20ms 涨到了 300ms,EXPLAIN显示扫描的行数远超预期,实际返回的行却很少。八成是膨胀。
排查顺序:先看死元组比例(用 2.2 节那条 SQL),再看表的物理体积和逻辑体积对比:
SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, pg_size_pretty(pg_relation_size(relid)) AS table_size, pg_size_pretty(pg_indexes_size(relid)) AS index_size FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;如果发现表体积远大于实际数据量,接下来要做的是调参而不是立刻清理。默认的 autovacuum 触发阈值对大表太宽松,做法是给大表单独设置存储参数:
ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 1000, autovacuum_analyze_scale_factor = 0.01 );把比例从 0.2 降到 0.02,意味着死元组到 2% 就开始清理,频率高了但每次工作量小,对业务影响更平缓。设完之后观察一两天,看last_autovacuum是否规律触发。
已经胖起来的表怎么办?重建是唯一能真正归还磁盘空间的方式。但要注意:常规的重建命令会持有排他锁,大表上等于停机。生产环境的做法是用在线重建工具(通过触发器和增量同步实现表替换),或者干脆走“建新表 + 双写 + 切换”的流程。不要在业务高峰期做这件事,这是我用一次十分钟的服务中断换来的教训。
还有一个常被忽略的点:autovacuum的工作进程数量。默认只有 3 个,表多了会排队。数据表数量超过几十张且更新频繁的库,把autovacuum_max_workers调到 5 到 6,配合autovacuum_naptime调到 30 秒,整体清理会更及时。
5.2 索引建错的五种典型姿势
索引不是越多越好,这个道理都懂,但具体错在哪经常说不清。下面五种是我在代码评审里见得最多的。
第一种:重复索引。已经有(a, b)的联合索引,又单独建了一个(a)的索引。后者是冗余的,因为联合索引的最左前缀已经覆盖了它。这类冗余索引会白白增加写入成本。查冗余索引可以用系统视图看索引定义,重点比对列顺序。
第二种:低区分度列上的单列索引。比如在“状态”字段上建索引,而这个字段只有五个取值。查询规划器大概率会忽略它,因为走索引再回表比全表扫描还慢。这类字段除非和别的列组成联合索引,否则意义不大。
第三种:函数列上没有表达式索引。查询写的是WHERE lower(email) = 'x@y.com',索引建在email上,那这个索引就用不上。正确做法是建表达式索引:
CREATE INDEX idx_users_email_lower ON users (lower(email));建完之后,WHERE lower(email) = ...就能命中。注意表达式必须和查询里写的完全一致,包括函数名和参数顺序,差一点都匹配不上。
第四种:LIKE '%关键词%'配普通索引。前置通配符让 B-tree 完全失效。解决办法有两个:装三元组扩展建 GIN 索引,或者改用全文检索。前者适合短文本模糊匹配,后者适合长文本。
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_tickets_title_trgm ON tickets USING gin (title gin_trgm_ops);第五种:索引过多拖慢写入。一张高频更新的表上挂了十几个索引,每次插入和更新都要维护十几棵 B-tree。我见过一张写入压力很大的表,砍掉四个冗余索引之后,写入吞吐提升了将近三成。判断标准很简单:用系统视图统计每个索引的使用次数,长期为 0 的,评估后可以删掉。
SELECT schemaname, relname, indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY relname;注意统计是累计值,重启或统计重置后会归零,观察周期建议至少覆盖一个完整的业务周期(比如一个月,包含月末结算这类特殊时点)。
5.3 慢查询定位:一套固定动作链
线上反馈“某个页面慢”,到我这里基本会走同一套流程,五分钟内能定位到具体语句。
第一步,确认是不是数据库的问题。查当前活跃会话,看有没有长时间运行或正在等待的语句:
SELECT pid, now() - query_start AS duration, wait_event_type, wait_event, state, left(query, 120) AS query_head FROM pg_stat_activity WHERE state <> 'idle' ORDER BY duration DESC LIMIT 20;wait_event_type是 LOCK 的时候基本能确定是锁等待,去查阻塞源;是 IO 的时候要考虑是不是在刷脏页或者扫描大表。
第二步,从统计视图里找累计耗时最高的语句。前提是装了pg_stat_statements扩展,并且加进了shared_preload_libraries(这个参数需要重启才生效,别到出问题才想起来装)。
SELECT round(total_exec_time::numeric, 1) AS total_ms, calls, round(mean_exec_time::numeric, 2) AS mean_ms, round(100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct, left(query, 150) AS query_head FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;字段名在不同大版本之间有差异,较老版本里是total_time和mean_time,查的时候注意版本。hit_pct这个指标很关键:缓存命中率低的语句,说明它在大量读磁盘,加内存或者改查询的收益都比较明显。
第三步,拿具体语句跑执行计划,必须带ANALYZE和BUFFERS:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT o.id, o.amount, u.name FROM orders o JOIN users u ON u.id = o.user_id WHERE o.created_at >= now() - interval '7 days' AND o.status = 'paid';看三个东西:估算行数和实际行数的偏差(偏差大说明统计信息过期,跑一下ANALYZE 表名)、有没有走索引、BUFFERS 里的 shared read 数量。如果某个节点实际行数是估算的几十倍,优化器选的连接方式很可能就是错的,这种时候强制更新统计或者改写查询比调参数有效。
第四步,改写。常见手段有:把子查询改成 JOIN、把NOT IN改成NOT EXISTS、在大表上先做过滤再关联、拆分超长事务、把 OR 条件改成 UNION ALL。这些改法都不玄乎,核心思路是让优化器拿到更准确的信息、更小的中间结果集。
5.4 JSONB 用得爽,但查询写崩的三种原因
JSONB 是我们用得很重的一个特性,也是最容易出问题的地方。踩过的坑主要集中在三类。
第一类:写法导致索引失效。WHERE>-- 包含查询,走 GIN 索引 SELECT * FROM orders WHERE data @> '{"status": "paid"}'; -- 频繁访问的字段,建表达式索引 CREATE INDEX idx_orders_status ON orders ((data->>'status')); -- 或者把高频字段提升为独立列(推荐) ALTER TABLE orders ADD COLUMN status text GENERATED ALWAYS AS (data->>'status') STORED;
生成的存储列是我最推荐的做法:既有 JSONB 的灵活性,又有独立列的索引和类型约束,还能参与统计信息收集。
第二类:GIN 索引的操作符类选错。默认的jsonb_ops支持更多操作符,但索引体积大;jsonb_path_ops只支持包含查询@>,索引体积小很多、查询更快,但用不了?、?|这类键存在判断。选型依据是:只做包含查询就用jsonb_path_ops,这个在文档量大时差别很明显。
CREATE INDEX idx_orders_data_path ON orders USING gin (data jsonb_path_ops);第三类:嵌套数组查询写成了全表扫描。比如要查“包含任意一个指定标签”的记录,写成WHERE>CREATE EXTENSION IF NOT EXISTS pg_trgm;
但背后有几个前置条件容易被忽略。第一,扩展的二进制文件必须已经装在数据库服务器上,光有 SQL 脚本不够,很多扩展还需要对应的系统依赖库。第二,部分扩展要求写进shared_preload_libraries并重启实例才能生效,这类扩展不能热装。第三,扩展有版本概念,ALTER EXTENSION ... UPDATE可以升级,但升级前要看清楚变更说明,有些版本会改动存储格式。
运维上要建立一条规矩:扩展清单要纳入版本管理。我们用一个 SQL 文件记录所有CREATE EXTENSION语句,随数据库初始化脚本一起走版本控制。新环境部署时一次性装齐,避免出现“开发环境有、生产环境没有”这种低级问题。这个坑我踩过,函数在测试环境跑得好好的,上线报“类型不存在”,排查了半小时才发现是扩展没装。
6.2 三个高频扩展方向的实际取舍
空间计算方向。这是用得最广的一类。门店距离计算、配送范围判定、区域统计,全都是它。判断要不要引入的标准是:你有没有“按距离排序”或“在某个几何范围内筛选”的需求。有,就值得装;没有,不要为了可能的未来需求提前装,几何类型和空间索引的维护成本不低。实际使用时,距离计算注意单位,以及地理坐标和平面坐标的区别——用错坐标系算出来的距离误差可能是公里级的。
向量检索方向。近两年需求增长很快,典型场景是语义搜索、相似内容推荐、图片去重。它的价值在于:你不需要为了向量检索单独维护一套服务,向量数据和业务数据在同一张表里,可以做带业务过滤条件的近邻查询。比如“在过去 30 天、状态为已发布的文档里找语义最接近的 10 篇”,一条 SQL 搞定。取舍点在于:如果向量规模到了千万级以上、且 QPS 要求很高,专门的向量检索服务在延迟上仍然有优势。中小规模(百万级以内)用扩展方案完全够用。
模糊匹配与中文检索方向。英文全文检索开箱即用,中文需要额外处理分词。常见路线有两条:装中文分词扩展,或者在应用层用分词库切好再写入tsvector。我倾向于后者,原因是分词逻辑放在应用层更容易调整,词库更新也不用动数据库。具体做法是写入时把分词结果拼成tsvector存到独立列,建 GIN 索引,查询时把用户输入同样分词后转成tsquery。
-- 写入示例(分词在应用层完成后拼成字符串) UPDATE docs SET search_vec = to_tsvector('simple', :segmented_text); -- 查询示例 SELECT id, title FROM docs WHERE search_vec @@ to_tsquery('simple', :segmented_query) ORDER BY ts_rank(search_vec, to_tsquery('simple', :segmented_query)) DESC LIMIT 20;用simple配置而不是english,避免英文词干化规则干扰中文分词结果。这是一个很细但很关键的点。
6.3 从小库到中大型库的演进节奏
最后说说演进。很多团队的焦虑是“现在选了它,将来撑不住怎么办”。我的经验是,演进路径可以分阶段规划,每一步都有明确的触发条件,不用提前焦虑。
| 阶段 | 数据规模参考 | 主要动作 | 触发信号 |
|---|---|---|---|
| 起步 | 百万行以内 | 单实例 + 连接池 + 定时逻辑备份 | 无 |
| 成长 | 千万行级 | 大表分区、流复制加只读副本、WAL 归档 | 单表超千万、备份窗口变长 |
| 稳定 | 亿行级 | 读写分离、按时间分区滚动、热点字段抽列 | 主库 CPU 长期高位、慢查询增多 |
| 扩展 | 单机容量瓶颈 | 按业务域拆库、逻辑复制同步、明细下沉分析存储 | 单机磁盘或 IOPS 打满 |
关键在于每一步都是被信号触发的,而不是被预期触发的。我见过太多项目在只有几万行数据的时候就规划分库分表,结果架构复杂度上去了,业务迭代速度下来了,最后数据量根本没长到需要拆的程度。反过来也有团队一路硬扛,等到主库 IOPS 打满才动手,那时候改造窗口已经很小了。
分区表是成长阶段性价比最高的动作,值得单独提一句。按时间做范围分区,老数据可以直接整分区归档或删除,不用DELETE大范围数据(那样会产生大量死元组和锁)。分区裁剪还能让时间范围查询只扫描相关分区。前提是查询条件里带上分区键,否则裁剪失效,反而比不分区的表更慢——这一点我在项目里踩过,加了分区之后某个查询变慢了,查了半天发现是条件里没带时间字段。
我个人在这些项目里最深的体会是:PostgreSQL 真正省心的地方,不在于它某个功能比别人强多少,而在于它把“现在够用”和“将来能长”这两件事同时给了你。你可以在第一天就用最简单的单实例把它跑起来,然后在接下来两三年里,随着业务变化一点点把 JSONB 换成正经列、把大表拆成分区、把只读流量分到副本、把某个能力换成扩展,每一次演进都是渐进的、可回退的,不需要推倒重来。
如果非要给一个起步建议:先用最保守的配置把实例跑起来,把连接池、备份验证、慢查询日志这三件事做扎实,剩下的能力等真有需求再伸刀。军刀的价值从来不是一次把十八般兵器都亮出来,而是需要哪把的时候,它刚好在。