一些 HTTP 客户端往往会内置一些重试策略。
Feign 内部有一个 Ribbon 组件负责客户端负载均衡, 在客户端配置服务端2个节点:
SmsClient.ribbon.listOfServers=localhost:45679,localhost:45678
feign默认读取超时时间是1s。
设服务端的接口耗时是2s。那么客户端调用1s后,客户端feign读取超时,会自动发起调用另一台服务器一次,又一次读取超时(feign保证2次的请求参数是一样的),这时客户端才打印超时异常。
客户端代码:
@RestController
@RequestMapping("ribbonretryissueclient")
@Slf4j
public class RibbonRetryIssueClientController {
@Autowired
private SmsClient smsClient;
@GetMapping("wrong")
public String wrong() {
log.info("client is called");
try{
//通过Feign调用发送短信接口
smsClient.sendSmsWrong("13600000000", UUID.randomUUID().toString());
} catch (Exception ex) {
//捕获可能出现的网络错误
log.error("send sms failed : {}", ex.getMessage());
}
return "done";
}
}
@FeignClient(name = "SmsClient")
public interface SmsClient {
@GetMapping("/ribbonretryissueserver/sms")
void sendSmsWrong(@RequestParam("mobile") String mobile, @RequestParam("message") String message);
}
服务端代码:
@RestController
@RequestMapping("ribbonretryissueserver")
@Slf4j
public class RibbonRetryIssueServerController {
@GetMapping("sms")
public void sendSmsWrong(@RequestParam("mobile") String mobile, @RequestParam("message") String message, HttpServletRequest request) throws InterruptedException {
//输出调用参数后休眠2秒
log.info("{} is called, {}=>{}", request.getRequestURL().toString(), mobile, message);
TimeUnit.SECONDS.sleep(2);
}
}
客户端上的日志:
[12:49:29.020] [http-nio-45678-exec-4] [INFO ] [c.d.RibbonRetryIssueClientController:23 ] - client is called
[12:49:29.026] [http-nio-45678-exec-5] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45678/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
[12:49:31.029] [http-nio-45678-exec-4] [ERROR] [c.d.RibbonRetryIssueClientController:27 ] - send sms failed : Read timed out executing GET http://SmsClient/ribbonretryissueserver/sms?mobile=13600000000&message=a2aa1b32-a044-40e9-8950-7f0189582418
服务端的日志:
[12:49:30.029] [http-nio-45679-exec-2] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45679/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
重试原因:
翻看 Ribbon 的源码可以发现,MaxAutoRetriesNextServer 参数默认为 1
,也就是 Get 请求
在某个服务端节点出现问题(比如读取超时)时,Ribbon 会自动重试一次:
// DefaultClientConfigImpl
public static final int DEFAULT_MAX_AUTO_RETRIES_NEXT_SERVER = 1;
public static final int DEFAULT_MAX_AUTO_RETRIES = 0;
// RibbonLoadBalancedRetryPolicy
public boolean canRetry(LoadBalancedRetryContext context) {
HttpMethod method = context.getRequest().getMethod();
return HttpMethod.GET == method || lbContext.isOkToRetryOnAllOperations();
}
@Override
public boolean canRetrySameServer(LoadBalancedRetryContext context) {
return sameServerCount < lbContext.getRetryHandler().getMaxRetriesOnSameServer()
&& canRetry(context);
}
@Override
public boolean canRetryNextServer(LoadBalancedRetryContext context) {
// this will be called after a failure occurs and we increment the counter
// so we check that the count is less than or equals to too make sure
// we try the next server the right number of times
return nextServerCount <= lbContext.getRetryHandler().getMaxRetriesOnNextServer()
&& canRetry(context);
}
解决办法:
一是,把服务端接口从 Get 改为 Post。其实,这里还有一个 API 设计问题,有状态的 API 接口不应该定义为 Get。根据 HTTP 协议的规范,Get 请求用于数据查询,而 Post 才是把数据提交到服务端用于修改或新增。
二是,将 MaxAutoRetriesNextServer 参数配置为 0,禁用服务调用失败后在下一个服务端节点的自动重试。在配置文件中添加一行即可:
ribbon.MaxAutoRetriesNextServer=0
总结:
作为客户端,应该注意http客户端是否有重试功能。
作为服务端,注意接口幂等处理。
对于重试,因为 HTTP 协议认为 Get 请求是数据查询操作,是无状态的,又考虑到网络出现丢包是比较常见的事情,有些 HTTP 客户端或代理服务器会自动重试 Get/Head 请求。如果你的接口设计不支持幂等,需要关闭自动重试。但,更好的解决方案是,遵从 HTTP 协议的建议来使用合适的 HTTP 方法。
彩蛋
Nginx 也有类似的重试功能。