Old-School Backend / 2026 ・ No.15

古法后端入门

第15讲(最终讲) 运行 ── 把申请应用带出自己的电脑

发布

终端画面。docker compose down -v 之后执行 SEED=true docker compose up -d --wait,ctx-app-1・ctx-db-1・ctx-web-1 为 Healthy。docker compose ps 显示 app expenses:15 Up 13 seconds (healthy)、db postgres:16 healthy、web nginx:1.30-alpine Up 2 seconds。curl -sk https://localhost:8443/api/v10/claims/EXP-2026-7001 返回 id EXP-2026-7001、item 交通費-1、amount 1280.00、status submitted、actions approve return reject
从一无所有开始,用 compose 启动数据库、应用、nginx 这 3 个服务,用 https 打开申请(笔者于 2026 年 10 月 31 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

这个连载的应用,一直在笔者的练习房间(网络空间和练习用数据库)里运行。最终讲要把它带出去。
先用一个全新的数据库运行第 14 讲的 jar。把数据库结构放进源码(Flyway),装进 Docker 镜像,用 compose 排列数据库・应用・nginx 这 3 个服务。然后测量能处理多少请求、虚拟线程改变了什么、停止时和在小容器里会发生什么。最后回顾全部 15 讲。

一

走出练习房间

新井测试也有 107 件,到哪儿都能运行吧。

古井用全新的数据库试试。

全 15 讲路线图(当前:第 15 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
# 第 14 讲 jar 里的设置(unzip -p app.jar BOOT-INF/classes/application.properties)
  spring.datasource.url=jdbc:postgresql://db.internal:5432/expenses
  spring.datasource.password=practice
  spring.sql.init.mode=always
  demo.store=/home/claude/be13/store
# 用 Docker 启动空的 PostgreSQL 16,只改连接地址运行第 14 讲的 jar
  Started ExpensesApplication in 6.051 seconds
  建成的表expense   (只有 schema.sql 的 1 张)
  GET  /api/expenses                 200  []
  GET  /api/v10/claims/EXP-2026-0012 500   ← column "applicant" does not exist
  POST /api/login                    401   ← relation "app_user" does not exist

jar 里装着练习用数据库的地址(db.internal)、密码、文件存放位置(笔者的工作文件夹)。只改连接地址运行,应用 6 秒就启动了。但数据库里建出的表,只有第 1 讲 schema.sql 里的那 1 张。打开申请是 500,登录是 401。

401 和“密码错误”是同样的回复,但真正的原因是用户表不存在(日志里写着)。启动了,测试是绿的,可在外面跑不起来。第 14 讲的测试之所以通过,是因为另外持有一份测试用的数据库结构。

  • 结构 ── 把数据库结构放进源码(Flyway),装进镜像,用 compose 排列(实测)
  • 入口 ── nginx 的一处设置,就改变 cookie・413・502(实测)
  • 处理量 ── 虚拟线程能提升和不能提升的工作(实测)
  • 停止方式 ── 停止的宽限时间,以及 512 MB 容器里的堆(实测)
二

把数据库结构放进源码

从第 4 讲开始,数据库结构都是手动执行每一讲的 SQL 改出来的。把它们作为带编号的文件放进源码,应用启动时只按顺序执行还没执行过的,这就是 Flyway。只要加入了 Flyway,Spring Boot 就会在启动时自动运行它。

# src/main/resources/db/migration/
  V1__baseline.sql                 第 1〜13 讲的结构(26 条,来自 pg_dump -s)
  V2__expense_applicant_index.sql  create index … on expense (applicant);
# ① 用空数据库启动
  Migrating schema "public" to version "1 - baseline"
  Successfully applied 1 migration … now at version v1 (execution time 00:00.039s)
# ② 再启动一次
  Schema "public" is up to date. No migration necessary.
# ③ 给已执行过的 V1 加一行说明后启动
  Migration checksum mismatch for migration version 1
  -> Applied to database : -1908829391
  -> Resolved locally    : -853976515
  → 应用没有启动就退出(Exited (1))
# ④ 恢复 V1,加上 V2 后启动
  Migrating schema "public" to version "2 - expense applicant index"   (00:00.014s)
  flyway_schema_history:  1 | baseline | 39 ms     2 | expense applicant index | 14 ms

执行记录保存在数据库里的 flyway_schema_history 中。第 2 次启动什么也不做。在 ③ 中,只是给已执行过的 V1 加了一行说明,应用就启动不了了。因为文件的指纹(checksum)和执行时不同了。执行过的文件不要改。想改就加下一个编号。V2 用 14 ms 执行完毕。

第 14 讲的测试也不再用测试专用的 DDL,改为使用同样的 V1・V2。107 件照样全部通过。测试和生产从同一个结构出发。

三

装进镜像

接下来是 Docker 镜像。把 jar 放进装有 Java 的镜像(eclipse-temurin:25-jre)就能运行,但我们稍微下了点功夫。

# 分层放入,用非 root 用户运行
FROM eclipse-temurin:25-jre AS build
WORKDIR /w
COPY app.jar .
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination out

FROM eclipse-temurin:25-jre
RUN useradd --system --uid 10001 app && mkdir -p /data/store/receipts && chown -R app /data
WORKDIR /app
COPY --from=build /w/out/dependencies/ ./
COPY --from=build /w/out/spring-boot-loader/ ./
COPY --from=build /w/out/snapshot-dependencies/ ./
COPY --from=build /w/out/application/ ./
USER app
ENV DEMO_STORE=/data/store
EXPOSE 8080 8081
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
# 镜像大小(docker image ls)
  eclipse-temurin:25-jre  480MB   (Ubuntu 26.04 与 Java 25.0.5)
  expenses:naive          543MB   (只放入 1 个 jar)
  expenses:15             544MB   (分层)
# 改 1 行窗口代码后重建镜像,变化的层
  expenses:naive   1 层35.7MB   COPY app.jar /app.jar
  expenses:15      1 层451kB    COPY /w/out/application/   (库的 35.6MB 不变)
# 以谁的身份运行(docker run --entrypoint id)
  expenses:naive   uid=0(root) gid=0(root) groups=0(root)
  expenses:15      uid=10001(app) gid=999(app) groups=999(app)
# 密码在哪里
  jar 设置里的 password 行第 14 讲 1 → 第 15 讲 0
  镜像历史(docker history)中出现的练习用密码0
  → 启动时由 compose 从 .env 用环境变量传入

两者大小都在 540 MB 左右,大部分是底层的 Ubuntu 和 Java。差别出现在重建时。整个 jar 放进去的镜像,每改 1 行就要替换 35.7 MB 的层。用 Spring Boot 的 jarmode=tools 分层后,变化的只有自己代码的 451 kB。库的层可以原样复用,分发量和等待时间都减少了。

还有一点:朴素的镜像是以 root 运行的。像第 12 讲那样,当应用能把文件写到意想不到的地方时,root 就能写到容器里的任何地方。我们创建专用用户来运行。密码从 jar 和镜像中都去掉,启动时用环境变量传入。

四

排列 3 个服务

把数据库・应用・nginx 这 3 个写进 compose.yaml。应用部分如下。

services:
  db:  …
  app:
    build: .
    image: expenses:15
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/expenses
      SPRING_DATASOURCE_USERNAME: app
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
      JAVA_TOOL_OPTIONS: ${JAVA_OPTS:-}
      SPRING_THREADS_VIRTUAL_ENABLED: ${VIRTUAL:-false}
      SERVER_FORWARD_HEADERS_STRATEGY: ${FORWARD:-native}
      DEMO_SEED: ${SEED:-false}
    volumes:
      - store:/data/store
    restart: ${RESTART:-no}  # 倒下了就重新启动
    stop_grace_period: 40s  # 比 Spring 的等待(默认 30 秒)长。docker 默认 10 秒
    depends_on:
      db:
        condition: service_healthy
    healthcheck:  # 镜像里没有 curl,用 bash 询问 8081 的 health
      test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8081 && printf 'GET /actuator/health HTTP/1.0\\r\\n\\r\\n' >&3 && grep -q '\"status\":\"UP\"' <&3"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 60s
  web:  …

Java 镜像里没有 curl,所以健康检查用 bash 直接询问 8081 端口(第 13 讲的 /actuator/health)。在 depends_on 里写上 service_healthy,就会按数据库健康后启动应用、应用健康后启动 nginx 的顺序进行。密码从与 compose 同目录的 .env 传入,不放进镜像,也不放进 Git。

# 从一无所有(数据库也是空的)开始 docker compose up -d --wait
  db   Healthy → app   Healthy → web   Healthy        11.1 s
  应用内部Flyway V1 39 ms,  Started ExpensesApplication in 5.457 seconds
  GET https://localhost:8443/api/v10/claims/EXP-2026-7001   200

完成后的结构

完成后的结构(compose.yaml 的 3 个) 用户 浏览器 nginx 入口 应用 expenses:15 PostgreSQL 16 db :8443 :8080 :5432 TLS・http→https 上限 10 MB gzip・隐藏版本 X-Forwarded-* 复用连接 非 root 用户 秘密用环境变量 8081 健康检查 停止宽限 40 秒 结构用 Flyway 数据放在卷里 pg_isready 只用 https compose 等待 healthy 依次启动(11.1 秒) 源码里有的是:镜像的做法、3 个的排列、数据库的结构。秘密和数据在外面 只要是同一个 compose.yaml,换台电脑也能以同样的结构启动

从一无所有开始,11 秒内 Flyway 建好结构,用 https 打开了申请。换一台电脑,只要有这个 compose.yaml 和 .env,就能以同样的结构启动。

五

在前面放 nginx

在用户和应用之间放上 nginx。由它负责 https(TLS),应用只在内部网络可见。先比较“只转交给应用”的朴素设置和面向生产的设置(证书是练习用的自签名证书)。

# lab15.py nginx:登录,发送 2 张收据,再看 http 和列表
                                朴素的设置面向生产
  登录的 cookieHttpOnly                      Secure; HttpOnly
  3.54 MB 的照片413 text/html                 200 application/json
  12.05 MB 的照片413 text/html                 413 application/problem+json
  用 http(80 端口)打开200                           301 → https
  回复的 Servernginx/1.30.6                  nginx
  100 件列表的大小10,293 B                      701 B (gzip)
# 朴素设置下 413 的内容(不是应用,而是 nginx 的 HTML)
  <html> <head><title>413 Request Entity Too Large</title></head> <body> <center><h1>413 Request E…
# 即使是面向生产的 nginx,若应用的 forward-headers-strategy 为 none
  JSESSIONID=…; Path=/; HttpOnly   ← 没有 Secure
# 面向生产:http 转到 https,把来源告诉应用,大小上限与应用一致
upstream app {                                # proxy_pass 直接写名字时,没有复用连接(实测)
    server app:8080;
    keepalive 32;                             # 复用连接。1.29.7 起是 upstream 的默认(这里明确写出)
}

server {
    listen 80;
    server_tokens off;
    return 301 https://$host:8443$request_uri;
}

server {
    listen 443 ssl;
    server_tokens off;                        # 回复里不写 nginx 的版本
    ssl_certificate     /etc/nginx/certs/cert.pem;
    ssl_certificate_key /etc/nginx/certs/key.pem;

    client_max_body_size 10m;                 # 与应用的上限(10MB)一致。默认是 1m
    error_page 413 = @too_large;              # 拒绝时也用和应用相同的 JSON 形式

    location @too_large {
        default_type application/problem+json;
        return 413 '{"type":"https://expenses.example/problems/too-large","title":"ファイルが大きすぎます","status":413}';
    }

    location /api/ {
        proxy_pass http://app;
        proxy_http_version 1.1;                # 这 2 行 1.29.7 起也是默认。旧版 nginx 需要
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port 8443;
        gzip on;
        gzip_types application/json application/problem+json;
    }
}

朴素设置下,3.54 MB 的手机照片被 nginx 的 413(HTML)拒绝了。nginx 默认上限是 1 MB,第 12 讲把应用设为 10 MB 的事,在这里就被挡住了。画面在等 JSON,回来的却是 HTML。面向生产的设置把上限与应用一致设为 10 MB,拒绝时也用和应用相同的 JSON 形式。

登录 cookie 没有 Secure(只在 https 时发送),是因为从应用看来,来自 nginx 的请求是 http。nginx 加上 X-Forwarded-Proto: https,应用用 server.forward-headers-strategy=native 信任它之后,就有了。只做其中一边是不会有的(实测)。此外,http 转到 https,列表 JSON 用 gzip 从 10,293 字节变成 701 字节,回复里去掉了 nginx 的版本。

六

能处理多少

用 wrk 对打开 1 份申请的 GET 各压 10 秒。这台机器只有 2 个 CPU,wrk・nginx・应用・数据库互相争抢。数字是“在这个环境里用来比较”的。

# 用 wrk 对 GET /api/v10/claims/EXP-2026-7001 压 10 秒(3 次的范围,每秒件数)
  并发直连应用nginx(朴素)nginx(复用连接)
  1       1765〜1909/s374〜945/s1164〜1197/s
  10      2565〜2902/s794〜1190/s1728〜1822/s
  50      2562〜2886/s657〜1199/s1685〜1885/s
  200     2679〜2998/s627〜1109/s (502 209)1734〜1923/s
  中位等待时间(并发 50)18〜20 / 38〜69 / 27〜29 ms
# 朴素 nginx 内部(并发 200 之后)
  TIME_WAIT 20159
  [crit] connect() to 172.20.0.3:8080 failed (99: Address not available)   × 253   → 502
# 确认:nginx 1.30.6 写什么才会复用(并发 200・10 秒之后,到应用的连接)
  proxy_pass http://app:8080;       TIME_WAIT 5472
  只写 upstream 块ESTABLISHED 37   TIME_WAIT 354
  upstream + keepalive 32 等ESTABLISHED 25   TIME_WAIT 1138

直连应用是每秒 2,500〜3,000 件。可是经过朴素的 nginx,就降到一半以下,并发 200 时还混入了 502。查看 nginx 内部,到应用的连接有 2 万个处于 TIME_WAIT(关闭后的等待)。每个请求都新建到应用的连接,把可用的端口号用光了。

写上 upstream 块后,连接被复用,502 消失,变成每秒 1,700〜1,900 件。从 nginx 1.29.7 起,upstream 的连接复用已成为默认。但这次的 1.30.6,在 proxy_pass 里直接写应用名字的形式下并没有复用。即使默认变了,自己的设置是否如此,也要测量确认。

七

等待型工作与虚拟线程

申请应用里常有等待其他公司 API 的工作(确认交通费的票价、通知会计系统等)。我们做了一个用“休眠 200 ms”代替它的窗口。

/** 第 15 讲:假装等待外部工作(其他公司的 API 等)而休眠。在事务外,还是事务内 */
@Service
public class WaitService {
    private final ClaimFlowService flow;

    public WaitService(ClaimFlowService flow) {
        this.flow = flow;
    }

    public ClaimState waitOutside(String id, long ms) {  // 等待之后,短暂读数据库
        sleep(ms);
        return flow.find(id);
    }

    @Transactional  // 握着数据库连接等待
    public ClaimState waitInside(String id, long ms) {
        ClaimState c = flow.find(id);
        sleep(ms);
        return c;
    }

    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Tomcat 的普通线程(platform)上限是 200 个。Java 21 起的虚拟线程在等待期间会让出线程,因此不必在意数量上限。在 Spring Boot 里只需 1 行 spring.threads.virtual.enabled=true。

终端画面。benchwait.sh 的结果。platform 在并发 100 时每秒 471 件,400 时 961,1000 时 965,线程 131〜229。virtual 在并发 100 时 459,400 时 1580,1000 时 2560,线程 24。中位等待:platform 1000 为 1.24 秒,virtual 1000 为 361 ms
以并发 100・400・1000 向等待 200 ms 的窗口发送。普通线程在每秒 960 件左右封顶,虚拟线程一直伸到 2,560 件(笔者于 2026 年 10 月 31 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 等 200 ms 再读数据库的窗口。按并发 100 → 400 → 1000 顺序各 15 秒(3 次的范围)
                            每秒中位等待线程
  platform c=100            471〜475204 ms        131
  platform c=400            961〜967468〜486 ms229
  platform c=1000           959〜9651240〜1260 ms 228〜229
  virtual  c=100            459〜460203〜205 ms24
  virtual  c=400            1516〜1689220〜238 ms24
  virtual  c=1000           2452〜2760347〜376 ms24
# 刚启动(预热前)就只用虚拟线程发并发 1000 时
  1196 / 1060 / 1042 / 930 /s   (同一 JVM 的第 2 次为 1509 /s)

等待型窗口每秒件数(实测)

等待型窗口每秒件数(实测,3 次中位数) 0 1,000 2,000 200 个线程 ÷ 0.2 秒 = 1,000 474 459 并发 100 963 1,580 并发 400 962 2,560 并发 1000 platform(线程上限 200) virtual(虚拟线程) 等待型工作多时,虚拟线程能一直伸到并发数 在事务内等待时,两者都是每秒 49・48 件(连接池 10 个封顶)

普通线程在 200 个 ÷ 0.2 秒 = 每秒 1,000 件封顶,并发 1000 时中位等待延长到 1.24 秒。虚拟线程在并发 1000 时每秒 2,450〜2,760 件,中位等待约 0.35 秒。真正的 OS 线程一直是 24 个。

有一点要注意。刚启动就只发并发 1000,即使是虚拟线程也只有每秒 930〜1,200 件。在同一个 JVM 上第 2 次发送是 1,509 件,用轻负载预热之后在 2,500 件左右。JVM 预热后才会变快(和第 1 讲“第一件很慢”是同一回事)。测量时,也要写下预热方式。

八

虚拟线程也增加不了的东西

这次把同样的 200 ms 放在事务内等待。和第 9 讲“在事务内等待通知”是同样的形式。

# 在事务内等待 200 ms 的窗口(15 秒)
                        每秒中位等待等连接线程
  platform c=100        49.0        2.02 s        90                130
  platform c=400        42.7        5.81 s        191               229
  virtual  c=100        48.4        2.03 s        90                24
  virtual  c=400        41.7        7.71 s        392               24
  连接池 10 个 × 每秒 5 次(200 ms)= 上限 50 件/秒

两种线程都是每秒 49 件左右。数据库连接池有 10 个,每个握住 200 ms 的话,每秒上限就是 50 件。换成虚拟线程,只是等连接的队列(pending)伸长到 392。虚拟线程增加的是“能等的数量”,不是数据库连接。外部工作要在事务外等待——第 9 讲的结论,换成虚拟线程也不变。

另外,虚拟线程在 synchronized 内无法让出 OS 线程的问题,已在 JDK 24 中修正(JEP 491)。这一讲用的 Java 是 25。

九

停止时,小容器

替换成新版本时,要停掉旧应用。我们在有 20 件正在处理时试着停止。

# 有 20 件正在处理时 docker compose stop app
  每件 3 秒,宽限 10 秒(默认)停止用时 2.9 s200 × 20
  每件 15 秒,宽限 10 秒(默认)停止用时 10.4 sConnectionError × 20   Exited (137)
  每件 15 秒,宽限 40 秒停止用时 15.0 s200 × 20                 Exited (143)
  日志Commencing graceful shutdown. Waiting for active requests to complete

Spring Boot 停止时,会等待正在处理的请求完成(默认最多 30 秒)。可是 docker 默认的宽限是 10 秒,超过就强制终止(137)。需要 15 秒的请求 20 件全部被切断。在 compose 里把宽限设为 40 秒后,20 件都以 200 结束后才停止。让停止方和被停止方的等待时间一致。

接下来是容器的大小。在云上,应用运行在限定了内存和 CPU 的容器里。

# 同一个镜像,只改容器大小,运行 java -XX:+PrintFlagsFinal -XshowSettings:system
  容器CPU   堆上限
  无限制(这台机器 8 GB・2 CPU)2     2,008 MB
  --memory=512m --cpus=1                  1     128 MB   (25%)
    + -XX:MaxRAMPercentage=75             1     384 MB

JVM 会读取容器大小,默认把堆设为其 25%。512 MB 的容器就是 128 MB。在这个容器里,对虚拟线程发送并发 1000。

终端画面。运行 oom15.sh 2 次。第 1 次(默认)t=3s healthy GET=000,t=24s unhealthy 但仍是 running,wrk 0 requests,日志中 http-nio-8081-Acceptor 和 http-nio-8080-Acceptor 出现 OutOfMemoryError: Java heap space。第 2 次(-XX:+ExitOnOutOfMemoryError 加 restart unless-stopped)t=10s restarts=1,t=23s GET=200,t=25s healthy,日志中有 Terminating due to java.lang.OutOfMemoryError
在内存 512 MB 的容器里让堆用尽。默认设置下,什么都不接收却还活着。设成退出后重启,20 多秒就恢复了(笔者于 2026 年 10 月 31 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 512 MB・1 个 CPU 的容器,虚拟线程,对等待 200 ms 的窗口并发 1000(15 秒)
  堆 25%(默认)堆用尽。接收线程(Acceptor)停止,什么都不接收
                                    处理了 0〜247 件(4 次)。进程还活着
                                    约 25 秒后 unhealthy,但仍是 running
  堆 75%堆+其他超过 512 MB,被内核终止(OOMKilled)
  25% + ExitOnOutOfMemoryError立即退出 → restart 重启,20 多秒后 200
  堆 60%15 秒 7121 件。用掉内存 466 MiB / 512 MiB

在 512 MB 容器里改变堆比例(实测)

在 512 MB 容器里改变堆比例(实测) 25%(默认) 堆 128 MB 不工作却还活着 75% 堆 384 MB OOMKilled 60% 堆 308 MB 7,121 件 / 15 秒 512 MB 堆由容器大小决定。太少或太多都会停 堆之外还有线程栈、JIT、库等占用的内存 用 -XX:+ExitOnOutOfMemoryError 和 restart,消除“停着却活着”的状态

默认的 25% 时,堆被用尽,连接收线程也停了。进程还活着,所以在 Docker 看来仍然是 running。健康检查会变成 unhealthy,但 restart 机制是在进程结束时才起作用,所以从这个状态回不来。用 -XX:+ExitOnOutOfMemoryError 让 JVM 退出后,就被 restart 重新启动,20 多秒后返回了 200。

把堆设为 75%,这次加上堆外部分(线程栈、JIT、库)超过了 512 MB,被内核终止。60% 时 15 秒处理了 7,121 件,用了 466 MiB。堆由容器决定,太少或太多都会停。先施加负载、看实际用量再决定。

十

回顾全部 15 讲

一个只接收报销申请的应用,用了 15 讲,加上了许多“理所当然”。

讲主题值得记住的一行
1全景图一件申请经过 Tomcat→窗口→负责人→仓库管理员→数据库
2Java 准备钱用 BigDecimal,瞬间用 Instant,日期注意时差
3徒手 HTTP长度按字节,一个个处理会被不说话的客人卡住
4徒手数据库不要拼接字符串做 SQL
5Spring BootBean 只有一个,不把请求的值放进字段
6输入检查标注放在窗口,最后的防线是数据库的 CHECK
7异常与回复回复用 ProblemDetail,500 附咨询编号
8数据库访问答案相同也要看 SQL 的条数(N+1)
9并发更新不要读了再减再写,在 SQL 里减或用版本号
10状态迁移迁移用表决定,用 where 守住
11认证与授权不相信自称,别人的申请返回 404
12文件名字和自报的内容都不信,存放的名字自己起
13日志与监控每件一行加编号,整体看数字,存活看 health
14测试改好之后,弄坏它看测试失败
15运行结构放源码里,秘密放外面,等待时间与容器大小自己定

每一讲都是先朴素地写,再测量,然后修正遇到的问题。修好的地方由第 14 讲的测试记住,这一讲让它在别的地方也能以同样的结构运行。新工具今后还会不断出现,但写出来、测一测、弄坏再确认的做法,古往今来都没有变。

结 — 一页总结与结语

  1. 在练习房间外面,只建出 1 张表,返回 500 和 401。数据库结构用 Flyway 放进源码。执行过的文件不改。
  2. 镜像分层(变化的只有 451 kB),不用 root,秘密用环境变量。
  3. compose 等待健康检查依次启动。11 秒备齐 3 个服务。
  4. nginx 要设好上限・转发头・连接复用。保持朴素就是 HTML 的 413、没有 Secure、502。
  5. 虚拟线程在等待型工作中把每秒 960 件提到 2,560 件。在事务内等待则仍是 49 件。
  6. 停止宽限要比 Spring 长。堆由容器决定,不要造出“停着却活着”的状态。

新井古法的做法,结果全都是现在还在用的东西呢。

古井工具的名字会变,但请求到达、有人确认、写到某处、给出回复,这个流程是一样的。15 讲,辛苦了。

参考

记录是笔者于 2026 年 10 月 31 日,在云端 Linux(Ubuntu 24.04、内核 6.18、2 个 CPU、内存 8 GB)上,使用 Docker 29.9.2・Docker Compose 5.6.1,以 eclipse-temurin:25-jre(Temurin 25.0.5、Ubuntu 26.04)镜像运行 Spring Boot 4.1.2(Flyway 12.4.0、Tomcat 11.0.27、HikariCP 7.0.2、MyBatis 3.5.19),并与 PostgreSQL 16.15(postgres:16)和 nginx 1.30.6(nginx:1.30-alpine)一起运行取得的。负载用 wrk 4.1.0 从同一台机器发出(因为争抢 CPU,数字用于比较)。表中范围是 3 次实测的最小值和最大值,图中柱子是中位数。证书和密码都是练习用的。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。

古法后端入门 第15讲(最终讲) — 运行 / 2026.10

← 返回文章列表