用 ClickHouse 扛住 200 万次/天缓存查询:从 PostgreSQL 迁移的工程取舍

2026-07-08 37 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

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) 适合 hitmisserror 这类低基数字段。
  • 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 承接新查询或影子查询;等延迟、正确性和运维指标都稳定,再逐步切换读流量。这样能把数据库迁移从一次豪赌,拆成一组可观测、可回滚的工程步骤。


相关推荐