Momentic 在重构其 AI 软件测试平台的缓存系统时,把存储层从 PostgreSQL 迁到列式数据库 ClickHouse,用来支撑每天超过 200 万次查询、总量约 200 亿条缓存记录,并把平均响应延迟维持在约 250 ms。这个案例值得关注,不是因为“PostgreSQL 不行”,而是因为缓存查询的形态一旦变成大规模读、宽表扫描、按条件过滤和聚合,行式数据库和列式数据库的差异会被迅速放大。
这类缓存为什么会压垮传统关系型设计
PostgreSQL 很适合事务、约束、复杂 OLTP 业务逻辑。但缓存系统的压力通常来自另一组问题:
- 数据量增长快,历史条目不断累积。
- 查询更像“按 key、版本、状态、时间窗口查找”,而不是频繁更新单行事务。
- 读流量远大于写流量,且需要稳定低延迟。
- 表规模上到十亿、百亿级后,索引膨胀、VACUUM、分区管理、冷热数据归档都会变成日常成本。
Momentic 的数据规模是 200 亿条缓存记录,每天超过 200 万次查询。在这个数量级下,缓存层已经不是简单的 Redis key-value 或一张 PostgreSQL 表能轻松解释的问题,它更接近一套面向分析式读取优化的存储系统。
ClickHouse 的列式存储、压缩、分区、稀疏索引和 MergeTree 系列表引擎,正好适合“写入追加、读取过滤、批量扫描”的工作负载。它不会替代 PostgreSQL 的所有角色,但在这类缓存查询场景里,确实更容易把吞吐和延迟拉到可控范围。
列式数据库适合的不是“缓存”这个词,而是查询形态
判断是否该考虑 ClickHouse,关键不是系统名字叫 cache、log、event 还是 result store,而是看访问模式。
适合 ClickHouse 的缓存/结果存储通常有这些特征:
- 写入以追加为主,更新和删除较少。
- 查询条件集中在少数维度,例如 project_id、test_id、commit_sha、cache_key、created_at。
- 返回结果可以接受最终一致或近实时写入可见性。
- 数据量很大,需要压缩和分区控制成本。
- 经常需要统计命中率、延迟分布、失败原因等派生指标。
不太适合的情况也要说清楚:如果你的缓存需要强事务、频繁按主键更新、复杂外键约束,或者每次请求只做极小范围的点查,PostgreSQL、Redis、FoundationDB 这类系统可能仍然更直接。ClickHouse 的优势来自批量和列式读取,不是来自万能的低延迟点查。
可以这样实践:用 ClickHouse 建一个缓存结果表
下面是一个可以直接改造的最小示例。它不是 Momentic 的真实 schema,而是基于这类“测试结果缓存/AI 平台缓存”场景的实践模板。
运行前需要本机有 Docker。启动 ClickHouse:
docker run --rm -d \
--name clickhouse-cache-demo \
-p 8123:8123 \
-p 9000:9000 \
clickhouse/clickhouse-server:24.8
# 等待服务启动
sleep 5
创建一张缓存结果表:
curl 'http://localhost:8123/' --data-binary "
CREATE DATABASE IF NOT EXISTS demo;
CREATE TABLE IF NOT EXISTS demo.cache_entries
(
project_id UInt64,
cache_key String,
source_hash String,
status LowCardinality(String),
payload String,
payload_bytes UInt32,
created_at DateTime,
expires_at DateTime
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY (project_id, cache_key, source_hash, created_at)
TTL expires_at DELETE;
"
插入几条测试数据:
curl 'http://localhost:8123/?query=INSERT%20INTO%20demo.cache_entries%20FORMAT%20JSONEachRow' \
--data-binary '
{"project_id":1,"cache_key":"test:login","source_hash":"abc123","status":"hit","payload":"{\"result\":\"passed\"}","payload_bytes":19,"created_at":"2026-07-07 10:00:00","expires_at":"2026-08-07 10:00:00"}
{"project_id":1,"cache_key":"test:checkout","source_hash":"def456","status":"miss","payload":"{\"reason\":\"not_found\"}","payload_bytes":22,"created_at":"2026-07-07 10:01:00","expires_at":"2026-08-07 10:01:00"}
{"project_id":1,"cache_key":"test:login","source_hash":"abc123","status":"hit","payload":"{\"result\":\"passed\"}","payload_bytes":19,"created_at":"2026-07-07 10:02:00","expires_at":"2026-08-07 10:02:00"}
'
查询某个缓存 key 的最新结果:
curl 'http://localhost:8123/' --data-binary "
SELECT
project_id,
cache_key,
source_hash,
status,
payload,
created_at
FROM demo.cache_entries
WHERE project_id = 1
AND cache_key = 'test:login'
AND source_hash = 'abc123'
ORDER BY created_at DESC
LIMIT 1;
"
统计最近一天缓存命中率:
curl 'http://localhost:8123/' --data-binary "
SELECT
project_id,
count() AS total,
countIf(status = 'hit') AS hits,
round(hits / total, 4) AS hit_ratio
FROM demo.cache_entries
WHERE created_at >= now() - INTERVAL 1 DAY
GROUP BY project_id
ORDER BY total DESC;
"
这个设计里有几个点值得注意:
ORDER BY (project_id, cache_key, source_hash, created_at)要贴近最常见过滤条件。PARTITION BY toYYYYMM(created_at)方便按月管理数据和 TTL 清理。LowCardinality(String)适合hit、miss、error这类低基数字段。TTL expires_at DELETE让过期缓存最终被自动清理,但它不是毫秒级删除机制。
迁移时最容易低估的三件事
第一是查询语义变化。PostgreSQL 的唯一约束、事务隔离、UPDATE ... RETURNING、行锁等能力,在 ClickHouse 中不是同一种使用方式。迁移缓存系统时,通常要把模型调整为追加写入,然后用 ORDER BY created_at DESC LIMIT 1 或物化视图维护最新状态。
第二是 schema 设计要围绕查询写。ClickHouse 的 ORDER BY 不是装饰,它决定数据在磁盘上的排序和稀疏索引效果。把高频过滤字段放在前面,通常比照搬 PostgreSQL 主键更重要。
第三是写入批量化。ClickHouse 可以高吞吐写入,但它更喜欢批量插入。如果应用每次请求都单行写入,后台 merge 压力会升高。可以在服务侧做缓冲,或者通过队列把写入聚合成批次。
可以这样用 Python 做一个简单批量写入器:
import json
import time
import requests
CLICKHOUSE_URL = "http://localhost:8123/?query=INSERT%20INTO%20demo.cache_entries%20FORMAT%20JSONEachRow"
rows = [
{
"project_id": 42,
"cache_key": f"test:{i}",
"source_hash": "build-789",
"status": "hit" if i % 3 else "miss",
"payload": json.dumps({"index": i}),
"payload_bytes": len(json.dumps({"index": i})),
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"expires_at": "2026-08-07 10:00:00",
}
for i in range(1000)
]
body = "\n".join(json.dumps(row) for row in rows)
response = requests.post(CLICKHOUSE_URL, data=body.encode("utf-8"), timeout=10)
response.raise_for_status()
print("inserted", len(rows), "rows")
运行前安装依赖:
python -m pip install requests
python batch_insert_cache.py
实际生产中,还需要把重试、幂等 key、批次大小、失败落盘和监控补上。示例只是说明 ClickHouse 更适合“攒一批写进去,再高效读出来”的节奏。
采用建议:别把它当成 PostgreSQL 的平替
Momentic 的案例说明,面对 200 亿级缓存条目和每天百万级查询,换成 ClickHouse 可以显著改善性能和扩展性。但迁移成功通常来自完整重构,而不是把连接字符串从 PostgreSQL 改成 ClickHouse。
落地前可以按这份清单评估:
- 查询是否以追加写入、条件过滤、聚合统计为主。
- 是否能接受最终一致的过期清理和后台 merge。
- 是否能把写入改成批量模式。
- 是否已经明确最常用的
WHERE条件,并据此设计ORDER BY。 - 是否保留 PostgreSQL 处理事务型元数据,把 ClickHouse 用作大规模结果/缓存存储。
- 是否建立了查询延迟、插入队列、part 数量、merge 压力和磁盘占用监控。
比较稳妥的路线是双写一段时间:PostgreSQL 保持原有路径,ClickHouse 承接新查询或影子查询;等延迟、正确性和运维指标都稳定,再逐步切换读流量。这样能把数据库迁移从一次豪赌,拆成一组可观测、可回滚的工程步骤。