🚀TiDB
TiCDC

TiCDC log puller memory quota 耗尽:resolved-ts 停滞导致 changefeed 卡住

单 span/表 region 数量极大且存在大量 holes 时,resolved-ts 推进计算过慢引发 dynstream pending 积压与 PauseArea,最终 resolved-ts 停滞;对应修复 PR #4088。

1. 问题现象

当该问题发生时,常见表现是:

  • changefeed 的 resolved-ts / checkpoint-ts 长时间不推进,下游延迟持续扩大
  • log puller(dynstream)pending 内存使用率持续上涨,触发 PauseArea(默认阈值 >= 80%
  • Pause 后上游事件推送被阻塞,现象上看像是 “TiCDC 卡住不再处理事件”,影响范围可能扩散到多个 changefeed

对应公开 issue:#4084 Log Puller Memory Quota Exhaustion Leads to All Changefeed Getting Stuck

2. 快速判定

你通常可以从 指标 + 日志 两条线快速确认:

  1. dynstream 内存/队列指标异常

    • log puller 的 dynstream used/max 持续上升并接近 1GB 上限(默认 area max pending size 1GB)
    • pending queue length/事件通道长度持续增加且不回落
  2. 典型日志信号

    • 收到 Pause/Resume 反馈:

      subscription client pause push region event
      subscription client resume push region event
    • 出现大量 holes(未成功订阅/未覆盖的 key range):

      subscription client holes exist
  3. 业务侧影响面

    • resolved-ts lag / checkpoint lag 线性增长
    • 下游消费延迟扩大、告警触发

3. 快速修复(Mitigation)

该问题的“最小变更缓解手段”是 打开 resolved-ts 推进节流

  • kvclient.advance-interval-in-ms0 调整为 100(或更大,例如 200),降低全局 resolved-ts 计算频率
  • 重启/滚动重启 TiCDC 以快速清空已堆积的 pending(不改配置只重启往往会复现)

示例(TiCDC server config):

[kv-client]
advance-interval-in-ms = 100

说明:

  • advance-interval-in-ms = 0 等价于“尽可能快地推进 resolved-ts”,在单 span 内 region 数量极大(例如几十万)时可能导致明显的 CPU/遍历开销与队列积压。
  • 适当的节流(例如 100ms)通常不会对业务延迟造成可见影响,但能显著降低 per-event 开销。

4. 完全解决(Resolution)

升级到包含 PR #4088 的版本(或回合入该补丁):

PR #4088 做了两类关键修复:

  1. 默认配置调整:将 advance-interval-in-ms 默认值从 0 改为 100,避免在大规模 region 场景下的高频推进开销。
  2. 推进逻辑优化handleResolvedTsadvanceInterval > 0 时只在节流窗口触发推进,并使用更稳妥的最小值计算路径,减少每个 resolved-ts 事件的额外开销。

5. 根因链路

5.1 根因结论

当一个订阅 span(通常是一张表)包含极大量 region 且系统存在大量 holes/unlocked ranges 时,log puller 在处理高频 resolved-ts 事件时会频繁触发全局最小 resolved-ts 计算,计算开销大到无法跟上 TiKV 的事件推送速度,导致 dynstream pending 持续堆积并触发 PauseArea,最终 resolved-ts 停滞、changefeed 卡住。

5.2 原理与链路细节

下述代码链接固定到 PR #4088 的 merge commit,方便对照(会更稳定,不受后续改动影响)。

  1. TiKV 通过 CDC gRPC stream 周期性发送 ChangeDataEvents,其中 resolved-ts 批次可能包含 大量 Regions。TiCDC 在 receiveAndDispatchChangeEvents 收到后进入 resolved-ts 分支并调用 dispatchResolvedTsEvent
  2. dispatchResolvedTsEvent 会把一个 resolved-ts 批次打包成一个 dynstream 事件:regionEvent{resolvedTs, states}states 包含该批次涉及的所有 region 状态)并 push 到 dynstream(同上链接)。
  3. 订阅 span 创建 dynstream path 时,puller area 的 maxPendingSize 默认是 1GBSubscribe 设置 NewAreaSettingsWithMaxPendingSize(1*1024*1024*1024, ...)
  4. dynstream 处理 resolved-ts 事件时,会对 event.states 逐个调用 handleResolvedTs(...):见 regionEventHandler.Handle 的 resolved-ts 分支
  5. 修复前默认 kvclient.advance-interval-in-ms=0(即“尽快推进”),使得 handleResolvedTs(...) 在每次调用时都会尝试推进 span resolved-ts,并触发全局最小值计算(热点函数:handleResolvedTsRangeLock.GetHeapMinTs)。
  6. GetHeapMinTs() 里除了 heap 的最小值外,还会取 unlockedRanges.getMinTs()(同上链接)。而 rangeTsMap.getMinTs() 是对 btree 的全量 Ascend 遍历:当 holes/unlocked ranges 很多时,这一步会变得非常慢。
  7. resolved-ts 批次内 region 越多,就会执行越多次 handleResolvedTs(...),并在 advance-interval-in-ms=0 时触发越多次 btree 遍历 → 消费速度远低于输入速度 → dynstream pending 持续上升。
  8. pending 使用率达到 80% 后,puller 的内存控制触发 PauseArea(阈值:ShouldPauseArea)。subscription client 收到后将 paused=true 并使上游 push 在 pushRegionEventToDS 阻塞等待 resume → gRPC 收包/派发链路被卡住 → 背压传回 TiKV,最终可能扩大影响面。

5.3 可视化链路

flowchart TB
  subgraph TiKV["TiKV"]
    tikv_resolved["CDC stream send<br/>ResolvedTs batch (many Regions)"]
  end

  subgraph TiCDC["TiCDC (log puller / dynstream)"]
    recv["regionRequestWorker.Recv<br/>receiveAndDispatchChangeEvents"]
    pack["dispatchResolvedTsEvent<br/>regionEvent{resolvedTs, states[]}"]
    ds["dynstream pending (1GB max)<br/>MemoryControlForPuller"]
    handler["regionEventHandler.Handle<br/>for state in states: handleResolvedTs"]
    mincalc["RangeLock global min<br/>GetHeapMinTs / rangeTsMap.getMinTs"]
    pause["PauseArea (>=80%)<br/>ResumeArea (<50%)"]
    blocked["subscriptionClient.paused=true<br/>pushRegionEventToDS blocks"]
  end

  tikv_resolved --> recv --> pack --> ds --> handler --> mincalc

  mincalc -.->|too slow| handler
  handler -.->|can’t keep up| ds
  ds -.->|pending grows| pause -.-> blocked -.-> recv
  recv -.->|gRPC backpressure| tikv_resolved

5.4 为什么 Pause 之后“几乎降不下去”

Pause 之后依旧可能长时间无法恢复,主要原因是:

  • 恢复阈值更低(滞回):Pause 的触发线是 >= 0.8,但 Resume 需要 < 0.5,意味着必须 drain 掉非常大的一段 backlog 才能恢复。
  • backlog 过大且 drain 仍然很慢:即使输入停止,堆积的 resolved-ts 事件仍需逐个处理;如果每次推进仍然触发昂贵计算,drain 速度会极低。
  • 可能存在 blocking path:当 handler 进入 await 模式(等待下游完成回调)时,dynstream 会将该 path 标记为 blocking,blocking path 的 pending 队列不会继续出队,导致内存难以下降到 Resume 线以下。

5.5 修复后的机制(#4088)

PR #4088 通过 “默认节流 + 逻辑分支优化” 把高频路径变轻:

  • 默认把 advance-interval-in-ms 设为 100ms,使全局最小值计算与推进最多每 100ms 触发一次,而不是每个 resolved-ts 事件都触发。
  • handleResolvedTsadvanceInterval > 0 时避免每个事件都维护/更新最小值结构,把重计算集中到节流触发点。

6. 参考链接