解决go使用redis原子计数器中出现的并发问题

403 阅读3分钟

背景

  • 业务线需要生成定长计数器,用于各种业务维度使用

技术方案

  • unix时间戳(毫秒)+ 00-99计数器(需保持原子性)

计数器原子性选型

  • 采用数据库锁:mysql采用myisam引擎,单列自增,到100后重置为1继续自增
  • 采用redis原子性锁:redis的incr和decr自带原子性

实施(采用redis原子性锁)

//采用go语言开启10个协程,每个协程生成1w个计数器,计数器锁采用redis原子性保证
func Test_Count(t *testing.T) {
   redisCli := utility.InitClient(ctx)
   for i := 0; i < 10; i++ {
      wg.Add(1)
      go func(ctx context.Context) {
         for i := 0; i < 10000; i++ {
            id := redisCli.Incr(ctx, "total")
            if id == -1 {
               g.Log().Errorf(ctx, "处理redis报错%v", id)
               return
            }
            if id > 99 {
               cl.Del(ctx, "total")
               id = redisCli.Incr(ctx, "total")
            }
            ids := gtime.TimestampMilliStr() + gconv.String(id)
            g.Log().Infof(ctx, "id=%v", ids)
         }
         defer wg.Done()
      }(ctx)
   }
   wg.Wait()
}

对日志进行分析

awk '{print $4}' log/2023-03-07.log|sort -nr|uniq -c|sort -nr|head

image.png

  • 出现大量的重复数值,仔细观察尾部。
  • 出现在1附近很多,说明在99重置1的过程中出现了并发问题,del操作非原子性

优化思路

  • 加锁解锁:采用redis的加锁机制(不建议),高频00-99加解锁影响性能
  • 运算机制:仅需要00-99后两位数字,id%100取尾数即可,需要运行前清理计数器(避免过大)
cl := utility.InitClient(ctx)
cl.Del(ctx, "total")
for i := 0; i < 10; i++ {
   wg.Add(1)
   go func(ctx context.Context) {
      for i := 0; i < 10000; i++ {
         id := cl.Incr(ctx, "total") % 100
         if id == -1 {
            g.Log().Errorf(ctx, "处理redis报错%v", id)
            return
         }
         timeUrl := gtime.TimestampMilliStr() + gconv.String(id)
         g.Log().Infof(ctx, "id=%v", timeUrl)
      }
      defer wg.Done()
   }(ctx)
}
wg.Wait()

进行压测

  • go启用5个协程压测,使用10w数据量压测3次会出现1次计数器重复情况,现象如下
2 id=16781716988081
1 id=16781716988082
1 id=16781716988083
  • 16781716988081 出现了2次,日志增加计数器相关时间消耗明细
时间                       计数器产生业务号 计数器 消耗时间(毫秒)
2023-03-07 17:20:21.640   16781716988081 18740 61
2023-03-07 17:20:21.731   16781716988081 18840 3 
  • 因为一共开了5个协程,原来是go的1个协程使用的redis计数器是18740处理速度太慢消耗61毫秒,
  • 另外1个协程使用的redis计数器是18840处理快消耗3毫秒,造成取余%100出现了同1秒计数器碰撞了40

优化思路

  • 扩大计数器产生累加边界,避免慢协程出现碰撞
  • 去掉unix时间戳头部1位即可
 unix时间戳(毫秒+去掉头部1位)+ 000-999计数器(需保持原子性)
  • 再次压测,慢协程统计后99%在10毫秒内处理完成,1%在80毫秒内完成,0-999范围无法再出现碰撞

总结

  • 整个过程遇到2个问题:原子性计数器边界重置并发问题;慢协程造成的计数器碰撞问题
  • 往往研发思考惯性会深陷其中:看看如何进行redis加解锁设计;看看如何优化慢协程
  • 其实每种开源组件都有其优势和劣势,充分运用其优势,换个维度避其劣势会更微妙