第一章:为什么你的Go服务总在凌晨OOM?——资深SRE揭秘K8s环境下内存泄漏的4层根因分析法
凌晨三点,告警突响:kube_pod_container_resource_limits_memory_bytes{container="api"} < 10% free。你紧急扩容、滚动重启,问题却在48小时后重现。这不是资源配额不足,而是典型的分层内存泄漏——Go runtime、应用逻辑、K8s调度与可观测性基建共同掩盖了真相。
Go运行时内存管理的隐式陷阱
Go的GC并非实时回收:GOGC=100(默认)意味着堆增长100%才触发GC。若服务持续接收未释放的[]byte或*http.Request.Body,runtime.MemStats.HeapInuse将阶梯式攀升。验证方法:
# 进入Pod执行,观察3分钟内HeapInuse是否单向增长
kubectl exec -it <pod-name> -- go tool pprof http://localhost:6060/debug/pprof/heap
(pprof) top -cum 10
应用层泄漏模式:goroutine与缓存失控
常见泄漏源包括:
- 未关闭的
http.Response.Body导致底层bufio.Reader持续持有内存 sync.Map或lru.Cache中键值未过期,且key为非字符串(如结构体指针)引发不可回收- 使用
time.AfterFunc注册回调但未提供取消机制,goroutine永久驻留
K8s调度层的资源错配
当requests.memory远低于limits.memory(如requests: 512Mi, limits: 2Gi),Kubelet仅按requests做调度,但OOM Killer依据limits判定。容器可能在内存使用达1.8Gi时被杀,而监控显示“使用率仅90%”。检查命令:
kubectl get pod <pod-name> -o jsonpath='{.spec.containers[0].resources}'
可观测性断层:缺失关键指标链
| 以下指标必须联合分析: | 指标 | 数据源 | 关键阈值 |
|---|---|---|---|
go_memstats_heap_alloc_bytes |
Prometheus + /metrics | 持续上升无回落 | |
container_memory_working_set_bytes |
cAdvisor | 接近limits的95% | |
process_resident_memory_bytes |
Process Exporter | 高于Go Heap Alloc,暗示Cgo泄漏 |
立即行动:在main.go中注入内存健康检查端点,返回runtime.ReadMemStats并暴露HeapAlloc与HeapSys比值——比值长期>0.85即存在回收瓶颈。
第二章:第一层根因:Go运行时内存模型与GC行为失配
2.1 Go堆内存布局与pprof heap profile解码实践
Go运行时将堆内存划分为span、mcentral、mcache三级管理结构,其中span是页级内存单元,按对象大小分类(如8B/16B/32B…)并由mcentral统一调度。
堆采样机制
- 默认每分配512KB触发一次堆采样(
runtime.MemProfileRate = 512 * 1024) - 仅记录堆上活跃对象的分配栈,不包含释放信息
解码heap profile示例
go tool pprof -http=:8080 mem.pprof
启动交互式Web界面,支持火焰图、调用树、Top列表多维分析;
-inuse_space显示当前驻留内存,-alloc_space显示历史总分配量。
| 字段 | 含义 | 典型值 |
|---|---|---|
inuse_objects |
当前存活对象数 | 12,489 |
inuse_space |
当前占用字节数 | 3.2 MB |
alloc_objects |
累计分配对象数 | 217,654 |
// 手动触发堆profile采集
f, _ := os.Create("mem.pprof")
runtime.GC() // 强制清理,减少噪声
pprof.WriteHeapProfile(f)
f.Close()
调用
WriteHeapProfile前建议显式GC,确保profile反映真实内存压力;文件为二进制格式,需go tool pprof解析。
2.2 GC触发阈值与GOGC动态漂移导致的OOM雪崩
Go 运行时通过 GOGC 环境变量控制 GC 触发阈值(默认 GOGC=100),即当堆内存增长到上一次 GC 后存活对象大小的 100% 时触发。但该阈值并非静态——它会随每次 GC 后的堆实际占用动态漂移。
GOGC 漂移机制示意
// runtime/mgc.go 中核心逻辑简化
nextTrigger := heapLive * (100 + GOGC) / 100 // 注意:heapLive 是当前存活对象,非总堆分配量
逻辑分析:
heapLive若因内存泄漏或缓存膨胀被高估,nextTrigger将被推高;后续 GC 延迟,导致堆持续增长,形成正反馈循环。
OOM 雪崩路径
- 初始小泄漏 →
heapLive缓慢上升 - GC 触发点被动抬高 → 更多内存驻留
- Go 内存归还 OS 滞后(需满足
MADV_FREE条件)→ RSS 持续攀高 - 最终触发 Linux OOM Killer
| 阶段 | heapLive (MB) | GOGC=100 下 nextTrigger (MB) | 实际 RSS (MB) |
|---|---|---|---|
| T₀ | 50 | 100 | 120 |
| T₁ | 90 | 180 | 210 |
| T₂ | 160 | 320 | 450 → OOM kill |
graph TD
A[初始内存泄漏] --> B[heapLive↑]
B --> C[nextTrigger被动抬高]
C --> D[GC延迟触发]
D --> E[更多对象驻留→heapLive进一步↑]
E --> F[RSS持续超限→OOM雪崩]
2.3 持久化goroutine栈膨胀与runtime.MemStats关键字段误读
Go 运行时采用动态栈管理,新 goroutine 初始栈仅 2KB,按需倍增(最大 1GB)。当长期存活的 goroutine 频繁触发栈复制(如递归调用或大局部变量),其栈会持续膨胀并无法收缩回初始大小——这是“持久化栈膨胀”的本质。
runtime.MemStats 中易混淆字段
| 字段 | 含义 | 常见误读 |
|---|---|---|
StackInuse |
当前所有 goroutine 栈内存总占用(含已分配未使用的冗余部分) | 误认为等于活跃栈大小 |
StackSys |
操作系统为栈分配的虚拟内存总量(含未映射页) | 误等同于物理内存消耗 |
func observeStackGrowth() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("StackInuse: %v KB\n", m.StackInuse/1024) // 实际驻留栈内存(含碎片)
}
此调用返回的是运行时统计快照:
StackInuse包含因多次扩容遗留的不可回收栈页,不反映当前活跃栈使用量;StackSys更受 OS 内存映射策略影响,与 GC 无直接关联。
栈膨胀的典型诱因
- 长生命周期 goroutine 执行深度递归
- 使用
defer链过长且携带大闭包 runtime.Stack()调用触发栈快照捕获(强制保留当前栈帧)
graph TD
A[goroutine 创建] --> B[初始栈 2KB]
B --> C{调用深度/局部变量 > 当前栈容量?}
C -->|是| D[分配新栈、复制数据、释放旧栈]
D --> E[栈大小翻倍]
C -->|否| F[继续执行]
E --> G[若后续无收缩触发,新栈尺寸持久化]
2.4 无缓冲channel阻塞引发的goroutine泄漏+内存累积复现实验
数据同步机制
无缓冲 channel 要求发送与接收必须同时就绪,否则 sender 永久阻塞于 ch <- val。
复现代码
func leakDemo() {
ch := make(chan int) // 无缓冲
go func() {
for i := 0; i < 1000; i++ {
ch <- i // 阻塞:无 goroutine 接收 → 持续挂起
}
}()
// 主 goroutine 不读取,泄漏发生
time.Sleep(time.Second)
}
逻辑分析:ch <- i 在无接收方时永久阻塞,每个迭代均创建不可调度的 goroutine;i 变量被闭包捕获,导致堆内存持续增长。
关键特征对比
| 特性 | 无缓冲 channel | 有缓冲 channel(cap=1) |
|---|---|---|
| 阻塞条件 | 发送/接收必须同步就绪 | 发送仅在缓冲满时阻塞 |
| 泄漏风险 | 极高 | 仅当缓冲耗尽且无消费者时存在 |
内存累积路径
graph TD
A[goroutine 启动] --> B[ch <- i 阻塞]
B --> C[栈帧 + 闭包变量驻留堆]
C --> D[GC 无法回收活跃 goroutine]
D --> E[RSS 持续上升]
2.5 基于go tool trace定位GC停顿异常与内存分配热点
go tool trace 是 Go 运行时提供的深度可观测性工具,专为分析调度、GC、阻塞和内存分配行为而设计。
启动 trace 收集
# 在程序启动时注入 trace 收集(需 import _ "net/http/pprof")
GODEBUG=gctrace=1 go run -gcflags="-l" main.go 2>&1 | grep "gc " &
go tool trace -http=:8080 trace.out
该命令启用 GC 详细日志并生成 trace.out;-http 启动 Web UI,支持交互式火焰图与事件时间轴分析。
关键视图识别模式
- Goroutine analysis:定位长时间阻塞或频繁抢占的 goroutine
- Heap profile:结合
pprof查看分配热点(如runtime.mallocgc调用栈) - GC events timeline:观察 STW 阶段(
GCSTW)是否超预期(>100μs 即需关注)
| 事件类型 | 典型耗时阈值 | 异常信号 |
|---|---|---|
| GC Pause (STW) | >100μs | 内存碎片/大对象逃逸 |
| Sweep Done | >5ms | 大量未被复用的 span |
| Mark Assist | 频繁触发 | 分配速率远超 GC 扫描速率 |
graph TD
A[程序运行] --> B[writeTraceEvent]
B --> C[GCStart → GCDone]
C --> D[记录STW/Mark/Sweep子阶段]
D --> E[trace.out二进制流]
E --> F[go tool trace解析并渲染]
第三章:第二层根因:Kubernetes资源约束与调度语义陷阱
3.1 Limit/Request不对等引发的OOMKilled伪信号与cgroup v2 memory.pressure分析
当 Pod 的 memory.limit 远高于 memory.request(如 limit=4Gi, request=512Mi),Kubernetes 调度器仅按 request 预留节点内存,而 cgroup v2 在内存压力陡增时可能触发 OOMKilled——但此时节点整体内存尚充裕,属伪 OOM。
memory.pressure 的三态语义
cgroup v2 暴露 /sys/fs/cgroup/.../memory.pressure,含三档信号:
some:任意进程遭遇短暂延迟(毫秒级)full:至少一个进程被阻塞等待内存回收(秒级)medium:介于两者之间,是关键预警指标
压力阈值关联示例
# 查看当前 pod cgroup 的 medium 压力阈值(单位:毫秒/second)
cat /sys/fs/cgroup/kubepods/pod*/myapp-*/memory.pressure
# 输出示例:some=500000 full=120000 medium=300000
此输出中
medium=300000表示过去 1 秒内,有累计 300ms 的内存等待时间,已触发内核主动 reclaim,但尚未阻塞。若持续 >500ms,则 likely 触发 OOMKiller —— 即使free -h显示节点仍有 2Gi 空闲内存。
| 指标 | 含义 | 典型阈值(ms/s) | 风险等级 |
|---|---|---|---|
some |
轻微延迟 | >100k | ⚠️ 预警 |
medium |
持续争抢 | >300k | 🚨 高危 |
full |
进程挂起 | >100k | 💀 即将 OOM |
伪 OOM 根因流程
graph TD
A[Pod limit >> request] --> B[调度器低水位预留]
B --> C[cgroup v2 内存子树无隔离保障]
C --> D[突发负载触发 local reclaim]
D --> E[memory.pressure.medium 持续超标]
E --> F[内核判定“不可恢复延迟”→ OOMKilled]
3.2 VerticalPodAutoscaler误配导致的内存水位“假稳定”与凌晨突刺归因
当 VPA 的 updateMode 设为 Off 或 Initial,且 resourcePolicy.containerPolicies.minAllowed.memory 设置过高时,VPA 不会主动调高内存请求,仅依赖初始调度值——这造成监控显示内存使用率长期“平稳”,实则容器在 OOM 边缘持续承压。
典型误配 YAML 片段
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
spec:
updatePolicy:
updateMode: "Off" # ❌ 禁用自动更新,丧失弹性响应能力
resourcePolicy:
containerPolicies:
- containerName: "api-server"
minAllowed:
memory: "4Gi" # ⚠️ 远超实际基线(实测均值仅1.2Gi),人为抬高下限
该配置使 Kubelet 始终按 4Gi 分配内存,但应用凌晨流量激增时,RSS 快速突破 3.8Gi,触发内核 OOMKiller —— 而监控因 container_memory_usage_bytes 分母固定(request=4Gi)而呈现“稳定”假象。
内存水位失真机制
| 指标 | 表面值 | 实际风险 |
|---|---|---|
memory/utilization |
75% | RSS 已达 3.8Gi |
container_memory_rss |
↑↑↑ | 凌晨突刺无告警 |
kube_pod_container_status_restarts |
突增 | OOMKiller 日志佐证 |
graph TD
A[凌晨流量上涨] --> B[RSS飙升至3.9Gi]
B --> C{memory.limit == 4Gi?}
C -->|Yes| D[OOMKiller介入]
C -->|No| E[平滑扩容]
D --> F[Pod重启→突刺归因]
3.3 InitContainer内存占用未计入主容器Limit的隐蔽泄漏路径
InitContainer在Pod启动阶段独立运行,其内存消耗不纳入主容器cgroup memory.limit_in_bytes统计范围,导致实际内存使用突破Pod级Limit却无OOM Kill。
内存隔离机制盲区
Kubernetes仅将主容器进程加入/sys/fs/cgroup/memory/kubepods/.../memory.limit_in_bytes,而InitContainer在退出后即脱离该cgroup树,其峰值内存被完全忽略。
典型配置陷阱
# init-container-memory-leak.yaml
initContainers:
- name: config-loader
image: alpine:3.18
command: ["sh", "-c"]
args: ["dd if=/dev/zero of=/tmp/big bs=1M count=500 && sleep 30"]
resources:
limits:
memory: "256Mi" # 此limit仅用于调度与准入,不约束cgroup
resources.limits.memory对InitContainer仅触发调度检查和Admission Control(如ResourceQuota),不创建对应cgroup memory subsystem限制。该InitContainer可瞬时申请500Mi内存,但Pod整体cgroup仍只按主容器Limit(如1Gi)监管,造成“合法超限”。
监控识别方法
| 指标源 | 是否反映InitContainer内存 | 说明 |
|---|---|---|
container_memory_usage_bytes{container="config-loader"} |
✅ | cAdvisor暴露,但常被过滤 |
kube_pod_container_resource_limits_memory_bytes |
❌ | 仅主容器有值 |
node_memory_MemAvailable_bytes |
⚠️ | 全局视角,无法归因 |
graph TD
A[InitContainer启动] --> B[分配内存至主机全局页框]
B --> C{是否受Pod cgroup限制?}
C -->|否| D[内存计入Node MemUsed,但绕过Pod Limit]
C -->|是| E[触发OOMKilled]
D --> F[主容器OOM时无法追溯InitContainer历史峰值]
第四章:第三层根因:Go生态组件的非显式内存持有模式
4.1 http.Server的ConnContext泄漏与context.WithCancel未释放的goroutine链
ConnContext 钩子的生命周期陷阱
http.Server.ConnContext 允许为每个连接注入自定义 context.Context,但若返回的 context 持有 context.WithCancel() 创建的 cancel func 且未在连接关闭时显式调用,将导致 goroutine 泄漏。
典型泄漏代码示例
srv := &http.Server{
Addr: ":8080",
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
ctx, cancel := context.WithCancel(ctx) // ❌ cancel 无处调用
go func() {
<-ctx.Done() // 等待取消,但 cancel 永不触发
log.Println("cleanup deferred")
}()
return ctx
},
}
逻辑分析:WithCancel 返回的 cancel 函数未绑定到连接生命周期事件(如 c.Close() 或 http.ConnState 回调),导致 goroutine 永驻内存;ctx 引用链阻断 GC,cancel 本身亦持闭包变量。
修复路径对比
| 方案 | 是否安全 | 关键约束 |
|---|---|---|
使用 context.WithTimeout + 自动超时 |
✅ | 超时后自动 cancel,但无法响应连接提前关闭 |
注册 http.Server.RegisterOnShutdown |
⚠️ | 仅覆盖服务器整体关闭,不覆盖单连接 |
基于 net.Conn 封装并监听 Close() |
✅ | 需自定义 conn 类型,精确匹配生命周期 |
正确实践:绑定连接状态
srv := &http.Server{
Addr: ":8080",
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
ctx, cancel := context.WithCancel(ctx)
// 利用 ConnState 监听连接终止
srv.SetKeepAlivesEnabled(true)
go func() {
<-c.(interface{ Close() error }).Close()
cancel()
}()
return ctx
},
}
4.2 sync.Pool误用:Put前未清空指针引用导致对象无法回收
问题根源:隐式强引用滞留
sync.Pool 中对象的生命周期由 Go 的垃圾回收器(GC)管理,但若对象字段仍持有对其他堆对象的指针(如 *bytes.Buffer、*http.Request),而 Put 前未显式置为 nil,则该对象会被池长期持有——连带其引用链上所有对象均无法被 GC 回收。
典型错误示例
var bufPool = sync.Pool{
New: func() interface{} { return new(bytes.Buffer) },
}
func badReuse() {
buf := bufPool.Get().(*bytes.Buffer)
buf.WriteString("hello")
// ❌ 忘记清空内部切片引用(底层 []byte 可能指向大内存)
// buf.Reset() 仅清长度,不释放底层数组
bufPool.Put(buf) // 潜在内存泄漏!
}
buf.Reset()仅重置len,cap和底层数组仍保留;若此前写入大量数据,该数组将持续驻留于 Pool 中,阻塞 GC。
正确做法对比
| 操作 | 是否释放底层内存 | 是否推荐 |
|---|---|---|
buf.Reset() |
否 | ❌ |
buf.Truncate(0) |
否 | ❌ |
buf = bytes.Buffer{} |
是(新结构体,原底层数组可被 GC) | ✅ |
安全回收流程
graph TD
A[Get from Pool] --> B[Use object]
B --> C{Reset all pointer fields?}
C -->|No| D[Put → leak risk]
C -->|Yes| E[buf.Reset(); buf = bytes.Buffer{}]
E --> F[Put → safe]
4.3 zap.Logger全局实例+field缓存引发的结构体逃逸与内存驻留
当使用 zap.NewProduction() 创建全局 *zap.Logger 实例并频繁调用 With() 缓存静态字段时,若传入非字面量结构体(如 User{ID: id, Name: name}),会触发编译器逃逸分析判定为堆分配。
type User struct { Name string; ID int }
var logger = zap.NewExample().With(zap.String("svc", "api"))
func logUser(u User) { // u 逃逸:非指针传参 + With 内部复制
logger.With(zap.Any("user", u)).Info("login") // ← u 整体分配在堆
}
逻辑分析:zap.Any() 接收 interface{},强制 u 装箱;With() 构造新 *Logger 时需深拷贝字段,导致 User 实例无法栈驻留。参数 u 未加 &,且 zap.Any 无内联优化提示,逃逸不可避免。
逃逸关键路径
- 非指针结构体 →
interface{}装箱 → 堆分配 With()复制[]Field→ 字段值被复制而非引用
| 优化方式 | 是否避免逃逸 | 原因 |
|---|---|---|
logUser(&u) |
✅ | 指针传递,Any 可序列化地址 |
zap.Object("user", u) |
⚠️ | 仍需反射,但可复用缓冲池 |
预构建 Field |
✅ | zap.String("id", u.ID) 等字面量字段 |
graph TD
A[logUser(u User)] --> B[u passed by value]
B --> C[zap.Any casts to interface{}]
C --> D[compiler escapes u to heap]
D --> E[GC 周期延长内存驻留]
4.4 grpc-go客户端连接池未Close导致net.Conn与buffer持续增长
问题现象
当 grpc.ClientConn 实例被复用但未调用 Close(),底层 net.Conn 不会释放,http2Client 持有的读写 buffer(如 recvBufferPool)持续累积,引发内存泄漏与文件描述符耗尽。
核心代码示例
// ❌ 错误:conn 生命周期失控
conn, _ := grpc.Dial("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()))
client := pb.NewUserServiceClient(conn)
// 忘记 conn.Close()
// ✅ 正确:显式管理生命周期
defer conn.Close() // 或使用 sync.Pool 管理 conn
grpc.Dial返回的*grpc.ClientConn是重量级资源,内部维护http2Client、addrConn、transport及多个sync.Pool缓冲区。未Close()将阻断 transport 关闭流程,使net.Conn无法归还至系统,recvBuffer持续扩容。
资源占用对比
| 指标 | 未 Close(1h) | 正常 Close(1h) |
|---|---|---|
| goroutine 数 | +120 | 基线稳定 |
| net.Conn 数 | 32768+ | |
| heap_inuse (MB) | 1200+ | 45 |
修复路径
- 使用
sync.Pool[*grpc.ClientConn]复用连接并确保Put前Close() - 启用
grpc.WithBlock()+ 超时控制避免连接悬挂 - 监控
grpc_client_conn_opened和grpc_client_conn_closed指标差值
第五章:第四层根因:业务代码中的隐式内存生命周期错误
什么是隐式内存生命周期错误
这类错误不触发编译报错,也不抛出运行时异常,却在特定负载或时间窗口下悄然引发内存泄漏、use-after-free 或野指针访问。其本质是业务逻辑与内存管理契约的隐式脱节——开发者未显式声明对象的存活边界,而依赖语言特性(如引用计数、GC 周期)或调用方行为“自动兜底”,结果在异步回调、闭包捕获、事件监听器注册等场景中,对象被意外长期持有。
真实案例:React 组件中未清理的定时器与闭包引用
某电商后台仪表盘组件使用 useEffect 启动轮询:
function Dashboard() {
const [data, setData] = useState([]);
useEffect(() => {
const timer = setInterval(() => {
fetch('/api/metrics').then(res => res.json()).then(setData);
}, 5000);
// ❌ 遗漏 cleanup 函数
return () => clearInterval(timer); // ← 此行被注释导致 timer 持有对 setData 的闭包引用
}, []);
return <div>{data.length} 条指标</div>;
}
当用户快速切换路由后,该组件已卸载,但 setInterval 回调仍在执行,并持续调用已失效的 setState,造成内存中残留大量废弃组件实例及关联 DOM 节点。
关键诊断线索表
| 现象 | 可能成因 | 排查工具 |
|---|---|---|
| 内存占用随页面跳转持续增长 | 未解绑事件监听器或定时器 | Chrome DevTools → Memory → Heap Snapshot 对比 |
| GC 后堆内存无法回落至基线 | 闭包捕获了大型对象(如整个 form state) | console.memory + performance.memory 监控 |
Node.js 进程 RSS 持续上升且 process.memoryUsage().heapUsed 波动小 |
全局 Map/WeakMap 键未释放,或 setTimeout 回调持有外部作用域 |
node --inspect + Chrome DevTools → Allocation Instrumentation on Timeline |
Mermaid 流程图:隐式生命周期错误的触发链
flowchart LR
A[用户点击导航] --> B[React 卸载旧组件]
B --> C[useEffect cleanup 未执行]
C --> D[setInterval 回调继续运行]
D --> E[调用已销毁组件的 setState]
E --> F[React 将组件实例保留在内部 pending 队列]
F --> G[DOM 节点、事件处理器、闭包变量全部无法 GC]
Go 中的典型陷阱:切片截取导致底层数组泄露
某日志服务为节省内存,对原始日志字节切片做 log[:1024] 截断后传入异步写入协程:
func handleLog(raw []byte) {
truncated := raw[:min(len(raw), 1024)]
go writeAsync(truncated) // ❌ truncated 仍指向原始大数组的底层数组
}
即使 raw 是 MB 级日志,truncated 的底层 cap 仍为原始容量,导致整个大数组被 writeAsync 协程长期持有,GC 无法回收。
防御性实践清单
- 所有异步资源(定时器、WebSocket、EventSource)必须在明确退出路径中显式销毁;
- 在闭包中避免直接捕获
this、self或大型状态对象,改用useCallback+ 依赖数组精确控制; - Go 中需用
copy(dst, src[:n])替代切片截取传递,确保新 slice 底层数组独立; - TypeScript 项目启用
--noUnusedLocals和--noUnusedParameters,配合 ESLint 规则@typescript-eslint/no-misused-promises; - 在 CI 阶段集成 Puppeteer 自动化内存快照比对脚本,检测路由切换后内存残留。
工具链推荐:从检测到修复闭环
- 前端:
@sentry/memory插件 + 自定义beforeNavigatehook 记录组件挂载/卸载时间戳; - Node.js:
clinic.js的bubbleprof可视化异步资源生命周期,定位未关闭的ReadStream或Timer; - Rust:
cargo-valgrind+miri在测试中强制检查Drop是否被调用; - 通用:编写 Jest 测试用例,
jest.useFakeTimers()控制时间流,断言clearInterval是否被调用。
