Old-School Backend / 2026 ・ No.01
古法后端入门
第1讲 全景图 ── 一件申请在服务器里进入数据库的过程
发布
前端篇做了申请表单,网络篇一行一行地读了这件申请穿过线路的样子。终于轮到接收的一方:后端。语言是 Java,计划共 15 讲。
第 1 讲是全景图。在练习网络上运行用 Java 和 Spring Boot 写的申请服务器,以及 PostgreSQL 数据库服务器,实测一件申请如何在服务器里传递、进入数据库。
走向接收的一方
新井线路之旅结束了。今天开始进服务器里面了吧!
古井对。一直在发送的 POST /api/expenses,这次要自己来接收。
新井就是 Spring Boot,加上注解就能跑的那个吧。让 AI 写,一下子就好。
古井一下子就能跑,也一下子就搞不清里面发生了什么。所以先画全景图,把背后在动的东西一个一个打开。
全 15 讲路线图(当前位置:第 1 讲)
- 全景图 ── 一件申请在服务器里经过 8 个人的手进入数据库(实测与 Wireshark)
- 徒手做做看 ── 第 3、4 讲不用框架处理 HTTP 和数据库,确认 Spring 替我们做了什么
- 业务系统的常见工作 ── 输入检查、错误的返回方式、事务、审批流程、认证、文件、日志、测试
- 每次都实测 ── 在练习服务器上运行,用日志、SQL、数据包、时间的数字来说明
题材和之前一样,是报销系统。前端篇第 10 讲从页面发出、网络篇第 10 讲一行一行读过的那件申请,这次用 Java 接收,存进数据库。
服务器是“在等待的程序”
练习网络有 3 台:电脑(192.168.1.10)、申请服务器(192.168.1.20,名字是 expenses.example)、数据库服务器(192.168.1.30)。在申请服务器上,启动为本讲写的 Java 程序。
# 在申请服务器(192.168.1.20)上启动 Java 程序
app$ java -jar expenses-0.0.1-SNAPSHOT.jar
… Tomcat initialized with port 8080 (http)
… HikariPool-1 - Start completed.
… Started ExpensesApplication in 3.183 seconds
# 是谁在哪里等待
app$ ss -ltnp 'sport = :8080'
LISTEN 0.0.0.0:8080 users:(("java",pid=760,fd=18))
# 到数据库的连接(启动时已经建立)
app$ ss -tn 'dport = :5432' | wc -l
10
# 这个程序的大小
app$ ls -l expenses-0.0.1-SNAPSHOT.jar ; grep VmRSS /proc/760/status ; ls /proc/760/task | wc -l
24,319,744 bytes
VmRSS: 200876 kB
38
“服务器”并不是指特别的机器,而是打开端口、等待请求的程序。用 ss 看,java 在 0.0.0.0 的 8080 号上等待(网络篇第 11 讲的“在所有口上等待”)。而且启动时,已经向数据库服务器的 5432 号建好了 10 条连接。原因在第七章。
启动用了 3.2 秒,内存约 200 MB,线程 38 个。即使是小小的申请服务器,里面也已经有不少工作开始运转了。
一件申请的旅程
从电脑发送和网络篇一样的申请。
# 从电脑发送和第 10 讲一样的申请
pc$ curl -D - -X POST -H 'Content-Type: application/json' \
-d '{"date":"2026-10-26","item":"交通費","amount":1280}' \
http://expenses.example:8080/api/expenses
HTTP/1.1 201
Location: /api/expenses/EXP-2026-0012
Content-Type: application/json
{"id":"EXP-2026-0012","status":"submitted"} (0.281 s)
# 同样的申请再发 5 次
201 (0.009 s) 201 (0.010 s) 201 (0.007 s) 201 (0.005 s) 201 (0.005 s)
返回了 201 Created 和受理编号,受理编号也放进了 Location(网络篇第 10 讲)。这时申请服务器里发生了什么,事先让它写进了日志(日志内容就是程序里的日语)。
# 申请服务器的日志(第 2 件。线程都是 http-nio-8080-exec-2)
15:47:44.473 ExpenseController 窓口:申請を受け取った ExpenseRequest[…, amount=1280]
15:47:44.473 JdbcTransactionManager Creating new transaction with name [example.expenses.ExpenseService.submit]
15:47:44.473 JdbcTransactionManager Acquired Connection […] for JDBC transaction
15:47:44.474 JdbcTemplate Executing prepared SQL statement [select nextval('expense_no')]
15:47:44.474 ExpenseService 担当者:受付番号を決めた EXP-2026-0013
15:47:44.474 JdbcTemplate Executing prepared SQL statement [insert into expense (…) values (?, ?, ?, ?, 'submitted')]
15:47:44.475 ExpenseRepository 倉庫番:1行書いた EXP-2026-0013
15:47:44.475 JdbcTransactionManager Committing JDBC transaction on Connection […]
15:47:44.476 JdbcTransactionManager Releasing JDBC Connection […] after transaction
15:47:44.476 ExpenseController 窓口:201 を返す EXP-2026-0013
一件申请在服务器里经过 8 个人的手
Tomcat 接收 HTTP,Jackson 把 JSON 变成 Java 的值,Hibernate Validator 检查内容。这些都做完后,才轮到自己写的“窗口”。窗口交给“负责人”,负责人开始事务、决定受理编号,“仓库管理员”用 SQL 往表里写一行。提交后把连接还回池子,窗口返回 201。按日志的时刻,从接收到返回是 3 毫秒。
按 Wireshark 的时刻来分,从申请到达到开始和数据库说话 3.3 毫秒,和数据库来回 4 次 2.5 毫秒,写好回复发出 1.6 毫秒。
窗口、负责人、仓库管理员
自己写的部分分成了三个角色。这是实际的代码(注释和日志是日语)。
// 窗口(Controller):接收 HTTP,交给负责人,写回 201
@PostMapping
public ResponseEntity<Map<String, String>> submit(@Valid @RequestBody ExpenseRequest req) {
log.info("窓口:申請を受け取った {}", req);
String id = service.submit(req);
log.info("窓口:201 を返す {}", id);
return ResponseEntity.created(URI.create("/api/expenses/" + id))
.body(Map.of("id", id, "status", "submitted"));
}
// 负责人(Service):业务规则。从这里开始是一个事务
@Transactional
public String submit(ExpenseRequest req) {
String id = "EXP-2026-%04d".formatted(repo.nextNumber());
log.info("担当者:受付番号を決めた {}", id);
repo.insert(id, req);
return id;
}
// 仓库管理员(Repository):写 SQL,和数据库对话
void insert(String id, ExpenseRequest r) {
jdbc.sql("insert into expense (id, applied_on, item, amount, status) values (?, ?, ?, ?, 'submitted')")
.params(id, r.date(), r.item(), r.amount())
.update();
log.info("倉庫番:1行書いた {}", id);
}
按角色分开类
窗口(Controller)只管 HTTP,负责人(Service)只管业务规则,仓库管理员(Repository)只管 SQL。在窗口里写 SQL 也能跑,但随着审批流程和权限检查增加,一个类会变得难以收拾。按角色分开,“换数据库”只改仓库管理员,“改金额上限”只改负责人就行。
新井和网络的“层”是一个思路呢。上层不用知道下层的内容。
古井对。古时的工匠,也把账房、厨房和仓库分开。
数据库是另一个程序
数据库不是申请服务器里的零件,而是在另一台服务器上运行的另一个程序。它和申请服务器通过 TCP 的 5432 号相连。封面照片是在申请服务器的口上记录的。电脑发来的申请(第 4 个)和回复(第 19 个)之间,夹着和数据库服务器的往来(第 6~16 个)。
往来共 4 次。最开始的“空查询”,是确认从连接池借来的连接是否还活着。接着是 BEGIN(事务开始)和取出受理编号,然后是 INSERT,最后是 COMMIT(确定)。事务在第 9 讲详细介绍。
请注意,SQL 的语句和值是分开发送的。语句里保留着 $1, $2, $3, $4 这样的空格,填进去的受理编号、日期、项目、金额,装在名为 Bind 的另一个箱子里送达。这样一来,即使项目里混进了恶意的字符串,也不会被当作 SQL 语句来读。第 4 讲会故意把语句和值混在一起,看看会怎样坏掉。
# 在数据库服务器上查看表的内容
db$ psql -d expenses -c 'select id, applied_on, item, amount, status from expense order by id limit 3'
id | applied_on | item | amount | status
---------------+------------+--------+---------+-----------
EXP-2026-0012 | 2026-10-26 | 交通費 | 1280.00 | submitted
EXP-2026-0013 | 2026-10-26 | 交通費 | 1280.00 | submitted
EXP-2026-0014 | 2026-10-26 | 交通費 | 1280.00 | submitted
在数据库服务器一侧看表,申请确实一行一行地存进去了。金额是 1280.00。从页面发出的 1280,在数据库里成了带两位小数的数。在 Java 里怎样存放钱,是下一讲的重要话题。
第一件总是慢
第三章的申请,按 curl 的时钟用了 0.281 秒。可之后的 5 件只要 0.005~0.010 秒。
差距几乎都在和数据库说话之前。第 1 件从到达到第一次询问数据库用了 0.22 秒,预热后只要 0.003 秒。看日志,第 1 件在到达窗口之前用了约 0.15 秒。读 JSON 的工具和输入检查的工具在第一次时做好准备,Java 的类被读入,常走的路径被 JVM 逐渐改写成更快的机器码。
Java 服务器“刚启动时最慢”。测性能时,要先扔掉最初的几件再测。正式环境刚替换后响应变慢,也是同样的原因(第 15 讲)。
同时来很多
月底,为了赶财务的截止日,申请会集中到来。从电脑同时发送 50 件试试。
# 从电脑同时发送 50 件
pc$ python3 par.py
status {201: 50}
total 0.157 s slowest 0.135 s fastest 0.030 s
# 在申请服务器的日志里,数一数接收的线程名
app$ grep … app.log | (thread names) | sort -u | wc -l
36
# Java 程序的线程数(刚启动时是 38)
app$ ls /proc/760/task | wc -l
66
一件一个线程,数据库连接从池子里借
50 件在 0.157 秒内全部 201。看日志,接收的线程有 36 种。Tomcat 把到达的请求一件一件分配给线程(程序里能同时干活的工人)。而到数据库的连接,则复用启动时建好的 10 条(连接池)。这是为了不每次都从 TCP 的寒暄重新开始,正是网络篇第 6 讲的“连接复用”。
36 个工人轮流用 10 部电话,所以会出现等电话空出来的情况。线程数、连接数,以及虚拟线程(Java 21 起)的话题,在第 15 讲实测。
工具阵容
这个申请服务器,是在 Spring Initializr(start.spring.io)生成的模板上,加写了 5 个 Java 文件和表的定义(schema.sql),并稍微改了改配置。构建后成为一个 jar 文件。
jar 的内容。本讲的 Java 112 行,和 48 个库
| 工具 | 作用 | 本讲使用的版本 |
|---|---|---|
| JDK(OpenJDK) | 编译 Java,用 JVM 运行 | 25.0.4 |
| Maven | 收集库并构建 | 模板里的 mvnw |
| Spring Boot | 组装库,省去配置的麻烦 | 4.1.2 |
| Tomcat | 接收 HTTP(随 Spring Boot 附带) | 11.0 |
| PostgreSQL | 数据库服务器 | 16 |
Java 含模板中的一个共 112 行。接收 HTTP、读写 JSON、输入检查、连接池,全是库的工作。这本身是好事。只是不知道库在做什么,一旦不动了就无从下手。所以本连载在第 3、4 讲特意徒手处理 HTTP 和数据库,第 5 讲再回到 Spring Boot。
后端的常见工作
今天的申请服务器还只是“接收、存起来”。业务系统的后端还有其他常见的工作。按本连载介绍的顺序排列如下。
| 工作 | 要注意的地方 | 讲 |
|---|---|---|
| 正确地存放值 | 金额不用小数计算,日期时刻是“哪里的时刻” | 第 2 讲 |
| 输入检查 | 页面的检查是体贴,守护靠服务器 | 第 6 讲 |
| 错误的返回方式 | 状态码,和读得懂的错误正文 | 第 7 讲 |
| 数据库读写 | SQL 注入,件数一多就变慢的写法(N+1) | 第 4、8 讲 |
| 事务 | 中途失败要退回到哪里,同时更新怎么办 | 第 9 讲 |
| 审批流程 | 申请 → 批准 → 退回的状态在哪里守住 | 第 10 讲 |
| 认证与权限 | 能看到别人申请的漏洞,密码的保存 | 第 11 讲 |
| 文件 | 上传收据,导出 CSV、Excel 明细 | 第 12 讲 |
| 日志与监控 | 事后能追查的记录,不输出个人信息 | 第 13 讲 |
| 测试 | 改了之后自动确认 | 第 14 讲 |
| 持续运行 | 部署、性能、库的更新 | 第 15 讲 |
数据库读写有 JPA 和 MyBatis 两种有名的做法。第 8 讲会用同一件申请把两者都试一遍,比较不同。在那之前,先用自己写 SQL 的朴素做法(Spring 的 JdbcClient)推进。
在自己的电脑上准备
为了下一讲能动手,先把工具备齐吧。
| 要准备的 | 安装方式示例 | 确认方法 |
|---|---|---|
| JDK 25 | 安装 Eclipse Temurin 等发行版 | java -version |
| 编辑器 | IntelliJ IDEA,或 VS Code 加 Java 扩展 | 能打开新项目 |
| PostgreSQL | 用 Docker 运行最方便 | docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=practice postgres:16 |
| 模板 | 在 start.spring.io 选择 Spring Web、JDBC API、PostgreSQL Driver、Validation | ./mvnw spring-boot:run |
| curl | Mac、Windows、Linux 都自带 | curl --version |
Maven 会由模板里的 mvnw(Windows 是 mvnw.cmd)自动准备,不必另外安装。
结 — 一页总结与下一讲
- 服务器是打开端口、等待的程序。申请服务器是 8080 号,数据库服务器是 5432 号。
- 一件申请经过 Tomcat → Jackson → Validator → 窗口 → 负责人 → 仓库管理员 → 数据库 → 回复,8 个人的手。预热后 7.4 毫秒。
- 自己写的是窗口、负责人、仓库管理员。按角色分开类。
- 数据库是另一个程序。SQL 的语句和值分开发送,用 BEGIN 和 COMMIT 夹起来。
- Java 服务器第一件慢(0.28 秒 → 0.008 秒)。
- 同时到来时,一件一个线程。数据库连接从池子里借来复用。
- 本讲的 Java 112 行,由 48 个库支撑。为了了解里面,第 3、4 讲徒手来做。
新井一件申请,竟然有这么多人参与。
古井那留个作业。申请日期发的是 26 日,回复的 Date 头部却是 25 日,日志的时刻是 15 点多,数据库的记录是 0 点多。哪个是对的?
新井……全都对?
古井下一讲是 Java 的准备。从正确地存放钱、日期和时刻开始。
参考
- Spring Boot Reference Documentation
- Spring Boot — Servlet Web Applications
- Spring Framework — JDBC Core Classes(JdbcClient)
- Spring Framework — Declarative Transaction Management
- Spring Initializr
- OpenJDK — JDK 25
- Eclipse Adoptium(Temurin)
- Apache Tomcat
- Jakarta Validation
- PostgreSQL 16 — Frontend/Backend Protocol: Message Flow
记录来自笔者在云端 Linux(Ubuntu 24.04,内核 6.18)上用网络命名空间搭建的练习网络,于 2026 年 10 月 26 日抓取。申请服务器是 OpenJDK 25.0.4 和 Spring Boot 4.1.2(在 Spring Initializr 的模板上加了本讲的代码。与数据库的往来用 Spring 的 JdbcClient),数据库服务器是 PostgreSQL 16,电脑一侧是 curl 8.5.0 和 Python 3。名字和地址都是为练习而制作的。日志时刻保持申请服务器的设置(UTC)。表中的数字和输出都是从这些记录和当时命令的输出读出的实际数值(较长的输出省略了一部分)。登场人物均为虚构。
古法后端入门 第1讲 — 全景图 / 2026.10