hello,大家好呀,我是小樓。
前幾天不是寫了這篇文章《發現一個開源專案優化點,點進來就是你的了》嘛。
文章介紹了Sentinl的自適應快取時間戳演算法,從原理到實現都手把手解讀了,而且還發現Sentinel-Go還未實現這個自適應演算法,於是我就覺得,這簡單啊,把Java程式碼翻譯成Go不就可以混個PR?
甚至在文章初稿中把這個描述為:「有手就可以」,感覺不太妥當,後來被我刪掉了。
過了幾天,我想去看看有沒有人看了我的文章真的去提了個PR,發現仍然是沒有,心想,可能是大家太忙(懶)了吧。
於是準備自己來實現一遍,週末我拿出電腦試著寫一下這段程式碼,結果被當頭一棒敲醒,原來這程式碼不好寫啊。
如何實現
先簡單介紹一下我當時是如何實現的。
首先,定義了系統的四種狀態:
const (
UNINITIALIZED = iota
IDLE
PREPARE
RUNNING
)
這裡為了讓程式碼更加貼近Go的習慣,用了iota
。
用了4種狀態,第一個狀態UNINITIALIZED
是Java版裡沒有的,因為Java在系統初始化時預設就啟動了定時快取時間戳執行緒。
但Go版本不是這樣的,它有個開關,當開關開啟時,會呼叫StartTimeTicker
來啟動快取時間戳的協程,所以當沒有初始化時是需要直接返回系統時間戳,所以這裡多了一個UNINITIALIZED
狀態。
然後我們需要能夠統計QPS的方法,這塊直接抄Java的實現,由於不是重點,但又怕你不理解,所以直接貼一點程式碼,不想看可以往下劃。
定義我們需要的BucketWrap:
type statistic struct {
reads uint64
writes uint64
}
func (s *statistic) NewEmptyBucket() interface{} {
return statistic{
reads: 0,
writes: 0,
}
}
func (s *statistic) ResetBucketTo(bucket *base.BucketWrap, startTime uint64) *base.BucketWrap {
atomic.StoreUint64(&bucket.BucketStart, startTime)
bucket.Value.Store(statistic{
reads: 0,
writes: 0,
})
return bucket
}
獲取當前的Bucket:
func currentCounter(now uint64) (*statistic, error) {
if statistics == nil {
return nil, fmt.Errorf("statistics is nil")
}
bk, err := statistics.CurrentBucketOfTime(now, bucketGenerator)
if err != nil {
return nil, err
}
if bk == nil {
return nil, fmt.Errorf("current bucket is nil")
}
v := bk.Value.Load()
if v == nil {
return nil, fmt.Errorf("current bucket value is nil")
}
counter, ok := v.(*statistic)
if !ok {
return nil, fmt.Errorf("bucket fail to do type assert, expect: *statistic, in fact: %s", reflect.TypeOf(v).Name())
}
return counter, nil
}
獲取當前的QPS:
func currentQps(now uint64) (uint64, uint64) {
if statistics == nil {
return 0, 0
}
list := statistics.ValuesConditional(now, func(ws uint64) bool {
return ws <= now && now < ws+uint64(bucketLengthInMs)
})
var reads, writes, cnt uint64
for _, w := range list {
if w == nil {
continue
}
v := w.Value.Load()
if v == nil {
continue
}
s, ok := v.(*statistic)
if !ok {
continue
}
cnt++
reads += s.reads
writes += s.writes
}
if cnt < 1 {
return 0, 0
}
return reads / cnt, writes / cnt
}
當我們有了這些準備後,來寫核心的check邏輯:
func check() {
now := CurrentTimeMillsWithTicker(true)
if now-lastCheck < checkInterval {
return
}
lastCheck = now
qps, tps := currentQps(now)
if state == IDLE && qps > hitsUpperBoundary {
logging.Warn("[time_ticker check] switches to PREPARE for better performance", "reads", qps, "writes", tps)
state = PREPARE
} else if state == RUNNING && qps < hitsLowerBoundary {
logging.Warn("[time_ticker check] switches to IDLE due to not enough load", "reads", qps, "writes", tps)
state = IDLE
}
}
最後是呼叫check的地方:
func StartTimeTicker() {
var err error
statistics, err = base.NewLeapArray(sampleCount, intervalInMs, bucketGenerator)
if err != nil {
logging.Warn("[time_ticker StartTimeTicker] new leap array failed", "error", err.Error())
}
atomic.StoreUint64(&nowInMs, uint64(time.Now().UnixNano())/unixTimeUnitOffset)
state = IDLE
go func() {
for {
check()
if state == RUNNING {
now := uint64(time.Now().UnixNano()) / unixTimeUnitOffset
atomic.StoreUint64(&nowInMs, now)
counter, err := currentCounter(now)
if err != nil && counter != nil {
atomic.AddUint64(&counter.writes, 1)
}
time.Sleep(time.Millisecond)
continue
}
if state == IDLE {
time.Sleep(300 * time.Millisecond)
continue
}
if state == PREPARE {
now := uint64(time.Now().UnixNano()) / unixTimeUnitOffset
atomic.StoreUint64(&nowInMs, now)
state = RUNNING
continue
}
}
}()
}
自此,我們就實(抄)現(完)了自適應的快取時間戳演算法。
測試一下
先編譯一下,咚,報錯了: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也出現了迴圈依賴,但它沒事!
這瞬間勾起了我的興趣,如果我讓它執行時形成依賴環,會怎麼樣呢?
簡單做個測試,搞兩個包,互相呼叫,比如pk1
和pk2
的code
方法都呼叫對方:
package org.newboo.pk1;
import org.newboo.pk2.Test2;
public class Test1 {
public static int code() {
return Test2.code();
}
public static void main(String[] args) {
System.out.println(code());
}
}
編譯可以通過,但執行報錯棧溢位了:
Exception in thread "main" java.lang.StackOverflowError
at org.newboo.pk1.Test1.code(Test1.java:7)
at org.newboo.pk2.Test2.code(Test2.java:7)
...
這麼看來是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就不互相依賴了。
---- util
---- ---- common
---- base
---- ---- common
這個方法也是最常見,最正規的方法。
但在這裡,似乎也不好操作。因為獲取時間戳這個方法已經非常底層了,沒辦法抽出一個和統計QPS共用的方法,反正我是沒能想出來,如果有讀者朋友可以做到,歡迎私聊我,真心求教。
花了很多時間,還是沒能搞定。當時的感覺是,這下翻車了,這題可沒那麼簡單啊!
方法三
這個方法比較難想到,我也是在前兩個方法怎麼都搞不定的情況下諮詢了組裡的Go大佬才知道。
仔細看獲取時間戳的程式碼:
// Returns the current Unix timestamp in milliseconds.
func CurrentTimeMillis() uint64 {
return CurrentClock().CurrentTimeMillis()
}
這裡的CurrentClock()
是什麼?其實是返回了一個Clock
介面的實現
type Clock interface {
Now() time.Time
Sleep(d time.Duration)
CurrentTimeMillis() uint64
CurrentTimeNano() uint64
}
作者這麼寫的目的是為了在測試的時候,可以靈活地替換真實實現
實際使用時RealClock,也就是呼叫了我們正在調優的時間戳獲取;MockClock則是測試時使用的。
這個實現是什麼時候注入的呢?
func init() {
realClock := NewRealClock()
currentClock = new(atomic.Value)
SetClock(realClock)
realTickerCreator := NewRealTickerCreator()
currentTickerCreator = new(atomic.Value)
SetTickerCreator(realTickerCreator)
}
在util初始化時,就寫死注入了realClock。
這麼一細說,是不是對迴圈依賴的解決有點眉目了?
我們的realClock實際上依賴了base,但這個realClock可以放在util包外,util包內只留一個介面。
注入真實的realClock的地方也不能放在util的初始化中,也得放在util包外(比如Sentinel初始化的地方),這樣一來,util就不再直接依賴base了。
這樣一改造,編譯就能通過了,當然這程式碼只是個示意,還需要精雕細琢。
最後
我們發現就算給你現成的程式碼,抄起來也是比較難的,有點類似「腦子會了,但手不會」的尷尬境地。
同時每個程式語言都有自己的風格,也就是我們通常說的,Go程式碼要寫得更「Go」一點,所以語言不止是一個工具這麼簡單,它的背後也存在著自己的思考方式。
本文其實是從一個案例分享瞭如何解決Go的迴圈依賴問題,以及一些和Java對比的思考,更偏向程式碼工程。
如果你覺得還不過癮,也可以看看這篇文章,也是關於程式碼工程的:
看完,記得點個關注
、贊
、在看
哦,這樣我才有動力持續輸出優質技術文章 ~ 我們下期再見吧。
- 搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。