PostgreSQL做所有事:队列、搜索、向量在一个数据库
1. 概述
一个典型的系统通常由许多组件组成:PostgreSQL存储数据、一个消息代理做队列、一个搜索引擎用于搜索,最近还加上一个向量数据库用于AI功能。每个组件都会带来运维、监控、备份和数据同步的工作。
“Postgres做所有事”的趋势反过来提问:如果PostgreSQL已经能够以足够好的水平完成大部分这些工作,是否需要再添加新系统呢?在这篇文章中,你将学到如何使用PostgreSQL做job队列、全文搜索和向量搜索,配合Spring Boot 3.x。你也会了解到什么时候应该停止,转而选择专用工具。
2. 为什么这个趋势受到关注
有几个主要原因:
- 降低运维复杂性。 每添加一个系统就多一个可能在半夜故障的东西。对于小团队来说,这个成本通常大于性能收益。
- 事务一致性。 当订单和发送邮件的job都在同一个数据库中时,你可以在一个事务内同时写入两者。不会出现订单已保存但消息丢失,或反之的情况。
- 生态系统中的扩展。 PostgreSQL有强大的扩展机制。比如
pgvector添加了向量数据类型和最近邻搜索索引。 - AI功能带来了存储embedding的需求。 许多团队想要添加语义搜索功能,但不想为此建立一个新的数据库。
这个趋势的精神是从简单开始,当有数据证明需要分离时再分离。它并不意味着PostgreSQL可以替代所有系统。
3. 准备
3.1. Maven依赖
你需要JDBC、PostgreSQL驱动和Testcontainers用于测试。Spring Boot已经管理版本,所以你不需要声明它们:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2. 运行带pgvector的PostgreSQL
pgvector/pgvector镜像预装了PostgreSQL和vector扩展。选择与你使用的PostgreSQL主版本相应的标签:
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
配置Spring Boot以连接并在启动时自动运行schema.sql:
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.sql.init.mode=always
在实际项目中,你应该使用Flyway或Liquibase替代schema.sql。
4. 使用FOR UPDATE SKIP LOCKED的Job队列
4.1. 数据库模式
一个最小的队列只需要一张表:
CREATE TABLE IF NOT EXISTS job_queue (
id BIGSERIAL PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_job_queue_run_at ON job_queue (run_at);
run_at列允许你安排job何时运行。对run_at的索引有助于worker快速获取最早的job,而不是扫描整个表。
4.2. 获取并处理job
关键是FOR UPDATE SKIP LOCKED子句。多个worker可以同时运行这个查询。每个worker锁定一行,其他worker跳过已被锁定的行而不是等待:
public record Job(long id, String type, String payload) {}
@Repository
public class JobQueue {
private final JdbcTemplate jdbc;
public JobQueue(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void enqueue(String type, String payloadJson) {
jdbc.update("INSERT INTO job_queue (job_type, payload) VALUES (?, ?::jsonb)",
type, payloadJson);
}
@Transactional
public boolean processNext(Consumer<Job> handler) {
List<Job> jobs = jdbc.query("""
SELECT id, job_type, payload::text
FROM job_queue
WHERE run_at <= now()
ORDER BY run_at
LIMIT 1
FOR UPDATE SKIP LOCKED
""",
(rs, i) -> new Job(rs.getLong(1), rs.getString(2), rs.getString(3)));
if (jobs.isEmpty()) {
return false;
}
Job job = jobs.getFirst();
handler.accept(job);
jdbc.update("DELETE FROM job_queue WHERE id = ?", job.id());
return true;
}
}
解释:
enqueue插入一个job。?::jsonb将字符串强制转换为JSONB类型。processNext在一个事务内运行。SELECT ... FOR UPDATE SKIP LOCKED语句锁定恰好一个未被任何人处理的job。- 如果
handler运行完成,job被删除并且事务提交。 - 如果
handler抛出异常,事务回滚。Job仍然在表中并将被重新处理。如果worker在中途崩溃,PostgreSQL会在连接关闭时自动释放锁。
Worker可以是一个简单的@Scheduled方法:
@Component
public class EmailWorker {
private final JobQueue jobQueue;
public EmailWorker(JobQueue jobQueue) {
this.jobQueue = jobQueue;
}
@Scheduled(fixedDelay = 1000)
public void poll() {
while (jobQueue.processNext(job ->
System.out.println("为工作 " + job.id() + " 发送邮件: " + job.payload()))) {
// 继续直到没有job
}
}
}
记得在一个配置类中添加@EnableScheduling。当有job时,控制台会输出:
为工作 1 发送邮件: {"orderId": 42, "email": "an@example.com"}
注意: 在处理job时保持事务的方式非常简单安全,即使worker崩溃也不会丢失数据。代价是事务会根据处理时间拉长。如果job需要很长时间运行(调用缓慢的API、处理大文件),你应该改用带有
status和locked_until列的模型,或者切换到专用的队列库。你还应该添加一个attempts列以防止一个失败的job无限重试。
4.3. 真正的优势:在业务事务中一起入队
这是使用Postgres做队列的最强理由:
@Service
public class OrderService {
private final JdbcTemplate jdbc;
private final JobQueue jobQueue;
public OrderService(JdbcTemplate jdbc, JobQueue jobQueue) {
this.jdbc = jdbc;
this.jobQueue = jobQueue;
}
@Transactional
public void placeOrder(long orderId, String customerEmail) {
jdbc.update("INSERT INTO orders (id, customer_email) VALUES (?, ?)", orderId, customerEmail);
jobQueue.enqueue("SEND_CONFIRMATION_EMAIL",
"{\"orderId\": %d, \"email\": \"%s\"}".formatted(orderId, customerEmail));
}
}
订单和job一起提交或一起回滚。使用单独的代理,你必须自己构建事务性outbox模式才能达到这个保证。(例子使用formatted以求简洁。真实代码应该使用Jackson序列化payload以正确转义字符。)
如果你不想让worker持续polling,你可以结合PostgreSQL的LISTEN/NOTIFY来在有新job时唤醒worker。Polling仍然应该保留为备选方案。
5. 使用tsvector和GIN索引的全文搜索
5.1. 数据库模式
PostgreSQL内置了全文搜索。你可以使用一个生成列在数据更改时自动更新搜索向量:
CREATE TABLE IF NOT EXISTS product (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', name), 'A') ||
setweight(to_tsvector('simple', description), 'B')
) STORED
);
CREATE INDEX IF NOT EXISTS idx_product_search ON product USING GIN (search_vector);
解释:
to_tsvector('simple', ...)将文本分解为标记。simple配置仅转换为小写,不进行词根提取。这个选择适合中文,因为PostgreSQL没有中文分词的默认配置。setweight为名称分配权重A,为描述分配权重B。名称中的匹配将被排名更高。- GIN索引帮助在
tsvector列上快速查询。
5.2. 查询
public record ProductHit(long id, String name, double rank) {}
@Repository
public class ProductSearch {
private final JdbcTemplate jdbc;
public ProductSearch(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public List<ProductHit> search(String userQuery) {
return jdbc.query("""
SELECT id, name, ts_rank(search_vector, q) AS rank
FROM product, websearch_to_tsquery('simple', ?) AS q
WHERE search_vector @@ q
ORDER BY rank DESC
LIMIT 10
""",
(rs, i) -> new ProductHit(rs.getLong("id"), rs.getString("name"), rs.getDouble("rank")),
userQuery);
}
}
websearch_to_tsquery接受类似于普通搜索框的语法:"防风衣" -长 表示包含短语“防风衣”且不包含“长”。@@运算符检查向量是否与查询匹配。ts_rank用于对结果排序。
使用包含“防风衣”和“牛仔裤”的示例数据(其描述中有“防水”),查询防风返回:
ProductHit[id=1, name=防风衣, rank=0.6079271]
ProductHit[id=2, name=牛仔裤, rank=0.24317084]
注意: 用户经常输入不同形式的查询。你可以使用扩展来改进匹配。默认情况下某些函数没有标记为
IMMUTABLE,所以要在生成列或索引中使用它,你需要将其包装在一个单独的IMMUTABLE函数中。要进行容错拼写搜索,你可以使用pg_trgm扩展。
6. 使用pgvector的向量搜索
6.1. 数据库模式
Embedding是由AI模型生成的实数数组。含义相近的文本会生成相近的向量。启用扩展并添加列:
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE product ADD COLUMN IF NOT EXISTS embedding vector(3);
CREATE INDEX IF NOT EXISTS idx_product_embedding
ON product USING hnsw (embedding vector_cosine_ops);
例子使用3维来便于阅读。实际上,维数必须恰好匹配你选择的embedding模型,通常是几百到几千维。HNSW索引允许非常快速的近似最近邻查询。vector_cosine_ops指示索引用于余弦距离。
6.2. 保存和按向量搜索
不需要额外的库。你将float[]转换为[0.1,0.2,0.3]格式的字符串,然后强制转换为::vector:
@Repository
public class ProductVectorSearch {
private final JdbcTemplate jdbc;
public ProductVectorSearch(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void saveEmbedding(long productId, float[] embedding) {
jdbc.update("UPDATE product SET embedding = ?::vector WHERE id = ?",
toLiteral(embedding), productId);
}
public List<String> findSimilar(float[] queryEmbedding, int limit) {
return jdbc.queryForList("""
SELECT name FROM product
WHERE embedding IS NOT NULL
ORDER BY embedding <=> ?::vector
LIMIT ?
""", String.class, toLiteral(queryEmbedding), limit);
}
private static String toLiteral(float[] v) {
StringJoiner joiner = new StringJoiner(",", "[", "]");
for (float f : v) {
joiner.add(Float.toString(f));
}
return joiner.toString();
}
}
<=>运算符计算余弦距离。ORDER BY ... LIMIT获取最相似的产品。查询向量来自与为产品生成embedding的同一模型,例如当用户输入“下雨天穿的衣服”时。
由于产品和embedding位于同一张表中,你可以轻松组合过滤:WHERE category = 'outerwear' AND price < 500000 ORDER BY embedding <=> ?。你也可以混合ts_rank分数和向量距离来进行混合搜索。使用单独的向量数据库,你必须将这些属性同步到那里。
注意: 当将高选择性的
WHERE与HNSW索引结合时,返回的结果数量可能少于LIMIT。原因是索引在扫描有限的候选集合后进行过滤。请使用真实数据仔细测试,并参考pgvector的调优参数。
7. 使用Testcontainers编写测试
测试在真实的PostgreSQL上运行,带有pgvector的镜像。@ServiceConnection自动配置datasource:
@SpringBootTest
@Testcontainers
class PostgresFeaturesTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("pgvector/pgvector:pg16").asCompatibleSubstituteFor("postgres"));
@Autowired JobQueue jobQueue;
@Autowired ProductSearch productSearch;
@Autowired ProductVectorSearch vectorSearch;
@Autowired JdbcTemplate jdbc;
@Test
void processesEachJobExactlyOnce() {
jobQueue.enqueue("SEND_EMAIL", "{\"orderId\": 1}");
jobQueue.enqueue("SEND_EMAIL", "{\"orderId\": 2}");
List<Long> processed = new ArrayList<>();
while (jobQueue.processNext(job -> processed.add(job.id()))) { }
assertThat(processed).hasSize(2).doesNotHaveDuplicates();
assertThat(jdbc.queryForObject("SELECT count(*) FROM job_queue", Long.class)).isZero();
}
@Test
void failedJobStaysInQueue() {
jobQueue.enqueue("SEND_EMAIL", "{\"orderId\": 3}");
assertThatThrownBy(() -> jobQueue.processNext(job -> {
throw new IllegalStateException("SMTP down");
})).isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("SELECT count(*) FROM job_queue", Long.class)).isEqualTo(1);
}
@Test
void searchesByTextAndVector() {
jdbc.update("INSERT INTO product (name, description) VALUES ('防风衣', '防水轻质')");
jdbc.update("INSERT INTO product (name, description) VALUES ('牛仔裤', '厚布料')");
long jacketId = jdbc.queryForObject("SELECT id FROM product WHERE name = '防风衣'", Long.class);
long jeansId = jdbc.queryForObject("SELECT id FROM product WHERE name = '牛仔裤'", Long.class);
vectorSearch.saveEmbedding(jacketId, new float[]{0.9f, 0.1f, 0.0f});
vectorSearch.saveEmbedding(jeansId, new float[]{0.0f, 0.2f, 0.9f});
assertThat(productSearch.search("防风"))
.extracting(ProductHit::name)
.containsExactly("防风衣");
assertThat(vectorSearch.findSimilar(new float[]{1.0f, 0.0f, 0.0f}, 1))
.containsExactly("防风衣");
}
}
第二个测试检查队列最重要的行为:当处理程序失败时,job不会丢失。测试将每个方法的数据设置到位。在实际项目中,你应该在@BeforeEach中清空表,以使测试相互独立。
8. 什么时候不应该为所有事情使用Postgres
把所有东西都放在一个地方也有代价。当满足以下条件时,你应该考虑专用系统:
- 队列吞吐量非常高或需要流式处理。 如果你需要重放事件流、有多个独立的消费者组,或者需要长期保留消息,使用Kafka或类似工具。Postgres上的队列由于持续的
DELETE/UPDATE会产生许多死元组。你必须仔细监控autovacuum。 - 复杂的搜索。 多维分面、输入时建议、优质的多语言支持、细粒度的相关性调整:专用搜索引擎做得更好。
- 向量数据量非常大。 HNSW索引需要大量RAM。当向量数据占用大部分资源时,它会与主要事务工作量竞争。
- 不均匀的负载互相影响。 一个繁重的向量查询或一批job涌入可能会减慢支付API的速度。读副本帮助分离一些读取负载,但不能完全解决问题。
- 你的团队已经成功运维了专用系统。 如果基础设施已经到位且稳定,为了“简洁”而将所有内容迁移回Postgres可能不值得费力。
注意: 一个合理的策略是在每个功能的后面放一个接口(
JobQueue、ProductSearch…)。当监控数据表明Postgres成为瓶颈时,你可以替换底层实现而无需修改业务代码。
9. 结论
PostgreSQL足以通过FOR UPDATE SKIP LOCKED承载job队列,通过tsvector和GIN索引进行全文搜索,以及通过pgvector扩展进行向量搜索。最大的优势是需要运维的系统更少,并且可以在同一个事务中写入业务数据、job和embedding。
将这个视为小型和中型系统的默认起点,而不是永久的终点。从Postgres开始,仔细测量,仅当数据表明需要时才切换到专用工具。