多年来,“直接使用Postgres”这一建议一直被广泛传播,但大多数支持这一观点的文章都属于意见性文章。而我需要的则是客观的数据和事实。
因此,我构建了一套基准测试套件,用来在受控条件下比较普通版本的PostgreSQL与经过功能优化的PostgreSQL版本在缓存、消息队列、全文本搜索以及发布/订阅等功能上的表现。
在这篇文章中,您将了解到如何利用PostgreSQL内置的功能来实现缓存、作业队列、全文本搜索以及发布/订阅等功能。您还会看到实际的基准测试结果(包括延迟百分比、吞吐量以及错误率),这些数据会对比普通使用方式与优化后的使用方式之间的差异,同时帮助您了解PostgreSQL的局限性,从而判断在自己的系统中是否真的需要这些额外的功能。
目录
先决条件
若要跟随这些步骤进行基准测试或复现相关实验,您需要准备以下工具:
-
Docker及Docker Compose
-
Node.js 20及以上版本(用于Express TypeScript API层开发)
-
用于负载测试的k6工具
-
对SQL和PostgreSQL有基本的了解
完整的基准测试项目已在GitHub上开源,您可以克隆该项目并亲自运行所有的测试。
环境搭建
本次基准测试使用了两个相同的PostgreSQL 17版本实例,这两个实例都在Docker容器中运行,且都配备了固定的资源限制(2颗CPU、2GB内存)。这两个实例共享相同的Express TypeScript API层,唯一的区别在于它们所启用的PostgreSQL功能不同。
┌─────────┐ ┌──────────────────┐ ┌─────────────────┐
│ k6 │────>│ Express API │────>│ 基础版本PostgreSQL │
│ (负载测试) │────>│ 端口3001/3002 │────>│ 优化版PostgreSQL │
└─────────┘ └──────────────────┘ │ 启用的功能 │
└─────────────────┘
基础版本实例采用了传统的处理方式(普通表结构、ILIKE搜索算法、轮询机制);而优化版实例则利用了PostgreSQL内置的功能(未记录日志的表结构、带有GIN索引的tsvector类型、LISTEN/NOTIFY机制以及部分索引功能)。两者的硬件配置、API代码以及使用的数据都完全相同,唯一的不同在于所使用的数据库功能而已。
这两个实例都使用了相同的经过调整的《postgresql.conf》文件:
# 内存分配设置
shared_buffers = 512MB # 占可用RAM内存的25%
effective_cache_size = 1536MB # 占RAM内存的75%——有助于提升查询效率
work_mem = 16MB # 用于排序和哈希运算的内存
# 为SSD优化的配置设置
random_page_cost = 1.1 # 默认值为4.0,该值适用于普通磁盘
effective_io_concurrency = 200 # 允许在SSD上进行并行I/O操作
这些配置设置非常重要。默认值是基于21世纪初的普通磁盘设计的。将`random_page_cost`设置为1.1,意味着查询规划器会认为,在SSD上进行随机读取的操作速度与顺序读取几乎相同,因此系统会更倾向于使用索引而不是进行顺序扫描操作。
基准测试1:使用未记录日志的表进行缓存
原理:利用未记录日志的表作为数据库内的缓存。这类表会跳过PostgreSQL的写前日志机制——这种机制原本是为保证数据持久性而设计的。由于缓存数据本质上是临时性的,因此在系统崩溃时丢失这些数据也是可以接受的;而跳过写前日志机制则能够有效消除写入操作中的性能瓶颈。
-- 修改后的代码:使用未记录日志的表作为缓存
CREATE UNLOGGED TABLE cache_entries (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
expires_at TIMESTAMPTZ
);
-- 基线配置:使用普通的、会记录日志的表
CREATE TABLE cacheentries (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
expires_at TIMESTAMPTZ
);
测试结果(200个虚拟用户参与测试)
| 测试模式 | p50百分位值 | p95百分位值 | 平均响应时间 | 每秒请求次数 |
|---|---|---|---|---|
| 基线配置(普通表) | 1.87毫秒 | 6.00毫秒 | 2.50毫秒 | 1,754次/秒 |
| 修改后的配置(使用未记录日志的表) | 1.71毫秒 | 5.24毫秒 | 2.17毫秒 | 1,760次/秒 |
在所有百分位值范围内,性能提升幅度都稳定在13%左右。这个提升效果虽然不算显著,但实现起来非常简单——只需在《CREATE TABLE》语句中修改一个关键字即可。
在高负载环境下测试(1,000个虚拟用户,不进行睡眠模拟)
| 测试模式 | p50百分位值 | p95百分位值 | 平均响应时间 | 总请求次数 |
|---|---|---|---|---|
| 基线配置 | 83.38毫秒 | 143.23毫秒 | 7,663次/秒 | 728,021次 |
| 修改后的配置 | 77.69毫秒 | 126.39毫秒 | 8,062次/秒 | 765,934次 |
无论负载水平如何,性能提升幅度始终稳定在12%到13%之间。未记录日志的表所带来的优化效果主要体现在每次写入操作时所需的I/O资源量上——无论是每秒进行100次写入操作还是10,000次写入操作,所消耗的资源都是相同的。而使用修改后的配置后,在相同的时间内,系统能够处理多出37,000次请求。
最终结论
对于那些需要亚毫秒级响应速度的应用场景(比如实时竞价系统或游戏排行榜),未记录日志的表显然无法与Redis相媲美。但对于普通的网络应用来说,2毫秒和5毫秒之间的差异对用户来说是完全感觉不到的;因此,使用未记录日志的表不仅可以提升性能,还能消除对额外基础设施的依赖,而且不会增加任何复杂性。
你确实会放弃Redis提供的数据结构(如排序集、HyperLogLog以及数据流功能)。如果这些功能对你来说必不可少,那么使用专用的缓存系统仍然是更合适的选择。
基准测试2:采用SKIP LOCKED机制的工作队列
原理如下:利用PostgreSQL作为工作队列系统,通过SELECT ... FOR UPDATE SKIP LOCKED语句来实现数据访问控制。多个处理任务会同时查询同一张表,而SKIP LOCKED机制能确保每个任务都能获取不同的数据行——从而避免数据重复或竞争冲突。
-- 创建仅包含待处理任务的表格,并为其建立部分索引
CREATE TABLE job_queue (
id SERIAL PRIMARY KEY,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 建立部分索引,仅针对待处理任务
-- 当任务完成时,它们会从索引中移除,因此索引的大小始终保持在较小范围内
CREATE INDEX idx_pending_jobs ON job_queue (created_at)
WHERE status = 'pending';
数据取出流程如下:
-- 通过一条SQL语句完成数据的选取与更新操作
UPDATE job_queue SET status = 'processing'
WHERE id = (
SELECT id FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED -- 跳过其他任务已锁定的行
) RETURNING *;
SKIP LOCKED机制的工作原理是:假设工人A锁定了第1行数据,工人B尝试访问同一行时会发现已被锁定,于是会跳过这一行并选择第2行进行操作。这种机制既不会导致阻塞,也不会产生重复数据。如果某个工人出现故障,相关事务会被回滚,被锁定的数据行也会重新变得可访问。
测试结果(100个生产者 + 50个消费者)
| 测试模式 | p50值 | p95值 | 平均耗时 | 每秒请求次数 |
|---|---|---|---|---|
| 基线测试(使用完整索引) | 1.90毫秒 | 5.01毫秒 | 2.30毫秒 | 1,053次/秒 |
| 修改后的测试模式(使用部分索引) | 1.81毫秒 | 5.28毫秒 | 2.29毫秒 | 1,052次/秒 |
测试结果显示,使用部分索引与使用完整索引的效果几乎完全相同。在60秒的测试时间内,由于表格中完成的任务数量还不够多,因此部分索引的优势并不明显;但在一个拥有数百万已完成任务的生产环境中,部分索引能够使索引文件的大小保持在几KB范围内,而使用完整索引则会使索引文件的大小达到GB级别。
最终结论
SKIP LOCKED机制完全适用于工作队列系统。像pg-boss(Node.js版本)和river(Go版本)这样的工具库正是基于这一机制开发的。
不过,如果你需要交换/路由功能或带有消息重放功能的消费者组,那么使用专用的消息代理服务仍然是更合适的选择。对于那些只需要“处理一次任务”这类简单的工作负载来说,PostgreSQL完全能够满足需求。
基准测试3:利用tsvector进行的全文搜索
思路: 使用 PostgreSQL 内置的全文搜索功能,而非单独的搜索服务。通过 `tsvector` 列存储预处理过的搜索词元,而 GIN(通用倒排索引)则能够利用与 Elasticsearch 相同的倒排索引技术实现快速查询。
-- 为优化搜索性能而设计的文章表结构
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
search_vector tsvector -- 预先计算好的搜索词元
);
-- 创建 GIN 索引以支持全文搜索
CREATE INDEX idx_search ON articles USING GIN (search_vector);
-- 在插入或更新数据时自动更新 search_vector 列
CREATE OR REPLACE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('english',
COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.body, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_search
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
基准测试方案采用带有通配符的前缀的 `ILIKE` 查询语句——这是大多数开发人员首先会尝试的方法:
-- 基准测试:每次查询都进行顺序扫描
SELECT * FROM articles
WHERE title ILIKE '%postgresql%' OR body ILIKE '%postgresql%';
-- 修改后的方案:利用 GIN 索引并进行相关性排序
SELECT id, title,
ts_rank(search_vector, plainto_tsquery('english', 'postgresql')) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'postgresql')
ORDER BY rank DESC LIMIT 20;
测试结果(500个虚拟用户)
| 测试模式 | p50百分位值 | p95百分位值 | 平均响应时间 | 每秒请求量 |
|---|---|---|---|---|
| 基准测试(ILIKE查询方式) | 1.96毫秒 | 101.83毫秒 | 25.22毫秒 | 561次/秒 |
| 修改后的测试方案(使用tsvector + GIN索引) | 2.76毫秒 | 10.39毫秒 | 3.76毫秒 | 675次/秒 |
这一结果最为显著:基准测试方案中的p95百分位值需101毫秒,而修改后的方案仅需10毫秒,性能提升了10倍。
为什么基准测试方案中的p50百分位值(1.96毫秒)略优于修改后的方案(2.76毫秒)呢?原因在于:当数据量较小且能够容纳在 `shared_buffers` 中时,简单的 `ILIKE` 查询语句确实可以快速完成查询。然而,当请求量增加导致缓冲区竞争加剧时,顺序扫描方式的性能会急剧下降;而 GIN 索引则能保持稳定的查询效率。
在高负载环境下(500个虚拟用户,无休眠限制)
| 测试模式 | p50百分位值 | p95百分位值 | 每秒请求量 | 总请求数量 |
|---|---|---|---|---|
| 基准测试(ILIKE查询方式) | 599毫秒 | 1,000毫秒 | 558次/秒 | 50,212次 |
| 修改后的测试方案(使用tsvector + GIN索引) | 209毫秒 | 396毫秒 | 1,441次/秒 | 129,679次 |
在高负载环境下,使用 `ILIKE` 查询语句会导致p95百分位值达到1秒;因为每次查询都需要对全部10,000篇文章进行顺序扫描,这会占用大量的系统资源,从而影响其他并发请求的执行效率。而修改后的方案利用 GIN 索引,能够在相同的时间内处理2.6倍量的请求,因为其查询效率与并发数量无关,始终为 O(log n) 级别。
结论
这是整个测试中最为有力的论据。这种解决方案完全不需要任何额外的扩展功能——to_tsvector()、plainto_tsquery()以及CREATE INDEX USING GIN这些函数都是PostgreSQL核心系统中所内置的。如果你在对行数超过几千的表使用WHERE column ILIKE '%term%'这样的查询语句,那么你就会严重影响系统的性能。
不过,采用这种方案后,你就无法利用分布式搜索功能、针对中文等语言设计的复杂分析工具,也无法使用聚合或分面搜索技术了。但对于产品搜索栏、博客搜索或内部工具来说,PostgreSQL已经足够使用了。
测试4:使用LISTEN/NOTIFY的Pub/Sub机制
原理:利用PostgreSQL内置的LISTEN/NOTIFY功能来实现Pub/Sub消息传递,当有新数据插入数据库时,会通过触发器自动触发相应的通知操作。
-- 创建一个触发器,在每有一条新消息插入时发送通知
CREATE OR REPLACE FUNCTION notify_message() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify(NEW.channel, NEW.payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trgnotify
AFTER INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION notify_message();
测试结果(200个虚拟用户环境)
| 测试模式 | p50值 | p95值 | 平均值 | 每秒请求次数 |
|---|---|---|---|---|
| 基准测试模式 | 1.99毫秒 | 6.04毫秒 | 2.84毫秒 | 1,116次/秒 |
| 修改后的测试模式 | 1.65毫秒 | 4.80毫秒 | 2.13毫秒 | 1,131次/秒 |
从数据可以看出,在p95这个指标上,修改后的测试模式性能提升了20%。虽然基于触发器的这种处理方式在每次数据插入时需要执行更多的操作(包括插入数据和发送通知),但由于减少了网络请求的次数,并且提高了连接资源的复用效率,因此这些开销被有效地抵消了。
结论
对于那些需要实时处理数据的场景来说,LISTEN/NOTIFY确实是一个非常实用的功能;不过它的主要局限性在于数据传输的最大长度仅为8,000字节,而且还需要专用的连接通道(在事务模式下与PgBouncer无法同时使用)。
综合负载测试:真实的考验
单独来看,各项测试结果都相当不错。但真正的问题是:一个PostgreSQL实例是否能够在不降低性能的前提下,同时处理缓存、队列、搜索以及Pub/Sub消息传递等功能呢?
测试结果(所有四种负载同时运行时)
| 测试模式 | p50值 | p95值 | 平均值 | 每秒请求次数 |
|---|---|---|---|---|
| 基准测试模式 | 1.65毫秒 | 5.24毫秒 | 2.17毫秒 | 1,424次/秒 |
| 修改后的测试模式 | 1.86毫秒 | 6.05毫秒 | 2.47毫秒 | 1,417次/秒 |
在同时运行所有四种负载的情况下,基准测试模式的性能略优于修改后的测试模式。虽然修改后的PostgreSQL在每次操作时需要执行更多的任务(例如维护GIN索引、触发相关操作等),但这些额外的工作并不会导致整体性能的下降;不过当这些功能同时激活时,系统的延迟还是会增加大约15%。
但无论是哪种配置方式,在95%的请求中,延迟时间都稳定在10毫秒以下。对于大多数Web应用来说,这样的性能已经足够好了。
我学到了什么
在进行了所有这些测试之后,我想对那些正在考虑是否“直接使用PostgreSQL”的团队说以下几点:
-
为了实现全文搜索功能,请务必采用这种配置:将查询方式从
ILIKE改为使用带有GIN索引的tsvector,性能提升幅度可达10倍,而且完全不需要添加任何额外的扩展模块。这是整个PostgreSQL生态系统中性价比最高的优化措施,但大多数开发者却并不知道它的存在。 -
对于作业队列的处理,也推荐采用这种配置:
SKIP LOCKED功能已经完全可以用于生产环境,它能够替代RabbitMQ来处理那些简单的“执行某项任务”类型的操作。建议使用pg-boss或river这样的第三方库,而无需自己开发相关代码。 -
在缓存场景中也可以考虑使用PostgreSQL:未记录日志的表相比普通表,性能可以提高13%。如果对延迟时间没有特别严格的要求(对于大多数Web应用来说确实如此),那么完全可以完全放弃使用Redis。
-
要如实评估这些配置带来的开销:同时运行这四种功能时,整体延迟会比单独运行其中任何一种功能增加大约15%。这种延迟是否会对应用程序产生实质性影响,取决于你的性能需求。
-
要知道何时该停止使用PostgreSQL:在缓存速度需要达到亚毫秒级、每秒需要处理数百万条消息,或者需要进行复杂分析的分布式搜索时,PostgreSQL是无法与Redis、Kafka或Elasticsearch相媲美的。这些场景才是PostgreSQL的局限性所在。
坦率地说,“PostgreSQL能解决所有问题”这种说法并不正确。实际上,对于大多数应用来说,一个配置得当的PostgreSQL实例就能满足80%的需求——这意味着你需要部署、监控和维护的基础设施会更少,而且在半夜3点出现故障的概率也会更低。
不过,那些需要每秒处理数百万条消息、为数百万用户提供亚毫秒级缓存服务,或者需要对海量文档进行分布式搜索的企业级应用,仍然需要专门的工具。这些工具的存在是有原因的,在这种规模下,使用它们所带来的性能提升足以弥补其运营成本。
但对我们大多数人来说,并没有达到这样的应用规模,也可能永远不需要这样做。从一开始就选择使用PostgreSQL来处理这些需求,意味着你可以更快地启动项目,而且所需的组件也会更少。当你的需求超出了PostgreSQL的能力范围时,测试结果会清楚地告诉你哪些功能需要被分离出来,变成独立的服务。这显然比一开始就假设自己需要五种不同的服务要好得多。
如果你想重现这些测试结果,或者根据自己的实际需求调整这些测试用例,这个基准测试项目是开源的。
你可以在我的个人网站上阅读我更多的文章。
