背景
技术方案
- unix时间戳(毫秒)+ 00-99计数器(需保持原子性)
计数器原子性选型
- 采用数据库锁:mysql采用myisam引擎,单列自增,到100后重置为1继续自增
- 采用redis原子性锁:redis的incr和decr自带原子性
实施(采用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

- 出现大量的重复数值,仔细观察尾部。
- 出现在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加解锁设计;看看如何优化慢协程
- 其实每种开源组件都有其优势和劣势,充分运用其优势,换个维度避其劣势会更微妙