在实时性要求高的业务系统里,单一数据库往往无法同时满足低延迟和高吞吐的分析需求。Redis作为内存数据库,单节点就能支撑每秒十万级请求,响应时间稳定在亚毫秒级别;ClickHouse则是列式存储的OLAP引擎,处理十亿级数据的聚合查询依然能在秒级返回。把两者结合使用,让各自发挥所长,是构建实时查询系统的经典方案。本文将从架构设计、数据同步、缓存策略和代码实现几个方面,详细讲解如何落地这套组合。

一、为什么需要Redis与ClickHouse组合
先看两种数据库的定位差异。Redis将数据全部保存在内存中,读写路径极短,非常适合高并发的点查场景,比如查询某个用户的实时余额、某条订单的最新状态。但它的短板同样明显:内存成本高,不适合存储海量历史数据,而且它的聚合计算能力有限,复杂的统计分析要么写起来极其别扭,要么性能很差。
ClickHouse正好互补。它采用列式存储和向量化执行引擎,对大规模数据的扫描、过滤、聚合做了深度优化,十亿行数据的分组统计常常一两秒就能完成。代价是每一次查询都要触发磁盘IO和数据扫描,即使加了主键索引,点查的延迟也在几十毫秒级别,如果直接暴露给高并发的在线服务,很容易把集群打垮。
典型的组合思路是:明细数据和大规模分析查询交给ClickHouse,热点数据的点查和缓存加速交给Redis。比如实时大屏场景,图表的聚合结果由ClickHouse计算,算好之后写入Redis,前端页面每秒轮询时直接从Redis读取,ClickHouse只需要每隔几秒被触发一次。这样既保证了数据的准实时性,又将查询压力降到最低。
二、数据同步方案设计
要让这套架构跑起来,核心问题是数据如何从业务库流转到ClickHouse和Redis。常见的链路有三种,各有适用场景。
第一种是双写方案:业务代码在写MySQL的同时,分别写入ClickHouse和Redis。优点是实现简单、延迟最低;缺点是侵入业务逻辑,且需要自行处理失败重试,容易出现数据不一致。第二种是CDC方案,通过Canal或Debezium监听MySQL的binlog,把变更数据投递到Kafka,再由消费程序分别写入ClickHouse和Redis。这种方案对业务无侵入,一致性有保障,是生产环境的主流选择。第三种是定时批量同步,用调度任务周期性地从源库抽数写入ClickHouse,实现最简单,但实时性差,只适合T+1级别的分析场景。
以CDC方案为例,消费程序写ClickHouse时要特别注意批量写入。ClickHouse对高频小批量插入非常敏感,官方建议每次插入至少一万行或者每秒不超过一次插入,否则会产生大量小文件分片,导致后台合并压力剧增,严重时会让整个集群查询变慢。正确的做法是在消费端做缓冲,攒够一批再写入:
// 消费Kafka数据后缓冲批量写入ClickHouse
List<OrderEvent> buffer = new ArrayList<>();
public void onMessage(OrderEvent event) {
buffer.add(event);
if (buffer.size() >= 5000 || System.currentTimeMillis() - lastFlush > 5000) {
flushToClickHouse(buffer);
buffer.clear();
lastFlush = System.currentTimeMillis();
}
}
private void flushToClickHouse(List<OrderEvent> batch) {
try {
// 使用JDBC批量写入,失败时记录到重试队列
clickHouseJdbcTemplate.batchUpdate(
"INSERT INTO orders (order_id, user_id, amount, created_at) VALUES (?, ?, ?, ?)",
batch);
} catch (Exception e) {
retryQueue.offer(batch); // 写入失败进入重试队列,避免数据丢失
}
}
三、缓存策略与一致性处理
Redis在这里承担两个角色:一是热点数据的点查缓存,二是分析结果的预计算缓存。两种场景的缓存策略设计重点不同。
热点数据点查通常采用Cache Aside模式:先查Redis,命中直接返回;未命中则查ClickHouse并回填缓存,同时设置过期时间。这里有一个必须处理的细节:ClickHouse的最终一致性查询特性意味着刚写入的数据可能查不到,回填时最好设置较短的TTL(比如30秒到5分钟),让缓存成为短暂的数据快照,而不是长期的事实来源。对于金额、库存这类强一致性数据,应该以MySQL为准,Redis和ClickHouse都只做读加速。
分析结果缓存则建议配合key过期时间做主动刷新。比如实时大屏的GMV指标,可以由定时任务每5秒查询一次ClickHouse,将结果写入Redis的固定key,并设置10秒的过期时间作为兜底。这样即使定时任务某次失败,缓存过期后前端也不会拿到过久的历史数据。下面是一段典型的查询代码:
public BigDecimal getRealtimeGmv(String dateStr) {
String cacheKey = "dashboard:gmv:" + dateStr;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return new BigDecimal(cached); // 缓存命中,亚毫秒级返回
}
// 缓存未命中,查询ClickHouse并回填
BigDecimal gmv = clickHouseJdbcTemplate.queryForObject(
"SELECT sum(amount) FROM orders WHERE toDate(created_at) = ?",
BigDecimal.class, dateStr);
redisTemplate.opsForValue().set(cacheKey, gmv.toPlainString(),
Duration.ofSeconds(30), // 设置过期时间防止脏数据长期驻留
RedisNode.TIMEOUT_SET_IF_ABSENT_NOT);
return gmv;
}
需要额外提醒的是缓存穿透问题。如果某个查询条件在ClickHouse中本来就没有数据,每次请求都会穿透到ClickHouse。可以在Redis中写入空值标记并设置短TTL,或者对查询参数做合法性校验,把明显非法的请求挡在缓存层之前。
四、查询层面的分工与优化
明确了数据链路和缓存策略后,还需要在查询入口做清晰的分流。一般原则是:按主键查单条或少量记录走Redis,涉及扫描、聚合、多维分析的查询走ClickHouse,介于两者之间的实时明细列表查询(比如最近100条订单)可以走Redis的List或ZSet结构。
ClickHouse侧的查询优化也有几个要点。第一,建表时一定要设计好排序键,让最常用的过滤条件排在ORDER BY的前面,这样能利用稀疏索引大幅减少扫描量。第二,避免使用SELECT *,列式存储下只查需要的列能显著降低IO。第三,避免高基数的GROUP BY和实时去重,这类操作内存消耗大,必要时用近似函数替代精确计算。下面是一个优化过的实时查询SQL:
-- 查询某用户最近一小时按分钟统计的订单金额
SELECT
toStartOfMinute(created_at) AS minute,
sum(amount) AS total_amount,
count() AS order_count
FROM orders
WHERE user_id = 10086
AND created_at >= now() - INTERVAL 1 HOUR
GROUP BY minute
ORDER BY minute
Redis侧则要控制大key的产生。缓存的value尽量精简,大结果集可以考虑压缩后存储,或者拆分成多个key。同时建议对缓存key做统一的命名规范,比如业务模块加冒号加维度的形式,方便后续的监控、排查和批量清理。如果某些热点key的读压力极高,还可以在应用层加本地缓存做二级防护,形成本地缓存、Redis、ClickHouse的三级查询链路。
总结一下,Redis与ClickHouse的组合本质上是读写路径的精细化分工:Redis挡住高并发点查,ClickHouse扛起海量分析,中间用CDC链路和合理的缓存策略保证数据的时效性与一致性。落地时抓住批量写入、缓存TTL、缓存穿透这几个关键点,这套架构就能在实时性和成本之间取得很好的平衡。
RedisClickHouse实时查询修改时间:2026-09-02 04:16:35