Old-School Backend / 2026 ・ No.15
古法后端入门
第15讲(最终讲) 运行 ── 把申请应用带出自己的电脑
发布
这个连载的应用,一直在笔者的练习房间(网络空间和练习用数据库)里运行。最终讲要把它带出去。
先用一个全新的数据库运行第 14 讲的 jar。把数据库结构放进源码(Flyway),装进 Docker 镜像,用 compose 排列数据库・应用・nginx 这 3 个服务。然后测量能处理多少请求、虚拟线程改变了什么、停止时和在小容器里会发生什么。最后回顾全部 15 讲。
走出练习房间
新井测试也有 107 件,到哪儿都能运行吧。
古井用全新的数据库试试。
全 15 讲路线图(当前:第 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
完成后的结构
从一无所有开始,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。
# 等 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)
等待型窗口每秒件数(实测)
普通线程在 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。
# 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 容器里改变堆比例(实测)
默认的 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→窗口→负责人→仓库管理员→数据库 |
| 2 | Java 准备 | 钱用 BigDecimal,瞬间用 Instant,日期注意时差 |
| 3 | 徒手 HTTP | 长度按字节,一个个处理会被不说话的客人卡住 |
| 4 | 徒手数据库 | 不要拼接字符串做 SQL |
| 5 | Spring Boot | Bean 只有一个,不把请求的值放进字段 |
| 6 | 输入检查 | 标注放在窗口,最后的防线是数据库的 CHECK |
| 7 | 异常与回复 | 回复用 ProblemDetail,500 附咨询编号 |
| 8 | 数据库访问 | 答案相同也要看 SQL 的条数(N+1) |
| 9 | 并发更新 | 不要读了再减再写,在 SQL 里减或用版本号 |
| 10 | 状态迁移 | 迁移用表决定,用 where 守住 |
| 11 | 认证与授权 | 不相信自称,别人的申请返回 404 |
| 12 | 文件 | 名字和自报的内容都不信,存放的名字自己起 |
| 13 | 日志与监控 | 每件一行加编号,整体看数字,存活看 health |
| 14 | 测试 | 改好之后,弄坏它看测试失败 |
| 15 | 运行 | 结构放源码里,秘密放外面,等待时间与容器大小自己定 |
每一讲都是先朴素地写,再测量,然后修正遇到的问题。修好的地方由第 14 讲的测试记住,这一讲让它在别的地方也能以同样的结构运行。新工具今后还会不断出现,但写出来、测一测、弄坏再确认的做法,古往今来都没有变。
结 — 一页总结与结语
- 在练习房间外面,只建出 1 张表,返回 500 和 401。数据库结构用 Flyway 放进源码。执行过的文件不改。
- 镜像分层(变化的只有 451 kB),不用 root,秘密用环境变量。
- compose 等待健康检查依次启动。11 秒备齐 3 个服务。
- nginx 要设好上限・转发头・连接复用。保持朴素就是 HTML 的 413、没有 Secure、502。
- 虚拟线程在等待型工作中把每秒 960 件提到 2,560 件。在事务内等待则仍是 49 件。
- 停止宽限要比 Spring 长。堆由容器决定,不要造出“停着却活着”的状态。
新井古法的做法,结果全都是现在还在用的东西呢。
古井工具的名字会变,但请求到达、有人确认、写到某处、给出回复,这个流程是一样的。15 讲,辛苦了。
参考
- Flyway — Versioned migrations
- Flyway — Validate(checksum)
- Spring Boot — Dockerfiles(jarmode tools、layers)
- Spring Boot — Graceful Shutdown
- Docker — Control startup and shutdown order in Compose
- nginx — ngx_http_upstream_module(keepalive)
- NGINX Blog — Keep-alive to upstreams is now default in NGINX 1.29.7
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
记录是笔者于 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