time.Now() 自带单调时钟, time.After 在循环里是内存泄漏制造机, Timer.Reset 有三步安全用法 — Duration / 时区 / 布局一次讲透
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 回拨也不出负数
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 — 关键: 裸数字是纳秒!
for { select { case <-time.After(30 * time.Second): // 每轮新建 Timer return // 1.23 前未到期不回收 → 循环里堆积 case m := <-inbox: handle(m) } }
Stop() 出堆; 注意 Stop 只是不再触发, 不清理已经进 C 的值。tk := time.NewTicker(time.Second) defer tk.Stop() // 关键: 资源句柄, 用完必须出堆 for range tk.C { tick() } // 注意: Stop 只是不再触发, 不清已进 C 的值
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 后立刻假触发一次
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)
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 — 关键: 带时区, 跨语言零歧义
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 四叉小顶堆, 调度循环顺手查堆顶 // Ticker = 到期后重新入堆的 Timer tk := time.NewTicker(time.Second) defer tk.Stop() // 关键: 不 Stop → 常驻堆上泄漏
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, 秒级跑完
每条消息的 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+ 也能自愈, 但复用写法全版本安全。
压测下游限流阈值, 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 } } }
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 } }
两轮并发写同一张对账表会产生重复行; 用原子标志"上一轮没跑完就跳过":
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 唯一键)。
全球站点的活动时间, 存储层混用本地时区是事故之源:
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"))
新人最常犯: 把 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")
搜索网关拿到剩余预算后决定走哪条链路 —— 时间本身就是路由依据:
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) // 余量充足: 全量检索 } }
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()) }
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)"重对齐 —— 那是整点对齐的需求, 普通监控会掩盖真实延迟。
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 应已过期") } }
NewTimer + Reset 复用, 见场景 1。// 错: for { select { case <-time.After(30*time.Second): … } } // 1.23 前每轮新 Timer 到期前不回收 → 内存缓涨 t := time.NewTimer(d) // 对: 循环外建一个, Stop+drain+Reset 复用 defer t.Stop()
defer tk.Stop() 是肌肉记忆; 长 ticker 要有 owner 负责停。// 错: tk := time.NewTicker(d) 之后不 Stop — 常驻堆上泄漏 tk := time.NewTicker(time.Second) defer tk.Stop() // 对: 肌肉记忆; 长 ticker 要有 owner 停
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)
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 走单调钟)
// 错: end.Unix() - start.Unix() 用 wall — NTP 回拨出负数 d := time.Since(start) // 对: Since 用 mono 段, 只前进 fmt.Println(d >= 0) // → true, 限速器不再超额放行
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) // 对
time.Sleep(30) 是 30 纳秒, 编译不报错, 表现为"超时从不触发"。原因: Duration 是 int64 无单位保护。正解: 永远写 30 * time.Second 字面量。time.Sleep(30) // 错: 30 纳秒! 编译不报错, 超时从不触发 fmt.Println(time.Duration(30)) // → 30ns time.Sleep(30 * time.Second) // 对: 永远写字面量
select { case <-ctx.Done(): case <-timer.C: }。time.Sleep(time.Minute) // 错: 停机窗口内卡满, 被强杀 select { // 对: 可中断等待 case <-time.After(time.Minute): case <-ctx.Done(): return ctx.Err() }
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
time.Until(下一个整点) 重算下次触发; 只监控就记录漂移别硬修。// 错: 以为 Ticker 会整点对齐 — 处理耗时越拖越晚 next := time.Now().Truncate(time.Hour).Add(time.Hour) <-time.After(time.Until(next)) // 对: 重算到下个整点
Before/After/Equal; 判零值用 t.IsZero()。a := time.Now() b := a.UTC() // 同一时刻, 只换了时区 fmt.Println(a == b) // 错: → false (loc 指针不同) fmt.Println(a.Equal(b)) // 对: → true
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
t.UTC() 统一; 团队写死"存储 UTC、展示本地"一条规矩。// 错: 默认 RFC3339 带 +08:00, DB 无时区列解析偏 8 小时 b, _ := json.Marshal(t.UTC()) // 对: 入库前统一 UTC fmt.Println(string(b)) // → "2026-09-26T02:00:00Z"
case <-time.After(d) 写在循环内的 select 里, 每进一轮就创建新对象, 高频路径泄漏放大。正解: 提到循环外 NewTimer, 或升 Go 1.23+。// 错: case <-time.After(d) 写在循环内 select — 每轮求值新建 t := time.NewTimer(d) // 对: 提到循环外, Reset 复用 defer t.Stop()
// 错: 每个 Pod 都跑 cron — 账单重复生成 ok, _ := rds.SetNX(ctx, "lock:bill:2026-09", 1, 10*time.Minute).Result() if !ok { return } // 对: SETNX+TTL, 单实例执行
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
UnixMilli(), DB 用 BIGINT 毫秒列。// 错: 前端传毫秒当纳秒解析 → 时间戳回到 1970 年 ts := time.Now().UnixMilli() // 对: 接口契约写死毫秒 t := time.UnixMilli(ts) // DB 用 BIGINT 毫秒列
import _ "time/tzdata" 把时区表编进二进制。// 错: scratch/alpine 无 /usr/share/zoneinfo // → time.LoadLocation 返回 unknown timezone 错误 import _ "time/tzdata" // 对: 时区表编进二进制
Sleep(1 * time.Millisecond) 实测 2~5ms, 精确限速失真。原因: runtime 调度与 OS 定时器粒度。正解: 接受毛刺并监控; 亚毫秒精度需求改时间轮或硬件方案。time.Sleep(time.Millisecond) // 错: 实测 2~5ms, 精确限速失真 // 对: 接受毛刺并监控漂移; 亚毫秒需求改时间轮 schedLag.Observe(time.Since(start).Seconds())
time.Until 重算补发。tk := time.NewTicker(time.Second) // 错: 消费慢时 C(缓冲=1) 中间 tick 直接丢, 计数对不上 // (设计如此, 防落后者雪崩) // 对: 要一次不落 → 自己排队列 + time.Until 重算补发