系统采用微服务开发后每个服务职责更加单一、清晰,更容易开发维护和迭代,但也带来了一些管理上的困难,例如本篇要讨论的服务优雅下线。
服务下线通常是在我们发版更新的时候,简单的下线很容易理解,只要把服务干掉就行了。但现实中要考虑的问题比较多,例如下线过程其它服务还调用它就报错了,下线过程如果还有定时任务在执行耗时逻辑也可能会报错,强制下线还可能导致数据不一致等问题。
那么什么是服务优雅下线呢?服务优雅下线要实现的是下线过程不对系统产生影响,包括调用方报错,下线时服务报错,数据出现异常等。
前提
要做到服务优雅下线至少要部署两个或以上服务实例,只有一个实例,在下线过程中一定会停止对外服务,有请求就一定会报错。如果有多个实例,就可以滚动的下线上线,一个实例下线过程中还有其它实例对外提供服务。
本篇我们以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注册中心将服务标记为下线状态,但仍存在以下问题
eureka多级缓存,默认情况下使用的是三级缓存,从注册中心下线只是从一二级缓存下线,而三级缓存
readOnlyCacheMap
默认每30s才从二级缓存同步数据,此时可能还缓存着服务的活跃状态,其它服务从三级缓存同步到也是活跃状态,会认为服务还存活着,可以继续调用。客户端缓存,客户端从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(null, null);
}
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
为例,停止接收任务可以调用shutdown
和shutdownNow
方法,两者的区别是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
END
精品资料,超赞福利,免费领
最近开发整理了一个用于速刷面试题的小程序《面试手册》【点击使用】;其中收录了上千道常见面试题及答案(包含基础、并发、JVM、MySQL、Redis、Spring、SpringMVC、SpringBoot、SpringCloud、消息队列等多个类型),欢迎您的使用。
👇👇