压测时 Agent 读到了三小时前的数据
7 月 18 日我们对知识问答 Agent 做季度压测。压到 1,200 QPS 的时候,测试同学反馈一个问题:刚在后台改的商品价格,问 Agent 还是旧值。
一开始我以为是缓存。查了一圈发现不是——向量库里的切片更新时间是三小时前,而业务库的变更早就提交了。
这篇记录我们把"业务数据 → 向量索引"这条链路重做的过程,以及多模数据库这个选项我们为什么选了一半。
先看清链路到底有多长
改造前的架构是典型的"业务库 + 独立向量库"双写模式:
业务服务 ──写──> PostgreSQL(订单/商品/用户)
│
└──MQ──> 同步服务 ──> 切分 ──> embedding ──> Qdrant
│
用户提问 ──> Agent ──────────────────────> 向量检索 ─┘
└────────────────────> 业务库查详情
这条链路有 5 个环节,每个都可能成为延迟来源。压测时的问题出在 MQ 积压——同步服务的消费速度跟不上,1,200 QPS 下积压了 47 万条,追平需要 3 小时。
更要命的是,这个问题在压测前一直存在,只是没人测过。平时 QPS 只有 80,同步延迟 P50 是 800ms,看起来"实时",实际脆弱得不行。
我们把延迟分布拉出来看:
| 环节 | P50 | P99 | 最坏情况 |
|---|---|---|---|
| MQ 投递 | 12ms | 340ms | 积压时 3 小时 |
| 同步服务处理 | 45ms | 1.2s | — |
| embedding 调用 | 210ms | 2.4s | 限流时 30s |
| 向量库 upsert | 38ms | 620ms | — |
| 端到端 | 800ms | 42s | 3 小时 |
三个选项和它们的真实代价
问题清楚了,接下来是方案。我们评估了三条路。
方案一:继续双写,优化同步链路
最简单,把消费能力提上去、加并行、加批量。我们试了一版,把 embedding 调用改成批量(batch=32),同步服务消费能力从 180 QPS 提到 900 QPS。
但这条路有一个无解的硬伤:业务库和向量库是两个存储,无法在同一个事务里提交。任何时刻都可能存在"业务库有、向量库没有"的窗口。压到 1,200 QPS 还是会积压,只是阈值提高了。
而且双写带来一个隐性问题:两边的数据模型不一致。业务库里商品的"状态"是个枚举,向量库里是切分成的一段文本。改了状态字段的含义,两边要同步改,没人能保证。
方案二:CDC 替代双写
把 MQ 那一段换成 Debezium 抓 binlog。好处是业务代码零侵入,不会漏掉任何一条变更(包括 DBA 手工改的数据)。
@Component
public class ProductCdcListener {
@KafkaListener(topics = "pg.public.product")
public void onChange(ConsumerRecord<String, Envelope> rec) {
Envelope env = rec.value();
if (env.op().isDelete()) {
vectorStore.deleteBySourceId(env.before().id());
return;
}
Product p = env.after();
// 关键:用 binlog 的 LSN 做版本,保证乱序到达时旧数据覆盖不了新数据
vectorStore.upsert(toDocuments(p), Version.of(env.lsn()));
}
}
Version.of(env.lsn()) 这行是必须的。CDC 消息不保证严格有序(我们按主键分区,同一主键有序,跨主键无序),如果商品 A 和 B 有关联内容(比如同一个类目的聚合切片),乱序到达会导致旧数据覆盖新的。用 LSN 做版本号,向量库侧做"只接受更新的版本"。
CDC 的另一个收益是能捕获删除。之前的双写逻辑里,删除操作经常漏——业务代码里 7 处删除商品的地方,只有 5 处发了 MQ 通知。这个问题我们查了两天。
但 CDC 没解决根本问题:仍然是两个存储,仍然有延迟窗口。
方案三:多模数据库,把向量放进业务库
这条路我们最后走了一半。
思路是:既然业务数据本来就在 PostgreSQL 里,那把 embedding 也存进去,用 pgvector。这样"改商品"和"改向量"就能在同一个事务里完成,一致性问题从根上消失。
ALTER TABLE product ADD COLUMN embedding vector(1024);
-- 业务字段和向量同一个事务更新
BEGIN;
UPDATE product
SET price = 12900,
status = 'ON_SALE',
updated_at = now(),
embedding = '[0.021,-0.118,...]'::vector,
embed_version = 'bge-m3-v2'
WHERE id = 90211;
COMMIT;
-- 混合检索:结构化过滤 + 向量相似度,一条 SQL
SELECT id, name, price,
1 - (embedding <=> $1::vector) AS score
FROM product
WHERE tenant_id = $2
AND status = 'ON_SALE'
AND price BETWEEN $3 AND $4
ORDER BY embedding <=> $1::vector
LIMIT 10;
这个 WHERE + 向量 ORDER BY 的组合,是独立向量库很难做好的事。Qdrant、Milvus 都支持 payload 过滤,但当过滤条件的选择性很高时(比如"某租户 + 某类目 + 价格区间"命中 0.1%),性能会明显下降,因为它们要先向量检索再过滤,或者维护额外的索引。
我们实测:在 420 万条商品数据上,tenant_id + status 过滤后命中 3,800 条的场景,pgvector 的 HNSW 索引 + 部分索引组合耗时 8.4ms;Qdrant 用 payload 索引耗时 31ms。
事务一致性是更大的收益。改造后我们在 ISO 层面根本不存在"业务库改了向量库没改"这个状态。
为什么只走了一半
pgvector 不是万能的,它有几个硬约束。
规模上限。我们的知识库有 41 万切片,全塞进 PostgreSQL 之后,HNSW 索引占 7.2GB,加上原表,单表 12GB。这个量级 PostgreSQL 扛得住,但如果涨到千万级,HNSW 索引的内存占用会失控。我们算了一下,1,000 万条 1024 维向量的 HNSW 索引约 45GB,必须全在内存里才有好性能,这机器成本不划算。
文档类内容和业务数据的访问模式完全不同。商品数据读写都频繁,知识库(PDF、工单、IM 记录)基本是写多读少、批量倒入。把它们放一个库里,备份策略、扩容节奏、慢查询治理全都互相干扰。
所以最后的分工是:
| 数据类型 | 特点 | 存储 | 一致性方式 |
|---|---|---|---|
| 业务实体(商品/订单/用户) | 强一致要求、量中等、过滤条件多 | PostgreSQL + pgvector | 同事务 |
| 知识文档(PDF/工单/IM) | 量大、批量更新、最终一致可接受 | Qdrant | CDC + LSN 版本 |
| 对话记忆 | 写多读少、需时间衰减 | Redis + PG 归档 | 异步落盘 |
这个划分的依据不是技术,是业务对一致性的要求。商品改了价格,Agent 必须立刻说新价格;知识文档改了条款,5 分钟内生效就行。把它统一了,反而是过度设计。
一致性保障:延迟要可观测
接受了"部分最终一致"之后,剩下的工作就是把它变得可观测。我们的做法是一个持续运行的校验任务:
@Scheduled(fixedDelay = 10_000)
public void probeFreshness() {
// 写入一个探针记录,带时间戳
long probeId = probeDao.insert(Instant.now());
// 轮询向量库,看多久能查到
Instant start = Instant.now();
while (Duration.between(start, Instant.now()).toSeconds() < 60) {
if (vectorStore.exists("probe", probeId)) {
long lagMs = Duration.between(start, Instant.now()).toMillis();
metrics.timer("rag.sync.lag").record(lagMs, MILLISECONDS);
probeDao.delete(probeId);
return;
}
sleep(200);
}
metrics.counter("rag.sync.timeout").increment(); // 60 秒还没同步,告警
alerting.page("向量同步超过 60s");
}
这个探针每 10 秒跑一次,成本可以忽略,但它把"同步延迟"从一个模糊的概念变成了一条可以告警的曲线。
还有一个我们觉得更有用的指标:答案新鲜度。做法是对一小部分(1%)真实请求做双路查询,一路走向量检索,一路直接查业务库拿权威值,比对答案里的关键字段是否一致。
// 抽样比对:向量召回的 price 和业务库的 price 是否一致
if (sampler.shouldSample(0.01)) {
BigDecimal fromVector = extractPrice(agentAnswer);
BigDecimal fromSource = productDao.priceOf(orderNo);
if (fromVector.compareTo(fromSource) != 0) {
metrics.counter("rag.stale_answer").increment();
log.warn("stale: vector={} source={} traceId={}", fromVector, fromSource, traceId);
}
}
7 月 25 日这个指标报了一次警:rag.stale_answer 在 20 分钟内从 0.02% 涨到 1.8%。查下来是某个商品的批量导入任务绕过了 CDC,直接写了业务库但没触发 binlog(用了 COPY)。如果没有这个指标,我们可能要等用户投诉。
改造后的数据
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 业务实体同步延迟 P50 | 800ms | 0(同事务) |
| 业务实体同步延迟 P99 | 42s | 0 |
| 知识文档同步延迟 P99 | 42s | 3.2s |
| 1,200 QPS 压测积压 | 47 万条 | 0 |
| 混合检索(高选择性过滤)P99 | 31ms | 8.4ms |
| 陈旧答案率 | 0.9% | 0.03% |
| 存储成本 | Qdrant 3 节点 ¥8,400/月 | PG 从库扩容 + Qdrant 2 节点 ¥6,900/月 |
成本这块要说清楚:省下的钱不多(¥1,500/月),主要收益是一致性。别指望靠这个省钱。
还没统一的三块
坦白讲,"从 OLTP 到向量检索的统一"这个说法在业界喊了一年多,我目前看到的落地都是局部的。我们这边至少三块还没想清楚。
图谱数据。GraphRAG 我们做了个原型,实体关系存在 Neo4j 里,又是第三个存储。多模库号称支持图,但性能和查询表达力跟专业图库差得远。短期看不到统一的可能。
跨库事务。虽然业务实体和向量同库了,但一次 Agent 请求往往还要读知识库(Qdrant)和记忆(Redis)。跨这三个存储的一致性只能靠应用层,我们目前的做法是"以业务库为准,其余作为补充",出错时优先信业务库。
向量索引的在线重建。换 embedding 模型要重建全部向量。pgvector 上重建 420 万条需要 4 小时,期间索引不可用。我们试过并发建索引(CREATE INDEX CONCURRENTLY),但 HNSW 的并发构建内存占用会翻倍。现在的做法是双索引切换,成本是多一倍存储。
留个问题
关于《AI 应用的数据架构:从 OLTP 到向量检索的统一》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。