Fork me on GitHub

阿里技术 | 如何设计可靠的灰度方案

图片

一 灰度的基本概念

1 一个典型的灰度方案

一个较大的业务或系统改动,往往会影响整个产品的用户体验或操作流程。为了控制影响面,可以选取一批特定用户、流程、单据等,只允许这一部分用户或数据按照变更后的新逻辑在系统中流转,而另一部分用户仍然执行变更前的老逻辑。这一步是线上系统灰度方案的起点。

将用户按照特定规则分隔为两类之后,我们主要需要关注命中灰度的这部分用户,是否按照预期执行了新逻辑、产生了符合预期的数据,以及系统整体的变化等。此阶段即灰度观察阶段,线上验证工作也是其中的关键步骤。

随着系统中使用新逻辑的用户、订单等数据的逐步累计,即可证明新系统的正确性、有效性,那么更多的用户就应当被迁移进新逻辑中,这一阶段一般称作灰度推进。灰度推进有时是小流量验证后立即切全量的,也有需要逐步放量的,这需要结合实际业务&系统能力做出决定。

最终,全部用户被纳入到新逻辑的范围内,此时需要决定是否将灰度逻辑本身和系统中的老业务逻辑同步下线,全部用户仅可以使用新逻辑,此时即灰度完成。也有由于历史数据原因,长期无法完成全量灰度切换的,此时业务系统中将会长期驻留两套逻辑。

2 灰度在解决什么问题

一个变更如果在发布后立即全量上线,那么如果出现系统、逻辑、数据等问题,将会是灾难性的,比如全部用户无法创建新订单、全部新订单出现脏数据等,甚至有可能会影响到变更前的数据。

灰度过程就是在规避变更过程中这个最大的风险:全局影响。通过减小影响范围,再配合灰度线上验证、监控报警等手段,将出现问题时影响面,控制在有限的范围内,如减少订正的数据量或降低资损金额等。

安全生产规则中所谓的“无灰度,不发布”就是这个思想,通过灰度尽可能的减少问题的影响面。如果通过灰度过程发现一个线上问题,那么去掉灰度的保护,可能就会产生一个严重的故障。

3 灰度会带来什么风险

灰度方案可以规避全局性的影响,但是会不会带来其他的风险呢?答案是肯定的,工程中没有一劳永逸的银弹。

首先是如何发现灰度过程中的问题

这与上线过程中的监控报警有一定的相似性,二者主要都是依赖日志&监控&报警规则的建设和配置;但二者又存在一定的差异,如报警阈值如何配置才能有效发现小流量异常?灰度名单外的老逻辑会不会触发新逻辑的监控报警?灰度系统影响的上下游是否也有对应的灰度监控?这些问题都可能影响灰度问题能否被发现与发现问题的时效性。

此外,对灰度系统要重点关注资损风险。资损字段在上线前一定要做好核对的保障,或者至少应当在灰度开始阶段之前完成,尤其是对新变更引入或影响的资损字段,要做到全覆盖,“无核对不上线”。

灰度过程中还可以协同客户、运营、产品等多条线的同学做好布防,及时感知处理相关舆情,使用非技术手段作为问题发现的兜底与补充。

其次,如何控制灰度中问题的影响面

灰度过程中产生的灰度数据,不能侵入非灰度数据,反之亦然,要确保二者的充分隔离。

但是灰度系统需要与上下游联动,灰度本身也需要推进,一旦遇到问题,还需要进行灰度停止、灰度回退等更复杂的操作,因此灰度整体是一个动态的过程,而在整个动态过程中,需要严格保持灰度数据&非灰度数据的隔离,否则将会导致问题影响面扩大化,危及整个系统,甚至发生严重故障。

这里尤其需要注意的是灰度停止与灰度回退的复杂性:如果灰度停止手段不能生效,那么问题影响就无法得到有效控制;灰度回退则需要涉及阻断灰度流程、修改已有灰度数据、修复错误数据等,一般来说是整套灰度方案中最复杂的部分。

最后,发生问题时的处理也会比较复杂

生产系统往往没有太多的资源或条件进行AB-test,灰度与非灰度数据都是真实的业务数据,一旦出现问题,并不能通过删除灰度数据或脏数据的方式解决问题,一般需要进行数据订正,或发布新的变更进行修复。数据订正的数量、订正数据的正确性、如何甄别灰度用户、如何保证新变更的正确性、如何保证新变更可以有效修复问题数据等,都是恢复过程中的难点工作与潜在风险。

本章结语:

复杂的灰度方案会引入各种各样问题与风险,整个系统的复杂度也将成倍的增加,对灰度的质量保障方案也会同时变得更为复杂。那么如何有效的控制这些风险,同时高质量的达成项目目标呢?我们常说,好质量不是测出来的,对于复杂的灰度系统来说,这句话同样适用。一个高质量的灰度方案,不仅需要完善的测试,更要依赖于良好的设计。保障安全生产和达成项目目标二者绝不是矛盾的,只要灰度方案设计得当,鱼与熊掌可兼得之。

二 灰度设计要解决的基本问题

1 灰度维度的选取

生产系统中常见的灰度的规则,有用户id尾号、业务单据id尾号、白名单、黑名单、时间戳等。

白名单常用于线上测试,如使用测试账号等进行单独的验证。这种方式不适合单独使用,因为无法快速扩大灰度范围,但是推荐与其他方式联合使用,增加灰度过程的灵活性。

黑名单则是一种兜底手段,可以对特殊用户(如数据量特别大用户、重点客户等)进行屏蔽,减少或避免其受到灰度的影响,尤其是在灰度过程出现问题时,直接阻断其进入系统中的问题逻辑。

采用用户id尾号或业务单据id尾号作为灰度key,是更常见的灰度区分方式。但如何选取这类灰度key,需要注意几个要点。

第一,选取的key应当是均匀分布或近似均匀分布的,如集团的havanaId等,否则全量用户无法分批分散的命中新逻辑,灰度的逐步放量的能力就失去了作用,极端地,整个灰度能力会退化为布尔化的全局开关。

这里容易犯的错误并不是使用了全部相同的灰度key,而是误认为某个id是均匀分布的。举例来说,某单元化应用中如果使用用户id的后四位作为灰度key,那么很可能会出问题,因为用户id已经是用于区分单元化的标记了。常见的id的生成本身是随机的,但触达业务系统时,可能已经带有某种特定的规律了,因此需要对此类情况做好识别与防范。

第二,计算key的逻辑需要尽量简化

系统中使用灰度key来判别走新逻辑还是旧逻辑,这个条件判断一般会在系统中反复出现、多次执行,此时如果设计特别复杂计算方式,则会给系统带来额外的开销。除此之外,简化key的计算逻辑也会带来业务语义上的简化,便于整个业务链上的技术同学与非技术同学快速理解,也便于遇到问题时快速定位与排查,更有利于系统的长期维护。

第三,要结合业务实际选取

如果选取一个当次变更新增的业务字段作为灰度key,那么上下游系统是否需要做同步改造?离线数据&报表是否需要配合改造?如果选取一个对下游业务未记录的或无意义的字段呢?这些都是通过合理设计可以节省的改造成本。

因此在选取灰度key时,需要选取上下游业务已有的、通用的、具有业务意义的字段。

2 简化灰度逻辑

灰度逻辑仅仅是将一个用户或单据非此即彼的区分开,因此灰度逻辑不仅没有必要做的太过复杂,而且还应当尽量简化,如果业务上有条件,最好能用一个字段或一个变量搞定。

首先,有利于完成灰度进度的调整,如灰度推进,灰度暂停等,可以通过单变量的调整快速完成,否则一次性调整几个灰度变量,会出现灰度推进情况不符合预期、灰度覆盖不全,灰度数据不一致等复杂问题。比如同时调整用户id覆盖范围与订单创建时间,则可能导致一部分用户被跳过,也可能导致调整后的灰度范围远超预期等问题。其实这类问题在实际生产中是最常见的,回想一下,每次在灰度推进或灰度暂停等进度调整时,是不是都需要多人共同监督灰度脚本,反复确认发布内容?甚至在加入了如此重的流程之后,仍然不能达到百分百的无问题。

其次,开始灰度后,灰度数据往往错综复杂,如果需要多个条件协同判断,对问题定位则是不利因素,甚至可能会导致误判。还用上面的用户id+时间戳的例子来说,原本是灰度逻辑出错时产生的数据,可能被误判成由于时间未到而走旧逻辑产生的数据,这种复杂性导致的误判将会严重影响线上问题的止血与处理效率。

最后,对可灰度的用户或单据,应当宽进严出,适当提升灰度准入的门槛,这样做有利于将大部分数据快速的排除到灰度范围之外。因为总体而言,当我们决定采用灰度方案去推动变更时,我们总是抱着对系统悲观的态度,防止潜在的问题快速扩大化。因此在初始阶段让尽可能少的数据走到新逻辑,可以给我们留出时间做人工数据校验、监控报警有效性校验、核对有效性校验等等工作,防止第一波灰度用户出问题时,直接演变成大问题。那样的话,就完全失去了做灰度的意义。

这里还要做一个简短的解释,减少灰度变量和灰度命中宽进严出二者并不矛盾,前者一般是动态的、配置在开关内供应用读取的,后者一般是静态写在代码中的固定条件。举例来说,某一个变更使用用户id作为灰度变量,但初期应当设置仅对某等级以上的用户开放的门槛。

3 灰度数据如何初始化

灰度最好是可以从0启动的,就是说无需事先通过数据订正或批量触发的方式修改初始数据,而是通过某个真实的业务请求来触发,比如用户下单等。这里常见的做法是,当业务请求中的数据命中灰度之后,在创建对应的DB记录时,打上特殊的标记,用以标识灰度命中。如果有必要的话,还可以单独建立新的表,在DB中写入一条新记录就代表相关的用户或单据命中灰度。

这种方式的优点就是0启动,无需前置数据准备流程,但问题是整体的灰度进展可能会变慢。因为在上线前产生的部分部分线上数据已经被确定为仅能走旧逻辑,想要进行全量灰度后的灰度逻辑下线,一般来说,只能等待业务数据自然关闭。

举一个简化的例子,灰度启动前已经付款的订单走旧逻辑,如果不对这部分订单数据做处理,那么只能等待这部分订单全部确认收货,才能对灰度逻辑和旧逻辑进行整体下线。而在实际的生产系统,还要考虑退款、计费等等相关流程,所以等待的周期只会变得更长。

但有些灰度方案并不能简单的通过请求中携带的数据进行灰度初始化,还需要对全量的用户数据做一次初始化。比如将线上A系统中的数据,按一定规则导入本次变更涉及的B系统中,作为灰度过程的数据准备。这样做的好处有两个。

第一是可以在一些场景中简化灰度门槛的判断,即可认定所有的数据全部符合某一个前提条件,节约一次判断。而且这次查询一般会是一个查库操作,而使用全量业务数据去查库,常常会出现DB性能问题,甚至会出现由于灰度数据的分布问题导致分布式DB出现单库单表的热点,这里的DB问题不做深入。总之这个方案可以有效减轻甚至规避此类问题。

第二就是在业务上可以加速整体的灰度进度,缩短从灰度开始到全量的周期,有时出于业务的考量,我们可能不得不选择这个方案。

但这样做的缺点也是明显的。举例来说,比如数据初始化的方案是从A表导入B表,那么首先需要对数据迁移的逻辑进行经过额外的验证工作;之后进行迁移数据时也需要占用一定的项目周期;还要在设计中考虑迁移数据过程AB系统的数据一致性如何保障,比如迁移数据的过程中,A系统有产生了新的业务数据,要迁移吗?还是迁移时要对A表的部分记录加锁?或者甚至停掉A表对应的服务?真的需要停服务的话,那这也太不互联网了。

4 灰度过程中保持数据一致性

前文描述了灰度初始阶段的问题,但是灰度过程往往会从前一个业务步骤开始,随后才会影响下一个业务步骤。举例来说,同一个用户在t时刻命中了灰度规则,并在写表时打标命中灰度;而在之后的t+1时刻,发生了一个需要更新表记录的操作,但由于灰度回退或其他原因,导致没有命中灰度规则,这时要怎么判定?

这类问题其实就是灰度数据一致性的问题,也是灰度设计中最核心的问题。

原则1:以已有的灰度命中数据为准

在很多业务场景下,前一步写表后一步更新的操作是非常常见的,创建时打标无需多言,更新时的基本的判断原则应当是将已有的灰度数据作为判断标准,而不是以灰度key是否命中为判断标准。即后一步更新操作时总是以查DB的结果为准:DB中记录为灰度命中,那么就要执行新逻辑,否则按照灰度未命中的旧逻辑执行。

原则2:优先考虑灰度推进过程中数据的一致性

当灰度推进时,更多的用户或单据会被纳入到灰度命中的范围内,因此要考虑此部分数据能否进入新逻辑。

举例来说,以用户当月账单id尾号为灰度规则,那么用户的当月账单一旦被打标为灰度命中,后续账单再次更新时,也一定要遵循新逻辑;而在账单创建时如果为灰度未命中,那么这笔账单将会一直保持旧逻辑直到结清。

这个原则与前一条有一定的相似性,但核心关注的是灰度进展导致的灰度key命中情况在创建和更新两个阶段发生了变化,这时一般仍要遵循以DB记录打标为准的原则。

另一方面,灰度推进前已命中灰度的数据,要确保在灰度推进后仍能命中灰度。这是一条不言自明的规则,在确保数据一致性的基础上,只有这样才能被称为灰度推进。但在实际操作推进的过程中,有时会因灰度开关配置错误等原因违背了这一规则,因此可以考虑对配置项进行一定的防错设计。

此外,灰度推进过程中,还需要关注集群内各机器开关数据数据的一致性。首先要确保变更后的灰度开关值被推送到集群内的全部机器中,其次为了灰度推进时间的一致性,一般会在灰度开关内加入一个生效时间戳,避免开关推送延迟可能带来的问题。

原则3:如果需要快速推进灰度,可以尝试在第一个灰度维度全量后,再开始另一个灰度维度

上述原则中提到,更新数据时发现记录创建未打标的,即使灰度key已被命中,仍应使用旧的业务逻辑。但是这样做,整体的灰度进展将会被拉到非常长,比如确认收货后90天内都可以发起退款,那么是否要等到4个月之后才能全量切到新逻辑?业务上允许这样做吗?

对上述这个例子可以这样做,就是当订单创建已经全量灰度后,那么就可以理解为创建已经全部切入新逻辑,此时继续在付款或确认收货操作时进行灰度打标,这样仍然可以保持一次仅对一个变量进行灰度的原则。

这里给出几个不是很理想的快速推进灰度的做法。

1、同时对多个灰度维度做推进

这是上文在讨论简化灰度逻辑时,就力图避免的一种设计。与其这样做,还不如在验证充分之后,对第一个灰度维度直接全量,之后再推进第二个灰度维度。

2、在多个入口同时进行灰度打标

这个方式看上去可以加速消除数据创建时未打标的记录,但多个入口打同一个标,出现问题的时候怎么排查原因?更新时要不要覆盖创建时的标记?灰度暂停时如何同步停止打标?总之这是个复杂度高且验证工作量大的方案。

3、手工数据订正

既然要做数据订正,还不如在灰度启动前就做一轮,这样在整个灰度方案开始时就可以获得收益。灰度进展中做订正,成本更高,收益却更低,从ROI角度看很不划算。

灰度设计的过程中,不要轻易尝试去推翻上述这些简单的原则,因为越是简单基础的原则,其影响就越大,对这些原则的改动,往往会造成前述的设计被全盘推翻。

当然,也存在一些只创建记录,而不再更新的业务,这种业务考虑的重点往往不是灰度推进,而是下面的灰度暂停&回滚策略。

5 灰度暂停与灰度回滚

灰度是服务于安全生产的,那么相应的,一定要建立适当的熔断与回滚机制。

原则4:灰度过程务必具备整体暂停能力,也即灰度熔断。

灰度熔断不要求对已经进入灰度的数据进行纠正,而是只需要不继续产生更多的灰度数据即可。

为什么灰度不继续推进了还不行,还需要加入一个这样的开关?下面举个例子。

使用用户id尾号作为灰度key,已有n个用户进入新逻辑时,我们发现DB侧出现了瓶颈需要修复。这时业务层的应用有三种应对方式可选。

第一,立即缩小灰度范围或对代码进行回滚。这是不可取的,已经命中灰度进入新逻辑的用户,往往不能再轻易的回退到旧逻辑中。

第二,不继续推进灰度,也不操作灰度开关,放任系统继续运行。这也是有风险的,因为现阶段只有n个用户进入新逻辑,但按照用户群体总数*灰度比例测算,可能还有m个用户即将命中灰度进入新逻辑,甚至m>>n,如果DB问题不能在用户大量进入之前修复,整个系统将面临灾难性的后果。

第三,灰度熔断。不操作灰度开关,但停止新用户命中灰度规则,即目前有n个用户进入新逻辑,那么稍后即使有m个用户命中灰度规则,也仍然不能进入新逻辑,这样就可以确保在DB问题得到修复之前,系统保持现状继续运行。

通过这个例子可以充分说明,建设灰度暂停能力的必要性。

原则5:可操作的灰度回滚方案,才是有意义的灰度回滚方案。

一般来讲,我们都希望可以做到灰度的可观测、可回滚。但前文反复讲述的这类灰度方案中,避免出现数据一致性问题,对业务来讲才是更重要和更安全的。出现问题时机械的执行回滚,反而会造成更大的影响,而使用灰度暂停能力进行快速止血,并积极修复问题,反而是更合适的。

那么回到灰度回滚方案中来,在保证数据一致性等原则的前提下,可以设计一个合理的回滚方案吗?

我认为应该是可以的,但遗憾的是,我们在项目实践中没能成功的做到这一点。因为工程中的资源往往都是有限的,我们不可能把大量的时间和精力,投入到高度复杂的回滚方案中去。

因此对于灰度回滚方案,我有一些比较负面的结论: 灰度回滚方案的复杂性如果难以控制,那么正确性也将难以验证;

复杂的设计将带来开发周期和测试周期的延长,对业务的伤害可能更大;

就像应急预案需要提前演练一样,没有人敢在线上直接使用未经验证的回滚方案,最终做了也是白做。

所以,我建议仅在模型上更为简化的业务中,才去考虑设计完整的灰度回滚策略。

本章结语:

本章讨论的范围主要是从技术视角出发的,已经基本可以满足一个常规的灰度方案的设计要求。但可达成不代表做得好,除了技术手段外,还有更多的其他类型的手段可以应用到灰度方案中,帮助我们将方案变得更加完善、健壮,实现可观测、可度量等工程目标,构建起高质量的灰度设计方案。

三 更完善的灰度方案

1 具备良好的可测性

我们一般在复杂的项目下才会考虑使用较为细致或复杂的灰度方案,在项目本身的业务复杂度之上,再叠加灰度引入的技术复杂度,此时如何进行完备的测试就成了一个不小的挑战。我们需要明确,可测性问题是需要在设计中认真考虑的问题。让系统内的数据流动&状态迁移都是可观测的,把请求、处理数据的过程值、开关值、分支判断结果等信息明确的、无遗漏的持久化到日志或DB中,尤其是灰度是否命中、灰度判定规则等关键信息;而不要让复杂的系统变成一个黑盒,只有起始的输入和最终的输出。否则的话,在调试和测试的阶段,都要花费大量的沟通成本,甚至可能埋下无法被发现的缺陷。

对于日志的处理,应当尽量保持上下游的一致性。最好的代码是自解释的,最好的日志也应当是自解释的。上游的系统如果使用了一个灰度标记,则下游的系统应当使用相同的标记;如果有下游有业务语义的变化,可以新增一个字段,而不是将上游的同名字段覆盖或清除。这样在跨多个系统或团队进行联调或处理问题时,大家对同一个标记或概念都持有同一个理解,这是对提效非常有帮助的。举个具体的例子,比如上游命中AA规则后记录了AA=true的日志,下游根据AA规则衍生了BB规则,那么记录日志时可以保留AA字段的信息,再额外记录BB=true。

对于落库的数据的处理,则要考虑可核对性方面的问题。数据在跨系统传递时,应明确各个系统中的业务主键是什么,下游系统要将上游系统的主键或唯一键落库,如果条件允许,还要将上游传入的键值作为平铺字段,甚至为其在DB中建立索引。这样做的好处首先是为了后续建设核对方便,方便使用相同的唯一键查找上下游系统的关联记录。这也是为将来的系统扩展性做考虑,如果将来下游系统的下游还需要再接其他系统,此时通过上游的这个统一键值即可有效串联多个系统。典型的例子是将交易系统唯一id透传到下游的所有额度明细、账单明细、退款等系统中。

以上这些提升可测性的设计思路,不仅针对灰度方案,也针对不涉及灰度的方案;不仅需要测试同学在设计阶段识别和发现方案的可测性短板,也需要开发同学有意识的去面向可测性进行设计。

2 关注全链路的压力

系统改造中需要关注对下游依赖的压力变化,灰度设计中也需要考虑这一点,尤其是系统压力随着灰度推进而改变的情况。

一种典型的场景是随着灰度推进,传递到下游的请求越来越多,这是一个比较好理解的例子,这里不做过多展开。这种情况下,主要需要梳理下游请求的增长率,是随着灰度推进线性增长、对数级增长、还是指数级增长(指数的情况就很可怕了,极易引发故障)?此外,灰度推进结束之后,流量模型是相对稳定的、还是继续变化的?

实际业务中的情况并不都是这么简单,有的场景中,在灰度开始启动的时,才是下游流量最大的时候。随着灰度的推进,下游的流量反而会越来越小。举例来说,开始灰度后,全量用户都需要查询某服务,而命中灰度的用户可以通过其他短路方式规避这个查询。如果评估发现这种特殊情况,除了按照常规做出压力评估,也可以考虑对依赖方案做调整以规避这种反直觉的情况。

其他的可能性还会有很多,再举一个极端的例子:本月的流量是随着灰度推进逐渐上升的,经过N天后灰度推进至全量,流量保持稳定;但到了下月1日,业务数据需要重新生成,由于已经灰度全量,导致突然爆发出了非常大的流量,给下游系统带来很大的影响。这种极端的场景如果不能提前发现识别,并做出合理的应对,则可能引起意想不到的严重故障。

除了下游的业务系统,我们还要关注DB侧可能存在的瓶颈,我们的业务系统一般可以快速地进行集群平行扩容以应对大流量,但DB的扩容就比较复杂了,可能会涉及到数据迁移、锁库、索引重建等操作,有些操作属于高危操作,如有不当,甚至会影响使用同库或同表的其他业务。当识别到这类问题时,需要提前与DBA联系,讨论合理的扩容方案,在灰度启动之前,预留充足的时间完成扩容。

当然,所有的压力评估,都可以用压测来进行检验。但是要把压测当做对设计成果的验收,而不是作为发现问题的兜底手段。即将上线之前才发现系统性的问题,可能为时已晚,强行上线或延期,成本都将是巨大的。

3 灰度的进展与监控

首先,监控是灰度前期最重要的观察手段,建立完整全面的监控,对于上线初期、灰度开放初期、灰度放量初期的数据观察,都至关重要:上线初期我们重点要关注新代码下的旧业务逻辑是否能正常运行;灰度开放初期则要观察何时出现命中新逻辑的数据,以及进入了哪些业务分支;灰度放量期间则要观察流量的变化是否能与开关调整相匹配,报错量是继续处于低位、还是随之线性甚至更快速的增加。此外,灰度放量过程中所关注的监控,在后续灰度全量后也仍然需要持续观察,有些还要建立相应的报警规则。

其次,针对灰度方案的核对也有一些不同。常见的核对一般都是以上游系统对应的A表作为左表(即核对数据源),下游系统的B表作为右表。但是下游系统在灰度阶段会将上游数据做二分类,命中灰度的写B表,未命中的不写,此时建立核对就要反转过来,将下游灰度命中后写入的B表作为左表,反过来与上游A表建立核对,确保所有命中灰度的数据仍与上游保持正确的关联关系。

但这里又有一个问题,一个用户本应命中灰度,但却没有写入B表,我们如何发现这类问题?这里我提出一个解法,即仍然建立从A到B的核对,但在核对规则中加入灰度规则的等效条件语句,并随着灰度推进修改核对规则。但这样做,核对规则将会非常复杂,而且也对如何设计落库字段提出了更高的要求。

最终,还可以为整个灰度方案建立一个小型的报表用于速查,从落库结果的角度判断某个特定的用户或数据是否已经命中灰度。更进一步的,还可以在报表中展示灰度相关的聚合与统计数据,判断数据分布是否符合灰度推进节奏,下一步需要加快推进还是暂缓。借助这些数据,一来为技术侧同学在做答疑或问题排查时提效,二来可以向业务侧的同学提供灰度的整体数据或局部情况,以便做出更多业务决策。

4 应急策略与修复手段

灰度推进过程中,我们可以通过各种方式和渠道获取来自系统和用户的反馈,包括但不限于监控、核对、用户咨询等。当发现不符合预期的、甚至存在严重问题的数据与场景时,标准的操作是先止血再修复:通用的止血方案可以是先将业务逻辑开关关闭、下线或回滚掉新逻辑的代码;随后在修复阶段对错误的数据进行订正。

但是回到最初讨论的问题,我们为什么要做灰度?灰度本身存在的价值就是在问题初期可以控制影响面,如果只是机械的执行上述的通用方案,为何还要设计复杂的灰度方案?

比如灰度方案中的止血开关,可以设计成全量下线新逻辑,也可以设计成不再产生新的灰度用户,这个已经在上一章中举过例子,这里不再赘述;在有多个灰度维度时,还可以设计成调整A维度与调整B维度可分别实现前述的目的。但需要明确的一点是,止血开关的语义应当设计的尽量简洁、无歧义,因为止血的意义就在于短时间内无需复杂判断即可立即执行。

详细判断与分析的工作,是在下一步的修复阶段完成的。上一章也提到,一般意义上的回滚可能引发更大的问题,所以可以在修复代码逻辑或修复数据后继续推进灰度;当然,进行灰度范围的回滚也是可选项,比如撤销之前已经命中灰度的用户或单据,将其修改为未命中。但这类回滚除了要考虑前述的数据一致性、系统复杂度等问题,还要从业务逻辑上看能否做到前向兼容,从产品视角去考量用户体验能否在回滚后得到保障,这也是一个要不要去做这类复杂灰度回滚方案的重要判断依据。举例来说,前一天用户命中了灰度,可以使用某个新功能;但第二天灰度回滚后反而不能用了,这大概率会引发用户咨询甚至投诉。

安全生产应当摆在重要的位置,但工程的目标从来不是单一的。灰度系统的设计在这个部分上一定要有所取舍,并不能一味地从系统稳定性的角度出发贪大求全,而应当结合实际业务情况,平衡由于复杂度提升而引入的设计、开发、测试、运维成本,和对产品、用户体验的影响。

5 灰度方案的终点

讨论到这里,我们基本把最复杂的部分与可能面临的失败情形都讲完了。下面谈谈在灰度进展一切顺利的情况下,还有哪些事项需要我们关注。

首先是灰度完成后,对灰度开关的下线。最明显的好处是简化代码复杂度,已经完成的灰度,基本等同于无用的业务代码。此时还可以把旧逻辑的代码也一同下线,全部直接执行新逻辑,也方便后续其他同学阅读与维护。不过这一步并不是非做不可,而且可能还会遇到一些限制。

灰度的终极目标当然是全量切换到新逻辑中,但实现这个目标有时候需要花费很长的时间。举个业务上的例子,比如从5月的某一天起开始灰度一个远月账单相关的功能,这时已经有部分用户产生了8月份的非灰度账单,那么按照预期,就要等到9月之后才能在理论上实现全量账单命中灰度。出现这种情况时,一般要和业务方充分沟通,因为业务上可能无法容忍漫长的灰度周期。压缩整体周期的手段,除了将灰度开关推到全量,还需要通过数据订正等方式让加速数据层面的灰度推进。

不过真实的情况可能更复杂,继续拿上面的例子来说,这笔8月账单如果出现用户逾期,那到9月时也仍然没有实现全量。在交易相关的系统中也有类似的例子,付款、确认收货、退款,每个周期都可以很长,如果再遇到纠纷等场景叠加在一起的情况,周期更是不可控,所以基本不可能在设计中对这类长尾值进行良好的处理。一般来说,在整体趋近于全量后,总会有个别的异常数据、离群数据的出现。所以从工程的角度来看,只要非灰度的数据趋于收敛,就是符合预期、可以接受的情况。

6 灰度的代价

前边我们反复提到过,在设计灰度方案中要有所取舍,尤其是要对最复杂的部分做取舍。不过简单的部分就没有代价了吗?不是的。项目中实现了一套灰度方案,就一定会付出相应的成本。应当总是从成本收益比的角度出发,来评价一个设计方案的价值,进而决定其最终应被保留还是被舍弃。

首当其冲的代价,就是复杂度的提升,这一点已经在上文多次提到。一般来讲,我们是能够在复杂项目中承受这一点的,因为项目本身的复杂度已经不低了,从边际效应的角度来理解,再加一点复杂度也不会造成太多额外的开销。但是对简单的项目,我们就要思考是否需要采用灰度了;或者出于安全生产的需要,我们一定要进行灰度,那简单的项目也一定要匹配最简化的灰度方案,避免造成大炮打蚊子式的浪费。

其次要面临的问题是发布时间延迟。设计、开发、验证环节都要花费额外的工作量和研发周期,来保障灰度逻辑的正确性与有效性,而且大概率还会出现修复灰度问题的时间。如何在项目开始时合理评估灰度引入的工作量,也不是容易的事情,因为灰度逻辑往往与业务逻辑正交。单从用例数量的视角看,理论上每增加一个灰度开关,相关的功能用例数量就要翻一倍。当然我们可以结合实际业务排除一些用例,但这样的用例数量增长趋势对于项目整体而言,并不是一个好信号。

接下来还有一个问题,就是灰度会使项目周期拉长。这里的周期指的是从发布后到灰度全量的周期。上面已经有案例说明,5月开始灰度的项目,到9月甚至更晚才能真正完成全量,这听起来就让人难以接受。极端地,这种漫长的灰度过程还可能会影响下一个项目的设计与上线,甚至会将影响扩散到下游系统中。如果出现这种情况,已经可以算作是设计失败了。

最后就是要慎重考虑不做灰度。不做灰度其实是反规则的,一般我们也不建议这样做。但工程上的事情总会有例外,有时也会遇到一些业务场景无法灰度,或者灰度还不如不灰度。如果决定不做灰度方案,那最好把灰度带来的问题和不做灰度的收益都提前整理好,同时也要充分评估放弃灰度的风险,让项目组的其他同学都能理解认同这个决策。

本章结语:

一个项目或产品的质量从来不是测试测出来的,而是在设计阶段就构建起来的。希望通过上述两章提及的各类设计手段与思路,给大家更多的输入与启发,在将来设计构建出更稳定健壮的工程。也欢迎大家对文章中的各项内容给予补充、指正。

下一章将从测试的角度讨论如何保障复杂灰度方案的正确性。

四 灰度方案的质量保障

之前的章节主要针对灰度方案的设计展开,但一个系统的正确性、稳定性,除了要依赖有效的设计,还需要全面合理的测试来保障。这一章就对灰度方案的质量保障体系进行详细的讨论,列举灰度系统中的各个测试覆盖要点。

1 灰度基本逻辑

这是最基础的测试点,即如何将数据非此即彼的区分开:满足预设的条件即为命中灰度,否则不命中灰度。

灰度命中的结果,不仅要是可预期的,还应当是稳定的。使用同样的数据与配置,不能在某次请求时命中灰度,另一次请求时却未命中灰度,否则将产生严重的问题。

举例来说,如果用户A在第一次命中灰度后,在将命中结果落库时,但意外的影响了灰度判断条件,那么稍后用户A再来请求时,就可能出现无法再次命中灰度的问题。这类型的低级缺陷要尽早发现,否则会阻塞后续的其他测试。

2 灰度命中后的持久化

命中灰度的数据有时还需要持久化到数据库中,在测试中除了检查灰度标记,还要检查新增的字段。如果灰度命中后写入全新的表,也要对全部字段进行完整的校验。

落库的数据与上游有关联关系的,要检查记录是否一致,如果可行,最好推动开发将其设为平铺字段,方便在上下游间建立核对。单据号等字段具备唯一性的,要额外做幂等性测试,防止同一条灰度命中数据多次写入。

除了结果数据,过程数据也至关重要。判断灰度过程中可能经历了多个条件,那么需要将每个条件的输入值、判断结果值都打印在日志中,方便联调与后续问题排查。此外还要检查日志中变量名的唯一性与变量值的正确性,防止打印语义混淆的废日志。

3 灰度兼容性

由于灰度过程中的请求会分为两部分,因此系统内应当对两类请求都有相应的处理能力,即在灰度全量之前,旧逻辑仍要保持可用。

新版本代码中的旧逻辑,在本质上已经和上一个版本的逻辑有所差异,因为上一个版本中是未经灰度判断,直接执行旧逻辑;而新版本代码中是多一层判断逻辑的。这层判断逻辑有时可能还会在入参上添加各类标记后,再进入系统的下游模块流转,并会引发更多复杂的情况。比如系统对未命中灰度的数据加入了一个属性,但下游流程判断有任意标记的流量都不再处理,那么这种情况下的旧逻辑就会受到影响。

如果灰度系统涉及多个应用,还要考虑应用间的兼容性。常见的测试要点包括:

灰度系统是否影响了上下游的流程交互,如命中未灰度走A应用,灰度命中则不走A应用,这样对A应用的监控和核对是否会造成影响;

灰度是否新引入了下游依赖,原有的依赖关系是否被解除或需要削弱,强弱依赖的设计是否合理,具体的依赖关系如何,是否引入了循环依赖,或者数据流是否构成回环;

上下游应用均有改动时,在下游应用先行发布后,是否会影响尚未发布的上游应用。

4 灰度推进

灰度从0开始,到部分覆盖,再到全量覆盖,这个灰度推进的过程也需要测试重点关注。

首先是一头一尾的情况,灰度开关配置为全量老逻辑和全量新逻辑的情况下,请求的结果是否符合预期;

其次是灰度推进的过程中,如果用户A在上一次请求时未命中灰度,但下次请求时由于灰度范围扩大而命中了灰度,那么用户A的请求能否正常处理?用户A能否按照预期被纳入或排除出灰度新逻辑的范围内?

最后还要评估灰度推进可能引起的兼容性问题,这里要关注的点是在灰度开关变化的情况下,动态的评估内部逻辑的兼容性,而这可能是上述静态的兼容性测试不能覆盖的点。这里需要结合实际业务与设计方案仔细分析,排除可能的、隐藏较深的、重现条件较为复杂的缺陷。举例来说,当月A用户第一次请求时未命中灰度,故写入一条不带灰度标记的记录,意味着本月A用户将不再命中灰度;当A用户第二次请求时,查询是否存在灰度不命中的记录时服务超时,且由于灰度推进导致A用户变为灰度命中,故又写入了一条带灰度标记的记录,导致库中同时存在两条业务语义存在相矛盾的记录。

5 灰度暂停或灰度熔断

上文已经反复讲过,灰度熔断的功能对灰度方案至关重要,在某些关键时刻甚至是系统唯一的逃生路径,因此对这里需要格外重视。

第一,熔断开关关闭时,要确保没有新增的灰度流量进入。这里有两层含义,一方面是未命中灰度的数据不能再命中灰度,另一方面是已经命中灰度的数据,要视灰度系统是否可回滚、是否前向兼容,决定是否可以继续命中灰度。

第二,熔断开关关闭时,要保证其他部分的灰度逻辑不受影响,这也是基本逻辑测试的一部分。

对此类应急方案的测试,还需要结合实际业务场景进行设计考虑,比如存在多个其他业务逻辑的开关时,是否要对所有开关组合进行测试,还是优先测试业务实际使用的组合,或者仅测试应急场景下必定出现的且数量有限的几个组合即可。

6 灰度回退

上文提到,在可能涉及灰度数据一致性问题的灰度方案中,我们一般不推荐引入复杂的灰度回退逻辑。但不可否认的是,灰度回退在部分场景下仍然是有价值的,此时也需要通过测试手段保障回退能力的质量。

首先是回退过程不再新增灰度命中数据。这里的保障要点,与熔断开关打开后是一致的。

第二是回退过程中,已命中灰度数据的一致性保障,这里最需要关注的场景是,在前一个业务流程中已经命中灰度的数据,在下一个业务流程中没有命中灰度时,系统将会如何处理。如,订单创建时命中灰度并打标,付款阶段反而不命中灰度,则此时需要将灰度标记移除。

此外,还要对灰度开关的回退能力进行测试,如果灰度开关存在多个维度或限制条件,这里的测试用例组合也会非常复杂,但与灰度推进逻辑的测试方案有一定的相似性,可以作为参考。

最后,灰度回退的过程一般还需要借助数据订正的手段对已经落库的灰度数据做变更,这里不涉及代码流程的测试,可以考虑建立核对规则进行保障。

7 对异常配置的容错

灰度逻辑底层常会依赖一个switch开关或diamond配置项,但进行配置时也有可能引入错误。把整个系统看成一个木桶,那么配置项常常是最短的那块木板。我们应当通过优化设计,规避由配置类问题导致的更严重问题。

首先,对灰度开关错配时,应用不能接收,仍应使用上一次的正确配置。虽然在diamond配置项中输入错误的配置值后,中间件层总会将这个错误值持久化,但应用可以在此时报错,并弃用中间件下发的错误值。

此外,如果在业务上有可行性的话,还可以在每次接收到错误值时,采用默认值来做兜底处理。

典型的例子是,前一版本的灰度配置包含尾号为00、01的用户,而后一版本的灰度配置中只包含尾号为0的用户,不包含尾号为1的。如果这个配置生效,那么尾号为01的用户的数据一致性将被破坏;此时若对后一版本的配置做校验,识别发现尾号为01的用户原本可命中灰度,但在推进后反而不命中,则可以避免这个问题。

这一点既是测试设计要考虑的异常逻辑,也是方案设计阶段需要考虑的防错机制(Poka-yoke)。

8 对异常数据的容错或报警

如果灰度过程中发现缺失了某个新字段,但可以通过一定的回补机制写入的,那么最好可以进行静默处理,容忍这样的错误数据。比如本应在用户浏览商品时对用户打上灰度标记,但后续加购时发现灰度范围内的用户仍然不带灰度标记,则此时可以再次对用户进行灰度打标。

但如果系统中核心依赖的字段遇到数据一致性错误时,就应当立即停止继续处理。如一个已经带灰度命中标记的订单,在确认收货时,缺少了一个应当在付款阶段写入的关键的新字段。那么此时应当不作处理,通过记录错误日志、抛出异常等手段,触发外部的监控报警,等待人工介入。

这里可以借助异常注入类的工具来简化测试方案。通过破坏灰度数据的一致性,检验系统对异常数据的处理是否符合预期。这部分功能的正确性,在遇到灰度回退等复杂情况时,将会起到很大的作用,如首次请求灰度未命中、二次请求时进行灰度命中补偿;或回退时数据订正不完全,系统处理此数据时触发报警,提醒再次订正等。

9 对外部系统的影响

除了要关注业务系统内部的数据流转情况,有时还要考虑对外部系统影响,比如在执行到某个节点时对外发送消息,而下游有若干外部业务方的监听者需要在收到消息后执行对应的系统逻辑;或者最常见的,落库的数据会定时的写入离线数据表中。

对外部系统的影响,应该在变更前期、设计方案确认后等关键节点,及时向下游业务方同步,评估下游需要的改动,并在预发环境进行有效的串测、验收,如有必要还要为新逻辑产生的数据单独建立监控或核对;此外,在灰度推进阶段需要向下游同步灰度变化节奏,观察监控变化情况是否符合预期。

对离线表的影响有两方面,首先要为变更的部分建立新的核对规则,其次也要评估对原先建立在这些离线表上的核对规则是否有影响,是否会导致核对误报或漏报。

10 灰度流量模型分析

灰度过程的流量模型是动态变化的:首先,在灰度未开始推进的初始状态下,就已经与上一版本的流量模型存在一定差异;随后随着灰度的推进,流量模型又会逐渐发生变化;最终在灰度全量后达到稳定。

在变更上线后、灰度启动前的阶段,一般不会与上一个版本的服务或DB依赖存在太大的出入,否则这些变化也应当被纳入灰度流程。这阶段主要需要对服务调用和DB新增字段进行评估,判断是否存在复杂的计算逻辑,或对DB读写存在影响。

相比之下,灰度推进阶段需要分析的点会比较多。灰度推进过程中,灰度判断逻辑的查询接口,按灰度命中结果分流后两套业务逻辑接口,落库时的DB,其他依赖或下游方的流量,都在同步的变化着。这里需要对这些变化点做逐个梳理,再分析流量变化可能引起的后果。

下面列举几个常见的随着压力逐渐变大,性能出现较大问题的场景:

触发下游服务限流,导致本系统的业务失败率升高;

下游服务rt变长,本系统业务随之超时,失败率升高;

灰度推进时,命中灰度的key值选取不当,经过分库分表规则后,导致单库热点;

灰度推进时,推进范围过大,导致短时间写库请求过大,引起整库流量或性能抖动;

为灰度字段添加的DB索引,不适用于灰度推进过程中的流量模型,导致DB性能不及预期。

灰度全量之后,流量模型将会达到或逐步达到一个新的稳定态,除了继续观察上述灰度推进过程中的各个要点,还要考虑在全量之后做切换的动作,比如对灰度判断逻辑做短路,以减少一次查询;或者将灰度条件的查询操作,从一个接口迁移到另一个性能更好的接口上。总之这个阶段可能只有性能优化,不太会有让整体性能变差的情况,此类优化除了确保基本功能的正确性外,无需过多关注。

11 对灰度系统进行压测

从上一节的列举的情况看,压力瓶颈常会出现在新增的服务接口与DB这两处,需要结合业务具体分析。但分析并不是万能的,新的接口或新的库表在上线前一般要按照规划的流量要求进行一轮压测,确保没有因为分析遗漏导致的隐藏缺陷。

压测流量的设定,需要结合当前线上业务的接口调用量进行评估,可按灰度全量后的流量值再放大1.2~2倍计算。放大的目的一方面是为了应对峰值流量,另一方面是为了快速暴露问题。常见的问题是流量在下游被成倍放大,比如一次请求,调用了两次某接口,当流量较小时,二者间的倍数关系体现的不明显,可能还会被误认为同时间段线上真实流量增大引起的扰动,导致无法发现问题;但流量较大时,倍数关系将会立即显现。

如果压测流量较大,需要在发布上线后使用线上集群做压测,那么还要考虑影子数据与真实数据隔离的问题。使用影子请求压测时需要按照全量灰度命中的新逻辑来执行,而对线上真实请求还不能开放灰度。这种情况需要在代码中额外添加一个供压测使用的开关,通过在入口处判断请求的压测流量标记字段,判断是否执行灰度逻辑。

12 为灰度建立核对规则

为了保证新项目上线后及时发现线上数据可能存在的问题,最晚在灰度启动之前就要将相关的核对规则全部上线。

在灰度项目中,常会出现灰度命中与未命中时落表不一致的情况。此时建立核对就要考虑如何选取左表。我们把系统的全量请求作为全集,把命中灰度的部分作为子集,那么灰度命中子集中的数据,必然要与全集的数据保持一定的关系。反之则不然,因为全集中还有部分灰度未命中的数据,无法与灰度命中子集中的数据保持一致。

举例来说,灰度系统位于下游,需要与上游系统进行核对,确保上游发来的请求全部被正确的处理了。这时就要用命中灰度之后的表作为左表,上游请求的表作为右表来建立核对。

此外,对于灰度未命中的部分也需要建立核对来保障一致性。这里的处理方式有两类:如果灰度命中只是新增落表,而不影响原有落表逻辑,那么可以先为旧逻辑做全量核对,即在灰度启动后,无论是否命中,都仍然应当遵循旧逻辑下的一致性约束;如果灰度命中后数据将会从旧表中迁走,只写入新表,就需要对灰度未命中的部分也进行上下游关系、子集全集关系的分析,然后选取子集作为左表建立核对,这与灰度命中的处理方式是相似的。

上述的原则可以帮助我们检查在灰度命中与未命中两种情况下,数据总是一致的,但是无法确保灰度命中与否这一结果的正确性。要想确保这一点,主要还是要依赖基本的功能测试,其次可以考虑在核对规则中引入与灰度规则等价的条件语句,并在每次灰度推进之后,同步修改这个条件语句。不过这种解法一般只能用在实时或准实时核对中,对离线数据的核对可能并不适用,因为离线表中历史数据所遵循的灰度规则,与当下的灰度规则可能是不一致的。如有需要,可以通过手工单次查询离线表,并结合灰度开关操作记录对结果进行判断。

最后,对于灰度系统的核对规则,我们还要适当提升时效性,因为从发现问题效率的角度讲,实时类型的核对是远优于离线与隔日核对的。灰度初期发现问题的概率更大,修复的成本也更小,但前提是能够及时的发现。

本章结语:

灰度方案的质量保障策略与设计策略是相匹配的,复杂的灰度系统设计一定会对应复杂的灰度测试方案。回到灰度本身的意义来看,它本就是服务于安全生产的,因此对灰度系统进行良好的全面的测试覆盖更是底线中的底线,务必要作为测试工作的重点。本文在此抛砖引玉,希望大家能在灰度质量保障这个话题上,分享更多的经验与心得。


本文地址:https://www.6aiq.com/article/1629664406203
本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出