Go · time 与定时器

time.Now() 自带单调时钟, time.After 在循环里是内存泄漏制造机, Timer.Reset 有三步安全用法 — Duration / 时区 / 布局一次讲透

NewTimer(d) Stop() Reset(d) time.Now() — 一个值, 两座钟 wall — 墙上时钟 Unix 秒 (1970 起算) 可被 NTP 回拨 / 跳变 展示 / 存储 / 跨机对齐用 monotonic — 单调时钟 进程启动起的纳秒计数 只前进, 与 NTP 无关 测时长: Since/Sub 自动选它 type time.Time struct { wall uint64; ext int64; loc } Now() 时 ext 存的就是 mono 段 两个 Now() 相减自动剥掉 wall, 用 mono time.After 在循环里 = 泄漏制造机 for { select { case <-time.After(30 * time.Second): case r := <-pending: } } runtime 定时器堆 — 未到期的 Timer 全挂着 t1 t2 t3 t4 t5 每轮循环 +1 个, 到期前不释放 (1.23 前) 修复: NewTimer + Reset 复用一个 t := time.NewTimer(d) 循环外建一次 每轮 Stop → drain → Reset, 零新增 Timer 状态机 + Reset 安全时序 Active 挂在 P 的 timer 堆上 Stopped 已出堆, 不再触发 Reset 三步安全法 (防双触发) if !t.Stop() { select { case <-t.C: default: } // drain 清残留 } t.Reset(d) Stop 返回 false = 已触发过, C 里可能有值 不 drain → 旧值残留, Reset 后立刻假触发 Duration — 就是 int64 纳秒 time.Second = 1_000_000_000 写 30 是 30ns, 不是 30 秒 类型不挡单位, 编译器不报错 time.Until(d) 返回 Duration d - d2 / time.Millisecond 换算 比较用 time.Second 字面量, 别手写魔数 格式化布局 — 参考时间点 2006-01-02 15:04:05 = 1月2日 15点4分5秒 2006年 不是 strftime: yyyy 会原样输出 01/02 03:04:05PM '06 -0700 RFC3339 = 2006-01-02T15:04:05Z07:00 API 对外统一 RFC3339: 带时区无歧义 底层 — runtime 定时器堆 每个 P 一个 timer 四叉小顶堆 调度循环检查堆顶 (详见 GMP 页) Ticker = 到期后重新入堆的 Timer Stop 只出堆, 不清已进 C 的值 timer 的 C 缓冲为 1: 慢消费丢 tick CPU profile 里的 checkTimers 就是它 Legend 双时钟 泄漏现场 堆积的 Timer 正确复用/状态机 Duration

Now() 一个值两座钟

  • • wall 可被 NTP 回拨, mono 只前进
  • • Since/Sub 自动用 mono 段, 测时长稳
  • • 跨机器对齐/存储才用 wall (UTC)
  • • 时间戳差值别手搓 Unix 秒相减

Timer 是资源不是语法糖

  • • NewTimer/NewTicker 用完必须 Stop
  • • 循环里 time.After = 泄漏制造机
  • • Reset 前先 Stop + drain 残留
  • • Sleep 不可取消, 可取消等待用 select

Duration 与布局

  • • Duration 是 int64 纳秒, 无单位保护
  • • 一律写 30 * time.Second 这类字面量
  • • 布局 = 参考时间点, 不是 strftime
  • • 对外序列化统一 RFC3339 带时区

💡 一句话理解

Go 的 time 包核心是一个设计: time.Now() 返回的值里同时装着两座钟 —— 墙上时钟负责"现在是几点" (展示、存储、跨机对齐, 但会被 NTP 校时回拨), 单调时钟负责"过了多久" (只前进, Since/Sub 自动优先用它)。定时方面, Timer/Ticker 是真实资源, 挂在 runtime 的定时器堆上: 循环里每轮 time.After 都往堆里塞一个新 Timer, Go 1.23 之前到期前不回收, 这就是著名的"泄漏制造机"; 复用一个 Timer 加 Stop → drain → Reset 三步就根治。Duration 本质是 int64 纳秒, 类型系统不帮你挡单位错, 全靠字面量纪律。

🧠 必知必会 必考 & 必会

单调时钟
time.Now() 的 time.Time 内部带 wall + mono 两段; Since/Sub 两个时刻都含 mono 时自动剥掉 wall 用 mono —— 测时长永远稳。
start := time.Now()
time.Sleep(10 * time.Millisecond)
fmt.Println(time.Since(start) > 0) // → true
// 关键: Since/Sub 自动用 mono 段, NTP 回拨也不出负数
Duration
底层就是 int64 纳秒, time.Second = 1e9。没有单位类型保护: 裸写 30 是 30 纳秒, 必须写 30 * time.Second。
var d time.Duration = 30 * time.Second
fmt.Println(d / time.Second)   // → 30
fmt.Println(time.Duration(30)) // → 30ns — 关键: 裸数字是纳秒!
time.After 一次性
每次调用新建一个 Timer, 未触发前一直占着 runtime 定时器堆; Go 1.23 起未触发的可被 GC, 之前版本循环里堆积成泄漏。
for {
    select {
    case <-time.After(30 * time.Second): // 每轮新建 Timer
        return // 1.23 前未到期不回收 → 循环里堆积
    case m := <-inbox: handle(m)
    }
}
NewTimer/NewTicker
拿到的是资源句柄, 用完必须 Stop() 出堆; 注意 Stop 只是不再触发, 不清理已经进 C 的值。
tk := time.NewTicker(time.Second)
defer tk.Stop() // 关键: 资源句柄, 用完必须出堆
for range tk.C { tick() }
// 注意: Stop 只是不再触发, 不清已进 C 的值
Reset 的 drain
Stop() 返回 false 表示 Timer 已触发、C 里可能有残留值; 先 select { case <-t.C: default: } 排掉再 Reset, 防双触发。
if !t.Stop() { // 关键: false = 已触发过, C 可能有残留
    select { case <-t.C: default: } // drain 排掉
}
t.Reset(d) // 不排掉 → Reset 后立刻假触发一次
Sleep 与取消
time.Sleep 无法被取消; 可中断的等待写 select { case <-ctx.Done(): case <-timer.C: }。
time.Sleep(time.Minute) // 无法取消, 只能干等
select {                        // 关键: 可中断的等待
case <-time.After(time.Minute):
case <-ctx.Done(): return ctx.Err()
}
时区处理
time.LoadLocation("Asia/Shanghai") 依赖系统 tzdata; 容器/scratch 镜像里 import _ "time/tzdata" 兜底。约定: 存储一律 UTC, 展示才转用户时区。
import _ "time/tzdata" // 容器无 tzdata 时兜底
loc, _ := time.LoadLocation("Asia/Shanghai")
fmt.Println(t.UTC())  // 存储一律 UTC
fmt.Println(t.In(loc)) // 关键: 展示才转用户时区, 时刻不变
布局参考时间
格式串是一个具体时刻: 2006-01-02 15:04:05 = 1月2日 15点4分5秒 2006年, 数字连起来 1 2 3 4 5 6。不是 strftime, yyyy-MM-dd 会原样输出。
ref := time.Date(2026, 9, 26, 15, 4, 5, 0, time.UTC)
fmt.Println(ref.Format("2006-01-02 15:04:05"))
// → 2026-09-26 15:04:05 (1月2 15:04:05 → 1 2 3 4 5 6)
RFC3339
time.RFC3339 = 带时区的 ISO 8601 子集; json 编解码 time.Time 默认用它 —— API 时间字段统一这个, 跨语言零歧义。
t, _ := time.Parse(time.RFC3339, "2026-09-26T10:00:00+08:00")
fmt.Println(t.UTC().Format(time.RFC3339))
// → 2026-09-26T02:00:00Z — 关键: 带时区, 跨语言零歧义
AddDate 语义
按日历加年/月/日, 溢出进位: 1月31日 AddDate(0,1,0) 得 3月3日。月末账单要"当月最后一天"封顶。
jan31 := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(jan31.AddDate(0, 1, 0).Format("2006-01-02"))
// → 2026-03-03 — 关键: 日历进位, 月末要 min 封顶
定时器底层
每个 P 一个 timer 四叉小顶堆, 调度循环顺手检查堆顶 (与 GMP 页的 findrunnable 呼应); Ticker 就是到期后重新入堆的 Timer。
// 每个 P 一个 timer 四叉小顶堆, 调度循环顺手查堆顶
// Ticker = 到期后重新入堆的 Timer
tk := time.NewTicker(time.Second)
defer tk.Stop() // 关键: 不 Stop → 常驻堆上泄漏
mock 时间
把 now func() time.Time 抽成依赖注入, 测试里拨时钟, 24 小时过期逻辑秒级跑完。
type Clock struct{ now func() time.Time }
c := &Clock{now: func() time.Time {
    return base.Add(25 * time.Hour) }} // 关键: 拨快时钟
fmt.Println(tokenExpired(c, issued)) // → true, 秒级跑完

🏭 生产实战 real world

场景 1 · 消息网关 4 天涨 2.3GB: 循环超时改 NewTimer+Reset

每条消息的 for-select 里各建一个 time.After, 未到期 Timer 在堆里堆积; pprof heap 差分显示 time.Timer 对象占新增 71%:

// 症状: 内存 4 天涨 2.3GB; 修复后 72h RSS 平直
t := time.NewTimer(idleTimeout)
defer t.Stop()                            // 用完必须出堆
for msg := range inbox {
    select {
    case resp := <-pending:
        handle(msg, resp)
        if !t.Stop() {                          // 停掉计时的旧值
            select { case <-t.C: default: }     // drain 清残留
        }
        t.Reset(idleTimeout)                    // 复用同一个堆节点
    case <-t.C:
        return                                  // 真空闲超时才退出
    }
}

峰值在堆 Timer 从 ~200 万个降到 1 个; 换 Go 1.23+ 也能自愈, 但复用写法全版本安全。

场景 2 · 下游报表接口固定 50 QPS 打点

压测下游限流阈值, Ticker 比 time.Sleep 循环更贴"恒定速率", 且必须监听取消:

func pump(ctx context.Context, out chan<- Req) {
    tk := time.NewTicker(time.Second / 50)      // 20ms 一个间隔
    defer tk.Stop()                         // 不 Stop = 堆常驻泄漏
    for {
        select {
        case <-tk.C:
            select {
            case out <- nextReq():
            case <-ctx.Done(): return        // 满了也要听取消
            }
        case <-ctx.Done():
            return
        }
    }
}

场景 3 · 优雅退出: 完成 / 外部强杀 / 兜底超时三路 select

K8s terminationGracePeriod 只有 30s, 排空在途任务既要等得了也要走得了:

func gracefulShutdown(ctx context.Context, srv *Server) error {
    done := make(chan error, 1)
    go func() { done <- srv.DrainTasks() }()      // 排空在途任务
    select {
    case err := <-done:                          // 正常收完
        return err
    case <-ctx.Done():                           // 外部又发了强杀
        return ctx.Err()
    case <-time.After(15 * time.Second):         // 兜底: 给强杀留余量
        return ErrShutdownTimeout
    }
}

场景 4 · cron 每 5 分钟一轮, 但上一轮要跑 8 分钟: 防重叠

两轮并发写同一张对账表会产生重复行; 用原子标志"上一轮没跑完就跳过":

var running atomic.Bool                     // CAS 抢占, 不排队
func run() {
    if !running.CompareAndSwap(false, true) {
        log.Print("上一轮未结束, 跳过本轮")          // 宁可少跑不可重叠
        return
    }
    defer running.Store(false)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
    defer cancel()                             // 超时硬停, 防连环重叠
    syncAllAccounts(ctx)
}

多实例部署时 atomic.Bool 只管单进程, 跨实例要换分布式锁 (Redis SETNX 或 DB 唯一键)。

场景 5 · 时区转换服务: UTC 存储 + 用户时区展示

全球站点的活动时间, 存储层混用本地时区是事故之源:

import _ "time/tzdata"                     // scratch 容器无系统时区表时兜底
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil { return err }
// 存储约定: DB 一律存 UTC; 展示层才转用户时区
utc := t.UTC().Format(time.RFC3339)
db.Exec("INSERT INTO events(at) VALUES (?)", utc)
// 展示: 同一时刻, 按用户 profile 的时区渲染
userT := t.In(loc)                              // 时刻不变, 只换坐标
fmt.Println(userT.Format("2006-01-02 15:04:05"))

场景 6 · 布局速记与 API 时间字段约定

新人最常犯: 把 strftime 的 yyyy-MM-dd 抄进来, 输出原样字符串还以为是对的:

// 参考时间点: 1月2日 15:04:05 2006年 → 1 2 3 4 5 6
ref := time.Date(2006, 1, 2, 15, 4, 5, 0, time.UTC)
ref.Format("2006-01-02 15:04:05")       // 2006-01-02 15:04:05
ref.Format("01/02 03:04:05PM '06 -0700") // 01/02 03:04:05PM '06 +0000
ref.Format("yyyy-MM-dd")                // 字面输出 yyyy-MM-dd !
// API 对外统一 RFC3339: 带时区, 跨语言无歧义
t, err := time.Parse(time.RFC3339, "2026-09-26T10:00:00+08:00")

场景 7 · deadline 驱动的分级降级

搜索网关拿到剩余预算后决定走哪条链路 —— 时间本身就是路由依据:

func route(ctx context.Context) (Plan, error) {
    left := time.Until(deadline)                // Duration, 单调钟测得
    switch {
    case left <= 0:
        return nil, ErrDeadlinePassed           // 已过期: 直接拒
    case left < 300*time.Millisecond:
        return cacheOnly(ctx)                   // 只剩零头: 走缓存
    case left < 2*time.Second:
        return localIndex(ctx)                  // 本地倒排索引
    default:
        return fullSearch(ctx)                  // 余量充足: 全量检索
    }
}

场景 8 · 月度账单的 AddDate 月末溢出修复

1月31日开通的会员, 2月没有 31 号, AddDate 直接溢出到 3月3日导致 2 月账单丢失:

// 坑: AddDate 按日历"进位", 1.31 + 1 月 = 3.3
start := time.Date(2026, 1, 31, 0, 0, 0, 0, loc)
fmt.Println(start.AddDate(0, 1, 0).Format("2006-01-02")) // 2026-03-03
// 修复: 天数取 min(开通日, 当月最后一天)
func nextBill(t time.Time) time.Time {
    y, m, _ := t.Date()
    last := time.Date(y, m+1, 1, 0, 0, 0, 0, t.Location()).AddDate(0, 0, -1)
    day := min(t.Day(), last.Day())             // 月末封顶: 2月 → 28/29
    return time.Date(y, m+1, day, 0, 0, 0, 0, t.Location())
}

场景 9 · Ticker 调度延迟监控: 期望 tick vs 实际 tick

GC 停顿或机器超卖会让"每 100ms 一次"变成 170ms, 漂移打点帮你抓到毛刺:

tk := time.NewTicker(100 * time.Millisecond)
defer tk.Stop()
var last = time.Now()
for range tk.C {
    now := time.Now()
    lag := now.Sub(last) - 100*time.Millisecond // 间隔外的部分
    if lag > 50*time.Millisecond {
        schedLag.Observe(lag.Seconds())          // 上报调度/GC 毛刺
    }
    last = now                                  // 用实际时刻: 不累积漂移
}

注意别用"固定 next = start.Add(k*d)"重对齐 —— 那是整点对齐的需求, 普通监控会掩盖真实延迟。

场景 10 · mock 时间注入: 24 小时过期测试秒级跑完

token 24h 过期逻辑, 真等一天不可接受; 把 now 抽成函数依赖:

type Clock struct{ now func() time.Time }
func NewClock() *Clock { return &Clock{now: time.Now} }
func (c *Clock) Now() time.Time { return c.now() }
// 测试里拨快时钟: 不用真等 24h, 秒级跑完
func TestTokenExpiry(t *testing.T) {
    base := time.Date(2026, 9, 26, 0, 0, 0, 0, time.UTC)
    c := &Clock{now: func() time.Time { return base.Add(25 * time.Hour) }}
    if !tokenExpired(c, issuedAt(base)) {       // 25h > 24h 有效期
        t.Fatal("token 应已过期")
    }
}

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 循环里 time.After 堆积泄漏 — 高频循环内存缓涨, heap 里全是 time.Timer。原因: 每轮新建 Timer, Go 1.23 前到期前不可回收。正解: 循环外 NewTimer + Reset 复用, 见场景 1。
// 错: for { select { case <-time.After(30*time.Second): … } }
//     1.23 前每轮新 Timer 到期前不回收 → 内存缓涨
t := time.NewTimer(d) // 对: 循环外建一个, Stop+drain+Reset 复用
defer t.Stop()
坑 2 · Ticker 忘 Stop — 函数返回后 Ticker 仍挂在 runtime 定时器堆上, 常驻泄漏。正解: defer tk.Stop() 是肌肉记忆; 长 ticker 要有 owner 负责停。
// 错: tk := time.NewTicker(d) 之后不 Stop — 常驻堆上泄漏
tk := time.NewTicker(time.Second)
defer tk.Stop() // 对: 肌肉记忆; 长 ticker 要有 owner 停
坑 3 · Reset 前不 drain — Reset 后立刻"假触发"一次, 状态机错乱; 或对已触发 Timer 死等。原因: C 里残留旧值。正解: if !t.Stop() { select { case <-t.C: default: } } 后再 Reset。
t.Reset(d) // 错: C 里残留旧值 → Reset 后立刻假触发
if !t.Stop() {
    select { case <-t.C: default: } // 对: 先 drain
}
t.Reset(d)
坑 4 · Unix 秒差值算时长 — t2.Unix() - t1.Unix() 只有秒级精度, 毫秒级耗时全变 0。正解: t2.Sub(t1) / time.Since(t1), 走单调钟还免疫回拨。
// 错: t2.Unix() - t1.Unix() — 秒级精度, 毫秒耗时全变 0
d := t2.Sub(t1)                // 对: Duration 精度到纳秒
fmt.Println(d.Milliseconds())  // → 37 (Sub 走单调钟)
坑 5 · 墙上时钟回拨出负时长 — NTP 校时回拨 300ms, 计时变成负数, 限速器直接放行超额。原因: 用 wall 相减。正解: 测时长一律 Since/Sub (自动用 mono 段)。
// 错: end.Unix() - start.Unix() 用 wall — NTP 回拨出负数
d := time.Since(start) // 对: Since 用 mono 段, 只前进
fmt.Println(d >= 0)   // → true, 限速器不再超额放行
坑 6 · time.Parse 不带时区静默偏 8 小时 — time.Parse("2006-01-02 15:04:05", s) 无时区信息时按 UTC 解析, 北京用户看到的中午变凌晨。正解: 用 time.ParseInLocation(layout, s, loc)。
t, _ := time.Parse(layout, "2026-09-26 12:00:00")
zn, _ := t.Zone()
fmt.Println(zn) // → UTC 错: 静默按 UTC, 北京中午变凌晨
t2, _ := time.ParseInLocation(layout, s, loc) // 对
坑 7 · Duration 裸写数字 — time.Sleep(30) 是 30 纳秒, 编译不报错, 表现为"超时从不触发"。原因: Duration 是 int64 无单位保护。正解: 永远写 30 * time.Second 字面量。
time.Sleep(30) // 错: 30 纳秒! 编译不报错, 超时从不触发
fmt.Println(time.Duration(30)) // → 30ns
time.Sleep(30 * time.Second) // 对: 永远写字面量
坑 8 · time.Sleep 无法取消 — 优雅退出时卡在 Sleep 里等满, 错过 shutdown 窗口被强杀。正解: 可中断等待用 select { case <-ctx.Done(): case <-timer.C: }。
time.Sleep(time.Minute) // 错: 停机窗口内卡满, 被强杀
select { // 对: 可中断等待
case <-time.After(time.Minute):
case <-ctx.Done(): return ctx.Err()
}
坑 9 · AddDate(0,1,0) 月末溢出 — 1月31日下月变 3月3日, 2月账单直接丢。原因: 日历进位语义。正解: 天数取 min(开通日, 当月最后一天) 封顶, 见场景 8。
fmt.Println(jan31.AddDate(0, 1, 0)) // 错: → 2026-03-03
day := min(t.Day(), last.Day())   // 对: 月末封顶
return time.Date(y, m+1, day, 0, 0, 0, 0, loc) // → 02-28
坑 10 · Ticker 周期累积漂移 — 处理耗时会让 tick 越拖越晚, 且 Ticker 不做整点对齐。正解: 需要精确整点用 time.Until(下一个整点) 重算下次触发; 只监控就记录漂移别硬修。
// 错: 以为 Ticker 会整点对齐 — 处理耗时越拖越晚
next := time.Now().Truncate(time.Hour).Add(time.Hour)
<-time.After(time.Until(next)) // 对: 重算到下个整点
坑 11 · time.Time 用 == 比较 — 两个"同一时刻"因为 loc 或 mono 段不同而判不等。正解: 一律 Before/After/Equal; 判零值用 t.IsZero()。
a := time.Now()
b := a.UTC()              // 同一时刻, 只换了时区
fmt.Println(a == b)       // 错: → false (loc 指针不同)
fmt.Println(a.Equal(b))   // 对: → true
坑 12 · 格式串写 yyyy-MM-dd — 编译不报错, 输出原样字符串 "yyyy-MM-dd", 上线才发现。原因: Go 布局不是 strftime。正解: 背参考时间点 2006-01-02 15:04:05。
fmt.Println(ref.Format("yyyy-MM-dd")) // 错: → yyyy-MM-dd 原样
fmt.Println(ref.Format("2006-01-02"))  // 对: → 2026-09-26
坑 13 · json 时间带时区踩 DB 约定 — 默认 RFC3339 带 +08:00, DB 列若按 UTC 无时区解析就偏 8 小时。正解: 入库前 t.UTC() 统一; 团队写死"存储 UTC、展示本地"一条规矩。
// 错: 默认 RFC3339 带 +08:00, DB 无时区列解析偏 8 小时
b, _ := json.Marshal(t.UTC()) // 对: 入库前统一 UTC
fmt.Println(string(b)) // → "2026-09-26T02:00:00Z"
坑 14 · select 里每轮求值 time.After — case <-time.After(d) 写在循环内的 select 里, 每进一轮就创建新对象, 高频路径泄漏放大。正解: 提到循环外 NewTimer, 或升 Go 1.23+。
// 错: case <-time.After(d) 写在循环内 select — 每轮求值新建
t := time.NewTimer(d) // 对: 提到循环外, Reset 复用
defer t.Stop()
坑 15 · 定时任务多实例重复跑 — 每个副本都触发 cron, 账单重复生成。原因: 进程内标志管不了多实例。正解: 分布式锁 (Redis SETNX + TTL) 或 K8s CronJob 只跑单 Pod。
// 错: 每个 Pod 都跑 cron — 账单重复生成
ok, _ := rds.SetNX(ctx, "lock:bill:2026-09",
    1, 10*time.Minute).Result()
if !ok { return } // 对: SETNX+TTL, 单实例执行
坑 16 · 夏令时切换日 23/25 小时 — 欧美时区 3 月某天只有 23 小时, "当天 24 次每小时一次"少跑一轮。正解: 按本地日历算间隔用 time.Date 重算, 别假设每天 24h。
// 错: interval := 24 * time.Hour — DST 切换日只有 23/25h
next := time.Date(y, m, d+1, 9, 0, 0, 0, loc)
return time.Until(next) // 对: 本地日历重算, 别乘 24h
坑 17 · UnixMilli 与 UnixNano 混用 — 前端传毫秒、后端当纳秒, 时间戳变成 1970 年。正解: 接口契约写死单位; 内部统一 UnixMilli(), DB 用 BIGINT 毫秒列。
// 错: 前端传毫秒当纳秒解析 → 时间戳回到 1970 年
ts := time.Now().UnixMilli() // 对: 接口契约写死毫秒
t := time.UnixMilli(ts)          // DB 用 BIGINT 毫秒列
坑 18 · LoadLocation 在容器里报错 — scratch/alpine 镜像没有 /usr/share/zoneinfo, 返回 "unknown timezone" 错误。正解: import _ "time/tzdata" 把时区表编进二进制。
// 错: scratch/alpine 无 /usr/share/zoneinfo
//     → time.LoadLocation 返回 unknown timezone 错误
import _ "time/tzdata" // 对: 时区表编进二进制
坑 19 · sleep 粒度毛刺 — Sleep(1 * time.Millisecond) 实测 2~5ms, 精确限速失真。原因: runtime 调度与 OS 定时器粒度。正解: 接受毛刺并监控; 亚毫秒精度需求改时间轮或硬件方案。
time.Sleep(time.Millisecond) // 错: 实测 2~5ms, 精确限速失真
// 对: 接受毛刺并监控漂移; 亚毫秒需求改时间轮
schedLag.Observe(time.Since(start).Seconds())
坑 20 · Ticker 慢消费丢 tick — C 的缓冲只有 1, 消费者处理慢时中间的 tick 直接丢掉, 计数对不上。原因: 设计如此, 防止落后者雪崩。正解: 要"一次不落"就别用 Ticker —— 自己排队列 + time.Until 重算补发。
tk := time.NewTicker(time.Second)
// 错: 消费慢时 C(缓冲=1) 中间 tick 直接丢, 计数对不上
//     (设计如此, 防落后者雪崩)
// 对: 要一次不落 → 自己排队列 + time.Until 重算补发