文章目录
一、灰度开关、降级开关
在日常工作中,我们经常会上线新功能,一般会使用AB
实验放量查看效果。但有时候并不需要AB
实验,希望直接切换逻辑,比如老接口迁移到新接口,老服务迁移到新服务等,为了在上线初期出现问题时能够及时回滚调用原来的逻辑,灰度开关就派上用场了。
所谓的开关编程其实就是加个if判断,但是可以动态去调整if里面的值,能够随时控制逻辑的走向。开关需要自己编写,自己控制,动态调整值则可以借助于配置中心,改变后实时刷新
。
案例一:新逻辑替换旧逻辑
假设你要对订单详情页面做调整,增加一部分内容或者修改老的逻辑。正常的做法就是直接改掉老逻辑,然后测试,然后上线。
如果测试覆盖了所有的场景,上线后也不会有任何问题。就怕有某些场景遗漏了,导致在测试环境中没有发现的问题,一上线就出问题了。此时你的逻辑已经是最新的了,唯一的解决办法就是回滚应用到之前的版本,回滚是下下策,不到万不得已千万不要做,因为回滚可能带来更严重的问题。
这次发布所有的新功能都丢了
如果执行回滚操作,也就意味着这次发布要上的新功能都没有了(一次上线可能开发了很多功能,开关控制的不过是其中一个小功能,这个小功能有问题则全局回滚,影响面扩大了),如果你的服务拆的够细,可能影响面会稍微小点。
假设服务端回滚了,如果此时客户端已经发布,像H5
还好说,也可以回滚,像APP
如果用户已经更新到最新版本了,你服务端回滚了,用户一用到新功能就直接报错了。所以请慎重回滚。
所以此时你可能没办法回滚,只能快马加鞭改Bug
,然后紧急发布进行修复,玩的就是心跳啊。
开关的作用来啦
在改动旧逻辑的时候,不要直接改,可以在内部采用分版本的方式进行调整。把之前的逻辑定位V1,要改的逻辑定位V2,然后通过开关去切换。
Go伪代码如下:
switch := false
// 从配置中心获取开关状态,如果能获取到就用配置中心的,否则用默认值false
// XXX 为我们在配置中心设置的key
res,err := getConfigFromConfigCenter("XXX")
if err == nil {
switch = res
}
if !switch {
// 走新改的逻辑
} else {
// 走旧逻辑
}
通过增加开关来保证稳定性,默认开关是关闭的,上线后可以走新逻辑,如果新逻辑出现了Bug
,立马将开关打开走旧逻辑,不用回滚整个服务,对同期上线的其他功能无任何影响。
案例二:大促前的功能降级
对于很多电商公司来说,大促必不可少。每年都有像周年庆,618,双十一,双十二之类的大促期。
大促的时候,流量也是最高的时候。此时最重要的就是P0级别的核心链路,其他不是很重要的可以降级,以免影响到主链路的功能。
比如订单详情页里面,大部分都是订单的快照信息,可能有个别信息是需要调用其他的接口进行展示的,但这个信息不是必要的,比如商品的评论,此时就可以在调用这个接口的地方加开关,平时关闭,大促之前打开,不进行调用,这样就保证了详情页的稳定性。
如下:在商品大促时,必须保证能够从商品详情服务获取商品图片、价格等信息,从订单服务发起下单等,但是评论服务为非核心服务,即使短暂的不可用,也不会对正常的查看商品和下单产生影响,因此是可以在必要时做降级处理的。
Go伪代码如下:
switch := false
xxxInfo := &XxxInfo{}
// 调用商品详情
xxxInfo.Detail = xxxRpc.getxxx();
// 从配置中心获取开关状态,如果能获取到就用配置中心的,否则用默认值false
// XXX 为我们在配置中心设置的key
res,err := getConfigFromConfigCenter("XXX")
if err == nil {
switch = res
}
if !switch {
// 降级开关关闭时,可以调用评论服务获取商品评论
xxxInfo.Comment = xxxRpc.getComment()
}
具体而言:在 Go 中,实现服务降级通常需要考虑以下几个关键点:
- 服务降级开关:这是控制是否执行服务降级的关键,当我们检测到系统某些关键指标(如
CPU
过高,网络延迟等)异常时,我们可以通过开关控制某个或者某些服务的降级。 - 服务降级策略:这是实现服务降级的核心,具体的策略可以是返回固定的值,返回缓存数据,或者直接抛出异常等。
以下是一个简单的 Go
服务降级的例子,降级开关一般需要放到动态配置中心
:
package main
import (
"fmt"
"math/rand"
"time"
)
// 定义服务接口
type Service interface {
DoSomething() (string, error)
}
// 正常服务的实现
type NormalService struct{}
func (s *NormalService) DoSomething() (string, error) {
return "I am normal service", nil
}
// 降级服务的实现
type DegradedService struct{}
func (s *DegradedService) DoSomething() (string, error) {
// 在这里你可以返回一些默认值或者缓存数据,或者抛出一个已知的异常
return "I am degraded service", nil
}
var (
// 服务降级开关
DegradeService bool
)
func main() {
normalService := &NormalService{}
degradedService := &DegradedService{}
// 模拟系统状态
go func() {
for {
// 随机改变服务降级开关状态
DegradeService = rand.Intn(2) == 1
fmt.Println("Service degrade status:", DegradeService)
time.Sleep(1 * time.Second)
}
}()
for {
var service Service
if DegradeService {
service = degradedService
} else {
service = normalService
}
result, err := service.DoSomething()
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Println("Result:", result)
time.Sleep(1 * time.Second)
}
}
在这个例子中,我们定义了一个 Service
接口以及两个实现这个接口的服务:一个正常服务 NormalService
以及一个降级服务 DegradedService
。我们在主函数 main
中,根据服务降级开关的状态来选择使用哪一个服务。我们还模拟了系统状态,会随机改变服务降级开关的状态。
这只是一个最基础的示例,实际中可能会有更加复杂的场景和需求,如需要精细控制哪些服务需要降级,哪些请求需要降级,以及如何进行降级等,可能需要引入一些开源的服务降级和熔断框架,如 Hystrix、Sentinel
等。
开关注意点
开关虽然很有用,但是凡事有利也有弊。当项目中出现了很多的开关之后,对于代码的可读性比较差,特别是对于新同学来说,这么多开关,可能也不知道干嘛的,所以第一点就是注释一定要写清楚。
然后对于那些保证上线稳定性的开关,在上线后过一点时间,功能稳定了,就应该及时删除开关的逻辑,提高代码可读性。对于降级的开关,还是要保留的。
开关总结
大家在工作中肯定会遇到一些场景需要用开关去做一些事情,特别是在上线新功能,或者改老功能,或者重构,或者迁移的时候。
有了开关,上线不慌,遇到问题可以及时切换开关状态进行回滚。
二、灰度放量
上面介绍了开关,但是开关还是属于一刀切的那种,如果流量特别大的情况下,影响面还是挺大的,所以此时可能需要用到另外一种方式,灰度放量。
当我们发布新功能时,需要尽可能降低因新功能发布所导致的线上风险,通常会采取灰度放量的方式将新功能逐步发布给用户。在具体实施灰度放量时,我们可以根据业务需求选择相应的放量规则,常见如按白名单放量(如仅 QA 可见)、按特定人群属性放量(如仅某个城市的用户可见)亦或是按用户百分比放量。
1、随机百分比放量,针对允许用户可一会是走新逻辑,一会又可以走旧逻辑的情况
该场景下,可以使用一种简单的策略,即为每一个请求分配一个0
到100
之间的随机数,然后根据这个随机数的值决定是否将请求引导到新的特性。
以下是如何在 Go 中实现这个策略的一个例子:
package main
import (
"fmt"
"math/rand"
"time"
)
// 定义处理请求的接口
type Handler interface {
HandleRequest()
}
// 新特性处理请求的实现
type NewFeatureHandler struct {
}
func (f *NewFeatureHandler) HandleRequest() {
fmt.Println("New feature handling request")
}
// 老特性处理请求的实现
type OldFeatureHandler struct {
}
func (f *OldFeatureHandler) HandleRequest() {
fmt.Println("Old feature handling request")
}
func main() {
// 设置随机数种子
rand.Seed(time.Now().UnixNano())
newHandler := &NewFeatureHandler{}
oldHandler := &OldFeatureHandler{}
// 此处设置的是灰度放量的百分比,这个10以及下面的100可以放到配置中心动态配置,逐步加大放量人群
percentage := 10
for i := 0; i < 100; i++ {
if rand.Intn(100) < percentage {
newHandler.HandleRequest()
} else {
oldHandler.HandleRequest()
}
}
}
在這個例子中,我们创建了两个处理请求的类型:NewFeatureHandler
和 OldFeatureHandler
,他们都实现了 Handler
接口。
在主函数 main
中,我们为每一个请求生成一个0
到100
之间的随机数。如果这个随机数小于我们设置的百分比值(这里是10
),那么我们就将请求交给 NewFeatureHandler
处理;否则,我们将请求交给 OldFeatureHandler
处理。
这个策略是最基础的,适用于简单的场景。在实际中,可能需要复杂的灰度策略,例如基于用户属性的灰度放量,基于请求频次的灰度放量等,这涉及到如何在系统中集成这些策略,可能需要使用到负载均衡框架如 Nginx
或者一些服务网格解决方案如 Istio
等。
2、基于用户百分比放量,一个用户从命中放量访问新特性起就应该一直是出于放量中,能够访问到新特性
当我们选择将功能以用户百分比放量时,会先将功能发布给小部分用户(如1
%),此时即便出现问题影响也相对可控,如观察没有问题后逐步扩大需要放量的用户百分比,实现从少量到全量平滑过渡的上线。与上面随机百分比放量不同的地方在于,不是随机生成数字,而是通过用户属性,如用户ID、用户年龄或者用户地区等 hash
后求模得到一个数字,因为同一内容hash
后每次得到的结果相同,放量比例又是在不断增大的,所以用户命中放量后,加大放量力度也会一直是命中放量状态的。
此时可能我们会在配置中心做如下配置
{
"greyRate" : 10, // 当前放量份数
"greyMax":1000, // 总切分份数
"greyObjectIds" : [] // 灰度白名单,一定命中灰度,一般用于测试账号
}
注:上诉配置即为将总体分为1000份,放量10份,即放量比例为10/1000 = 1%,放量时,如用用户uid控制,则为hash(uid) % greyMax < greyRate即为命中放量的用户。此外,当总流量非常大时,切分份数也可以更细,如切为100000份等,然后放量就是10/100000 =0.01%,相对总量上亿用户的话,0.01%其实也会影响不少人啦。
还有一种非常常用的方式则是直接认定总份数为1000,然后只需要在配置中心指定放量份数即可,当然,还可以包含黑名单,如下
// ControlGrayRule 灰度控制规则, 按字段优先级逐个判断规则, 未命中任何规则默认不灰度
type ControlGrayRule struct {
GlobalSwitch bool `json:"global_switch"` // 优先级1: 全局灰度开关, false-不允许灰度, 止损时一键关闭灰度
BlackList []string `json:"black_list"` // 优先级2: 灰度黑名单
WhiteList []string `json:"white_list"` // 优先级3: 灰度白名单
GrayThousandRate uint32 `json:"gray_thousand_rate"` // 优先级3: 灰度比例, 千分比, 0-1000
}
Go代码如下,该代码可以直接作为工具包使用
package gray
import (
"context"
"google.golang.org/appengine/log"
"hash/fnv"
)
// ControlGrayRule 灰度控制规则, 按字段优先级逐个判断规则, 未命中任何规则默认不灰度
type ControlGrayRule struct {
GlobalSwitch bool `json:"global_switch"` // 优先级1: 全局灰度开关, false-不允许灰度, 止损时一键关闭灰度
BlackList []string `json:"black_list"` // 优先级2: 灰度黑名单
WhiteList []string `json:"white_list"` // 优先级3: 灰度白名单
GrayThousandRate uint32 `json:"gray_thousand_rate"` // 优先级3: 灰度比例, 千分比, 0-1000
}
// IsHitGray 基于ID的灰度控制, 命中灰度返回true
func IsHitGray(ctx context.Context, grayID string, rule *ControlGrayRule) bool {
// 全局灰度开关关闭, 不灰度
if !rule.GlobalSwitch {
log.Infof(ctx, "[IsHitGray] gray global switch is close, not gray: grayID=%s", grayID)
return false
}
// 命中黑名单, 不灰度
if containsString(rule.BlackList, grayID) {
log.Infof(ctx, "[IsHitGray] hit black list, not gray: grayID=%s", grayID)
return false
}
// 命中白名单, 进行灰度
if containsString(rule.WhiteList, grayID) {
log.Infof(ctx, "[IsHitGray] hit white list, will gray: grayID=%s", grayID)
return true
}
// 命中灰度比例, 进行灰度
grayHash, err := hashStringToInt(grayID)
if err != nil {
// 实际上不会触发,因为 hash.Hash32 的 Write 方法不会返回 err
return false
}
log.Infof(ctx, "[IsHitGray] grayID=%s, grayHash=%d, GrayThousandRate=%d",
grayID, grayHash, rule.GrayThousandRate)
if grayHash%1000 < rule.GrayThousandRate {
log.Infof(ctx, "[IsHitGray] hit gray percent, will gray: grayID=%s", grayID)
return true
}
// 未命中任何规则, 默认不灰度
log.Infof(ctx, "[IsHitGray] does not hit any gray rule, not gray: grayID=%s", grayID)
return false
}
func hashStringToInt(s string) (uint32, error) {
h := fnv.New32a()
_, err := h.Write([]byte(s))
if err != nil {
return 0, err
}
return h.Sum32(), nil
}
func containsString(list []string, userId string) bool {
for _, val := range list {
if val == userId {
return true
}
}
return false
}
放量总结
灰度按百分比放量是一种软件开发中常用的功能发布方法,它可以帮助提高软件可靠性,提高用户体验,在实施时也需要注意几个方面:
-
确定放量目标:首先需要确定放量的目标,例如增加多少百分比的数据量。这个目标需要根据实际情况进行制定,例如需要考虑数据量的大小、计算资源的限制等因素。
-
确定放量规则:你需要确定在放量过程中,哪些功能会被启用,哪些功能会被禁用。你可以根据开发进度、测试结果和市场需求等因素来确定放量规则。
-
监控放量过程:在实施放量操作时,需要监控放量过程,以确保放量结果的稳定性和可靠性。如果出现异常情况,需要及时采取措施进行调整。