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. 快速判定
你通常可以从 指标 + 日志 两条线快速确认:
-
dynstream 内存/队列指标异常
- log puller 的 dynstream
used/max持续上升并接近 1GB 上限(默认 area max pending size 1GB) - pending queue length/事件通道长度持续增加且不回落
- log puller 的 dynstream
-
典型日志信号
-
收到 Pause/Resume 反馈:
subscription client pause push region event subscription client resume push region event -
出现大量 holes(未成功订阅/未覆盖的 key range):
subscription client holes exist
-
-
业务侧影响面
- resolved-ts lag / checkpoint lag 线性增长
- 下游消费延迟扩大、告警触发
3. 快速修复(Mitigation)
该问题的“最小变更缓解手段”是 打开 resolved-ts 推进节流:
- 将
kvclient.advance-interval-in-ms从0调整为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 做了两类关键修复:
- 默认配置调整:将
advance-interval-in-ms默认值从0改为100,避免在大规模 region 场景下的高频推进开销。 - 推进逻辑优化:
handleResolvedTs在advanceInterval > 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,方便对照(会更稳定,不受后续改动影响)。
- TiKV 通过 CDC gRPC stream 周期性发送 ChangeDataEvents,其中 resolved-ts 批次可能包含 大量 Regions。TiCDC 在
receiveAndDispatchChangeEvents收到后进入 resolved-ts 分支并调用dispatchResolvedTsEvent。 dispatchResolvedTsEvent会把一个 resolved-ts 批次打包成一个 dynstream 事件:regionEvent{resolvedTs, states}(states包含该批次涉及的所有 region 状态)并 push 到 dynstream(同上链接)。- 订阅 span 创建 dynstream path 时,puller area 的
maxPendingSize默认是 1GB:Subscribe设置NewAreaSettingsWithMaxPendingSize(1*1024*1024*1024, ...)。 - dynstream 处理 resolved-ts 事件时,会对
event.states逐个调用handleResolvedTs(...):见regionEventHandler.Handle的 resolved-ts 分支。 - 修复前默认
kvclient.advance-interval-in-ms=0(即“尽快推进”),使得handleResolvedTs(...)在每次调用时都会尝试推进 span resolved-ts,并触发全局最小值计算(热点函数:handleResolvedTs→RangeLock.GetHeapMinTs)。 GetHeapMinTs()里除了 heap 的最小值外,还会取unlockedRanges.getMinTs()(同上链接)。而rangeTsMap.getMinTs()是对 btree 的全量Ascend遍历:当 holes/unlocked ranges 很多时,这一步会变得非常慢。- resolved-ts 批次内 region 越多,就会执行越多次
handleResolvedTs(...),并在advance-interval-in-ms=0时触发越多次 btree 遍历 → 消费速度远低于输入速度 → dynstream pending 持续上升。 - 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_resolved5.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 事件都触发。 handleResolvedTs在advanceInterval > 0时避免每个事件都维护/更新最小值结构,把重计算集中到节流触发点。