掘金 后端 ( ) • 2024-04-11 16:53

问题描述

线上服务日志中突然出现很多主键冲突的异常,而这个主键是一个int64的id,这个id的生成依赖了秒级时间戳和机器码.那么下面先把问题代码贴出来,由于具体分析较长,这里先简述下根因,后面不感兴趣可以不看

简述根因

本质上是golang运行时的单调时钟和物理世界的墙上时钟不一致导致的。

当golang的单调时钟跑过1s后,会获取墙上时钟并塞到ticker.C中,上述两个操作并不同时,也不是同一个时间。

【单调时钟没有单位】

  1. 在单调时钟1000的时候,隔了0,调用了time.Now(),得到了XX:XX:11:9999;
  2. 在单调时钟2000的时候,隔了2,调用了time.Now(),得到了XX:XX:12:0001;
  3. 在单调时钟3000的时候,隔了0,调用了time.Now(),得到了XX:XX:12:9999;
  4. 在单调时钟4000的时候,隔了3,调用了time.Now(),得到了XX:XX:14:0002;

我们对比1,2,发现间隔超过了1s。对比2,3,发现间隔小于1s。

问题代码

func init() {
    // 先设置最初的时间,保证基本的正确性
    now := time.Now()
    updateUnixTimestamp(uint64(now.Unix()))

    var err error
    g, err = NewGenerator()
    if err != nil {
       panic(fmt.Sprintf("init default generator failed. err=%v", err))
    }
    g2, err = NewGenerator()
    if err != nil {
       panic(fmt.Sprintf("init second default generator failed. err=%v", err))
    }
    go func() {
       // sleep到下一秒开始,再创建一个ticker,尽量从某一秒的开始
       time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second)))
       tk := time.NewTicker(time.Second)
       // ticker的更新会从下一秒开始,当前的这一秒还是需要立刻更新
       now = time.Now()
       updateUnixTimestamp(uint64(now.Unix()))
       for {
          // 拿到这个ticker chan返回的时间
          now = <-tk.C
          updateUnixTimestamp(uint64(now.Unix()))
       }
    }()
}

// 为所有的generator设置时间counter
func updateUnixTimestamp(timestamp uint64) {
    atomic.StoreUint64(&gUnixTimestamp, timestamp)
    tsHigh := timestamp << 32
    gGeneratorsMutex.Lock()
    for i := range gGenerators {
       atomic.StoreUint64(&gGenerators[i].timestampCounter, tsHigh)
    }
    gGeneratorsMutex.Unlock()
}

func (i *Generator) NextUint64() uint64 {
    c := atomic.AddUint64(&i.timestampCounter, 1)
    return (c & high32) | ((c & low16) << 16) | i.Config.workerID16
}

根因分析

从上面的方法中可以看出,这个id生成依赖了秒级时间戳和机器码,机器码我们已经排查了不会重复,那么最可能得原因就是时间戳重复导致,起初我们怀疑是ntp服务问题导致的时间回退,但是排查后发现ntp并没有问题,我们把怀疑的方向转向go的timer实现,下面我们来看go的timer实现

timer实现

目前线上的服务使用的是Go 1.20版本,我们看下go 1.20版本的go ticker如何触发运行的,这里不会展示完整的timer实现链,如果想了解timer整体实现可以参考 深入解析go Timer 和Ticker实现原理

NewTicker

我们先看下ticker初始化,重点关注sendTime(也就是后续的f)

可以看到ticker是触发sendTime时才去获得的最新时间,并尝试塞给了channel,如果channel满了则丢弃

startTimer这个实现不再展示(使用的是runtime包的startTimer),大致逻辑是把这个timer绑定到proccesser上,并放到这个processer的timer堆中相应的位置上

func NewTicker(d Duration) *Ticker {
    if d <= 0 {
       panic(errors.New("non-positive interval for NewTicker"))
    }
    // Give the channel a 1-element time buffer.
    // If the client falls behind while reading, we drop ticks
    // on the floor until the client catches up.
    c := make(chan Time, 1)
    t := &Ticker{
       C: c,
       r: runtimeTimer{
          when:   when(d),
          period: int64(d),
          f:      sendTime,
          arg:    c,
       },
    }
    startTimer(&t.r)
    return t
}

// sendTime does a non-blocking send of the current time on c.
func sendTime(c any, seq uintptr) {
    select {
    case c.(chan Time) <- Now():
    default:
    }
}

runtimer

我们看下1.20的go如何运行的timer

func runtimer(pp *p, now int64) int64 {
    for {
       t := pp.timers[0]
       if t.pp.ptr() != pp {
          throw("runtimer: bad p")
       }
       switch s := t.status.Load(); s {
       case timerWaiting:
          if t.when > now {
             // Not ready to run.
             return t.when
          }

          if !t.status.CompareAndSwap(s, timerRunning) {
             continue
          }
          // 重点就是这个方法
          runOneTimer(pp, t, now)
          return 0

       case timerDeleted:
       // 下面的逻辑对这个问题没有影响 忽略
          .....
    }
}

func runOneTimer(pp *p, t *timer, now int64) {
    f := t.f
    arg := t.arg
    seq := t.seq

    if t.period > 0 {
       // 对于ticker 会先设置下次运行的时间,然后重新触发堆排序
       delta := t.when - now // t.when 一定小于等于 now,所以delta是个负数
       // 整数除整数,得到的还是整数。
       // delta一般会比t.period小特别多 (在1s的ticker下,t.period也已经是10^6了)
       // 所以这个除法的结果大概率是0,所以这里的加减不太影响 t.when 
       t.when += t.period * (1 + -delta/t.period) 
       if t.when < 0 { // check for overflow.
          t.when = maxWhen
}
       siftdownTimer(pp.timers, 0)
       if !t.status.CompareAndSwap(timerRunning, timerWaiting) {
          badTimer()
       }
       updateTimer0When(pp)
    } else {
       // Remove from heap.
       dodeltimer0(pp)
       if !t.status.CompareAndSwap(timerRunning, timerNoStatus) {
          badTimer()
       }
    }

    unlock(&pp.timersLock)
    // 触发sendTimer
    f(arg, seq)

    lock(&pp.timersLock)
}

从上面的代码其实就可以看到问题了,下次触发的时间和sendTime拿到的时间不是一致的,也就是说如果unlock或者其他操作执行的较慢,那很可能sendTime这次拿到的时间是比预期晚,而下次拿到的时间比预期早,正好这个id生成器尽量从整秒开始,当出现上面描述的情况就会出现两次在同一秒的情况,导致id重复,同时当go调度器较忙时,可能触发runtimer的时间比预期晚,这个时候相当于返回的时间大于1s了,很可能又把之前小于1s的误差追平了,这个时候如果再出现小于1s的情况,可能又会触发id重复。所以日志中会看到多次出现id重复问题

验证

我们写一个很简单的ticker

package main

import (
    "fmt"
    "time"
)

func main() {
    // 为了更容易复现问题,这里尽量从接近整秒但不足整秒开始
    time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(999099999 * time.Nanosecond)))
    tick := time.NewTicker(1 * time.Second)
    for i := 0; i < 5; i++ {
       c := <-tick.C
       fmt.Println("tick", i, ":", c.Format(time.StampNano))
    }
}

然后修改sendTime方法,我们记录下上次触发的时间戳,然后和这次的时间戳比较

var pre int64

// sendTime does a non-blocking send of the current time on c.
func sendTime(c any, seq uintptr) {
    var n = runtimeNano()
    println("send", n-pre)
    pre = n
    select {
    case c.(chan Time) <- Now():
    default:
    }
}

测试结果

可以看到这个sendTime的间隔先是不足1s后又超过1s,5次ticker中出现了2次落到同一秒的情况

观察上述的输出,和【简述根因】中的推演结果一致。结论成立。