原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-09-gated-launch.html
中型的互聯(lián)網(wǎng)公司往往有著以百萬計的用戶,而大型互聯(lián)網(wǎng)公司的系統(tǒng)則可能要服務千萬級甚至億級的用戶需求。大型系統(tǒng)的請求流入往往是源源不斷的,任何風吹草動,都一定會有最終用戶感受得到。例如你的系統(tǒng)在上線途中會拒絕一些上游過來的請求,而這時候依賴你的系統(tǒng)沒有做任何容錯,那么這個錯誤就會一直向上拋出,直到觸達最終用戶。形成一次對用戶切切實實的傷害。這種傷害可能是在用戶的 APP 上彈出一個讓用戶摸不著頭腦的詭異字符串,用戶只要刷新一下頁面就可以忘記這件事。但也可能會讓正在心急如焚地和幾萬競爭對手同時搶奪秒殺商品的用戶,因為代碼上的小問題,喪失掉了先發(fā)優(yōu)勢,與自己蹲了幾個月的心儀產(chǎn)品失之交臂。對用戶的傷害有多大,取決于你的系統(tǒng)對于你的用戶來說有多重要。
不管怎么說,在大型系統(tǒng)中容錯是重要的,能夠讓系統(tǒng)按百分比,分批次到達最終用戶,也是很重要的。雖然當今的互聯(lián)網(wǎng)公司系統(tǒng),名義上會說自己上線前都經(jīng)過了充分慎重嚴格的測試,但就算它們真得做到了,代碼的 bug 總是在所難免的。即使代碼沒有 bug,分布式服務之間的協(xié)作也是可能出現(xiàn) “邏輯” 上的非技術問題的。
這時候,灰度發(fā)布就顯得非常重要了,灰度發(fā)布也稱為金絲雀發(fā)布,傳說 17 世紀的英國礦井工人發(fā)現(xiàn)金絲雀對瓦斯氣體非常敏感,瓦斯達到一定濃度時,金絲雀即會死亡,但金絲雀的致死量瓦斯對人并不致死,因此金絲雀被用來當成他們的瓦斯檢測工具。互聯(lián)網(wǎng)系統(tǒng)的灰度發(fā)布一般通過兩種方式實現(xiàn):
在對系統(tǒng)的舊功能進行升級迭代時,第一種方式用的比較多。新功能上線時,第二種方式用的比較多。當然,對比較重要的老功能進行較大幅度的修改時,一般也會選擇按業(yè)務規(guī)則來進行發(fā)布,因為直接全量開放給所有用戶風險實在太大。
假如服務部署在 15 個實例(可能是物理機,也可能是容器)上,我們把這 15 個實例分為四組,按照先后順序,分別有 1-2-4-8 臺機器,保證每次擴展時大概都是二倍的關系。
圖 5-20 分組部署
為什么要用 2 倍?這樣能夠保證我們不管有多少臺機器,都不會把組劃分得太多。例如 1024 臺機器,也就只需要 1-2-4-8-16-32-64-128-256-512 部署十次就可以全部部署完畢。
這樣我們上線最開始影響到的用戶在整體用戶中占的比例也不大,比如 1000 臺機器的服務,我們上線后如果出現(xiàn)問題,也只影響 1/1000 的用戶。如果 10 組完全平均分,那一上線立刻就會影響 1/10 的用戶,1/10 的業(yè)務出問題,那可能對于公司來說就已經(jīng)是一場不可挽回的事故了。
在上線時,最有效的觀察手法是查看程序的錯誤日志,如果較明顯的邏輯錯誤,一般錯誤日志的滾動速度都會有肉眼可見的增加。這些錯誤也可以通過 metrics 一類的系統(tǒng)上報給公司內(nèi)的監(jiān)控系統(tǒng),所以在上線過程中,也可以通過觀察監(jiān)控曲線,來判斷是否有異常發(fā)生。
如果有異常情況,首先要做的自然就是回滾了。
常見的灰度策略有多種,較為簡單的需求,例如我們的策略是要按照千分比來發(fā)布,那么我們可以用用戶 id、手機號、用戶設備信息,等等,來生成一個簡單的哈希值,然后再求模,用偽代碼表示一下:
// pass 3/1000
func passed() bool {
key := hashFunctions(userID) % 1000
if key <= 2 {
return true
}
return false
}
常見的灰度發(fā)布系統(tǒng)會有下列規(guī)則提供選擇:
因為和公司的業(yè)務相關,所以城市、業(yè)務線、UA、分發(fā)渠道這些都可能會被直接編碼在系統(tǒng)里,不過功能其實大同小異。
按白名單發(fā)布比較簡單,功能上線時,可能我們希望只有公司內(nèi)部的員工和測試人員可以訪問到新功能,會直接把賬號、郵箱寫入到白名單,拒絕其它任何賬號的訪問。
按概率發(fā)布則是指實現(xiàn)一個簡單的函數(shù):
func isTrue() bool {
return true/false according to the rate provided by user
}
其可以按照用戶指定的概率返回 true
或者 false
,當然,true
的概率加 false
的概率應該是 100%。這個函數(shù)不需要任何輸入。
按百分比發(fā)布,是指實現(xiàn)下面這樣的函數(shù):
func isTrue(phone string) bool {
if hash of phone matches {
return true
}
return false
}
這種情況可以按照指定的百分比,返回對應的 true
和 false
,和上面的單純按照概率的區(qū)別是這里我們需要調(diào)用方提供給我們一個輸入?yún)?shù),我們以該輸入?yún)?shù)作為源來計算哈希,并以哈希后的結果來求模,并返回結果。這樣可以保證同一個用戶的返回結果多次調(diào)用是一致的,在下面這種場景下,必須使用這種結果可預期的灰度算法,見 圖 5-21 所示。
圖 5-21 先 set 然后馬上 get
如果采用隨機策略,可能會出現(xiàn)像 圖 5-22 這樣的問題:
圖 5-22 先 set 然后馬上 get
舉個具體的例子,網(wǎng)站的注冊環(huán)節(jié),可能有兩套 API,按照用戶 ID 進行灰度,分別是不同的存取邏輯。如果存儲時使用了 V1 版本的 API 而獲取時使用 V2 版本的 API,那么就可能出現(xiàn)用戶注冊成功后反而返回注冊失敗消息的詭異問題。
前面也提到了,提供給用戶的接口大概可以分為和業(yè)務綁定的簡單灰度判斷邏輯。以及輸入稍微復雜一些的哈?;叶?。我們來分別看看怎么實現(xiàn)這樣的灰度系統(tǒng)(函數(shù))。
公司內(nèi)一般都會有公共的城市名字和 id 的映射關系,如果業(yè)務只涉及中國國內(nèi),那么城市數(shù)量不會特別多,且 id 可能都在 10000 范圍以內(nèi)。那么我們只要開辟一個一萬大小左右的 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 來存儲映射關系,map 的查詢比數(shù)組稍慢,但擴展會靈活一些:
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è)務線、按 UA、按分發(fā)渠道發(fā)布,本質(zhì)上和按城市發(fā)布是一樣的,這里就不再贅述了。
按概率發(fā)布稍微特殊一些,不過不考慮輸入實現(xià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
}
注意初始化種子。
求哈??捎玫乃惴ǚ浅6啵热?md5,crc32,sha1 等等,但我們這里的目的只是為了給這些數(shù)據(jù)做個映射,并不想要因為計算哈希消耗過多的 cpu,所以現(xiàn)在業(yè)界使用較多的算法是 murmurhash,下面是我們對這些常見的 hash 算法的簡單 benchmark。
下面使用了標準庫的 md5,sha1 和開源的 murmur3 實現(xiàn)來進行對比。
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))
}
為這些算法寫一個基準測試:
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()
}
}
然后看看運行效果:
~/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 相比其它的算法有三倍以上的性能提升。顯然做負載均衡的話,用 murmurhash 要比 md5 和 sha1 都要好,這些年社區(qū)里還有另外一些更高效的哈希算法涌現(xiàn),感興趣的讀者可以自行調(diào)研。
對于哈希算法來說,除了性能方面的問題,還要考慮哈希后的值是否分布均勻。如果哈希后的值分布不均勻,那也自然就起不到均勻灰度的效果了。
以 murmur3 為例,我們先以 15810000000 開頭,造一千萬個和手機號類似的數(shù)字,然后將計算后的哈希值分十個桶,并觀察計數(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í)行結果:
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ā)布時,也應該從本節(jié)中提到的性能和均衡度兩方面出發(fā),對其進行考察。
![]() | ![]() |
更多建議: