第3章通用的服务可用性治理手段——3.2 重试

对于服务间RPC请求遇到网络抖动的情况,最简单的解决办法就是重试。重试可以提高RPC请求的最终成功率,增强服务应对网络抖动情况时的可用性。

3.2.1 幂等接口

当执行RPC请求调用下游服务接口遇到网络超时的情况时,我们并不知道RPC请求是否已经被下游服务成功处理,因为超时可能出现在请求处理的多个阶段。例如:

  • RPC请求发送超时,此时下游服务并未收到RPC请求。
  • RPC请求处理超时,下游服务已经收到RPC请求,但是处理时间过长。
  • RPC响应报文超时,下游服务已经处理完RPC请求,但是响应报文超时未回复。

我们的服务无法准确判断RPC请求是否被下游服务成功处理,所以只能假定最坏的情况:下游服务已经成功处理请求,但是我们的服务没有收到响应信息。此时,如果我们的服务要进行重试,那么下游服务必须保证再次处理同一请求的结果与用户预期相符。

怎样才算与用户预期相符呢?举一个电商产品下单服务的例子。用户选择购买价格为100元的产品时,下单服务会调用用户账户服务的扣款接口,从用户的余额中扣除100元。假如用户账户服务已经成功从用户的余额中扣除100元,但是对下单服务请求的响应超时,这时下单服务将重试调用扣款接口。如果用户的余额再次被扣除100元,即用户实际支付200元才下单了这个价格为100元的产品,那么这就不是与用户预期相符的情况。理论上,无论重试调用扣款接口多少次,用户的余额最终仅应该被扣除100元。

可以被重试调用的接口应该满足幂等性。幂等是一个数学与计算学的概念。如果一个函数$f$使用相同的参数重复执行并可获得相同的结果,即满足公式:f(x) = f(f(x)),则函数$f$是幂等函数。在编程世界里,幂等指的是对于某系统接口,无论同一请求被重复执行多少次,都应该与执行一次的结果相同。满足幂等性的接口被称为“幂等接口”,只有幂等接口可以被安全地重试调用。

所有读性质的RPC接口(即读接口)天然都是幂等接口,因为无论读接口执行多少次都不会改变数据;而写性质的RPC接口(即写接口)会改变数据,所以需要查看多次改变数据的结果是否与一次改变数据的结果相同。以在数据库中执行各种写操作的SQL语句为例:

  1. 在覆盖操作时,UPDATE table1 SET col1 = X WHERE col2 = Y,无论成功执行多少次,col1列的值都是X,因此它是幂等的写操作;
  2. 在更新操作时,UPDATE table1 SET col1 = col1 + 1 WHERE col2 = Y,每执行一次都会使col1列的值发生变化,因此它不是幂等的写操作;
  3. 在插入操作时,INSERT INTO table1(coll, col2) VALUES(X, Y),执行多次会插入多条重复数据,因此它也不是幂等的写操作。

涉及非幂等写操作的接口可以通过幂等性被设计成幂等接口。

  1. 如果某接口涉及数据库插入操作,则可以先对数据库的相关数据表设置唯一键。重复调用此接口时,数据库会报出“键重复”错误,表示此数据已经被插入;此时,若接口返回成功,此接口就可以成为幂等接口。
  2. 如果某接口涉及数据库更新操作,则可以借鉴CAS(Compare And Swap,比较与替换)的思想,为行数据引入数据版本号,重写SQL语句如下:
1
UPDATE table1 SET col1 = col1 + l WHERE version = X AND col2 = Y

重复调用此接口时,由于version字段的值已经发生变化,因此最终的SQL语句未命中数据行,即它并未真正执行,接口满足幂等性。

以上保证幂等性的方案要求写操作必须是数据库操作,通用性较差。更直接的做法是采用判断请求是否已处理的思路:

  1. 对于每个请求,使用分布式唯一ID(见第4章)作为UUID,同时在接口侧保存已处理的请求记录;
  2. 在请求调用接口时,由接口侧根据请求的唯一标识查询已处理的请求记录;
  3. 如果找到相应的记录,则说明它处理过此请求,直接返回成功即可。

其具体实现方式如下。

(1)Redis分布式锁

接口使用Redis的SET命令保存已处理请求的UUID,并结合NX参数保证:当且仅当键不存在时才成功写入,否则不写入。

  1. 接口接收请求后,先尝试在Redis中执行SET UUID NX命令写入请求的UUID。
  2. 如果Redis写入成功,则说明此请求未被接口处理过,接口可以真正处理此请求。
  3. 如果Redis写入失败,则说明Redis中已写入过此UUID,即此请求已被接口处理过,于是接口拦截此请求并直接返回成功。

如果接口使用Redis永久保存已处理的请求,那么随着时间的推移,Redis存储空间很快会被占满。更好的做法是使用SET命令并结合EX参数,为每个键设置一定长度的过期时间,比如3600s:

1
SET UUID EX 3600 NX

设置过期时间可以有效控制Redis存储空间的占用,过期时间越短,越可以节约Redis存储空间。但是当键过期后,也就无法再拦截重复的请求了,因此过期时间也不能太短。过期时间代表了接口对同一个请求保证幂等性的有效期,如上例中接口保证在1h内对同 一个请求处理的幂等性。

Redis分布式锁方案的流程如图3-2所示。

image-20250218092752740

(2)数据库防重表

利用数据库唯一索引的唯一性特点,可以专门创建一个表来保存接口处理过的请求记录,并以请求的UUID作为表的唯一索引。这个表被称为“防重表”。

  1. 接口在处理请求前,先将请求插入防重表中。
  2. 如果发生索引冲突,则说明此请求在防重表中已经存在,于是请求被拦截,接口直接返回。

数据库防重表交互的流程如图3-3所示。

image-20250218092945245

以用户账户服务的扣款接口为例,数据库除了维护用户账户表,还会维护一个交易流水表,并以用户ID与订单ID作为联合唯一索引。

  1. 当扣款请求到达用户账户服务时,先将扣款请求插入交易流水表,插入成功后再执行真正的扣款操作。
  2. 如果重复处理扣款请求,则会在插入交易流水表时发生索引冲突,不再执行扣款操作,这样就保证了扣款接口的幕等性。

(3)token

token(令牌)方案也是一种通用的实现接口幂等性的方案,它要求在正式向某服务调用接口前,先从此服务中获取一个token,然后携带token调用所需的接口

  • 上游服务先向目标服务申请获取token。

  • 目标服务生成分布式唯一ID作为token,先保存到Redis(也可以是其他存储系统)中,然后返回给上游服务。

  • 上游服务收到响应信息后,携带token真正调用目标服务的接口。

  • 目标服务通过在Redis中删除token的方式,来检查请求携带的token是否有效。

    • 如果删除token成功,则说明Redis中存储了此token,申请token的接口请求是首次访问,于是处理请求;
    • 如果删除token失败,则说明Redis中从未存储过此token或者token已经被删除,申请token的接口请求可能是重试访问,于是直接返回响应信息。

需要注意的是,获取token仅执行一次,而真正调用接口可以重试。token方案的整体流程如图3-4所示。

image-20250218093853616

3.2.2 重试时机

在明确了什么样的RPC接口请求可以被重试后,我们再来讨论一下重试时机。重试时机包括两个维度:

  1. RPC接口调用遇到什么错误时重试请求;
  2. 何时重试请求。

RPC接口调用遇到的错误可以分为如下几类。

  1. 业务逻辑错误,即下游服务认为请求不符合业务逻辑而返回的错误。比如下游服务在处理扣款请求时发现用户余额不足,或者某请求包含非法参数,这些都属于业务逻辑错误。
  2. 服务质量异常错误,即反映下游服务稳定性的相关错误。比如下游服务对请求限流(见3.4节)、下游服务拒绝提供服务,或者由于调用下游服务失败率过高请求被熔断(见3.3节),这些都属于服务质量异常错误。
  3. 网络错误,如请求超时、数据丢包、网络抖动、连接断开等。

重试的退避策略(backoff)决定何时重试请求。

  1. 无退避策略:请求失败后立即重试。
  2. 线性退避策略:每次请求失败后都等待固定的时间重试。
  3. 随机退避策略:在一个时间范围内随机选取一个时间等待重试。
  4. 指数退避策略:对一个请求连续重试时,每次等待的时长都是上一次的2倍。
  5. 综合退避策略:可以是指数退避策略与随机退避策略结合的形式,各开源消息中间件在应对消息消费失败时常使用此策略。

重试时机的经验总结:

  • 当RPC接口调用遇到业务逻辑错误时不应该重试请求,因为此请求涉及的业务逻辑有异常,无论重试多少次都是一样的结果;

  • 当RPC接口调用遇到服务质量异常错误时,由于服务质量处于异常状态是有一定的持续时间的,使用无退避策略(立即重试请求)会大概率继续请求失败,所以此时适合使用各种有退避的策略,比如指数退避策略,这样可以在一定程度上给足下游服务质量恢复的时间;

  • 当RPC接口调用遇到网络错误时,因为网络错误具有随机性,重试请求再次失败的概率很小,所以可以在下游服务未熔断的前提下立即重试请求。

3.2.3 重试风险与重试风暴

重试虽然可以提高服务质量,但是也会给下游服务带来服务质量风险。假设在用户请求量较大的晚高峰时间,由于下游服务容量不足导致服务负载升高,质量下降,上游服务的请求调用就会出现网络超时或网络断开的情况。这时,如果上游服务决定重试请求,那么就会导致下游服务的负载继续升高,服务质量继续下降,最终拖垮整个服务。重试带来的请求量放大会使下游服务的负载压力雪上加霜,每个请求的重试次数越多,下游服务的负载压力就越大。

如果上游服务未限制每个请求的重试次数(无限重试),则等同于请求量被无限放大,对下游服务的影响也会无限大。因此,上游服务应该为每个请求都设置最大重试次数。在大部分互联网公司的生产环境中,一般请求失败后最多重试3次,即额外重试2次。

即使对请求设置了最大重试次数,也可能会产生重试风暴现象。如图3-5所示。

image-20250218094917313

假设将每个服务对下游服务的请求最大重试次数均设置为3。如果数据库出现了负载过高的情况,Server-3服务对数据库的请求最多重试3次,而Server-3服务的上游服务Server-2由于得不到响应,对Server-3的请求重试3次,Server-1服务对Server-2服务的请求也重试3次,那么这时候离奇的现象就产生了:原本是来自Server-1服务的1次业务请求,由于层层重试变成了对数据库的27次请求!数据库本来就负载过高,这样的级联重试让数据库更加难以应对。这种现象就被称为“重试风暴”。

这里只举例说明了具有3个微服务的简单架构遇到的重试风暴,而在真实的互联网微服务架构环境中,一个请求经过数十个微服务是很常见的,此时如果某个环节出现故障,那么重试风暴造成的请求量放大更加可怕。

3.2.4 重试控制:不重试的请求

重试需要谨慎进行,否则它会成为洪水猛兽。重试是为了提高服务质量,如果某请求的重试不会保障服务质量,那么就一定不要重试。这里总结了一些不应该重试请求的场景。

(1)非关键下游服务

如果某个下游服务不是我们的服务的关键下游服务,那么我们应该坦然接受请求失败,而不执意重试。是否是关键的下游服务需要结合业务场景来综合判断,比如在查看某用户个人页时,我们更在意的是用户昵称、头像、发布的内容,而用户ip地址的归属地仅仅是一个锦上添花的信息,那么个人页服务在调用地理位置服务遇到失败时可以果断地不再重试请求,而将地理位置服务的容量大方地留给那些认为它更重要的上游服务。

(2)上游服务的重试请求不再重试

为了有效防止重试风暴,我们可以在收到上游服务的某请求后,检查这个请求是否是上游服务的重试请求,如果是,则在调用下游服务遇到失败时不再重试请求,防止请求量被级联放大。比如3.2.3节的例子,如果Server-1服务向Server-2服务发送了重试请求,而Server-2在此链路上调用Server-3服务时遇到错误,那么Server-2最好不执行重试请求。这种策略要求重试请求在请求头中携带一个标记,以便下游服务可以识别该请求是否为重试请求。

(3)服务质量异常错误

3.2.2节中提到,如果我们的服务遇到来自下游服务的限流错误,或者服务内部的熔断器已经将下游服务熔断,则说明下游服务的负载过高。为了不成为压垮下游服务的“最后一根稻草”,我们的服务也不重试请求。

3.2.5 重试控制:重试请求比

通过设置最大重试次数可以有效控制单个请求的重试放大倍数,但是如果有较多的请求被重试,那么下游服务依然会收到大量的重试请求,服务负载也依然会有进一步升高的风险,所以我们应该对上游服务的整体重试量级进行相应的控制。

Google SRE建议每个上游服务都要控制正常请求总数与重试请求总数的比例(重试请求比)。如果在一段时间内重试请求总数低于正常请求总数的10% (即重试请求比低于10%),那么只有当某请求失败时才允许被重试。

从具体的实现角度来说,可以使用如图3-6所示的滑动窗口方式。

image-20250218095545218

上游服务实时统计1s内的正常请求总数(SC)与重试请求总数(RC)并将其作为一个桶。假设重试控制策略是最近10s内重试请求比低于10%时才重试请求,那么在某请求被尝试重试前,取最近10s的10个桶并根据重试请求总数与正常请求总数来计算重试请求比。如果sum(RC) / sum(SC) < 0.1,则可以重试请求。通过这样的重试请求比来控制重试,可以使重试请求达到最大请求量被放大1.1倍的效果。

总结

什么是幂等性?

  • 幂等性是指无论同一请求被重复执行多少次,都应该与执行一次的结果相同。

对于数据库的插入操作、更新操作这些非幂等性的操作,如何设计成具有幂等性?

  1. 如果某接口涉及数据库插入操作,则可以先对数据库的相关数据表设置唯一键。重复调用此接口时,数据库会报出“键重复”错误,表示此数据已经被插入;此时,若接口返回成功,此接口就可以成为幂等接口。
  2. 如果某接口涉及数据库更新操作,则可以借鉴CAS(Compare And Swap,比较与替换)的思想,为行数据引入数据版本号,重写SQL语句如下:
1
UPDATE table1 SET col1 = col1 + l WHERE version = X AND col2 = Y

如何设计保证幂等性的通用方案?

以上保证幂等性的方案要求写操作必须是数据库操作,通用性较差。更直接的做法是采用判断请求是否已处理的思路:

  1. 对于每个请求,使用分布式唯一ID(见第4章)作为UUID,同时在接口侧保存已处理的请求记录;
  2. 在请求调用接口时,由接口侧根据请求的唯一标识查询已处理的请求记录;
  3. 如果找到相应的记录,则说明它处理过此请求,直接返回成功即可。

具体的实现方案包括以下三种:

  1. Redis分布式锁
  2. 数据库防重表
  3. token

Redis分布式锁的基本原理?

接口使用Redis的SET命令保存已处理请求的UUID,并结合NX参数保证:当且仅当键不存在时才成功写入,否则不写入。并结合EX参数,为每个键设置一定长度的过期时间。

  1. 接口接收请求后,先尝试在Redis中执行SET UUID EX 3600 NX命令写入请求的UUID。
  2. 如果Redis写入成功,则说明此请求未被接口处理过,接口可以真正处理此请求。
  3. 如果Redis写入失败,则说明Redis中已写入过此UUID,即此请求已被接口处理过,于是接口拦截此请求并直接返回成功。

数据库防重表的基本原理?

  • 利用数据库唯一索引的唯一性特点,可以专门创建一个表来保存接口处理过的请求记录,并以请求的UUID作为表的唯一索引。这个表被称为“防重表”。

token实现幂等性的基本原理?

token(令牌)方案也是一种通用的实现接口幂等性的方案,它要求在正式向某服务调用接口前,先从此服务中获取一个token,然后携带token调用所需的接口

  • 上游服务先向目标服务申请获取token。

  • 目标服务生成分布式唯一ID作为token,先保存到Redis(也可以是其他存储系统)中,然后返回给上游服务。

  • 上游服务收到响应信息后,携带token真正调用目标服务的接口。

  • 目标服务通过在Redis中删除token的方式,来检查请求携带的token是否有效。

    • 如果删除token成功,则说明Redis中存储了此token,申请token的接口请求是首次访问,于是处理请求;
    • 如果删除token失败,则说明Redis中从未存储过此token或者token已经被删除,申请token的接口请求可能是重试访问,于是直接返回响应信息。

RPC接口调用遇到的错误可以分为哪几类?

  1. 业务逻辑错误,即下游服务认为请求不符合业务逻辑而返回的错误。比如下游服务在处理扣款请求时发现用户余额不足,或者某请求包含非法参数,这些都属于业务逻辑错误。
  2. 服务质量异常错误,即反映下游服务稳定性的相关错误。比如下游服务对请求限流(见3.4节)、下游服务拒绝提供服务,或者由于调用下游服务失败率过高请求被熔断(见3.3节),这些都属于服务质量异常错误。
  3. 网络错误,如请求超时、数据丢包、网络抖动、连接断开等。

重试的退避策略有哪些?

  1. 无退避策略:请求失败后立即重试。
  2. 线性退避策略:每次请求失败后都等待固定的时间重试。
  3. 随机退避策略:在一个时间范围内随机选取一个时间等待重试。
  4. 指数退避策略:对一个请求连续重试时,每次等待的时长都是上一次的2倍。
  5. 综合退避策略:可以是指数退避策略与随机退避策略结合的形式,各开源消息中间件在应对消息消费失败时常使用此策略。

重试时机的经验之谈?

  • 当RPC接口调用遇到业务逻辑错误时不应该重试请求,因为此请求涉及的业务逻辑有异常,无论重试多少次都是一样的结果;

  • 当RPC接口调用遇到服务质量异常错误时,由于服务质量处于异常状态是有一定的持续时间的,使用无退避策略(立即重试请求)会大概率继续请求失败,所以此时适合使用各种有退避的策略,比如指数退避策略,这样可以在一定程度上给足下游服务质量恢复的时间;

  • 当RPC接口调用遇到网络错误时,因为网络错误具有随机性,重试请求再次失败的概率很小,所以可以在下游服务未熔断的前提下立即重试请求。

失败时的重试次数该取何值?

  • 上游服务应该为每个请求都设置最大重试次数。
  • 在大部分互联网公司的生产环境中,一般请求失败后最多重试3次,即额外重试2次。

不应该重试请求的场景有哪些?

  1. 非关键下游服务
  2. 上游服务的重试请求不再重试
  3. 服务质量异常错误

什么是重试请求比例?

  • 正常请求总数与重试请求总数的比例