实战④:测试、CI/CD 与上线交付
前面 45 篇已经把 BeeOrder 写完了:能下单、能扣库存、能收支付回调。这一篇只做一件事——把它从「在我电脑上跑得动」变成「任何人都能在别处跑得动,而且出事时退得回去」。交付的本质,是把三类隐性前提写成显式资产:可重复的构建(同一份代码在任何机器打出同一个产物)、可验证的质量(该拦的错误由机器拦,而不是靠人记得)、可退场的运行(新版本进来时,旧版本先排空再退出)。这三件事只要还有一件依赖「我记得」,故障就一定从那件发生。
先把六个词一句话解释(全文反复出现):
- 制品(artifact):一次构建产出的、之后不再修改的交付物——一个 jar 或一个镜像 tag。上线部署的永远是制品,不是源码
- 镜像 tag:制品的指纹。用 commit sha 打 tag,才能回答「线上此刻跑的到底是哪一次构建」这个要命的问题
- 就绪探针(readiness):回答「现在能不能给我流量」。依赖没备好,就该摘流量,而不是杀进程
- 存活探针(liveness):回答「这个进程还有救吗」。只该判断进程自身,配错了会让一次数据库抖动变成全体实例重启
- 优雅停机(graceful shutdown):先不再接新请求,等在途请求跑完,再退出进程
- 灰度发布(canary):先只把 5% 的流量交给新版本,确认没问题再逐步扩大
餐厅打烊。 一家店收摊的正确顺序是:先把门口的「营业中」翻成「暂停接待」(readiness 变红、停止接新请求),再让服务员把桌上客人的最后一道菜上完、账结掉(在途请求排空),最后才关灯锁门(进程退出)。直接拉闸(SIGKILL)是什么后果?客人嘴里还含着饭、账没结就走了——落到系统里,就是一条「库存已扣、订单未写」的中间态数据。所以优雅停机不是一个开关,而是一整套打烊流程。
便当盒分层装。 镜像不是一整块,而是叠起来的只读层:米饭(依赖,几乎不变)、菜(你的代码,天天变)、酱料(配置,按环境换)。分层的价值是——只换菜不用重蒸米饭:改一行业务代码时,那层几百 MB 的依赖照样从缓存里直接取。反过来说,把饭菜酱全炒成一团(COPY . . 之后再装依赖),改一个字就得整盒重做,构建从几十秒涨回几分钟。

上图五道关正好对应本篇的主线:质量护栏(第二、三节)、构建护栏(第四、五节)、运行护栏(第六至十节)、退场纪律(第八节)、发布节奏(第十二节)。任何一次上线翻车,都能在这棵树上找到一个唯一的位置——这就是排查的起点,也是本篇最后那张报错速查表的索引方式。

这条时间线是本篇最重要的一张图,后面每一节都在拆它的某一步。先记住三个结论:能上线的只有 CI 验证过的那个 tag(第①帧)、老实例的退场是「先摘流量再排空」两步而不是一步(第④⑤帧)、硬杀是宽限期到点的兜底,不是常规手段(第⑦帧)。
学完这一篇,你应该能回答三个问题:
- 「在我电脑上是好的」到底缺了哪三类前提?分别由哪个环节补上?
- 为什么 readiness 能挂数据库、liveness 绝对不能?配反了会发生什么具体事故?
- 一次
docker compose up -d之后,那 30 秒里应当按什么顺序确认哪些事?
「在我电脑上是好的」是交付史上最贵的一句话。它的潜台词是:我的机器上堆着一批隐性条件——版本、时区、内存、配置——恰好凑成了能跑的状态,而这些条件从来没被写下来。上线要做的,就是把这些隐性条件全部显式化。
三个真实翻车现场,每一个我都亲眼见过:
| 翻车现场 | 现象 | 根因 | 交付时该有的防线 |
|---|---|---|---|
| JDK 版本不一致 | 服务器 java -jar 第一行就 UnsupportedClassVersionError | 本地 JDK 21 编译,服务器只装了 17 | 镜像里锁定 JDK,构建产物与运行时同一基础镜像 |
| 时区没对齐 | 订单时间比用户看到的早 8 小时,对账对不上 | 容器默认 UTC,应用/数据库各按自己的默认跑 | 全链路统一时区,镜像显式设置 TZ |
| 连接池照搬本地 | 流量一上来就 Connection is not available, request timed out | 生产沿用默认 maximumPoolSize=10 | 连接池按数据库上限与并发推算,并纳入压测 |
这三个场景的共同点不是「某个参数写错了」,而是环境没有被版本化。把隐性前提摊开看,其实只有三类,每类都有自己的「显式化位置」和「验证者」:
| 隐性前提 | 平时藏在哪 | 显式化之后写在哪 | 谁来验证 |
|---|---|---|---|
| 构建前提:JDK 版本、Maven 插件、依赖树 | 你本机的 ~/.m2 和 IDE 里的 SDK 下拉框 | Dockerfile 的第一行 FROM + pom.xml | CI 每次开一台干净的 runner,不给缓存 |
| 运行前提:时区、内存限额、连接池、GC | 你本机 32GB 内存,以及「本地没人访问所以不炸」 | Compose 的 environment / JAVA_TOOL_OPTIONS / deploy.resources | 健康检查 + 压测 |
| 协作前提:谁 review 过、测试跑没跑、灰度切没切 | 团队的记忆和群聊记录 | 分支保护规则 + 流水线的 needs 门禁 | 机器,不接受「我本地跑过了」 |
交付不是「把 jar 拷上去」,而是「让任何人都能用同一条命令、在任意一台机器上,得到同一个可运行的系统」。凡是没写进代码或配置里的前提,都是未来的故障单。
测试不是写得越多越好。它的意义是在特定成本下覆盖特定风险。把测试分成三层,每层只负责一类问题,就不会出现「写了几百个测试还是没防住线上事故」的尴尬。
| 层级 | 关键注解 / 手段 | 覆盖对象 | 依赖 | 速度 | 建议配比 |
|---|---|---|---|---|---|
| 单元测试 | JUnit 5 + Mockito | Service 业务分支、金额计算、状态流转 | 全 Mock,无 IO | 毫秒级 | 60% |
| 切片测试 | @WebMvcTest | Controller 契约:路由、参数校验、状态码、JSON 结构 | 只装 Web 层,Mock Service | 百毫秒级 | 25% |
| 集成测试 | @SpringBootTest + Testcontainers | SQL、事务、唯一约束、幂等、连接池行为 | 真 MySQL / Redis 容器 | 秒级 | 15% |
比例不是教条,而是成本约束。别用集成测试去测一个 if-else 分支,那是用秒级成本验证毫秒级问题;也别用单元测试去确认「SQL 到底走没走索引」——Mock 永远给不出真实答案。
先测下单成功的核心分支:库存充足、金额正确、订单落库。
@ExtendWith(MockitoExtension.class)class OrderServiceTest { @Mock private OrderMapper orderMapper; @Mock private OrderItemMapper orderItemMapper; @Mock private InventoryService inventoryService; @InjectMocks private OrderService orderService; @Test void createOrder_shouldSucceed_whenStockIsEnough() { CreateOrderCmd cmd = new CreateOrderCmd(1001L, List.of(new ItemCmd(2001L, 2))); when(inventoryService.deduct(2001L, 2)).thenReturn(true); // 扣减成功 OrderVO vo = orderService.create("idem-key-001", cmd); assertThat(vo.orderNo()).startsWith("BO"); assertThat(vo.amount()).isEqualByComparingTo("199.00"); // 金额用 BigDecimal 比较 verify(orderMapper, times(1)).insert(any(Order.class)); // 恰好落库一次 }}@Mock造出假的 Mapper 与库存服务,测试完全不碰数据库when(...).thenReturn(true)编排「库存充足」这条前置事实isEqualByComparingTo比较金额,避开BigDecimal.equals对 scale 的敏感verify(..., times(1))断言「只落库一次」——这正是幂等要守住的底线
@Testvoid createOrder_shouldFail_whenStockIsNotEnough() { CreateOrderCmd cmd = new CreateOrderCmd(1001L, List.of(new ItemCmd(2001L, 2))); when(inventoryService.deduct(2001L, 2)).thenReturn(false); // 扣减失败 BizException ex = assertThrows(BizException.class, () -> orderService.create("idem-key-002", cmd)); assertThat(ex.getCode()).isEqualTo(ErrorCode.STOCK_NOT_ENOUGH); verify(orderMapper, never()).insert(any(Order.class)); // 关键:绝不落单}- 库存扣减返回 false,业务必须抛「库存不足」,而不是静默返回
never()断言订单表一条都没插,防止「扣减失败却把订单写了进去」这种脏数据- 异常走统一错误码体系(见 #44),前端才能稳定处理
支付网关最典型的行为就是重复回调。同一笔 trade_no 回调两次,系统只能入账一次。
@Testvoid handleCallback_shouldBeIdempotent_whenDuplicated() { PayCallback cb = new PayCallback("trade-9f3a", "BO20260207001", new BigDecimal("199.00")); boolean first = paymentService.handleCallback(cb); boolean second = paymentService.handleCallback(cb); // 完全相同的重复回调 assertThat(first).isTrue(); // 第一次真实入账 assertThat(second).isFalse(); // 第二次被幂等挡下,不再改状态 verify(orderMapper, times(1)).markPaid(anyString()); // 状态只流转一次}- 第一次回调把订单从
CREATED推到PAID,返回 true - 第二次命中唯一键
trade_no或状态机校验,直接返回 false,不再重复处理 times(1)是这份测试的灵魂:幂等的定义就是「副作用只发生一次」
契约一旦定死,前端才敢并行开工。@WebMvcTest 只装 Web 层,不启动数据库,专测「HTTP 这一面」。
@WebMvcTest(OrderController.class)class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean private OrderService orderService; @Test void createOrder_shouldReturn200_withOrderNo() throws Exception { when(orderService.create(anyString(), any())) .thenReturn(new OrderVO("BO20260207001", new BigDecimal("199.00"), "CREATED")); mockMvc.perform(post("/api/orders") .header("Idempotency-Key", "idem-key-001") .contentType(MediaType.APPLICATION_JSON) .content("{\"userId\":1001,\"items\":[{\"productId\":2001,\"quantity\":2}]}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.orderNo").value("BO20260207001")); }}@WebMvcTest只扫描 Controller,Service 用@MockBean顶替,启动极快- 断言的是响应契约:状态码、统一响应体的 code 与 data 结构
- 前端把这份测试当「接口说明书」,字段名对不上时 CI 先红
SQL、唯一约束、事务回滚这些只有真数据库才测得出来。Testcontainers 的妙处在于:用代码声明一个一次性的真 MySQL,测试机器无需预装数据库。
@SpringBootTest@Testcontainersclass OrderRepositoryIT { @Container static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0") .withDatabaseName("beeorder").withUsername("bee").withPassword("bee"); @DynamicPropertySource static void props(DynamicPropertyRegistry r) { r.add("spring.datasource.url", mysql::getJdbcUrl); r.add("spring.datasource.username", mysql::getUsername); r.add("spring.datasource.password", mysql::getPassword); } @Autowired private OrderService orderService; @Test void shouldNotOversell_underConcurrency() { // 真库上跑并发下单,验证条件更新真的挡住了超卖 }}@Testcontainers让 JUnit 管理容器生命周期:类加载时起,测试后销毁@DynamicPropertySource把容器随机端口回填给 Spring,配置零硬编码- 复杂 SQL 与
WHERE available >= ?的条件更新行为,只有在这里才能被验证
交付评审时最容易骗人的数字是「行覆盖 82%」。看下面两个测试,它们都能让那一行代码计入覆盖率,但只有第二个真的能挡住回归:
// 弱断言:只证明这行被执行过,算进了覆盖率,什么都没锁住@Testvoid createOrder_any() { OrderVO vo = orderService.create("k", cmd); assertThat(vo).isNotNull();}// 强断言:把「金额怎么算、状态怎么流转、副作用几次」全部钉住@Testvoid createOrder_contract() { OrderVO vo = orderService.create("k", cmd); assertThat(vo.amount()).isEqualByComparingTo("199.00"); assertThat(vo.status()).isEqualTo("CREATED"); verify(inventoryService, times(1)).deduct(2001L, 2); verify(notificationService, never()).notify(any()); // 提交前绝不发通知}- 覆盖率衡量「代码被执行过没有」,断言衡量「行为被钉住没有」,两者互不替换
- 一条实用纪律:每个新增的业务分支,至少一条
verify或assertThrows,光isNotNull()不算 - 最后那条
never()恰恰是 #45 第十四节的护栏——通知若在事务提交前发出,覆盖率一样是 100%
单元测试永远测不出超卖——因为 Mock 不会并发。并发问题必须用并发去验证,这是本篇唯一不能妥协的一条。
@Testvoid shouldNeverOversell_when100RequestsCompeteFor5Items() throws Exception { int threads = 100; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch start = new CountDownLatch(1); // 发令枪 CountDownLatch done = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(); for (int i = 0; i < threads; i++) { final long userId = 1000L + i; pool.submit(() -> { ready.countDown(); try { start.await(); // 所有线程在此待命,同时放行 orderService.create("key-" + userId, new CreateOrderCmd(userId, List.of(new ItemCmd(2001L, 1)))); success.incrementAndGet(); } catch (BizException e) { // 库存不足,属预期失败 } catch (Exception ignored) { } finally { done.countDown(); } }); } ready.await(); start.countDown(); // 真正的高并发发生在这之后 done.await(30, TimeUnit.SECONDS); Integer remain = jdbcTemplate.queryForObject( "SELECT available FROM inventory WHERE product_id = 2001", Integer.class); assertThat(success.get()).isEqualTo(5); // 恰好 5 单成功 assertThat(remain).isZero(); // 库存归零,无超卖}ready让 100 个线程全部就位,start当发令枪,把并发压到同一瞬间success计数器断言恰好 5 单成功,多一单就是超卖- 最后再查数据库
available,确认账实一致(库存必须是 0)
| 预期指标 | 通过标准 | 说明 |
|---|---|---|
| 成功下单数 | 恰好 5 | 多了是超卖,少了是误杀 |
| 剩余库存 | 0 | 不能为负 |
| 库存流水条数 | 5 | 与成功订单一一对应 |
| 失败订单数 | 95 | 均返回「库存不足」错误码 |
并发测试若不加 start 发令枪,线程会退化成串行执行,看起来「通过」其实什么都没测到。并发测试的成败取决于是否真的同时发生,而不是线程数写了多少。
| 纪律 | 反例 | 正解 |
|---|---|---|
| 每个测试自带数据,互不依赖 | 全类共用一条 order_id=1,先跑的测完改状态,后跑的直接失败 | @BeforeEach 建数据、@AfterEach 清,或干脆每次用新 ID |
| 时间不能靠系统时钟 | 「订单 15 分钟未支付就关闭」,测试要 sleep(15min) | 把 Clock 注入进来,测试里拨一个假时间 |
| 外部依赖必须有桩 | 测试真连支付网关,CI 一跑就产生一笔真扣款 | @MockBean 或 WireMock 起一个假网关,把签名也算进去 |
集成测试请一律用 Testcontainers 现场起库,共享的「测试环境数据库」是回归测试不可信的第一大原因——别人手动改了一行数据,你的测试第二天就红,而且没人知道为什么。
CI 的价值只有一句话:让错误在合并前暴露,而不是在上线后暴露。人肉检查一定会漏,机器不会(只要规则写对)。

下面是一份可直接用的 GitHub Actions 流水线,分三阶段:构建测试 → 打包发镜像 → 部署。阶段之间用 needs 串联,前一级失败后面直接不跑,实现失败快速反馈。
name: beeorder-cion: push: branches: [ main ] tags: [ 'v*' ] pull_request: branches: [ main ]jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' cache: maven # 缓存 ~/.m2,依赖下载提速 - name: Run tests run: mvn -B -ntp clean verify # 单元 + 切片 + 集成测试一起跑 - name: Upload test report if: always() uses: actions/upload-artifact@v4 with: name: surefire-reports path: target/surefire-reports package: needs: build-and-test # 测试不过,镜像不构建 if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image uses: docker/build-push-action@v6 with: context: . push: true tags: | ghcr.io/acme/beeorder:${{ github.sha }} ghcr.io/acme/beeorder:latest deploy: needs: package if: startsWith(github.ref, 'refs/tags/v') # 只有打 tag 才真正上线 runs-on: ubuntu-latest environment: production steps: - name: Deploy over SSH uses: appleboy/ssh-action@v1 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_KEY }} script: | cd /opt/beeorder export IMAGE_TAG=${{ github.sha }} docker compose pull app docker compose up -d app ./scripts/wait-healthy.sh app # 健康检查通过才认为部署成功cache: maven是流水线提速的关键一步,重复构建时依赖不再重新下载needs串起三阶段:测试红 → 不打包;打包失败 → 不部署,省下大量等待if: startsWith(github.ref, 'refs/tags/v')把「上线」收窄到打 tag,日常 push 绝不误发布wait-healthy.sh轮询容器健康状态,避免「容器起来了但服务没好」被误判为成功
提示:CI 里跑集成测试会真的启动 MySQL 容器,切忌把测试环境变量指向生产库。测试连接串一律由 Testcontainers 提供,生产密钥永远只存在于部署环境的 secrets 里。
流水线的最后一步产出的不是 jar 而是镜像,因为镜像把「应用 + JDK + 时区 + 启动参数」一起封住了,jar 只封住了应用。这就是 #37 那套写法在交付阶段的最终形态:
# ---------- 阶段一:构建(带 JDK + Maven,用完即弃) ----------FROM maven:3.9-eclipse-temurin-21 AS builderWORKDIR /buildCOPY pom.xml .RUN mvn -B dependency:go-offline # 依赖不变 → 这一层永远命中缓存COPY src ./srcRUN mvn -B clean package -DskipTests# 把 fat jar 拆成四层,让依赖层在镜像里也命中缓存RUN java -Djarmode=layertools -jar target/beeorder.jar extract# ---------- 阶段二:运行(只有 JRE,没有 Maven、没有源码) ----------FROM eclipse-temurin:21-jre-jammyWORKDIR /appENV TZ=Asia/Shanghai \ JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m -Xss512k"# 非 root 运行,降低容器逃逸面RUN useradd -r -u 1001 -s /usr/sbin/nologin beeUSER beeCOPY --from=builder /build/dependencies/ ./COPY --from=builder /build/spring-boot-loader/ ./COPY --from=builder /build/snapshot-dependencies/ ./COPY --from=builder /build/application/ ./EXPOSE 8080# exec 形式:java 自己是 PID 1,SIGTERM 才收得到(第八节的前提)ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]- 四条
COPY的顺序就是缓存策略:越稳定的越靠上,application/放最后 JAVA_TOOL_OPTIONS放在运行阶段的环境变量里,运维可以不改镜像用-e覆盖USER bee之后,容器里连curl都要注意镜像里到底有没有这个二进制(第七节的 healthcheck 就靠它)ENTRYPOINT必须是 exec 数组形式,否则第八节整套优雅停机配置一句都不会执行
构建阶段那三个卫生问题(.dockerignore 要不要先写、builder 层的 ~/.m2 为什么每次都重下、alpine 的 musl 为什么会偶发解析失败)都藏在这一节的取舍里,切到 build 那一档能逐帧看到:
四条 COPY 的顺序背后是 Docker 的层缓存规则:越稳定的层越先复制,越容易变的层越靠外——这样重建时失效的永远只是最上面那几层。下图把镜像从底到顶拆开,点每一层看它是被什么更新触发重建的:
镜像的骨架也可以直接生成出来:勾选你这一版需要的项,每一条都注释了它对应的前提(停机信号、内存感知这些,正是第八、九节的配置能在容器里生效的前提):
# ---------- 构建阶段:要完整 JDK 与 Maven ----------
FROM maven:21-eclipse-temurin AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline # 先只拷 pom,依赖层可被缓存
COPY src ./src
RUN mvn -B -DskipTests package \
&& java -Djarmode=layertools -jar target/*.jar extract --destination /app
# ---------- 运行阶段:只要 JRE ----------
FROM eclipse-temurin:21-jre
WORKDIR /app
# 变化频率从低到高排列,改代码不会让依赖层缓存失效
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-jar/ ./
COPY --from=build /app/application/ ./
RUN groupadd -r app && useradd -r -g app app && chown -R app:app /app
USER app
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENV SPRING_PROFILES_ACTIVE="prod"
EXPOSE 8080 9090
# exec 形式:java 就是 PID 1,SIGTERM 能送达(shell 形式做不到)
ENTRYPOINT ["java", "-jar", "app.jar"]
# docker build -t beeorder:0.0.1 .
# docker run --rm -p 8080:8080 -m 512m beeorder:0.0.1流水线里那行 ./scripts/wait-healthy.sh app 是整个 CI 唯一把「容器起来了」和「服务能用了」分开的地方。别用 sleep 30,要用轮询:
#!/usr/bin/env bash# scripts/wait-healthy.sh —— 只有 readiness 真的 UP 才返回 0set -euo pipefailSVC="${1:-app}"URL="${HEALTH_URL:-http://127.0.0.1:8080/actuator/health/readiness}"DEADLINE=$((SECONDS + 120))while [ $SECONDS -lt $DEADLINE ]; do body=$(curl -fsS "$URL" 2>/dev/null || true) case "$body" in *'"status":"UP"'*) echo "healthy after ${SECONDS}s"; exit 0 ;; esac sleep 3doneecho "not healthy in 120s, dumping logs:" >&2docker compose logs --tail=200 "$SVC" >&2exit 1 # 非 0 → 流水线红 → 后续 stage 不执行,等于自动阻断发布- 用
readiness而不是health:前者是「能不能接流量」,后者是聚合状态,可能包含无关组件 - 超时后打印日志再退出,把「为什么红」直接留在构建输出里,不用登服务器
exit 1的价值在于它让「回滚」变成一个决策而不是一个意外:既然红了就没切流量,直接不部署下一批
流水线不是孤立的,它必须和分支模型咬合。BeeOrder 采用最朴素的 trunk-based 变体:main 保护 + PR 必须绿 + tag 触发发布。
| 分支 / 事件 | 触发流水线 | 必须通过 | 产物 |
|---|---|---|---|
| feature 分支 push | 仅 build-and-test | 三层测试全绿 | 无(随手验证) |
| 向 main 发 PR | build-and-test | 测试 + 契约检查 | 无(门禁) |
| 合并进 main | build-and-test + package | 测试全绿 | :sha 与 :latest 镜像 |
打 v1.2.0 tag | 全流水线 + deploy | 全部 | 部署到生产 |
main 要打开分支保护(Branch protection),要求「PR 至少 1 个 review + 所有 status check 通过」才可合并。GitLab CI 的等价写法就是 rules 按分支/tag 匹配 + only: tags,思路一模一样,只是 yml 语法换了名字。
上线前必须做一次的选择,其实是「交付物长什么样」。这三者不是同一维度的替代品:jar 与 war 是打包格式,镜像是运行环境。
| 维度 | 可执行 fat jar | war(外部 Tomcat) | 容器镜像 |
|---|---|---|---|
| 谁提供 Servlet 容器 | 应用自己(内置 Tomcat 打进包里) | 宿主机上的 Tomcat | 应用自己(在镜像里) |
| JDK 版本谁保证 | 运维装,容易和 CI 不一致 | Tomcat 用的那套 JDK | Dockerfile 的 FROM |
| 依赖在哪 | BOOT-INF/lib,由自定义类加载器读 | WEB-INF/lib + WEB-INF/lib-provided | 分层 COPY 后与 #37 一致 |
| 启动命令 | java -jar app.jar | 拷进 webapps/ 由 Tomcat 解包 | docker run,入口仍是 java |
| 一台机器多实例 | 容易(换端口) | 受限于同一个 Tomcat | 最容易(换容器) |
| 适合 | 微服务、K8s、绝大多数新项目 | 公司强制的统一 Tomcat 基线 | 需要「环境一起交付」时 |
war 想用 java -jar 启动、又想丢进外部 Tomcat,有两个硬前提,少一个就 404:
// 前提一:启动类继承 SpringBootServletInitializer,外部容器才找得到引导入口public class BeeOrderApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(BeeOrderApplication.class); }}<!-- 前提二:内置容器改成 provided,否则外部 Tomcat 会和它抢 servlet-api --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope></dependency>类比:罐头和半成品菜。 fat jar 是一罐红烧肉——肉、汤、调味料全封在里面,开罐即食(java -jar 就能跑),代价是罐子本身也有重量(内置 Tomcat 那几十 MB)。war 是一袋半成品菜:菜是真的做好了,但必须倒进别人家的锅里(外部 Tomcat)才能吃,你连灶台都不用买。镜像则连微波炉一起打包——不是「菜」的差别,而是「连厨房一起交付」。
这也解释了交付阶段最常见的一类「明明打包成功了却起不来」:把 repackage 之后的 fat jar 用 java -cp app.jar com.beeorder.BeeOrderApplication 启动,于是找不到依赖——BOOT-INF/lib 里的 jar 不在系统 classpath 上,只有 JarLauncher 那个自定义类加载器才认得它。这个坑在实验里可以直接复现:
开发用的 Compose 只求「能跑起来」,生产用的 Compose 要回答四个问题:怎么重启、怎么保数据、怎么限资源、怎么收日志。
services: app: image: ghcr.io/acme/beeorder:${IMAGE_TAG:-latest} restart: unless-stopped env_file: [ .env.production ] environment: SPRING_PROFILES_ACTIVE: prod TZ: Asia/Shanghai JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC" SERVER_SHUTDOWN: graceful # 第八节:优雅停机由环境变量注入 SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: 25s depends_on: mysql: { condition: service_healthy } redis: { condition: service_healthy } ports: - "127.0.0.1:8080:8080" # 只暴露给宿主本机,由 Nginx 反代 stop_grace_period: 40s # docker stop 的 SIGKILL 宽限期,必须大于排空时间 healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health/readiness"] interval: 15s timeout: 5s retries: 5 start_period: 40s # 给 JVM 启动留足时间 logging: driver: json-file options: { max-size: "20m", max-file: "5" } deploy: resources: limits: { cpus: "2.0", memory: 1g } networks: [ bee-net ] mysql: image: mysql:8.0 restart: unless-stopped command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci environment: MYSQL_DATABASE: beeorder MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} TZ: Asia/Shanghai volumes: - mysql-data:/var/lib/mysql - ./backup:/backup # 备份脚本输出目录 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s retries: 6 networks: [ bee-net ] redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] volumes: [ redis-data:/data ] healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s retries: 6 networks: [ bee-net ]networks: bee-net: { driver: bridge }volumes: mysql-data: redis-data:restart: unless-stopped配合healthcheck,让容器崩溃与依赖未就绪都有确定的处理方式start_period: 40s是给 JVM 冷启动的宽限期,避免刚开机就被判死stop_grace_period: 40s必须大于 Spring 侧的timeout-per-shutdown-phase,否则排空到一半就被 SIGKILL(第八节详述)logging限制日志体积,deploy.resources限制 CPU/内存,防止单个服务拖垮整机mysql-data数据卷保证容器重建不丢数据;./backup挂载出来给mysqldump存备份
MySQL 备份就是一条 cron 加一条命令:
# /opt/beeorder/scripts/backup.shdocker compose exec -T mysql \ mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction beeorder \ | gzip > "/backup/beeorder-$(date +%F-%H%M).sql.gz"find /backup -name '*.sql.gz' -mtime +7 -delete # 保留 7 天| 维度 | 开发 Compose | 生产 Compose |
|---|---|---|
| 镜像来源 | 本地 build 或 Dockerfile | 仓库拉取固定 tag |
| 端口 | 直接暴露 8080 | 只绑 127.0.0.1,由 Nginx 对外 |
| 数据卷 | 可临时、可丢弃 | 命名卷 + 定时备份 |
| 资源限制 | 无 | cpus / memory 双重限制 |
| 日志 | 默认 stdout | json-file 轮转,20m × 5 |
| 健康检查 | 通常省略 | 每个服务都有,且含 start_period |
| 停机宽限 | 默认 10 秒 | stop_grace_period 显式写,与排空对齐 |
| 配置 | 明文 application-dev | env_file + secrets,配置外置 |
容器时区没设,MySQL 与应用各按自己的默认跑,订单时间就会错 8 小时。生产里应用、数据库、Redis 三者都要显式设置 TZ,并在存储与展示上统一约定(存 UTC 或统一本地时区,团队二选一,绝不混用)。
第三节那张动图的第④⑤⑥帧全在这一节。优雅停机不是一个配置,而是三个环节的接力:编排层把实例摘出流量池、应用等在途请求跑完、信号真的走到 JVM。任意一环断掉,症状都是「日志里一句 shutdown 都没有,请求被硬切」。
# application-prod.ymlserver: shutdown: graceful # 停止接收新请求,等 in-progress 的请求处理完spring: lifecycle: timeout-per-shutdown-phase: 25s # 排空预算,必须小于 stop_grace_periodmanagement: endpoint: health: probes: enabled: true # 打开 /actuator/health/liveness 与 /readiness 两个组 group: readiness: include: readinessState,db,redis # 依赖只进 readiness:故障就摘流量,不杀进程 liveness: include: livenessState # 进程自身的死循环/OOM 才归它管 health: livenessstate: enabled: true readinessstate: enabled: true三段配置对应三个环节,缺一不可:
| 环节 | 谁负责 | 配错的症状 | 正确姿势 |
|---|---|---|---|
| 摘流量 | 编排层(K8s 的 readiness / Compose + Nginx 的上游健康检查) | 新请求不断打进来,排空永远等不完 | 先让 readiness 变红,等 2~5 秒再开始收尾 |
| 排空 | Spring server.shutdown=graceful | 在途请求被 Connection reset | timeout-per-shutdown-phase 给够,且没有长事务 |
| 收到信号 | ENTRYPOINT 的 exec 形式 / tini | 日志里一句 shutdown 都没有,10 秒后被硬杀 | exec 数组形式,或 shell 形式里写 exec java -jar … |
两侧宽限期必须对齐,这是最常见的失配:Spring 侧配了 25 秒排空,而 docker stop 默认只等 10 秒就发 SIGKILL,于是后 15 秒的请求照样被腰斩。
# 三种正确的等法docker stop -t 40 beeorder-app # 单容器:显式加宽限期docker compose stop --timeout 40 app # Compose:同样要显式# K8s:terminationGracePeriodSeconds 必须 > timeout-per-shutdown-phase配错和配对的两种样子并排放在一起,就是下面这张对照图的全部内容——左边是那被腰斩的 15 秒,右边是两个数字对齐之后的从容:

验证方法很土但最有效:一边 docker compose stop --timeout 40 app,一边 docker compose logs -f app,看得见 Commencing graceful shutdown 和 Waiting for requests to complete 两行才算通。下面这个实验把信号链路和三种停机姿势走完:
电影院打烊。 关灯是「不再卖票」(readiness 变红、停止接新请求),放映厅里正在看的观众要看完这一段才离场(在途请求排空),广播通知得先递到领班手里才有用(信号走到 PID 1)。十分钟还不走,保安直接清人(SIGKILL)——爆米花就打翻在地了。
镜像里的信号这一环最容易断在 ENTRYPOINT 的写法上——dockerimg 的 signal 那一档专门演示 shell 形式如何把 /bin/sh 放上 PID 1、顺手把 SIGTERM 吞掉:
把三段接力连起来,就是下面这条完整的停机时序——从 readiness 变红到进程真正退出,每一拍谁在等谁、断在哪一拍会留下什么症状,都画在里面了:

这条链路与其读两遍,不如自己在内核控制台里点着走一遍——下面这几条命令从探针分工一路演到硬杀与优雅的对比,敲完再回去看配置,每行都认识:
上一节那两份 probes 配置由谁提供?就是 Actuator。它是交付阶段唯一的一个「运维接口」,也是安全审计最喜欢点名的地方。
management: server: port: 9090 # 独立管理端口:业务端口 8080 一个 actuator 都不放 endpoints: web: exposure: include: health,info,metrics,prometheus # 白名单,绝不写 * endpoint: health: show-details: when-authorized info: env: java: enabled: true// 给指标贴业务标签:下单成功率比 QPS 有用得多@Componentpublic class OrderMetrics { private final MeterRegistry registry; public OrderMetrics(MeterRegistry registry) { this.registry = registry; } public void recordCreate(String outcome) { // outcome: success / stock / validation registry.counter("beeorder.order.create", "outcome", outcome).increment(); }}management.server.port=9090之后,Nginx 只需反代 8080,Actuator 天然不出公网include白名单写*是最常见的高危配置:/actuator/env会泄露数据库密码,/actuator/heapdump能让你整个进程的密钥被人下载走show-details: when-authorized保证外部只看UP/DOWN,运维登录才看得到是哪个依赖挂了/actuator/info建议由构建插件写入 git sha —— 线上到底跑的哪一版,这个问题要能用一条 curl 回答
类比:厨房没备完菜,别挂「营业中」的牌子。 readiness 就是这个牌子:它问的是「菜备齐了吗、能上座吗」,所以数据库断了要变红(先别接客)。liveness 问的是「这家店还在不在」——房子没塌就别说它死了。要是把数据库也挂进 liveness,主从切换抖 3 秒,编排层就会把六家门店全拆了重建:牌子是红的,但店好好的。
探针、宽限期、镜像 tag——交付里的术语长得都像,区别却全在「它在回答哪个问题」,配错一个就是一次事故。来一局术语配对:先点左边的名词,再点右边它真正回答的那个问题:
容器里的 8080 不直接给公网。Nginx 负责 TLS、压缩、真实 IP 透传和大文件限制——这些都不该由应用自己做。
server { listen 80; server_name api.beeorder.example.com; # HTTP 一律跳 HTTPS return 301 https://$host$request_uri;}upstream beeorder { server app:8080 max_fails=3 fail_timeout=10s; # 单机只有一个后端,多实例时加行 keepalive 32;}server { listen 443 ssl; server_name api.beeorder.example.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; client_max_body_size 20m; # 商品图片等上传上限 gzip on; gzip_types application/json text/plain text/css application/javascript; gzip_min_length 1024; location / { proxy_pass http://beeorder; # app 是 Compose 里的服务名,直接走内网 DNS proxy_http_version 1.1; # 透传真实客户端信息,否则应用日志里全是 Nginx 的内网 IP proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_read_timeout 30s; # 发布瞬间后端正在排空:让 Nginx 换一台重试,而不是把 502 甩给用户 proxy_next_upstream error timeout http_502 http_503; } # 健康检查走内网,绝不暴露到公网 location = /healthz { proxy_pass http://beeorder/actuator/health/readiness; access_log off; }}proxy_pass http://app:8080用服务名直连,靠 Compose 内网 DNS 解析,无需写死 IPX-Real-IP/X-Forwarded-For/X-Forwarded-Proto三件套,把真实来源和协议告诉应用proxy_next_upstream是发布期间少有人配的一项:老实例正在排空时,Nginx 会先试别人,而不是直接吐 502- 生产还要把证书目录(
/etc/nginx/certs)以只读方式挂进 Nginx 容器,用 certbot 或 acme.sh 定期续期
应用侧必须配合打开 server.forward-headers-strategy=framework,原因很直接:默认情况下 Spring Boot 不信任这些代理头。不开的话,应用生成的重定向 URL 会变成 http:// 而不是 https://,request.getRemoteAddr() 拿到的是 Nginx 内网地址,绝对 URL 拼接也会出错。
server: forward-headers-strategy: framework # 让 Spring 读取并信任 X-Forwarded-* 头 tomcat: remoteip: remote-ip-header: X-Forwarded-For protocol-header: X-Forwarded-Proto注意:forward-headers-strategy 只有在确信流量一定经过可信代理时才能开。若应用端口直接暴露公网,客户端可以伪造 X-Forwarded-For,反而带来 IP 欺骗风险——上生产必须配合第七节那条「只绑 127.0.0.1」。
上线出的事,十有八九是「某个该检查的点在紧张时刻被忘掉了」。清单的意义是让 checklist 代替大脑记忆。

| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 配置外置 | 无任何环境相关配置写死在 jar 或代码里 |
| 2 | 密钥管理 | 数据库/Redis/支付密钥来自 secrets,不在 Git 中 |
| 3 | JVM 参数 | 设置了 MaxRAMPercentage 与 GC,不用默认堆 |
| 4 | 日志落盘与轮转 | 容器日志驱动限制大小,应用日志按天滚动 |
| 5 | 时区 | 应用、MySQL、Redis 均为同一时区 |
| 6 | 健康检查 | /actuator/health/readiness 可访问且返回 UP |
| 7 | 连接池 | 最大连接数按数据库上限推算并压测验证 |
| 8 | 慢查询开关 | 开启慢 SQL 日志(阈值 1s),线上可追溯 |
| 9 | 备份策略 | 数据库每日全量 + binlog,且有恢复演练 |
| 10 | 回滚方案 | 明确上一个可用镜像 tag,且回滚命令演练过 |
| 11 | 监控告警 | QPS/错误率/P95/连接池/磁盘均有阈值告警 |
| 12 | Actuator 端口隔离 | /actuator/env、/heapdump 等敏感端点不对外 |
| 13 | 数据库迁移 | DDL 已执行且可重复执行(幂等脚本) |
| 14 | 依赖服务就绪 | MySQL、Redis 健康后应用才启动 |
| 15 | 停机宽限 | stop_grace_period > timeout-per-shutdown-phase,且 exec 形式入口 |
| 16 | 冒烟测试 | 下单→支付→查单主链路人工跑通一遍 |
| 17 | 观察窗口 | 上线后 30 分钟内保持值守,随时可回滚 |
清单不是「越多越好」。真正值得留的,是那些不看清单就一定想不起来、而且错了代价极大的项——第 15 项(宽限期对齐)和第 12 项(Actuator 暴露面)正是这类。
| 策略 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 蓝绿 | 新旧两套环境,切流量 | 回滚秒级 | 资源翻倍 | 单机/关键系统 |
| 滚动 | 逐台替换实例 | 资源省 | 新旧版本短暂共存 | 多实例集群 |
| 金丝雀 / 灰度 | 先放 5% 流量 | 风险最小 | 需要流量控制能力 | 有网关/服务网格 |
| 按用户维度 | 白名单账号先吃新版本 | 能定向验证真实业务 | 样本有偏 | 有内部账号可用 |
先开一家分店试菜。 新菜直接写进全城 20 家店的菜单,一旦食材有问题就是全站事故;聪明的做法是先在大学城旁开一家分店,用 5% 的客流验证口味、翻台和出餐速度,没问题再铺到其他门店,有问题只关这一家。灰度的本质不是「分批」,而是「让失败的影响面可被限制」。
单机 Compose 场景下,最实用的是镜像版本切换式回滚:把「上一版」的 tag 记下来,出问题切回去即可。
# 部署新版本:把 tag 写入 .env,再拉起echo "IMAGE_TAG=v1.2.0" >> .envdocker compose pull appdocker compose up -d appdocker compose logs -f --tail=100 app # 盯一会儿日志# 出问题,回滚到上一版(例如 v1.1.9)—— 只换 tag,不重新构建sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=v1.1.9/' .envdocker compose pull appdocker compose up -d appcurl -fsS http://127.0.0.1:8080/actuator/health/readiness # 确认恢复- 回滚的本质是「切镜像 tag」,所以每次上线都必须保留上一个可用 tag,并且回滚流程要演练过
.env里只改一行,比改 yml 更不容易出错- 回滚后立刻验证健康端点,别把「命令执行成功」当成「服务恢复」
- 结构与数据要一起想:新 DDL 删了列,回滚镜像也回滚不了数据库——所以 #44 的迁移脚本必须向后兼容(只加列不改语义、先加后删、分两次发布)
灰度期间新旧两个版本并存,最容易出的不是 500,而是「同一笔请求两次落到不同版本」——所以灰度前置条件是:幂等键、状态机、数据库结构三者都能同时容纳两个版本。这个实验演示 5% 流量下两个版本的共存与切回:
healthcheck 的 interval 配太短(比如 2s)而 start_period 又没设,JVM 还没启动完就被判不健康,就会陷入反复重启。先把 start_period 给够(40s 起),再谈检测频率。
wrk 适合快速跑出一个「够用的数字」,JMeter 适合做复杂场景(参数化、阶梯加压)。选哪个不重要,关键是压测前先定义目标。
BeeOrder 下单接口的验收目标:
| 指标 | 目标 | 解读 |
|---|---|---|
| QPS | ≥ 500 | 峰值订单量的 2 倍余量 |
| P95 延迟 | < 200ms | 95% 的请求在这个时间内返回 |
| P99 延迟 | < 500ms | 长尾不能失控 |
| 错误率 | < 0.1% | 出现超时或 5xx 即为不合格 |
| 库存一致性 | 无超卖 | 压测后对账,账实必须一致 |
一次典型的下单压测(wrk):
# 12 线程、400 连接、持续 60 秒,POST 下单接口wrk -t12 -c400 -d60s --latency \ -s post-order.lua \ http://127.0.0.1:8080/api/orders瓶颈排查遵循一条固定路径,从外到内逐层排除:
- 应用层:线程池是否被打满(Tomcat
max-threads),有无锁竞争 - JVM:GC 是否频繁(
jstat -gcutil),堆是否够用 - 数据库:慢查询日志有没有新增,
EXPLAIN是否走索引 - 连接池:HikariCP 活跃连接数是否长期等于
maximumPoolSize
压测时 P95 突然抬高,八成不是应用算得慢,而是卡在数据库或连接池。先看连接池的 activeConnections,它往往是最早露馅的指标。
「慢请求与超时」这一条链路最容易被误判成应用问题,实际是被下游拖住。这个实验把五种结局(成功、校验失败、业务异常、数据库故障、慢请求)放在同一条流水线上对比,注意看第几步开始排队、超时在哪一层被截断:
「一次切多少」和「老进程怎么退场」是发布节奏里仅有的两个旋钮,也是所有发布事故的现场。下面这个沙盘把两个开关放在一起,每个格子里是同一次压测的六个读数:新版本错误率、P99、被切断的在途请求、脏数据笔数、以及回滚耗时。
新版本承接 25 QPS(总 500 QPS 的 5%)隐藏慢查询暴露:新版本错误率 0.4%,老实例 0%P99:新版本 1.2s / 老 180ms —— 只有 5% 的用户受影响readiness 变红,编排把 25 QPS 全部退回老实例老实例排空在途 80 条,被切断 0 条脏数据 0 笔 · 回滚耗时 4s(权重调回 0)
沙盘里的数字是推演值,结论是硬的:影响面由切流比例决定,脏数据由退场方式决定,两者互相独立、都必须配。灰度不是「大厂才做的事」——单机 Compose 也能用两台 Nginx upstream 权重做 5% 切流,成本只有几行配置。
下面每一行的「报错原文」都能整段复制去搜索,别意译、别缩写。交付阶段最烦人的地方在于:很多故障不以 Java 异常出现,症状是日志戛然而止或者端口连不上。
| 报错原文(片段) | 现象 | 真实原因 | 30 秒自救 | |
|---|---|---|---|---|
java.lang.ClassNotFoundException: com.beeorder.BeeOrderApplication | repackage 之后本地能跑,换个命令就找不到主类 | 用 java -cp app.jar 主类名 启动了 fat jar:主类在 BOOT-INF/classes,而不在 jar 根目录,系统 classpath 看不见 | 老老实实 java -jar app.jar(走 JarLauncher);layertools 拆层后用 java org.springframework.boot.loader.launch.JarLauncher | |
java.lang.NoClassDefFoundError: org/springframework/...(启动几十秒后炸) | 类找到了,但它依赖的类不在 classpath | fat jar 的依赖躺在 BOOT-INF/lib,由 LaunchedClassLoader 加载;手工 unzip 后重新压缩、或 CI 缓存了旧 jar,就丢掉这个结构 | 别手工改包:重新 mvn -B clean package;确认 Main-Class 是 JarLauncher:unzip -p app.jar META-INF/MANIFEST.MF | |
docker: 'java' is not a command(ENTRYPOINT ["java","-jar","app.jar"] 起不来) | 容器一起来就退出,日志只有这一行 | 基础镜像是 JRE-less 的 distroless/base 或 alpine 里根本没装 Java;或者 ENTRYPOINT 写成了 ["java","-jar"] 而可执行文件不在 PATH | docker run --rm --entrypoint sh <image> -c 'command -v java' 先看有没有;基础镜像换 eclipse-temurin:21-jre-jammy,distroless 要用 -java 变体并写绝对路径 /opt/java/openjdk/bin/java | |
java.lang.OutOfMemoryError: Metaspace | 启动或热部署一段时间后进程自杀,日志里有完整 Java 栈 | 类加载器泄漏:反复 reload、Groovy/脚本引擎动态生成类、或 -XX:MaxMetaspaceSize 设得比实际需要小(容器里常见于限额被压低后没同步调) | 显式封顶 -XX:MaxMetaspaceSize=256m 并配合 -XX:+HeapDumpOnOutOfMemoryError;查 jcmd <pid> GC.class_stats 或看是不是每轮热重载都在长类 | |
java.lang.OutOfMemoryError: Java heap space | Java 侧自己报的错,能抓到 hprof | 堆内真的装不下:集合无界增长、一次查出百万行、缓存没设上限 | 加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp,用 MAT 看支配树;这类失败不会留 exit 137,先分清是谁杀的 | |
容器日志最后一行 Killed,Java 侧一行异常都没有 | 进程被外部处决,无现场、无堆栈 | 总用量(堆 + 元空间 + 线程栈 + 直接内存)超 cgroup 限额,内核直接杀;-Xmx 写死成等于限额是最常见诱因 | 把 -Xmx 换成 -XX:MaxRAMPercentage=75.0;用 docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' 确认;堆外排查靠 NMT + jcmd <pid> VM.native_memory | |
OOMKilled + Exit Code 137(K8s kubectl describe pod) | 反复重启,业务日志戛然而止 | 137 = 128 + 9,即 SIGKILL。这是 cgroup 因总用量越限杀进程,跟堆够不够是两件事 | 先算总账:堆 + 200 条 Tomcat 线程栈(约 200MB)+ CodeCache + Netty 直接内存;再谈调比例,别急着加大限额掩盖问题 | |
Web server failed to start. Port 8080 was already in use. | 本地一启动就退出 | 上一个实例没退干净、IDE 还在跑、或本机另一个服务占了 8080。注意容器场景报的是另一个文案:Bind for 0.0.0.0:8080 failed: port is already allocated | Windows:`netstat -ano \ | findstr :8080 拿 PID 再 taskkill /PID <pid> /F;Linux/macOS:lsof -i:8080;开发环境直接 -Dserver.port=8081`,生产改 Compose 的宿主侧端口 |
Healthcheck failed: Read timed out / Compose 里服务长期 unhealthy | 部署脚本卡住或流水线红,但手工 curl 是通的 | JVM 冷启动 + 慢初始化(连不上依赖会重试)超过 start_period;或 timeout: 5s 小于一次健康指示器的真实耗时;或 healthcheck 里那个 curl 二进制根本不在镜像里 | 把 start_period 提到 40s 起、timeout 到 5~10s;健康检查打到 readiness 而不是聚合 /health;用 --entrypoint sh 进容器手跑一次那条 test 命令 | |
客户端 Connection refused | 一次请求都没进去,秒失败 | 没人监听那个端口:容器没起来、EXPOSE 写了但没 -p、或应用启动失败已退出。这是网络层没人接,与应用逻辑无关 | docker ps 看 PORTS 列有没有 0.0.0.0:8080->8080/tcp;curl -v http://127.0.0.1:8080/actuator/health/readiness 分段看;docker logs 确认进程活着 | |
客户端 Connection reset by peer / 偶发 502 | 请求进去了又被掐断,重试往往就成功 | 对端在请求处理中途退出:老实例被 SIGKILL 硬杀(排空没走完)、Nginx upstream 里还留着已摘除的实例、或 keep-alive 连接被后端先关 | 看是否只在发布瞬间发生 → 补 server.shutdown=graceful 与 stop_grace_period;Nginx 加 proxy_next_upstream error timeout http_502;preStop 先 sleep 2~5s 让流量传播停下来 | |
Connection is not available, request timed out after 30000ms | 压测一上来就大面积 500,日志里全是这个 | 连接池只有默认 10 条而事务在等外部接口(长事务),或慢查询把连接全占住 | 先查有没有「事务里调远程接口」(#45 第三节);再按数据库 max_connections 与实例数反推 maximum-pool-size,别直接调到 200 | |
UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 65.0) | 服务器第一行就炸 | 本地 JDK 21(class 65)编译,服务器 JRE 是 17(class 61)——典型的构建前提没显式化 | 镜像里锁 FROM eclipse-temurin:21-jre;pom.xml 里锁 <java.version>21</java.version> 与 maven.compiler.release,让 CI 而非机器负责版本 |
这张表里有三条(Killed、OOMKilled、Connection reset)根本不以 Java 异常的形式出现,症状是日志戛然而止。排查容器问题的第一句命令是 docker inspect <id> --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Status}}',一眼分清「谁杀的」。
表格第 1 行那个 ClassNotFoundException 值得单独留个现场——它最迷惑的地方在于:构建明明是绿的、jar 明明就在手里,报错却像在说「类没打进去」。先别看答案,点出你认为的凶手帧:
CI 绿了、镜像也推了,顺手把构建产物拷到服务器,用自写的启动脚本跑:java -cp beeorder.jar com.beeorder.BeeOrderApplication —— 一行没进去,JVM 直接抛 ClassNotFoundException。
先来一道热身题,考的是第九节那张决策卡与第八节的探针配置:
再来一道综合题,把第四、六、十五节串起来:
目标:亲手确认优雅停机真的生效——不是「配了」,而是「日志里看得见」。只需要一台装了 Docker 的机器和一个能跑起来的 BeeOrder 镜像。
第一步,用第七节的 Compose 起栈,确认健康:
docker compose up -ddocker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q app)"# 预期输出:healthy (若为 starting,等 start_period 的 40s 过去再看)第二步,造一点在途请求,再停:
# 终端 A:持续打请求,边打边记录响应码while true; do curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \ http://127.0.0.1:8080/api/orders/1; sleep 0.05; done# 终端 B:显式给 40 秒宽限期docker compose stop --timeout 40 app第三步,看日志确认三行按顺序出现(少任何一行都要回第八节排查):
Commencing graceful shutdown. Waiting for active requests to completegraceful shutdown complete... Tomcat shutdown ...| 观察点 | 期望 | 不期望时先查 |
|---|---|---|
| 终端 A 的响应码 | 全程 200,直到最后一刻才变成 Connection refused | 出现 5xx/reset → ENTRYPOINT 不是 exec 形式 |
日志有没有 Commencing graceful shutdown | 有 | 没有 → server.shutdown=graceful 没生效或信号被 shell 吞了 |
| 退出耗时 | 在途多久就等多久,且 < 40s | 恰好 10s 退出 → 宽限期没传下去(--timeout 写漏) |
目标:用一次人为的数据库故障,亲眼看到 readiness 变红而 liveness 不变红。
# 1. 先把 db 指示器放进 readiness 组(第八节的配置),重启应用docker compose restart app# 2. 记录两个探针的当前状态curl -s http://127.0.0.1:9090/actuator/health/liveness | python -m json.toolcurl -s http://127.0.0.1:9090/actuator/health/readiness | python -m json.tool# 3. 人为停掉数据库,再看一次:期望 liveness 依然 UP,readiness 变 DOWNdocker compose stop mysqlsleep 20curl -s http://127.0.0.1:9090/actuator/health/readiness # 期望 "status":"DOWN"curl -s http://127.0.0.1:9090/actuator/health/liveness # 期望 "status":"UP"# 4. 恢复数据库,确认 readiness 自动回到 UP(这一步就是「摘流量而不是杀进程」)docker compose start mysql如果第 3 步 liveness 也变红了,说明你把它指到了聚合的 /actuator/health——回去看第九节那段 group 配置。
写一份不超过一页的演练方案,必须回答这五个问题,并且每一问都要有「怎么验证」:
- 回滚要多久?谁来下这道命令?(提示:只换 tag,目标 < 60s)
- 演练期间故意注入一个「新版本 5% 流量的隐藏慢查询」,你怎么在 5 分钟内发现它?(提示:第十四节沙盘的前两格,以及按版本打 tag 的指标)
- 灰度期间一次支付回调先后落到新旧两个版本,幂等还成立吗?依据是什么?(提示:#45 第五节唯一索引兜底)
- DDL 是向前兼容的吗?回滚镜像时数据库能不能一起退?(提示:第十一节第 13 项,先加后删、分两次发布)
- 哪三个数字是你判断「可以继续扩大灰度」的唯一依据?(建议:错误率、P99、连接池 active 数——和第十二节表格里的一致)
验收标准只有一句:演练必须真的按回滚按钮,不是「我知道怎么回滚」。一个从没被执行过的回滚方案,等同于没有回滚方案。
- 交付 = 把隐性前提显式化:构建前提写进 Dockerfile,运行前提写进 Compose,协作前提写进分支保护与
needs - 测试分层按风险分,不按数量分:单元测分支、切片测契约、集成测 SQL 与并发;超卖只能用并发测,覆盖率替代不了断言强度
- 上线的产物是镜像 tag,不是 jar 也不是源码:没有 CI 验证过的 sha 就没资格部署,回滚只是换一个 tag
- 优雅停机是三段接力:摘流量 → 排空 → 退出,且两侧宽限期必须对齐,
ENTRYPOINT必须是 exec 形式 - liveness 不挂依赖,readiness 才挂:配反的代价是一次数据库抖动引发全站重启风暴
- 发布节奏只有两个旋钮:切多少流量决定影响面,怎么退场决定脏数据量,两者必须同时配
回头看,BeeOrder 的上线不是某一篇的功劳:
- #1 装 JDK 时你以为在装软件,其实是在理解「字节码 + 虚拟机」这层抽象
- #6~#14 学 IoC/AOP,是为了让 #45 的下单服务靠一个注解就拿到事务能力
- #21~#25 打下的配置与 REST 契约,是 #44 API 契约的现实基础
- #28 的连接池、#31 的事务内核、#32 的缓存,全部在第十三节压测和第十一节清单里复发
- #33~#37 的测试与部署,加上这一篇的 CI/CD,把「能跑」升级成「敢放上生产」
最后用 #45 那道题收束整个系列:下单与扣库存必须同一事务(要么都成功要么都回滚),记库存流水也在同一事务(它是账的一部分),但发通知、加积分必须独立提交——通知失败不该把订单回滚掉。这个边界判断,贯穿了从 #31 事务传播到 #45 幂等设计、再到本篇灰度回滚的每一处取舍。
交付这一篇把整个系列闭环了。三句话记住:测试分层,是为了让每类风险都有人守;CI 流水线,是为了让守门这件事不依赖人的自觉;生产编排、探针与上线清单,是为了让「能跑」变成「可回滚、可观测、可信任」。从 #1 的 java -version 到现在的一条 docker compose up -d,你交付的不再是一段代码,而是一套能被别人接手、能被机器验证、能在出事时退回来的系统——这才是一个后端工程师真正的「上线」。