求你别在 kill -9 了,这才是微服务优雅停机方式

文化   2024-12-06 09:05   湖北  

系统采用微服务开发后每个服务职责更加单一、清晰,更容易开发维护和迭代,但也带来了一些管理上的困难,例如本篇要讨论的服务优雅下线。

服务下线通常是在我们发版更新的时候,简单的下线很容易理解,只要把服务干掉就行了。但现实中要考虑的问题比较多,例如下线过程其它服务还调用它就报错了,下线过程如果还有定时任务在执行耗时逻辑也可能会报错,强制下线还可能导致数据不一致等问题。

那么什么是服务优雅下线呢?服务优雅下线要实现的是下线过程不对系统产生影响,包括调用方报错,下线时服务报错,数据出现异常等。

前提

要做到服务优雅下线至少要部署两个或以上服务实例,只有一个实例,在下线过程中一定会停止对外服务,有请求就一定会报错。如果有多个实例,就可以滚动的下线上线,一个实例下线过程中还有其它实例对外提供服务。

本篇我们以spring cloud为例,注册中心使用eureka,来探讨一下服务优雅下线的实现和一些问题。首先看下服务下线有哪些方式

kill -9 和 kill

kill -9简单粗暴,会强制杀死jvm进程,jvm进程不会收到相关退出信号,也就是我们没机会做下线前清理逻辑,不推荐使用。

不带参数的kill等同于kill -15,会发出一个SIGTERM信号给应用程序,这样程序有机会做一些清理逻辑,比较友好,在springboot中可以通过hook来响应这个信号。

Runtime.getRuntime().addShutdownHook(new Thread(() -> log.info("application shutdown")));

结合eureka来看,kill会有信号通知到springboot服务,所以它会先从eureka下线,然后再关闭服务。kill -9直接杀死进程,所以没有机会通知注册中心,eureka上看到还是活跃状态,只能等eureka检测一定时间服务没续约才会把服务改为下线状态。

问题:

尽管kill会通知服务,并从eureka注册中心将服务标记为下线状态,但仍存在以下问题

  1. eureka多级缓存,默认情况下使用的是三级缓存,从注册中心下线只是从一二级缓存下线,而三级缓存readOnlyCacheMap默认每30s才从二级缓存同步数据,此时可能还缓存着服务的活跃状态,其它服务从三级缓存同步到也是活跃状态,会认为服务还存活着,可以继续调用。

  2. 客户端缓存,客户端从eureka拉取服务实例后,也会在本地做一个缓存,就算eureka三级缓存更新为下线状态了,客户端也还使用着缓存,未同步最新的状态。

/actuator/shutdown 端点

该接口属于actuator的一部分,我们可以通过如下配置开启

management:
  endpoint:
    shutdown:
      enabled: true

shutdown和kill类似,会通知注册中心下线,再关闭服务,依然存在上面说的缓存问题。

/actuator/pause 端点

与shutdown相同,pause也会先通知注册中心下线,但是不会关闭服务,此时服务依然可以处理请求,这样调用方通过缓存实例调用就还是正常的,我们可以等一段时间(缓存过期后)后再通过shutdown或者kill关闭服务。

可以通过如下配置开启pause端点

management:
  endpoint:
    pause:
      enabled: true
    restart:
      enabled: true

初步看起来pause很好用,但不足的是它需要关闭eureka client healthcheck,也就是需要做如下配置,默认为true,pause端点会无效。

eureka:
  client:
    healthcheck:
      enabled: false

/actuator/service-registry 端点

service-registry端点可以修改服务的状态,我们可以先将服务修改为下线状态,然后等一段时间(缓存过期后)后再通过shutdown或者kill关闭服务。

可以通过如下配置开启service-registry端点

management:
  endpoint:
    service-registry:
      enabled: true

service-registry还有一个特点就是当我们修改服务为下线状态后,如果想回滚,恢复到正常状态,只需要将status改为UP即可。

curl --location --request POST 'localhost:8090/actuator/service-registry' \
--header 'Content-Type: application/json' \
--data-raw '{
    "status": "DOWN"
}'

和pause一样,由于没有关闭服务,所以service-registry不会触发shutdown hook

springboot 2.3优雅停机

springboot 2.3开始对停服提供了一些支持,可以做如下配置

# 开启优雅停机, 如果不配置是默认IMMEDIATE, 立即停机
server.shutdown=graceful
## 优雅停机宽限期时间
spring.lifecycle.timeout-per-shutdown-phase=90s    

当执行shutdown端点时,不会像低版本一样立刻关闭服务,而是会等待一定的时间,我们可以通过配置这个时间等没有新的请求和请求处理完成后,再通过shutdown或者kill关闭服务。

自定义端点

我们也可以自定义端点来实现一些自定义逻辑,自定义端点通过@Endpoint(id = "myshutdown")标记,接着可以使用@ReadOperation或者@WriteOperation标记方法,通过get/post请求访问/actuator/myshutdown

接着可以使用EurekaClient.shutdown通知注册中心下线,然后等一段时间(缓存过期后)后再通过shutdown或者kill关闭服务。

如下,自定义端点的缺点是绑定了eureka client,如果有一天注册中心换了这里就不能用了,而使用actuator提供的端点,一般注册中心都会实现spring cloud的标准,所以可以屏蔽具体的实现。

@Endpoint(id = "myshutdown")
public class GracefulShutdownEndpoint {

 @Autowired
 private EurekaClient eurekaClient;

 @WriteOperation
 public Result shutdown(String key) {
        eurekaClient.shutdown();
    }
|

问题

缓存问题

无论使用哪种端点,都没有解决上面提到的缓存问题,上面我们也提到第一步是从注册中心下线,需要等一段时间,让eureka三级缓存和客户端服务拉取到最新的状态,才能关闭服务。那么需要等多久呢?答案是需要根据实际配置决定,默认是90s。90s是取各个缓存最大的过期时间:

  • 30s eureka三级缓存同步时间

  • 30s 客户端服务从eureka server同步时间

  • 30s ribbon客户端缓存时间(默认使用ribbon的话)

所以正常情况下,当我们告诉注册中心下线后,客户端最长90s后就能感知到下线状态,就不会再调用下线的实例了。除此之外我们还要等正在执行的请求执行完,以http请求为例,通常请求都在毫秒级完成,假设刚好在90s仍然发出一个请求,我们可以再等个几秒钟处理完请求,再关闭服务,例如5s,那么总等待时间就是95s,在95s后可以放心地调用kill或者shutdown关闭服务。

对于耗时请求,例如执行时间大于5s的,这种建议使用异步的方式,请求只是个触发动作,结果通过其它接口查询,并且可以重试。

网关问题

上面我们讨论的服务的调用方是注册中心的其它服务,还有一个特殊的服务就是网关,因为网关的流量来自于外部,一般是nginx,不会有上面的缓存问题。如果网关在下线,nginx请求依然路由过来就会报错。

这个就没法从注册中心层面下手,只能通过运维,在下线网关的时候,先从nginx摘掉这个节点,上线后再重新添加。

流量问题

假设我们有两个节点,其中一个下线,流量必然都会流到另一个上面,这个时候得考虑下服务是否能扛得住。如果在流量高峰做这个操作,扛不住的话需要先加节点,例如加到3个,避免因为流量过大冲垮服务,或者在闲时再做这个操作。

其它

上面我们讨论的都是http请求范围,要让http请求执行完,并且没有新的http请求,这个时候来关闭服务。但是服务还有一些情况会在处理逻辑,例如消费mq,定时任务,线程池任务,大while循环,我们也希望这些逻辑在收到下线信号时停止执行,这样才能安全放心地关闭服务,接下来就讨论下如何处理这几种情况。

如果是自定义端点,我们可以在收到请求的时候,发出一个进程内消息,广播服务要准备下线了,让订阅方做相应的处理,进程内消息可以用guava eventbus来实现。

我们可以定义个ShutdownRegistry用来保存所有的Shutdown事件,当下线时,如上面自定义端点的shutdown方法调用ShutdownRegistry.shutdown,遍历所有事件发出通知

public class ShutdownRegistry {

 public static ConcurrentHashMap<Shutdown, EventBus> buses = new ConcurrentHashMap<>();

 public static void register(Shutdown shutdown) {
  Assert.notNull(shutdown, "shutdown must not null");
  EventBus eventBus = new EventBus("application-showdown");
  eventBus.register(shutdown);
  buses.put(shutdown, eventBus);
 }

 public static void remove(Shutdown shutdown) {
  Assert.notNull(shutdown, "shutdown must not null");
  buses.remove(shutdown);
 }

 public static void showdown() {
  buses.values().forEach(eventBus -> eventBus.post(new ShutdownEvent(System.currentTimeMillis())));
 }
}

Shutdown对象主要有两个属性,一个shutdown标记,表示是否触发下线,一个consumer,在事件触发的时候回调

public class Shutdown {

 String name;
 private boolean shutdown;
 private Consumer consumer;

 public Shutdown() {
  this(nullnull);
 }

 public Shutdown(String name) {
  this(name, null);
 }

 public Shutdown(Consumer consumer) {
  this(null, consumer);
 }

 public Shutdown(String name, Consumer consumer) {
  this.name = name == null ? "default" : name;
  this.consumer = consumer;
  ShutdownRegistry.register(this);
 }

 public void unRegister() {
  ShutdownRegistry.remove(this);
 }

 public Boolean hasShutdown() {
  return shutdown;
 }

 @Subscribe
 public void subcribe(ShutdownEvent se) {
  System.out.println(se.getTime());
  shutdown = true;
  if (consumer != null) {
   consumer.accept(se);
  }
 }
}

对于kafka,我们希望在服务下线时停止消息的消费。可以通过相应shutdown事件,使用pause停止对topic的消费,这样就不会再从kafka server pull消息了。

@Slf4j
@Component
public class ConsumerShutdown {

 @Autowired
 KafkaListenerEndpointRegistry registry;

 public ConsumerShutdown() {
  ShutdownRegistry.register(new Shutdown(s -> {
   for (MessageListenerContainer container : registry.getListenerContainers()) {
    if (!container.isPauseRequested()) {
     log.info("consumers with topics: {} paused because of shutdown application", container.getContainerProperties().getTopics());
     container.pause();
    }
   }
  }));
 }
}

对于定时任务,例如使用xxljob,我们希望在服务下线时,暂停定时任务的执行。对于循环执行的任务,例如1h执行一次,要支持在服务重新上线后可以继续执行,对于只执行一次的任务,例如每天15点触发一次,要么在服务重新上线后再次触发,要么避免在这个时候做服务下线。

我们可以通过一个切面拦截@XxlJob注解,在执行前判断shutdown标记,如果为true就直接返回。

@Aspect
@Component
public class XxlJobShutdown {

 private static ConcurrentHashMap<String, Shutdown> CACHE = new ConcurrentHashMap<>();

 @Around(value = "@annotation(xxlJob)")
 private ReturnT<String> before(ProceedingJoinPoint joinPoint, XxlJob xxlJob) throws Throwable {
  CACHE.computeIfAbsent(xxlJob.value(), s -> new Shutdown(xxlJob.value()));
  if (CACHE.get(xxlJob.value()).hasShutdown()) {
   return new ReturnT<>(500"application shutdown");
  }
  return (ReturnT<String>) joinPoint.proceed();
 }
}

对于线程池,也需要停止接收新的任务,后面提交的任务就不再接受了。

ThreadPoolExecutor为例,停止接收任务可以调用shutdownshutdownNow方法,两者的区别是shutdown会停止接收新的任务,但会处理完目前正在整理和在队列排队的任务,而shutdownNow会立刻停止接收新的任务,队列内的任务也不再执行,对于正在执行的线程会抛出InInterruptedException

这里我们不希望抛出异常,否则还需要处理异常,同时建议队列不要过长,一是占用较多内存资源,二是队列太长等待执行完的时间也会较长。这里我们使用之前封装的加强版ThreadPoolExecutor,在其构造函数内加一行代码即可

ShutdownRegistry.register(new Shutdown(s -> this.poolExecutor.shutdown()));

对于大while循环,例如程序有时候需要扫描某张表的所有数据,可能会有如下代码

while(true) {
    List<DbData> list = mapper.selectList(param);
    if(CollectionUtils.isEmpty(list)) {
        break;
    }
    //处理数据
}

这个时候我们希望在收到下线信号时,退出while循环。一种方式是侵入代码去判断,例如在while内每次都判断一下,一种是对while进行封装,如下

@Slf4j
public class LoopShutdown extends Shutdown {

 public LoopShutdown() {
 }

 public LoopShutdown(String name) {
  super(name);
 }

 public static LoopShutdown build() {
  return new LoopShutdown();
 }

 public static LoopShutdown build(String name) {
  return new LoopShutdown(name);
 }

 /**
  * while 循环
  * @param supplier return true:break while,return false:continue while
  */

 public void loop(Supplier<Boolean> supplier) {
  while (true) {
   if (hasShutdown()) {
    log.info("{} shutdown loop", name);
    break;
   }
   if (supplier.get()) {
    break;
   }
  }
  //循环后清除注册对象
  unRegister();
 }

 /**
  * while 循环
  * @param supplier return true:break for,return false:continue for
  */

 public void loop(int forTotal, Supplier<Boolean> supplier) {
  for (int i = 0; i < forTotal; i++) {
   if (hasShutdown()) {
    log.info("{} shutdown loop", name);
    break;
   }
   if (supplier.get()) {
    break;
   }
  }
  unRegister();
 }
}

原先的while需要改写如下,new LoopShutdown在基类就会注册eventbus,所以下线时会收到通知。

new LoopShutdown().loop(s -> {
    List<DbData> list = mapper.selectList(param);
    if(CollectionUtils.isEmpty(list)) {
        return true;
    }
    //处理数据
    return false;
})

以上就是应对下线时要处理的几种特殊情况,但这并不通用,例如有些应用使用的是rocketmq,定时任务使用的不是xxljob,这里主要提供思路,实际可以根据具体情况去实现。

作者:jtea
来源:https://github.com/jmilktea/jtea

👉最新2T+免费Java视频学习资料点击领取>>


END

精品资料,超赞福利,免费领


微信扫码/长按识别 添加【技术交流群
群内每天分享精品学习资料


最近开发整理了一个用于速刷面试题的小程序《面试手册》【点击使用】;其中收录了上千道常见面试题及答案(包含基础并发JVMMySQLRedisSpringSpringMVCSpringBootSpringCloud消息队列等多个类型),欢迎您的使用。


玩转 ReflectionUtils 工具类,离大佬又近一步
一款超好用的国产 Redis 可视化工具,高颜值 UI,真香!
小学弟把 mybatis-plus 用得炉火纯青
牛X,新同事把代码耗时从 26856ms 优化到了 748ms
【原创】怒肝3W字Java学习路线!从入门到封神全包了(建议收藏)
程序员专属导航站(baoboxs.com),一站式工作、学习、娱乐!

👇👇


👇点击"阅读原文",领更多资料(更新中...

一行Java
专注JAVA;技术分享,讨论交流。
 最新文章