17. 灰度开关、降级开关、灰度放量

一、灰度开关、降级开关

在日常工作中,我们经常会上线新功能,一般会使用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、随机百分比放量,针对允许用户可一会是走新逻辑,一会又可以走旧逻辑的情况

该场景下,可以使用一种简单的策略,即为每一个请求分配一个0100之间的随机数,然后根据这个随机数的值决定是否将请求引导到新的特性。

以下是如何在 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 中,我们为每一个请求生成一个0100之间的随机数。如果这个随机数小于我们设置的百分比值(这里是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
}

放量总结
灰度按百分比放量是一种软件开发中常用的功能发布方法,它可以帮助提高软件可靠性,提高用户体验,在实施时也需要注意几个方面:

  1. 确定放量目标:首先需要确定放量的目标,例如增加多少百分比的数据量。这个目标需要根据实际情况进行制定,例如需要考虑数据量的大小、计算资源的限制等因素。

  2. 确定放量规则:你需要确定在放量过程中,哪些功能会被启用,哪些功能会被禁用。你可以根据开发进度、测试结果和市场需求等因素来确定放量规则。

  3. 监控放量过程:在实施放量操作时,需要监控放量过程,以确保放量结果的稳定性和可靠性。如果出现异常情况,需要及时采取措施进行调整。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值