對不起,我錯了,這程式碼不好寫

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

前幾天不是寫了這篇文章《發現一個開源項目優化點,點進來就是你的了》嘛。

文章介紹了Sentinl的自適應快取時間戳演算法,從原理到實現都手把手解讀了,而且還發現Sentinel-Go還未實現這個自適應演算法,於是我就覺得,這簡單啊,把Java程式碼翻譯成Go不就可以混個PR?

甚至在文章初稿中把這個描述為:「有手就可以」,感覺不太妥當,後來被我刪掉了。

過了幾天,我想去看看有沒有人看了我的文章真的去提了個PR,發現仍然是沒有,心想,可能是大家太忙(懶)了吧。

於是準備自己來實現一遍,周末我拿出電腦試著寫一下這段程式碼,結果被當頭一棒敲醒,原來這程式碼不好寫啊。

image

如何實現

先簡單介紹一下我當時是如何實現的。

首先,定義了系統的四種狀態:

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!

image

啥意思呢?循環依賴了!

我們的時間戳獲取方法在包util中,然後我們使用的統計QPS相關的實現在base包中,util包依賴了base包,這個很好理解,反之,base包也依賴了util包,base包主要也使用了CurrentTimeMillis方法來獲取當前時間戳,我這裡截個圖,但不止這些,有好幾個地方都使用到了:

image

但我寫程式碼時是特地繞開了循環依賴,也就是util中調用base包中的方法是不會反向依賴回來形成環的,為此還單獨寫了個方法:

image

使用新方法,就不會形成依賴環。但實際上編譯還是通過不了,這是因為Go在編譯時就直接禁止了循環依賴。

那我就好奇了啊,Java是怎麼實現的?

這是com.alibaba.csp.sentinel.util

image

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

image

Java也出現了循環依賴,但它沒事!

這瞬間勾起了我的興趣,如果我讓它運行時形成依賴環,會怎麼樣呢?

簡單做個測試,搞兩個包,互相調用,比如pk1pk2code方法都調用對方:

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,編譯時就直接報錯。

image

再比如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
}

作者這麼寫的目的是為了在測試的時候,可以靈活地替換真實實現

image

實際使用時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包內只留一個介面。

image

注入真實的realClock的地方也不能放在util的初始化中,也得放在util包外(比如Sentinel初始化的地方),這樣一來,util就不再直接依賴base了。

image

這樣一改造,編譯就能通過了,當然這程式碼只是個示意,還需要精雕細琢。

最後

我們發現就算給你現成的程式碼,抄起來也是比較難的,有點類似「腦子會了,但手不會」的尷尬境地。

同時每個程式語言都有自己的風格,也就是我們通常說的,Go程式碼要寫得更「Go」一點,所以語言不止是一個工具這麼簡單,它的背後也存在著自己的思考方式。

本文其實是從一個案例分享了如何解決Go的循環依賴問題,以及一些和Java對比的思考,更偏向程式碼工程。

如果你覺得還不過癮,也可以看看這篇文章,也是關於程式碼工程的:

看完,記得點個關注在看哦,這樣我才有動力持續輸出優質技術文章 ~ 我們下期再見吧。


  • 搜索關注微信公眾號”捉蟲大師”,後端技術分享,架構設計、性能優化、源碼閱讀、問題排查、踩坑實踐。

image