《Java 服务端通信工程》15.3 运行手册与复盘

运行手册要在凌晨三点也能照着做。本节给出 linkd-v3 的启动停止、健康检查、五类常见故障的处置顺序、升级回滚步骤和值班交接模板,用两段可编译的 Java 代码实现优雅关闭与健康探针,并给出带「验收缺口」的复盘格式、回归证据要求和常见误操作清单。

本节目标:把前两节的架构与验收沉淀成值班人员能直接执行的步骤,减少「知道原理但不知道先敲哪条命令」的时间。
口径:命令是执行模板;带「本机」字样的输出才来自真实执行,其余为示意。

承接的问题

验收通过只说明系统在受控条件下能工作。上线之后,值班人员面对的是不完整的信息:告警只说「错误率上升」,没说哪一层;日志里有关键字,但没有上下文;仪表盘有十几个面板,不知道先看哪一个。

运行手册的作用不是重复架构文档,而是把「症状 → 第一条命令 → 判断 → 下一步」写成固定路径。好的手册有三个特征:

  • 每条命令都能直接复制,不需要先猜主机名或端口。
  • 每个判断都有明确的阈值或分支,不是「进一步排查」。
  • 每个高危动作都有回滚方式和影响说明。

本节按生命周期组织:启动、健康检查、故障处置、升级回滚、交接。复盘的「验收缺口」放在最后,让每次故障都能回到 15.2 的清单里。

启动与停止

启动顺序:先确认 libraryd 可用,再启动 comm-server 9093,最后启动 gateway 9094。顺序反了,网关会在启动自检时把下游标记为不可用,虽然后续会恢复,但告警会被误触发。

#!/usr/bin/env bash
set -euo pipefail
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home

# 1. 依赖先就绪
curl -fsS -o /dev/null http://127.0.0.1:8080/healthz

# 2. comm-server
nohup "$JAVA_HOME/bin/java" \
  -Xms512m -Xmx512m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -jar comm-server/target/comm-server-3.0.0-SNAPSHOT.jar \
  > /var/log/linkd/comm-server.log 2>&1 &
echo $! > /var/run/linkd-comm-server.pid

# 3. 等就绪,而不是 sleep 固定秒数
for i in $(seq 1 30); do
  if curl -fsS http://127.0.0.1:9095/readyz > /dev/null 2>&1; then
    break
  fi
  sleep 1
done
curl -fsS http://127.0.0.1:9095/readyz

# 4. gateway
nohup "$JAVA_HOME/bin/java" \
  -Xms512m -Xmx512m \
  -jar comm-gateway/target/comm-gateway-3.0.0-SNAPSHOT.jar \
  > /var/log/linkd/gateway.log 2>&1 &
echo $! > /var/run/linkd-gateway.pid

readyz 和 healthz 的语义要分开:healthz 回答「进程活着」,readyz 回答「能接流量」。启动脚本等 readyz,负载均衡也只把流量发给 readyz 通过的实例。只有 healthz 的实例可能在依赖未就绪时接流量,返回一片 503。

停止要用 SIGTERM,让进程执行优雅关闭:

kill -TERM "$(cat /var/run/linkd-gateway.pid)"
for i in $(seq 1 20); do
  if ! kill -0 "$(cat /var/run/linkd-gateway.pid)" 2>/dev/null; then
    echo "stopped in ${i}s"
    exit 0
  fi
  sleep 1
done
echo "still running after 20s" >&2
exit 1

不要用 kill -9 收尾。它会跳过关闭钩子,在途请求被重置,连接池不释放,日志也来不及 flush。如果 20 秒还没退出,先抓线程栈再决定,而不是直接强杀。

优雅关闭的实现要覆盖三件事:停止接收新请求、排空在途请求、限时退出。

package com.plume.linkd.v3.server;

import io.grpc.Server;

import java.util.concurrent.TimeUnit;

public final class GracefulShutdown {

    private final Server server;
    private final long graceSeconds;

    public GracefulShutdown(Server server, long graceSeconds) {
        this.server = server;
        this.graceSeconds = graceSeconds;
        Runtime.getRuntime().addShutdownHook(new Thread(this::stop, "grpc-shutdown"));
    }

    public void stop() {
        server.shutdown();
        try {
            if (!server.awaitTermination(graceSeconds, TimeUnit.SECONDS)) {
                server.shutdownNow();
                server.awaitTermination(5, TimeUnit.SECONDS);
            }
        } catch (InterruptedException e) {
            server.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

server.shutdown() 停止接收新调用,并让在途调用继续完成;awaitTermination 等待排空;超时后 shutdownNow() 强制取消。顺序不能反:先 shutdownNow() 会让在途请求直接失败,客户端看到的是 UNAVAILABLE,而不是完整的响应。

关闭钩子只适合单进程。容器环境要确认 SIGTERM 真的传到了 JVM,而不是被 entrypoint 脚本吞掉;Kubernetes 的 terminationGracePeriodSeconds 要大于这里的 graceSeconds。

健康检查

三个端点,职责不同:

端点端口检查内容失败时
/healthz9095进程存活、事件循环可调度重启实例
/readyz9095依赖可达、连接池已建立、限流器已加载摘除流量,不重启
/metrics9095Prometheus 文本告警,不影响业务
curl -fsS http://127.0.0.1:9095/healthz
curl -fsS http://127.0.0.1:9095/readyz
curl -fsS http://127.0.0.1:9095/metrics | head -40
lsof -nP -iTCP:9093 -sTCP:LISTEN
lsof -nP -iTCP:9094 -sTCP:LISTEN

readyz 不能检查太多东西。如果它依赖 libraryd,那么 libraryd 一抖动,所有实例同时被摘除,故障从「部分功能不可用」升级为「全站不可用」。正确的做法是:readyz 只检查「本实例能否接流量」的本地条件,下游健康由熔断和降级处理。

探针也可以用 Java 写,便于在测试和运维工具里复用:

package com.plume.linkd.v3.server;

import io.grpc.ConnectivityState;
import io.grpc.ManagedChannel;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class HealthProbe {

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofMillis(200))
            .build();

    public boolean httpOk(String baseUrl) {
        HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/readyz"))
                .timeout(Duration.ofMillis(500))
                .GET()
                .build();
        try {
            return http.send(request, HttpResponse.BodyHandlers.ofString()).statusCode() == 200;
        } catch (Exception e) {
            return false;
        }
    }

    public String channelState(ManagedChannel channel) {
        ConnectivityState state = channel.getState(true);
        return state == null ? "UNKNOWN" : state.name();
    }
}

channel.getState(true) 会尝试建立连接,适合启动自检,不适合每秒调用。运行期监控用 getState(false),只读当前状态,不触发连接。READY 表示至少有一条可用连接;TRANSIENT_FAILURE 通常意味着正在重连或下游不可达。

五类常见故障的处置

UNAVAILABLE

症状:网关返回 503,原因码 UPSTREAM_UNAVAILABLE,日志里出现 Connection refused 或 NameResolver 失败。

处置顺序:

lsof -nP -iTCP:8080 -sTCP:LISTEN          # libraryd 是否在听
pgrep -fl 'libraryd.*jar'                  # 进程是否存在
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/healthz
lsof -nP -iTCP:9093 -sTCP:ESTABLISHED | wc -l

判断:没有 LISTEN 是 libraryd 没起来;有 LISTEN 但 curl 超时,是应用卡住;ESTABLISHED 数为 0 但进程在,可能是连接池未建立或 DNS 解析失败。先恢复依赖,再观察熔断是否自动半开;不要急着重启网关,重启会丢掉熔断状态和连接池。

DEADLINE_EXCEEDED

症状:网关返回 504,原因码 DEADLINE_EXCEEDED,p99 上升,但错误率可能不高。

处置顺序:

curl -fsS http://127.0.0.1:9095/metrics | grep -E 'rpc_duration|queue|inflight'
jcmd "$(cat /var/run/linkd-comm-server.pid)" Thread.print > /tmp/threads-1.txt
sleep 5
jcmd "$(cat /var/run/linkd-comm-server.pid)" Thread.print > /tmp/threads-2.txt
netstat -anv -p tcp | awk '$4 ~ /\.8080$/ || $5 ~ /\.8080$/'

判断:线程栈两次都停在同一个业务方法或锁上,是代码阻塞;停在 socket read,是 libraryd 慢;Recv-Q 持续大于 0,是应用没来得及读。先确认是「下游慢」还是「自己卡」,再决定扩容、回滚还是限流。

RESOURCE_EXHAUSTED

症状:网关返回 429,原因码 RATE_LIMITED,或服务端返回 RESOURCE_EXHAUSTED。

处置顺序:

curl -fsS http://127.0.0.1:9095/metrics | grep -E 'ratelimit|concurrency|queue_rejected'
curl -sS -D - -o /dev/null http://127.0.0.1:9094/books/978-7-111-00001-1

判断:看 Retry-After 和限流维度。如果是单租户打满配额,通知租户或临时提额;如果是全局打满,先确认是真实流量还是重试风暴。重试风暴的特征是「入口 QPS 没涨,下游 QPS 涨了 3 倍」,此时应该降低重试次数,而不是扩容。

证书过期

症状:TLS 握手失败,客户端报 certificate expired,服务端日志出现 SSLHandshakeException。

openssl x509 -in /etc/linkd/server.crt -noout -subject -enddate
openssl s_client -connect 127.0.0.1:9093 -alpn h2 -servername localhost </dev/null
keytool -list -v -keystore /etc/linkd/truststore.p12 -storetype PKCS12 | grep -A2 'Valid'

判断:notAfter 已过就是过期;证书未过期但握手失败,检查 SAN 是否包含客户端使用的主机名、CA 链是否完整、客户端 truststore 是否包含该 CA。轮换证书要走灰度:先更新客户端信任,再更新服务端证书,否则会出现「服务端换了、客户端不认」的空窗。

连接泄漏

症状:lsof 的连接数持续上涨,TIME_WAIT 增多,最终出现 Too many open files。

lsof -nP -iTCP:9093 -sTCP:ESTABLISHED | wc -l
lsof -nP -p "$(cat /var/run/linkd-comm-server.pid)" | wc -l
netstat -anv -p tcp | awk '$6 == "TIME_WAIT"' | wc -l
jcmd "$(cat /var/run/linkd-comm-server.pid)" Thread.print | grep -c 'grpc-default-executor'

判断:连接数随请求数单调上涨、请求停止后不回落,是泄漏;TIME_WAIT 多但总数稳定,是正常的连接复用模式。看连接池的借出数与归还数是否相等,差值是泄漏的下界。修复前先重启实例止血,但要把现场(线程栈、连接列表、指标快照)保存下来。

macOS 没有 ss,连接状态用 netstat 和 lsof;抓包用 sudo tcpdump -i lo0 -nn port 9093。生产如果是 Linux,命令要换成对应的 ss -tanp,不要把 macOS 的命令直接抄过去。

升级与回滚

升级按灰度推进,每一步都有明确的观察窗口和回滚条件:

阶段流量观察时长回滚条件
部署0%就绪检查readyz 失败
金丝雀5%10 分钟错误率 > 1% 或 p99 > 500 ms
扩大25%15 分钟错误率 > 0.5% 或 p99 > 400 ms
全量100%30 分钟任一 SLO 跌破

回滚不是「重新部署旧版本」这么简单,要确认:

  • 数据库 schema 是否兼容旧版本(只加字段、不删不改)。
  • 新版本写入的数据,旧版本能否读取。
  • 连接是否会被强制断开,还是走 drain。
  • 配置项是否有新增必填项。
# 回滚到上一个版本
./deploy.sh --version "$(cat /var/run/linkd/previous-version)"
# 确认流量已切回
for i in $(seq 1 20); do
  curl -sS -H 'x-canary: v2' -o /dev/null -w '%{http_code} ' \
    http://127.0.0.1:9094/books/978-7-111-00001-1
done
echo
curl -fsS http://127.0.0.1:9095/readyz

回滚后至少观察 30 分钟,确认延迟、错误率、连接数都回到基线。只看「没有 5xx」不够:熔断可能还开着,流量其实没有真正恢复。

值班交接

交接不是「我下班了」。至少包含六项:

项内容证据
当前状态各服务版本、副本数、健康状态仪表盘截图
未闭环事件告警、工单、临时变更工单链接
容量余量当前 QPS / 上限、预计峰值容量表
证书与配额最近到期时间、剩余配额日期与数字
近期变更发布、配置、演练变更记录
待办与风险明日计划、已知隐患文字说明

口头交接容易漏项,用固定模板填一遍,双方确认。临时变更尤其要写:为了止血改的限流阈值、临时打开的注入开关、手工加的 DNS 记录,如果不写进交接,下一个人会在几小时后再次踩坑。

复盘:从现象回到验收缺口

复盘固定七段,最后一段是本书强调的「验收缺口」:

# <一句话标题>

## 影响
受影响接口、时间段、错误率、用户可见现象。

## 时间线
UTC+8 时间 | 事件 | 证据来源(日志/指标/命令输出)

## 根因
直接原因、触发条件、为什么现有测试没发现。

## 处置
止血动作、恢复时间、是否回滚。

## 改进项
动作 | 负责人 | 截止日期 | 对应章节

## 验收缺口
本应覆盖的检查项 | 为什么没覆盖 | 补进 15.2 的哪一组

## 回归证据
修复后重跑的命令 | 结果 | 证据存放位置

「验收缺口」要指到具体一行,例如「半开连接只测了 10 秒,没测 100 次循环后的 fd 增长,补进 15.2 故障注入第 4 条」。写「测试不足」无法验收,也无法分配负责人。

常见错误与边界

错误一:用 kill -9 停服务。 跳过关闭钩子,在途请求被重置,连接池不释放。除非进程完全无响应,否则先用 SIGTERM。

错误二:启动脚本用固定 sleep。 机器快时浪费时间,机器慢时还没就绪就接流量。用 readyz 轮询,带超时。

错误三:readyz 检查下游。 libraryd 抖动会让所有实例同时被摘除,故障扩大。readyz 只检查本地接流量的条件。

错误四:回滚只换镜像。 schema、配置、连接 drain 都可能不兼容。回滚前逐项确认。

错误五:交接只交「没事」。 临时变更、即将到期的证书、未闭环告警,都是下一个人最需要的信息。

错误六:复盘只写改进项,不写验收缺口。 改进项修的是这一次,验收缺口修的是下一次。

边界:运行手册会过时。 端口、命令、依赖版本变化后要更新;每次故障处置发现手册与实际不符,当场记下来,事后修订。

边界:手册不能替代判断。 它给的是默认路径,不是唯一路径。遇到手册没覆盖的情况,先止损、再记录、后补充。

小结

  • 启动等 readyz 而不是固定 sleep,停止用 SIGTERM 并验证 20 秒内退出。
  • 优雅关闭顺序:shutdown() 停新请求、awaitTermination 排空、超时才 shutdownNow()。
  • healthz 管存活、readyz 管接流量、metrics 管观测;readyz 不检查下游。
  • 五类故障各有固定第一条命令:UNAVAILABLE 查端口、DEADLINE 查线程栈、RESOURCE 查限流维度、证书查 notAfter、泄漏查连接数趋势。
  • 升级按 5%→25%→100% 灰度,回滚要确认 schema、配置、drain 和流量真正切回。
  • 交接写六项,复盘写七段;「验收缺口」指到 15.2 的具体检查项。

阅读导航:上一节 15.2 联调、验收清单与故障注入 | 下一节 目录与学习路径

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收