0%

jvm 优雅停机

本文讨论如何让 jvm 优雅停机

什么是优雅停机

向应用进程发出停止指令之后,能保证正在执行的业务操作不受影响,直到操作运行完毕之后再停止进程

要解决的问题

  1. 停止接收新的请求,停止启动新的定时任务,停止接收消息
  2. 正在处理的请求,等待请求处理完;正在执行的任务、消息,执行完毕

异常关闭应用程序的危害

如果暴力的关闭应用程序,比如通过kill -9 <pid>命令强制直接关闭应用程序进程,可能造成如下危害:

  1. 正在执行的任务数据丢失或者产生中间数据
  2. 任务所持有的全局资源等不到释放,比如当前任务持有 redis 的锁,并没有设置过期时间
  3. 注册中心场景,没有主动更新服务列表,导致一段时间内请求到不存在的实例

JVM 的停机场景

导致 JVM 正常关闭的场景有:

  1. 程序运行结束
  2. JVM 发生 OOM 而退出
  3. Java 程序执行 System.exit() 指令
  4. 操作系统关闭
  5. kill pid(kill -15 pid)
  6. 终端通过 Ctrl-C 终止进程

导致 JVM 异常关闭的场景有:

  1. kill -9
  2. 主机关机
  3. 主机内存不够触发操作系统 OOM-KILLER

JVM 优雅停机的实现

实现思路

  1. 利用 JDK 提供的 Runtime.getRuntime().addShutdownHook() 方法注册 Shutdown Hook,用来接收操作系统发送的停止信号;
  2. 当接收到停止信号时,执行停机的处理逻辑:
    1. 拒绝接收新的请求、消息、任务
    2. 处理已接收的请求、消息、任务
  3. 注意超时和异常:在处理已有请求和执行停机逻辑时,可能有时间过长的时间,需要设置一个超时时间,避免进程一直无法退出;捕获异常防止停机逻辑中断

处理已有请求

注册JDK的ShutdownHook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package org.example;

public class Main {

private static Integer redisLock;

private static void hook() {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Receive shutdown signal");
if (redisLock == 1) {
System.out.println("RedisLock not released");
// 释放锁
redisLock = 0;
System.out.println("redisLock is closed");
}
}));
}

public static void main(String[] args) throws InterruptedException {
// 注册 shutdownHook
hook();
// 建立连接 占用锁资源
redisLock = 1;
System.out.println("running...");
Thread.sleep(5000);
System.out.println("exit");
}
}

在 web 服务中,需要等待已接收的请求处理完,请求都有超时时间,只要等待大于超时时间即可

1
2
3
4
5
6
7
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
Thread.sleep(10000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));

对于线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static void hook(ExecutorService executor) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
executor.shutdown();
System.out.println("executor reject new tasks");
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("Executor did not terminate in time.");
executor.shutdownNow(); // 强制终止线程池
}
System.out.println("executor shutdown");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}

拒绝新的请求

如何拒绝新的请求,不同的服务架构做法不同,下面例举几个常见的场景

Nginx 挂载多服务实例

先了解下 nginx 如何维护健康实例列表

Nginx 支持两种健康检查模式:

  1. 被动检查

    nginx 在代理请求过程中,会检测 upstream_server 对请求的响应,如果得到某个 upstream_server 在单位周期(fail_timeout)内失败次数超过阈值(max_fails,默认值为1),标记该 upstream_server 为异常;等下一个 fail_timeout,会再去请求一次,如果成功,将 upstream_server 标记为可用

    upstream_server 不需要专门提供健康检查接口

    如果发现本次请求失败,nginx 会转发给其他节点处理

  2. 主动检查

    Nginx 服务定时向 upstream_server 发出健康检查请求,验证各个 upstream_server 的状态,同上

    upstream_server 需要专门给 nginx 提供一个健康检查的低消耗接口

使用 nginx 做负载均衡,执行shutdown Hook如下:

  1. 停机实例拒绝 nginx 的健康检查,nginx 将停机实例从均衡器列表中移除
  2. 停机实例关闭服务的Server端监听,保证不接受请求

服务注册发现

当服务端实例停止后,由于服务端不会主动去更改注册中心的注册信息,注册中心需要40秒(目前应用到Zookeeper的会话超时时间配置)才能剔除这个异常配置,在这40秒内会不断尝试访问这个不存在的实例,导致产生大量业务报错,所以在停机时需要主动从注册中心下线

  1. 从注册中心下线,断开与 zookeeper 的连接

    1. Provider 取消所有注册

    2. Consumer 取消所有订阅

  2. 协议关闭(关闭心跳检测、关闭通道)

    1. 关闭 provider 端的监听(server.close)

    2. 关闭 consumer 对其他服务的引用(client.close)

消息发布

消费者:

  1. 停止 poll 消息
  2. 关闭 consumer 与 broker 的连接(consumer.close)

Reference

项目上如何处理优雅启停的问题?

实操:大规模微服务架构下的优雅停机

dubbo - 优雅停机

[Dubbo源代码分析九:优雅停机](