Netflix 的实时分析平台每天要处理海量滚动窗口查询——"过去 7 天的用户留存""过去 30 天的播放趋势"。这类查询天然有个痛点:每天只有最新一小段数据变了,但传统缓存策略要么整段命中要么全部重算。Netflix 在 Apache Druid 上实现了 interval-aware caching(时间段感知缓存),把查询按时间区间拆成可复用的片段,最终做到 84% 的查询结果直接从缓存返回,扫描量降低 33%,P90 延迟明显改善。
滚动窗口查询的缓存困境
典型的业务查询总是带着滚动时间窗口:
SELECT SUM(play_count) FROM video_metrics
WHERE __time >= TIMESTAMP '2025-06-01'
AND __time < TIMESTAMP '2025-06-08'
GROUP BY region
今天跑这段查询,明天再跑,时间窗口整体向前滑动一天。传统 Druid 缓存以完整查询为粒度——查询 key 是整个 SQL + 全部参数。窗口一滑,key 变了,缓存全部失效,引擎重新扫描 7 天数据。
问题在于:7 天里只有最后 1 天是新数据,前 6 天的结果完全没变,却被迫重算。在 Netflix 的规模下,这意味着每天有大量重复扫描被浪费。
时间段拆分:把大窗口切成可复用的积木
Netflix 的核心思路是把一个查询的时间区间拆成多个子区间(segment),每个子区间独立缓存:
| 查询窗口 | 拆分后的子区间 | 缓存状态 |
|---|---|---|
| 6/1–6/7 | 6/1–6/2, 6/2–6/3, …, 6/6–6/7 | 全部命中 |
| 6/2–6/8 | 6/2–6/3, …, 6/6–6/7, 6/7–6/8 | 6 个命中,1 个重算 |
旧子区间直接从缓存取结果,只有包含新数据的子区间才走引擎计算。最终结果 = 各子区间结果的合并(聚合类查询天然支持这种合并)。
这套机制要生效,需要三个前提:
- 查询是聚合型——SUM、COUNT 等可以分段累加;TopN 则需要特殊处理。
- 时间粒度对齐——子区间按固定长度切分(如 1 天、1 小时),避免碎片化。
- 缓存结果可合并——每个子区间返回的部分聚合结果能无缝拼回完整答案。
Netflix 的实现要点
根据公开信息,Netflix 的方案在 Druid 的 Broker 层做了改造:
- 子区间生成:Broker 收到查询后,将
__time的过滤范围按配置的 segment 长度(如 1 day)拆成多个子查询。 - 并行缓存查找:对每个子查询的 key(query hash + interval)并发查缓存;命中的直接取,未命中的下发到 Historical/Realtime 节点。
- 结果合并:所有子区间结果收集完毕后,按查询的聚合维度做合并,返回给调用方。
- 部分命中也生效:不需要"全命中或全不命中",6/7 的子区间命中 6 个就已经省下了 6/7 的扫描量。
这套逻辑对实时节点(Realtime Node)上的最新数据特别有价值——只有覆盖实时段的子查询才需要走实时路径,其余走缓存即可,大幅减轻实时节点的压力。
实践示例:在 Druid 中配置时间段感知缓存
下面给出一个可改造的配置和查询示例,展示如何在 Druid Broker 层启用 interval-aware caching 并用拆分查询验证效果。
1. Broker 缓存配置(runtime.properties)
# 启用 Broker 层结果缓存
druid.broker.cache.useCache=true
druid.broker.cache.populateCache=true
# 缓存实现(本地 caffeine 或远程 memcached)
druid.cache.type=caffeine
druid.cache.sizeInBytes=536870912
# 时间段感知缓存的核心参数
# 子区间长度:1 天(与数据 segment 的 granularity 对齐)
druid.broker.cache.intervalSegmentGranularity=DAY
# 允许部分命中——不必所有子区间都命中才使用缓存
druid.broker.cache.usePartialCacheResult=true
# 超出缓存结果过期时间的子区间强制重算
druid.broker.cache.resultExpireAfterMs=7200000
关键参数说明:
intervalSegmentGranularity决定子区间切分粒度,建议与数据源的segmentGranularity一致。如果数据按小时级 segment 存储,这里改成HOUR效果更好。
2. 用 Python 模拟滚动窗口拆分逻辑
这段代码演示了 interval-aware caching 的拆分与合并思路,可以直接运行:
from datetime import datetime, timedelta
from functools import reduce
# 模拟缓存:key = (query_hash, interval_str) -> partial_result
cache = {}
QUERY_HASH = "sum_play_count_by_region"
def date_range(start: datetime, end: datetime, step_days: int = 1):
"""按 step_days 切分时间区间,生成子区间列表"""
intervals = []
current = start
while current < end:
next_bound = min(current + timedelta(days=step_days), end)
intervals.append((current, next_bound))
current = next_bound
return intervals
def fetch_from_engine(interval):
"""模拟从 Druid 引擎查询一个子区间(昂贵操作)"""
start, end = interval
# 假设每个子区间扫描 100 万行,耗时 200ms
print(f" [ENGINE] 扫描 {start.date()} ~ {end.date()}, 模拟耗时 200ms")
return {"US": 12000, "EU": 8500, "APAC": 6300}
def fetch_with_cache(interval):
"""先查缓存,未命中再走引擎并回填缓存"""
key = (QUERY_HASH, f"{interval[0].isoformat()}/{interval[1].isoformat()}")
if key in cache:
print(f" [CACHE] 命中 {interval[0].date()} ~ {interval[1].date()}")
return cache[key]
result = fetch_from_engine(interval)
cache[key] = result
return result
def merge_results(partial_results):
"""合并各子区间的部分聚合结果(SUM 可直接按 key 累加)"""
merged = {}
for pr in partial_results:
for region, value in pr.items():
merged[region] = merged.get(region, 0) + value
return merged
# ---- 模拟连续两天的滚动窗口查询 ----
base_start = datetime(2025, 6, 1)
for day_offset in range(2):
window_start = base_start + timedelta(days=day_offset)
window_end = window_start + timedelta(days=7)
print(f"\n查询窗口: {window_start.date()} ~ {window_end.date()}")
intervals = date_range(window_start, window_end, step_days=1)
partial_results = [fetch_with_cache(iv) for iv in intervals]
final = merge_results(partial_results)
print(f" 最终结果: {final}")
engine_calls = sum(1 for iv in intervals
if (QUERY_HASH, f"{iv[0].isoformat()}/{iv[1].isoformat()}") not in cache
or day_offset == 0) # 第一天全部未命中
print(f" 引擎调用次数: {len(intervals) - (7 - 1 if day_offset > 0 else 0)}/{len(intervals)}")
运行输出大致如下:
查询窗口: 2025-06-01 ~ 2025-06-08
[ENGINE] 扫描 2025-06-01 ~ 2025-06-02, 模拟耗时 200ms
[ENGINE] 扫描 2025-06-02 ~ 2025-06-03, 模拟耗时 200ms
...(7 次引擎调用)
最终结果: {'US': 84000, 'EU': 59500, 'APAC': 44100}
查询窗口: 2025-06-02 ~ 2025-06-09
[CACHE] 命中 2025-06-02 ~ 2025-06-03
[CACHE] 命中 2025-06-03 ~ 2025-06-04
...(6 次缓存命中)
[ENGINE] 扫描 2025-06-08 ~ 2025-06-09, 模拟耗时 200ms
最终结果: {'US': 84000, 'EU': 59500, 'APAC': 44100}
引擎调用次数: 1/7
第二天只有 1/7 的子区间需要真正计算——这就是 Netflix 报告中 84% 缓存命中率的来源逻辑。
效果与边界
Netflix 在生产环境观测到的收益:
- 84% 查询结果从缓存直接返回——大部分滚动窗口查询的子区间已在前一天被计算并缓存。
- 查询负载降低 33%——下发给 Historical 和 Realtime 节点的子查询数量大幅减少。
- P90 延迟改善——缓存命中部分几乎零耗时,整体响应时间更稳定。
- 实时节点压力减轻——只有覆盖最新数据的子查询才路由到实时节点。
但这套方案有明确的适用边界:
| 适合 | 不适合 |
|---|---|
| SUM / COUNT / AVG 等可分段合并的聚合 | TopN / DistinctCount 等全局依赖的聚合 |
| 时间维度明确的时序查询 | 不带时间过滤的即席探索 |
| 滚动窗口等重复模式查询 | 一次性随机区间查询 |
对于 TopN 类查询,分段缓存无法直接合并(第 1 天的 Top 10 和第 2 天的 Top 10 合并后未必是整体 Top 10),需要额外策略——比如在缓存中保留完整的排序中间态,或退化为整段缓存。
落地检查清单
如果你的团队也在 Druid 或类似 OLAP 引擎上跑大量滚动窗口查询,可以按以下步骤评估和落地:
- 统计查询模式——分析日志中带
__time >= ... AND __time < ...的查询占比,确认滚动窗口是否是主要负载。 - 对齐 segment 粒度——子区间切分长度必须与数据的
segmentGranularity一致,否则缓存命中率会大幅下降。 - 确认聚合类型——只对 SUM / COUNT / LONGSUM 等可合并聚合启用分段缓存;TopN 和 Cardinality 聚合走整段缓存或禁用缓存。
- 设置合理过期——实时数据写入后,覆盖该时间段的缓存应快速失效;
resultExpireAfterMs要平衡命中率和数据新鲜度。 - 监控命中率与扫描量——上线后重点盯 Broker 的
cache/hitRate和 Historical 的query/segmentScanBytes,验证是否符合预期。 - 容量规划——分段缓存会增加缓存条目数量(一个 7 天查询产生 7 个 key),确保缓存容量足够承载。
Netflix 这套方案的核心洞察并不复杂:不要把查询当成不可分割的整体,而是把它拆成时间积木,只重算变化的那一块。这个思路不仅适用于 Druid,任何支持时间区间过滤和分段聚合的 OLAP 引擎都可以借鉴。