Go 語(yǔ)言 灰度發(fā)布和 A/B 測(cè)試

2023-03-22 15:04 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-09-gated-launch.html


5.9 灰度發(fā)布和 A/B test

中型的互聯(lián)網(wǎng)公司往往有著以百萬(wàn)計(jì)的用戶,而大型互聯(lián)網(wǎng)公司的系統(tǒng)則可能要服務(wù)千萬(wàn)級(jí)甚至億級(jí)的用戶需求。大型系統(tǒng)的請(qǐng)求流入往往是源源不斷的,任何風(fēng)吹草動(dòng),都一定會(huì)有最終用戶感受得到。例如你的系統(tǒng)在上線途中會(huì)拒絕一些上游過來的請(qǐng)求,而這時(shí)候依賴你的系統(tǒng)沒有做任何容錯(cuò),那么這個(gè)錯(cuò)誤就會(huì)一直向上拋出,直到觸達(dá)最終用戶。形成一次對(duì)用戶切切實(shí)實(shí)的傷害。這種傷害可能是在用戶的 APP 上彈出一個(gè)讓用戶摸不著頭腦的詭異字符串,用戶只要刷新一下頁(yè)面就可以忘記這件事。但也可能會(huì)讓正在心急如焚地和幾萬(wàn)競(jìng)爭(zhēng)對(duì)手同時(shí)搶奪秒殺商品的用戶,因?yàn)榇a上的小問題,喪失掉了先發(fā)優(yōu)勢(shì),與自己蹲了幾個(gè)月的心儀產(chǎn)品失之交臂。對(duì)用戶的傷害有多大,取決于你的系統(tǒng)對(duì)于你的用戶來說有多重要。

不管怎么說,在大型系統(tǒng)中容錯(cuò)是重要的,能夠讓系統(tǒng)按百分比,分批次到達(dá)最終用戶,也是很重要的。雖然當(dāng)今的互聯(lián)網(wǎng)公司系統(tǒng),名義上會(huì)說自己上線前都經(jīng)過了充分慎重嚴(yán)格的測(cè)試,但就算它們真得做到了,代碼的 bug 總是在所難免的。即使代碼沒有 bug,分布式服務(wù)之間的協(xié)作也是可能出現(xiàn) “邏輯” 上的非技術(shù)問題的。

這時(shí)候,灰度發(fā)布就顯得非常重要了,灰度發(fā)布也稱為金絲雀發(fā)布,傳說 17 世紀(jì)的英國(guó)礦井工人發(fā)現(xiàn)金絲雀對(duì)瓦斯氣體非常敏感,瓦斯達(dá)到一定濃度時(shí),金絲雀即會(huì)死亡,但金絲雀的致死量瓦斯對(duì)人并不致死,因此金絲雀被用來當(dāng)成他們的瓦斯檢測(cè)工具。互聯(lián)網(wǎng)系統(tǒng)的灰度發(fā)布一般通過兩種方式實(shí)現(xiàn):

  1. 通過分批次部署實(shí)現(xiàn)灰度發(fā)布
  2. 通過業(yè)務(wù)規(guī)則進(jìn)行灰度發(fā)布

在對(duì)系統(tǒng)的舊功能進(jìn)行升級(jí)迭代時(shí),第一種方式用的比較多。新功能上線時(shí),第二種方式用的比較多。當(dāng)然,對(duì)比較重要的老功能進(jìn)行較大幅度的修改時(shí),一般也會(huì)選擇按業(yè)務(wù)規(guī)則來進(jìn)行發(fā)布,因?yàn)橹苯尤块_放給所有用戶風(fēng)險(xiǎn)實(shí)在太大。

5.9.1 通過分批次部署實(shí)現(xiàn)灰度發(fā)布

假如服務(wù)部署在 15 個(gè)實(shí)例(可能是物理機(jī),也可能是容器)上,我們把這 15 個(gè)實(shí)例分為四組,按照先后順序,分別有 1-2-4-8 臺(tái)機(jī)器,保證每次擴(kuò)展時(shí)大概都是二倍的關(guān)系。


圖 5-20 分組部署

為什么要用 2 倍?這樣能夠保證我們不管有多少臺(tái)機(jī)器,都不會(huì)把組劃分得太多。例如 1024 臺(tái)機(jī)器,也就只需要 1-2-4-8-16-32-64-128-256-512 部署十次就可以全部部署完畢。

這樣我們上線最開始影響到的用戶在整體用戶中占的比例也不大,比如 1000 臺(tái)機(jī)器的服務(wù),我們上線后如果出現(xiàn)問題,也只影響 1/1000 的用戶。如果 10 組完全平均分,那一上線立刻就會(huì)影響 1/10 的用戶,1/10 的業(yè)務(wù)出問題,那可能對(duì)于公司來說就已經(jīng)是一場(chǎng)不可挽回的事故了。

在上線時(shí),最有效的觀察手法是查看程序的錯(cuò)誤日志,如果較明顯的邏輯錯(cuò)誤,一般錯(cuò)誤日志的滾動(dòng)速度都會(huì)有肉眼可見的增加。這些錯(cuò)誤也可以通過 metrics 一類的系統(tǒng)上報(bào)給公司內(nèi)的監(jiān)控系統(tǒng),所以在上線過程中,也可以通過觀察監(jiān)控曲線,來判斷是否有異常發(fā)生。

如果有異常情況,首先要做的自然就是回滾了。

5.9.2 通過業(yè)務(wù)規(guī)則進(jìn)行灰度發(fā)布

常見的灰度策略有多種,較為簡(jiǎn)單的需求,例如我們的策略是要按照千分比來發(fā)布,那么我們可以用用戶 id、手機(jī)號(hào)、用戶設(shè)備信息,等等,來生成一個(gè)簡(jiǎn)單的哈希值,然后再求模,用偽代碼表示一下:

// pass 3/1000
func passed() bool {
    key := hashFunctions(userID) % 1000
    if key <= 2 {
        return true
    }

    return false
}

5.9.2.1 可選規(guī)則

常見的灰度發(fā)布系統(tǒng)會(huì)有下列規(guī)則提供選擇:

  1. 按城市發(fā)布
  2. 按概率發(fā)布
  3. 按百分比發(fā)布
  4. 按白名單發(fā)布
  5. 按業(yè)務(wù)線發(fā)布
  6. 按 UA 發(fā)布 (APP、Web、PC)
  7. 按分發(fā)渠道發(fā)布

因?yàn)楹凸镜臉I(yè)務(wù)相關(guān),所以城市、業(yè)務(wù)線、UA、分發(fā)渠道這些都可能會(huì)被直接編碼在系統(tǒng)里,不過功能其實(shí)大同小異。

按白名單發(fā)布比較簡(jiǎn)單,功能上線時(shí),可能我們希望只有公司內(nèi)部的員工和測(cè)試人員可以訪問到新功能,會(huì)直接把賬號(hào)、郵箱寫入到白名單,拒絕其它任何賬號(hào)的訪問。

按概率發(fā)布則是指實(shí)現(xiàn)一個(gè)簡(jiǎn)單的函數(shù):

func isTrue() bool {
    return true/false according to the rate provided by user
}

其可以按照用戶指定的概率返回 true 或者 false,當(dāng)然,true 的概率加 false 的概率應(yīng)該是 100%。這個(gè)函數(shù)不需要任何輸入。

按百分比發(fā)布,是指實(shí)現(xiàn)下面這樣的函數(shù):

func isTrue(phone string) bool {
    if hash of phone matches {
        return true
    }

    return false
}

這種情況可以按照指定的百分比,返回對(duì)應(yīng)的 true 和 false,和上面的單純按照概率的區(qū)別是這里我們需要調(diào)用方提供給我們一個(gè)輸入?yún)?shù),我們以該輸入?yún)?shù)作為源來計(jì)算哈希,并以哈希后的結(jié)果來求模,并返回結(jié)果。這樣可以保證同一個(gè)用戶的返回結(jié)果多次調(diào)用是一致的,在下面這種場(chǎng)景下,必須使用這種結(jié)果可預(yù)期的灰度算法,見 圖 5-21 所示。


圖 5-21 先 set 然后馬上 get

如果采用隨機(jī)策略,可能會(huì)出現(xiàn)像 圖 5-22 這樣的問題:


圖 5-22 先 set 然后馬上 get

舉個(gè)具體的例子,網(wǎng)站的注冊(cè)環(huán)節(jié),可能有兩套 API,按照用戶 ID 進(jìn)行灰度,分別是不同的存取邏輯。如果存儲(chǔ)時(shí)使用了 V1 版本的 API 而獲取時(shí)使用 V2 版本的 API,那么就可能出現(xiàn)用戶注冊(cè)成功后反而返回注冊(cè)失敗消息的詭異問題。

5.9.3 如何實(shí)現(xiàn)一套灰度發(fā)布系統(tǒng)

前面也提到了,提供給用戶的接口大概可以分為和業(yè)務(wù)綁定的簡(jiǎn)單灰度判斷邏輯。以及輸入稍微復(fù)雜一些的哈?;叶?。我們來分別看看怎么實(shí)現(xiàn)這樣的灰度系統(tǒng)(函數(shù))。

5.9.3.1 業(yè)務(wù)相關(guān)的簡(jiǎn)單灰度

公司內(nèi)一般都會(huì)有公共的城市名字和 id 的映射關(guān)系,如果業(yè)務(wù)只涉及中國(guó)國(guó)內(nèi),那么城市數(shù)量不會(huì)特別多,且 id 可能都在 10000 范圍以內(nèi)。那么我們只要開辟一個(gè)一萬(wàn)大小左右的 bool 數(shù)組,就可以滿足需求了:

var cityID2Open = [12000]bool{}

func init() {
    readConfig()
    for i:=0;i<len(cityID2Open);i++ {
        if city i is opened in configs {
            cityID2Open[i] = true
        }
    }
}

func isPassed(cityID int) bool {
    return cityID2Open[cityID]
}

如果公司給 cityID 賦的值比較大,那么我們可以考慮用 map 來存儲(chǔ)映射關(guān)系,map 的查詢比數(shù)組稍慢,但擴(kuò)展會(huì)靈活一些:

var cityID2Open = map[int]struct{}{}

func init() {
    readConfig()
    for _, city := range openCities {
        cityID2Open[city] = struct{}{}
    }
}

func isPassed(cityID int) bool {
    if _, ok := cityID2Open[cityID]; ok {
        return true
    }

    return false
}

按白名單、按業(yè)務(wù)線、按 UA、按分發(fā)渠道發(fā)布,本質(zhì)上和按城市發(fā)布是一樣的,這里就不再贅述了。

按概率發(fā)布稍微特殊一些,不過不考慮輸入實(shí)現(xiàn)起來也很簡(jiǎn)單:


func init() {
    rand.Seed(time.Now().UnixNano())
}

// rate 為 0~100
func isPassed(rate int) bool {
    if rate >= 100 {
        return true
    }

    if rate > 0 && rand.Int(100) > rate {
        return true
    }

    return false
}

注意初始化種子。

5.9.3.2 哈希算法

求哈??捎玫乃惴ǚ浅6?,比如 md5,crc32,sha1 等等,但我們這里的目的只是為了給這些數(shù)據(jù)做個(gè)映射,并不想要因?yàn)橛?jì)算哈希消耗過多的 cpu,所以現(xiàn)在業(yè)界使用較多的算法是 murmurhash,下面是我們對(duì)這些常見的 hash 算法的簡(jiǎn)單 benchmark。

下面使用了標(biāo)準(zhǔn)庫(kù)的 md5,sha1 和開源的 murmur3 實(shí)現(xiàn)來進(jìn)行對(duì)比。

package main

import (
    "crypto/md5"
    "crypto/sha1"

    "github.com/spaolacci/murmur3"
)

var str = "hello world"

func md5Hash() [16]byte {
    return md5.Sum([]byte(str))
}

func sha1Hash() [20]byte {
    return sha1.Sum([]byte(str))
}

func murmur32() uint32 {
    return murmur3.Sum32([]byte(str))
}

func murmur64() uint64 {
    return murmur3.Sum64([]byte(str))
}

為這些算法寫一個(gè)基準(zhǔn)測(cè)試:

package main

import "testing"

func BenchmarkMD5(b *testing.B) {
    for i := 0; i < b.N; i++ {
        md5Hash()
    }
}

func BenchmarkSHA1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sha1Hash()
    }
}

func BenchmarkMurmurHash32(b *testing.B) {
    for i := 0; i < b.N; i++ {
        murmur32()
    }
}

func BenchmarkMurmurHash64(b *testing.B) {
    for i := 0; i < b.N; i++ {
        murmur64()
    }
}

然后看看運(yùn)行效果:

~/t/g/hash_bench git:master ??? go test -bench=.
goos: darwin
goarch: amd64
BenchmarkMD5-4          10000000 180 ns/op
BenchmarkSHA1-4         10000000 211 ns/op
BenchmarkMurmurHash32-4 50000000  25.7 ns/op
BenchmarkMurmurHash64-4 20000000  66.2 ns/op
PASS
ok _/Users/caochunhui/test/go/hash_bench 7.050s

可見 murmurhash 相比其它的算法有三倍以上的性能提升。顯然做負(fù)載均衡的話,用 murmurhash 要比 md5 和 sha1 都要好,這些年社區(qū)里還有另外一些更高效的哈希算法涌現(xiàn),感興趣的讀者可以自行調(diào)研。

5.9.3.3 分布是否均勻

對(duì)于哈希算法來說,除了性能方面的問題,還要考慮哈希后的值是否分布均勻。如果哈希后的值分布不均勻,那也自然就起不到均勻灰度的效果了。

以 murmur3 為例,我們先以 15810000000 開頭,造一千萬(wàn)個(gè)和手機(jī)號(hào)類似的數(shù)字,然后將計(jì)算后的哈希值分十個(gè)桶,并觀察計(jì)數(shù)是否均勻:

package main

import (
    "fmt"

    "github.com/spaolacci/murmur3"
)

var bucketSize = 10

func main() {
    var bucketMap = map[uint64]int{}
    for i := 15000000000; i < 15000000000+10000000; i++ {
        hashInt := murmur64(fmt.Sprint(i)) % uint64(bucketSize)
        bucketMap[hashInt]++
    }
    fmt.Println(bucketMap)
}

func murmur64(p string) uint64 {
    return murmur3.Sum64([]byte(p))
}

看看執(zhí)行結(jié)果:

map[7:999475 5:1000359 1:999945 6:1000200 3:1000193 9:1000765 2:1000044 \
4:1000343 8:1000823 0:997853]

偏差都在 1/100 以內(nèi),可以接受。讀者在調(diào)研其它算法,并判斷是否可以用來做灰度發(fā)布時(shí),也應(yīng)該從本節(jié)中提到的性能和均衡度兩方面出發(fā),對(duì)其進(jìn)行考察。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)