为什么我越来越喜欢 PostgreSQL?从 JSONB、GIN 索引到 MVCC
众所不周知,我是个二把刀程序院。今天想和大家聊聊 PostgreSQL数据库
我的工作除了数据分析,也会做一些软件开发,包括数据库设计、数据入库和业务系统搭建。
这些年接触过不少数据库,比如 MySQL、MongoDB,还有 PostgreSQL。
但如果现在让我从头搭建一个新的数据平台,在没有特殊限制的情况下,我个人还是比较倾向于 PostgreSQL,简称 PG。
为什么呢?
其实有一个很简单、很具体的原因,就是我特别喜欢 PostgreSQL 的 JSONB。
一、为什么我喜欢在关系型数据库里存 JSON?
我们平时设计数据库表,一般会把数据整理成固定的字段。
比如一个人的基本信息,姓名、年龄、性别、身高、体重,每一个属性对应数据库中的一列。
这当然没有问题。
但是在实际开发中,尤其是处理医疗健康数据、问卷数据或者不同系统接口的时候,经常会遇到一个问题:
数据结构并不是永远固定的。
比如,一个人做了常规体检,可能只有几十项指标;另一个人做了更复杂的检查,可能有几百项指标。
有些指标是所有人都有的,有些则只有特定检查套餐才有。
如果每增加一个指标,就要修改一次数据库表结构,那会很麻烦。
过去我们可能有两种做法。
第一种是不断增加字段,最后形成一张有几百甚至上千列的大宽表。
第二种是使用 MongoDB 这类文档数据库,直接把数据作为一个 JSON 文档存进去。
但 PostgreSQL 给了我一个非常喜欢的折中方案。
我可以把确定的、经常查询的字段正常存成关系型列,然后在表的最后增加一个 JSONB 字段。
比如患者 ID、检查时间、数据来源这些字段是固定的。
但具体检查项目,可以整个放进 JSONB 里。
这就很像 Python 里的字典,也就是 dict。
我们在 Python 里处理数据时,经常写这样的东西:
data["BMI"]
或者:
data["lipids"]["TG"]
PostgreSQL 也可以用类似的路径操作来访问 JSONB。
比如通过 -> 和 ->> 提取内部字段,或者通过 jsonb_set 修改其中的值。
当然,SQL 里的 JSONB 不是真的 Python dict,底层实现也不同,但从开发者的使用体验来说,两者确实很接近。
这就意味着,我不一定要为了保存结构灵活的数据,额外引入一个 MongoDB。
在同一个 PostgreSQL 里,我既能使用关系型数据库的事务、约束和 JOIN,也能拥有接近文档数据库的灵活性。
这个特性对我来说,是 PostgreSQL 很重要的吸引力。
二、JSON 和 JSONB 到底有什么区别?
说到这里,就要解释 PostgreSQL 中的两个数据类型:JSON 和 JSONB。
它们都可以存储 JSON 数据,但是底层不一样。
JSON 类型主要保存输入时的原始 JSON 文本表示。
因此,原来的空格、对象中键的顺序,甚至重复的键,都可以保留下来。
而 JSONB 会把 JSON 解析成 PostgreSQL 内部使用的二进制结构。
这个过程会产生一定的写入开销,但以后读取、解析和检索通常会更方便。
JSONB 不保留原始的空格和键顺序,也不会保留重复的键。
所以如果只是希望保存原始 JSON 的文本形式,JSON 有它的价值。
但是对于绝大多数需要查询、过滤和操作内部数据的业务系统,我一般会优先考虑 JSONB。
而且 JSONB 还有一个非常重要的优势:
它支持 GIN 索引。
三、GIN 索引和我们熟悉的 B-tree 有什么不同?
大家学数据库的时候,最常见的索引一般就是 B-tree。
比如我们给年龄、患者 ID、检查时间建立索引。
B-tree 索引是一种有序的树结构。
它特别适合精确匹配、范围查询,以及符合条件的排序操作。
比如:
查找患者 ID 等于 1001 的记录。
或者查找 BMI 大于 25 的人。
B-tree 就非常适合这些场景。
但是 JSONB 有一个问题。
假设一条记录里面存储了一个疾病标签数组:
糖尿病、高血压、肥胖。
另一条记录里面可能只有高血压。
第三条记录可能有糖尿病和脂肪肝。
现在我要查找所有包含“糖尿病”标签的记录。
对于这种需求,传统的整列 B-tree 索引就不太适合了。
于是就有了 GIN。
GIN 的英文是 Generalized Inverted Index,通用倒排索引。
我觉得理解它最简单的方法,就是把查找方向反过来。
传统关系表中,我们通常从一条记录出发,找到它有哪些值。
而倒排索引,则是从某一个值或者元素出发,找到哪些记录包含它。
比如:
糖尿病对应记录 1、3、8。
高血压对应记录 1、2、5。
肥胖对应记录 1、6、9。
现在查找糖尿病,数据库就可以通过索引快速定位候选记录,而不需要每次把整个表中的 JSONB 都逐行解析一遍。
这有点类似搜索引擎的倒排索引。
搜索引擎可以通过一个词,快速找到包含这个词的文档。
而 PostgreSQL 的 GIN,可以通过 JSONB 中的键和值、数组元素等索引项,找到相关记录。
不过这里要强调一点。
GIN 并不是比 B-tree 更高级的索引,而是适合解决不同的问题。
B-tree 擅长有序的等值和范围查询。
GIN 擅长包含关系、成员查询以及全文检索。
如果我们需要查 BMI 大于 25,通常还是 B-tree 更合适。
如果我们要查 JSONB 里面是否包含某个疾病标签,就可以考虑 GIN。
数据库索引的关键,不是哪个技术更新,而是它的结构适不适合实际的查询模式。
四、修改 JSONB 中的一个值,数据库会发生什么?
这里就有一个比较有意思的问题。
假设 JSONB 里面保存了一个人的 BMI、血糖、血脂等几十项指标。
现在我只想把 BMI 从 26.5 改成 24.8。
从 Python dict 的角度来看,这件事非常简单。
直接修改一个键对应的值就行了。
但是在 PostgreSQL 里面,即使我们通过 jsonb_set 只修改一个字段,也不意味着数据库会直接找到那几个字节,原地覆盖。
通常情况下,PostgreSQL 会生成一个新的行版本。
注意,是新的行版本,不一定是一个新的磁盘页面。
为什么会这样?
因为 PostgreSQL 使用一个非常重要的并发控制机制:
MVCC。
它的全称叫 Multi-Version Concurrency Control,多版本并发控制。
五、MVCC 为什么要保留多个版本?
我们想象一个场景。
事务 A 正在读取一条患者记录。
与此同时,事务 B 准备修改这条记录。
如果采用传统的共享读锁和排他写锁机制,读写之间可能需要相互等待。
但是 PostgreSQL 的 MVCC 可以让事务 A 读取自己快照中可见的旧版本,而事务 B 同时更新出一个新版本。
这样,普通的读操作和写操作就不一定互相阻塞。
这就是 MVCC 非常核心的思想:
不是要求所有事务在同一个时刻只能看到唯一的数据状态,而是让事务根据快照判断哪个数据版本对自己可见。
不过,MVCC 并不意味着数据库不需要锁。
PostgreSQL 本身就有行锁、表锁、共享锁、排他锁等不同的锁机制。
比如两个事务同时修改同一条患者记录,就仍然需要进行写入冲突协调。
我们也可以通过 SELECT FOR UPDATE 显式锁定记录。
所以可以简单理解为:
MVCC 主要解决事务应该看到哪个版本。
锁则用来协调发生冲突的操作。
两者并不是竞争关系,而是共同构成 PostgreSQL 的并发控制机制。
六、旧版本越来越多,PostgreSQL 怎么处理?
讲到这里,很自然就会有另一个问题。
既然每次 UPDATE 通常都会产生新的行版本,那数据库是不是会越来越大?
确实,PostgreSQL 会面临旧行版本的回收问题。
这就引出了 VACUUM。
当旧版本不再被任何事务需要时,PostgreSQL 可以把它占用的空间回收,以便后续重复利用。
而且 PostgreSQL 默认具有后台自动维护机制,叫 Autovacuum。
它会根据表的更新、删除等情况,自动决定什么时候执行清理。
所以正常情况下,我们不需要每次 UPDATE 之后手动调用 VACUUM。
但这里有个很重要的区别:
普通 VACUUM 回收的空间主要用于数据库内部复用,不代表数据库文件会立即缩小。
如果存在大量长时间运行的事务,或者更新特别频繁的大表,旧版本就可能迟迟无法清理,进而造成表膨胀。
对于 JSONB,还有一个需要注意的机制叫 TOAST。
当 JSONB 很大时,PostgreSQL 可能把它压缩或者放到独立的 TOAST 存储中。
修改大型 JSONB 时,可能还会带来额外的存储重写成本。
因此,JSONB 虽然灵活,但也不是所有数据都应该往里面放。
七、回到我的实际工作:为什么我还是喜欢 PostgreSQL?
最后回到最开始的问题。
为什么我会比较喜欢 PostgreSQL?
一个很重要的原因,就是它让我能够在结构化和非结构化程度较高的数据之间取得平衡。
比如在做健康数据平台的时候,我可以把患者 ID、检查时间、数据来源这些固定属性保存在普通字段里。
而把不同检查套餐、问卷结果、设备接口返回的数据放进 JSONB。
如果后面需要根据某些疾病标签进行筛选,我还可以考虑建立 GIN 索引。
如果经常要按照血糖、BMI 等指标进行范围查询和统计分析,就把这些高频使用的指标设计成普通数值列,或者建立专门的表达式 B-tree 索引。
这让我不用在关系型数据库和文档数据库之间做一个非此即彼的选择。
当然,我也不会认为 JSONB 能替代所有关系表设计。
尤其是大规模数据分析、频繁更新或者需要严格类型约束的场景,结构化的数据模型仍然很重要。
另外,通过今天讨论的 MVCC 和 VACUUM,也能看到数据库的灵活性并不是没有代价。
一次看似简单的 JSONB 字段更新,背后可能涉及新的行版本、事务可见性、索引维护和空间回收。
所以做数据库设计时,不仅要考虑数据怎么存、怎么查,还要考虑数据以后会怎么修改,以及修改的频率有多高。
这也是我这些年做数据分析和软件开发的一个体会:
数据库选型不是简单比较功能多少,而是要理解数据结构、访问模式,以及底层存储机制之间的关系。
对我来说,PostgreSQL 最吸引人的地方,恰恰是它既保留了关系型数据库严谨的一面,又给开发者提供了很大的灵活性。
这就是今天想分享的内容。