对不起,我错了,这代码不好写

hello,大家好呀,我是小楼。

前几天不是写了这篇文章《发现一个开源项目优化点,点进来就是你的了》嘛。

文章介绍了Sentinl的自适应缓存时间戳算法,从原理到实现都手把手解读了,而且还发现Sentinel-Go还未实现这个自适应算法,于是我就觉得,这简单啊,把Java代码翻译成Go不就可以混个PR?

甚至在文章初稿中把这个描述为:「有手就可以」,感觉不太妥当,后来被我删掉了。

过了几天,我想去看看有没有人看了我的文章真的去提了个PR,发现仍然是没有,心想,可能是大家太忙(懒)了吧。

于是准备自己来实现一遍,周末我拿出电脑试着写一下这段代码,结果被当头一棒敲醒,原来这代码不好写啊。

如何实现

先简单介绍一下我当时是如何实现的。

首先,定义了系统的四种状态:

1const ( 2 UNINITIALIZED = iota 3 IDLE 4 PREPARE 5 RUNNING 6)

这里为了让代码更加贴近Go的习惯,用了iota

用了4种状态,第一个状态UNINITIALIZED是Java版里没有的,因为Java在系统初始化时默认就启动了定时缓存时间戳线程。

但Go版本不是这样的,它有个开关,当开关开启时,会调用StartTimeTicker来启动缓存时间戳的协程,所以当没有初始化时是需要直接返回系统时间戳,所以这里多了一个UNINITIALIZED状态。

然后我们需要能够统计QPS的方法,这块直接抄Java的实现,由于不是重点,但又怕你不理解,所以直接贴一点代码,不想看可以往下划。

定义我们需要的BucketWrap:

1type statistic struct { 2 reads uint64 3 writes uint64 4} 5 6func (s *statistic) NewEmptyBucket() interface{} { 7 return statistic{ 8 reads: 0, 9 writes: 0, 10 } 11} 12 13func (s *statistic) ResetBucketTo(bucket *base.BucketWrap, startTime uint64) *base.BucketWrap { 14 atomic.StoreUint64(&bucket.BucketStart, startTime) 15 bucket.Value.Store(statistic{ 16 reads: 0, 17 writes: 0, 18 }) 19 return bucket 20}

获取当前的Bucket:

1func currentCounter(now uint64) (*statistic, error) { 2 if statistics == nil { 3 return nil, fmt.Errorf("statistics is nil") 4 } 5 6 bk, err := statistics.CurrentBucketOfTime(now, bucketGenerator) 7 if err != nil { 8 return nil, err 9 } 10 if bk == nil { 11 return nil, fmt.Errorf("current bucket is nil") 12 } 13 14 v := bk.Value.Load() 15 if v == nil { 16 return nil, fmt.Errorf("current bucket value is nil") 17 } 18 counter, ok := v.(*statistic) 19 if !ok { 20 return nil, fmt.Errorf("bucket fail to do type assert, expect: *statistic, in fact: %s", reflect.TypeOf(v).Name()) 21 } 22 23 return counter, nil 24}

获取当前的QPS:

1func currentQps(now uint64) (uint64, uint64) { 2 if statistics == nil { 3 return 0, 0 4 } 5 6 list := statistics.ValuesConditional(now, func(ws uint64) bool { 7 return ws <= now && now < ws+uint64(bucketLengthInMs) 8 }) 9 10 var reads, writes, cnt uint64 11 for _, w := range list { 12 if w == nil { 13 continue 14 } 15 16 v := w.Value.Load() 17 if v == nil { 18 continue 19 } 20 21 s, ok := v.(*statistic) 22 if !ok { 23 continue 24 } 25 26 cnt++ 27 reads += s.reads 28 writes += s.writes 29 } 30 31 if cnt < 1 { 32 return 0, 0 33 } 34 35 return reads / cnt, writes / cnt 36}

当我们有了这些准备后,来写核心的check逻辑:

1func check() { 2 now := CurrentTimeMillsWithTicker(true) 3 if now-lastCheck < checkInterval { 4 return 5 } 6 7 lastCheck = now 8 qps, tps := currentQps(now) 9 if state == IDLE && qps > hitsUpperBoundary { 10 logging.Warn("[time_ticker check] switches to PREPARE for better performance", "reads", qps, "writes", tps) 11 state = PREPARE 12 } else if state == RUNNING && qps < hitsLowerBoundary { 13 logging.Warn("[time_ticker check] switches to IDLE due to not enough load", "reads", qps, "writes", tps) 14 state = IDLE 15 } 16}

最后是调用check的地方:

1func StartTimeTicker() { 2 var err error 3 statistics, err = base.NewLeapArray(sampleCount, intervalInMs, bucketGenerator) 4 if err != nil { 5 logging.Warn("[time_ticker StartTimeTicker] new leap array failed", "error", err.Error()) 6 } 7 8 atomic.StoreUint64(&nowInMs, uint64(time.Now().UnixNano())/unixTimeUnitOffset) 9 state = IDLE 10 go func() { 11 for { 12 check() 13 if state == RUNNING { 14 now := uint64(time.Now().UnixNano()) / unixTimeUnitOffset 15 atomic.StoreUint64(&nowInMs, now) 16 counter, err := currentCounter(now) 17 if err != nil && counter != nil { 18 atomic.AddUint64(&counter.writes, 1) 19 } 20 time.Sleep(time.Millisecond) 21 continue 22 } 23 if state == IDLE { 24 time.Sleep(300 * time.Millisecond) 25 continue 26 } 27 if state == PREPARE { 28 now := uint64(time.Now().UnixNano()) / unixTimeUnitOffset 29 atomic.StoreUint64(&nowInMs, now) 30 state = RUNNING 31 continue 32 } 33 } 34 }() 35}

自此,我们就实(抄)现(完)了自适应的缓存时间戳算法。

测试一下

先编译一下,咚,报错了:import cycle not allowed!

啥意思呢?循环依赖了!

我们的时间戳获取方法在包util中,然后我们使用的统计QPS相关的实现在base包中,util包依赖了base包,这个很好理解,反之,base包也依赖了util包,base包主要也使用了CurrentTimeMillis方法来获取当前时间戳,我这里截个图,但不止这些,有好几个地方都使用到了:

但我写代码时是特地绕开了循环依赖,也就是util中调用base包中的方法是不会反向依赖回来形成环的,为此还单独写了个方法:

使用新方法,就不会形成依赖环。但实际上编译还是通过不了,这是因为Go在编译时就直接禁止了循环依赖。

那我就好奇了啊,Java是怎么实现的?

这是com.alibaba.csp.sentinel.util

这是com.alibaba.csp.sentinel.slots.statistic.base

Java也出现了循环依赖,但它没事!

这瞬间勾起了我的兴趣,如果我让它运行时形成依赖环,会怎么样呢?

简单做个测试,搞两个包,互相调用,比如pk1pk2code方法都调用对方:

1package org.newboo.pk1; 2 3import org.newboo.pk2.Test2; 4 5public class Test1 { 6 public static int code() { 7 return Test2.code(); 8 } 9 10 public static void main(String[] args) { 11 System.out.println(code()); 12 } 13}

编译可以通过,但运行报错栈溢出了:

1Exception in thread "main" java.lang.StackOverflowError 2 at org.newboo.pk1.Test1.code(Test1.java:7) 3 at org.newboo.pk2.Test2.code(Test2.java:7) 4 ...

这么看来是Go编译器做了校验,强制不允许循环依赖。

说到这里,其实Java里也有循环依赖校验,比如:Maven不允许循环依赖,比如我在sentinel-core模块中依赖sentinel-benchmark,编译时就直接报错。

再比如SpringBoot2.6.x默认禁用循环依赖,如果想用,还得手动打开才行。

Java中强制禁止的只有maven,语言层面、框架层面基本都没有赶尽杀绝,但Go却在语言层面强制不让使用。

这让我想起了之前在写Go代码时,Go的锁不允许重入,经常写出死锁代码。这搁Java上一点问题都没有,当时我就没想通,为啥Go不支持锁的重入。

现在看来可能的原因:一是Go的设计者有代码洁癖,想强制约束大家都有良好的代码风格;二是由于Go有循环依赖的强制检测,导致锁重入的概率变小。

但这终究是理想状态,往往在实施起来的时候令人痛苦。

反观Java,一开始没有强制禁用循环依赖,导致后面基本不可避免地写出循环依赖的代码,SpringBoot认为这是不好的,但又不能强制,只能默认禁止,但如果你真的需要,也还是可以打开的。

但话又说回来,循环依赖真的「丑陋」吗?我看不一定,仁者见仁,智者见智。

如何解决

问题是这么个问题,可能大家都有不同的观点,或是吐槽Go,或是批判Java,这都不是重点,重点是我们还得在Go的规则下解决问题。

如何解决Go的循环依赖问题呢?稍微查了一下资料,大概有这么几种方法:

方法一

将两个包合成一个,这是最简单的方法,但这里肯定不行,合成一个这个PR铁定过不了。

方法二

抽取公共底层方法,双方都依赖这个底层方法。比如这里,我们把底层方法抽出来作为common,util和base同时依赖它,这样util和base就不互相依赖了。

1---- util 2---- ---- common 3---- base 4---- ---- common

这个方法也是最常见,最正规的方法。

但在这里,似乎也不好操作。因为获取时间戳这个方法已经非常底层了,没办法抽出一个和统计QPS共用的方法,反正我是没能想出来,如果有读者朋友可以做到,欢迎私聊我,真心求教。

花了很多时间,还是没能搞定。当时的感觉是,这下翻车了,这题可没那么简单啊!

方法三

这个方法比较难想到,我也是在前两个方法怎么都搞不定的情况下咨询了组里的Go大佬才知道。

仔细看获取时间戳的代码:

1// Returns the current Unix timestamp in milliseconds. 2func CurrentTimeMillis() uint64 { 3 return CurrentClock().CurrentTimeMillis() 4}

这里的CurrentClock()是什么?其实是返回了一个Clock接口的实现

1type Clock interface { 2 Now() time.Time 3 Sleep(d time.Duration) 4 CurrentTimeMillis() uint64 5 CurrentTimeNano() uint64 6}

作者这么写的目的是为了在测试的时候,可以灵活地替换真实实现

实际使用时RealClock,也就是调用了我们正在调优的时间戳获取;MockClock则是测试时使用的。

这个实现是什么时候注入的呢?

1func init() { 2 realClock := NewRealClock() 3 currentClock = new(atomic.Value) 4 SetClock(realClock) 5 6 realTickerCreator := NewRealTickerCreator() 7 currentTickerCreator = new(atomic.Value) 8 SetTickerCreator(realTickerCreator) 9}

在util初始化时,就写死注入了realClock。

这么一细说,是不是对循环依赖的解决有点眉目了?

我们的realClock实际上依赖了base,但这个realClock可以放在util包外,util包内只留一个接口。

注入真实的realClock的地方也不能放在util的初始化中,也得放在util包外(比如Sentinel初始化的地方),这样一来,util就不再直接依赖base了。

这样一改造,编译就能通过了,当然这代码只是个示意,还需要精雕细琢。

最后

我们发现就算给你现成的代码,抄起来也是比较难的,有点类似「脑子会了,但手不会」的尴尬境地。

同时每个编程语言都有自己的风格,也就是我们通常说的,Go代码要写得更「Go」一点,所以语言不止是一个工具这么简单,它的背后也存在着自己的思考方式。

本文其实是从一个案例分享了如何解决Go的循环依赖问题,以及一些和Java对比的思考,更偏向代码工程。

如果你觉得还不过瘾,也可以看看这篇文章,也是关于代码工程的:

看完,记得点个关注在看哦,这样我才有动力持续输出优质技术文章 ~ 我们下期再见吧。


  • 搜索关注微信公众号"捉虫大师",后端技术分享,架构设计、性能优化、源码阅读、问题排查、踩坑实践。

qrcode_small

点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

MySQL部分从库上面因为大量的临时表tmp_table造成慢查询

背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_

手写Java HashMap源码

HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22

Java日期时间API系列31

  时间戳是指格林威治时间1970年01月01日00时00分00秒起至现在的总毫秒数,是所有时间的基础,其他时间可以通过时间戳转换得到。Java中本来已经有相关获取时间戳的方法,Java8后增加新的类Instant等专用于处理时间戳问题。 1获取时间戳的方法和性能对比1.1获取时间戳方法Java8以前

2020年前端实用代码段,为你的工作保驾护航

有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )

对不起,我错了,这代码不好写 - HelloWorld