Netflix 用时间段感知缓存让 Druid 84% 的查询命中缓存

2026-05-11 32 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

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 个重算

旧子区间直接从缓存取结果,只有包含新数据的子区间才走引擎计算。最终结果 = 各子区间结果的合并(聚合类查询天然支持这种合并)。

这套机制要生效,需要三个前提:

  1. 查询是聚合型——SUM、COUNT 等可以分段累加;TopN 则需要特殊处理。
  2. 时间粒度对齐——子区间按固定长度切分(如 1 天、1 小时),避免碎片化。
  3. 缓存结果可合并——每个子区间返回的部分聚合结果能无缝拼回完整答案。

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 引擎上跑大量滚动窗口查询,可以按以下步骤评估和落地:

  1. 统计查询模式——分析日志中带 __time >= ... AND __time < ... 的查询占比,确认滚动窗口是否是主要负载。
  2. 对齐 segment 粒度——子区间切分长度必须与数据的 segmentGranularity 一致,否则缓存命中率会大幅下降。
  3. 确认聚合类型——只对 SUM / COUNT / LONGSUM 等可合并聚合启用分段缓存;TopN 和 Cardinality 聚合走整段缓存或禁用缓存。
  4. 设置合理过期——实时数据写入后,覆盖该时间段的缓存应快速失效;resultExpireAfterMs 要平衡命中率和数据新鲜度。
  5. 监控命中率与扫描量——上线后重点盯 Broker 的 cache/hitRate 和 Historical 的 query/segmentScanBytes,验证是否符合预期。
  6. 容量规划——分段缓存会增加缓存条目数量(一个 7 天查询产生 7 个 key),确保缓存容量足够承载。

Netflix 这套方案的核心洞察并不复杂:不要把查询当成不可分割的整体,而是把它拆成时间积木,只重算变化的那一块。这个思路不仅适用于 Druid,任何支持时间区间过滤和分段聚合的 OLAP 引擎都可以借鉴。


相关推荐