Old-School Backend / 2026 ・ No.01

古法后端入门

第1讲 全景图 ── 一件申请在服务器里进入数据库的过程

发布

Wireshark 的界面。在申请服务器(192.168.1.20)的口上记录的列表,第 4 个是电脑发来的 POST /api/expenses,第 6 到 16 个是与数据库服务器(192.168.1.30)的 PGSQL 往来,第 19 个是 HTTP/1.1 201。选中第 13 个,详情的 PostgreSQL(Type: Parse)里可以看到 Query: insert into expense (id, applied_on, item, amount, status) values ($1, $2, $3, $4, 'submitted') 和 Parameters: 4
一件申请的背后。申请服务器收到电脑的申请(第 4 个)后,在写回复(第 19 个)之前,和数据库服务器来回了 4 次。选中的第 13 个,是把申请写入表的 SQL(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 26 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

前端篇做了申请表单,网络篇一行一行地读了这件申请穿过线路的样子。终于轮到接收的一方:后端。语言是 Java,计划共 15 讲。
第 1 讲是全景图。在练习网络上运行用 Java 和 Spring Boot 写的申请服务器,以及 PostgreSQL 数据库服务器,实测一件申请如何在服务器里传递、进入数据库。

一

走向接收的一方

新井线路之旅结束了。今天开始进服务器里面了吧!

古井对。一直在发送的 POST /api/expenses,这次要自己来接收。

新井就是 Spring Boot,加上注解就能跑的那个吧。让 AI 写,一下子就好。

古井一下子就能跑,也一下子就搞不清里面发生了什么。所以先画全景图,把背后在动的东西一个一个打开。

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

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 全景图 ── 一件申请在服务器里经过 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 读 JSON Jackson 检查 Validator 窗口 Controller 负责人 Service 仓库管理员 Repository 数据库 PostgreSQL 回复 201 Jackson・Tomcat 3.3 ms 数据库之前 2.5 ms 与数据库来回 4 次 1.6 ms 写回复 预热后的一件申请(据 Wireshark 时刻,共 7.4 ms) 一件申请在服务器里经过 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 写回复 31 行 负责人 Service 业务规则 事务 26 行 仓库管理员 Repository 写 SQL 和数据库对话 28 行 DB PostgreSQL HTTP → 按角色分开类。窗口不知道 SQL,仓库管理员不知道 HTTP 行数是本讲实际文件去掉空行后的行数

窗口(Controller)只管 HTTP,负责人(Service)只管业务规则,仓库管理员(Repository)只管 SQL。在窗口里写 SQL 也能跑,但随着审批流程和权限检查增加,一个类会变得难以收拾。按角色分开,“换数据库”只改仓库管理员,“改金额上限”只改负责人就行。

新井和网络的“层”是一个思路呢。上层不用知道下层的内容。

古井对。古时的工匠,也把账房、厨房和仓库分开。

五

数据库是另一个程序

数据库不是申请服务器里的零件,而是在另一台服务器上运行的另一个程序。它和申请服务器通过 TCP 的 5432 号相连。封面照片是在申请服务器的口上记录的。电脑发来的申请(第 4 个)和回复(第 19 个)之间,夹着和数据库服务器的往来(第 6~16 个)。

往来共 4 次。最开始的“空查询”,是确认从连接池借来的连接是否还活着。接着是 BEGIN(事务开始)和取出受理编号,然后是 INSERT,最后是 COMMIT(确定)。事务在第 9 讲详细介绍。

Wireshark 的界面。同一个第 13 个的详情,展开第 2 个 PostgreSQL(Type: Bind)。Parameter formats: 4、Parameter values: 4。右侧字节栏里可以看到 EXP-2026-0012 和 2026-10-26
第 13 个的后半部分。与 SQL 语句(Parse)分开,用 Bind 发送填入 $1~$4 的值(受理编号、日期、项目、金额)。语句和值不混在一起,是第 4 讲防 SQL 注入的关键(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 26 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

请注意,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 秒。

把两个 Wireshark 数据包列表上下并排,都只显示有内容的数据包。上面是第 1 次,POST 在 0.000083 秒,第一个 PGSQL 在 0.219919 秒,201 在 0.280707 秒。下面是预热后的第 57 件,POST 在 0.000116 秒,第一个 PGSQL 在 0.003453 秒,201 在 0.008110 秒
同样的申请,启动后第 1 件要 0.28 秒,预热后只要 0.008 秒。差距几乎都在和数据库说话之前(读 JSON 的工具、输入检查的准备)(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 26 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

差距几乎都在和数据库说话之前。第 1 件从到达到第一次询问数据库用了 0.22 秒,预热后只要 0.003 秒。看日志,第 1 件在到达窗口之前用了约 0.15 秒。读 JSON 的工具和输入检查的工具在第一次时做好准备,Java 的类被读入,常走的路径被 JVM 逐渐改写成更快的机器码。

POINT

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 件 Tomcat 的线程 接手的有 36 个 连接池 HikariCP 10 个 DB 一件一个线程。到数据库的连接,复用预先建立的 10 个 50 件在 0.157 秒内全部 201。不每次重新建连接,是网络篇第 6 讲的内容 线程上限(默认 200 个)和连接数(默认 10 个)在第 15 讲研究

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 个库

expenses-0.0.1-SNAPSHOT.jar(24.3 MB,48 个库) Spring Boot 4.1.2 组装 Tomcat 11.0 接收 HTTP Jackson 3.1 JSON Hibernate Validator 9.1 输入检查 HikariCP 7.0 连接池 PostgreSQL JDBC 42.7 和数据库对话 本讲的 Java 是 6 个文件、112 行(含模板中的 1 个) JVM(OpenJDK 25) 这 112 行,背后是许多其他工匠的手艺在支撑
工具作用本讲使用的版本
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
curlMac、Windows、Linux 都自带curl --version

Maven 会由模板里的 mvnw(Windows 是 mvnw.cmd)自动准备,不必另外安装。

结 — 一页总结与下一讲

  1. 服务器是打开端口、等待的程序。申请服务器是 8080 号,数据库服务器是 5432 号。
  2. 一件申请经过 Tomcat → Jackson → Validator → 窗口 → 负责人 → 仓库管理员 → 数据库 → 回复,8 个人的手。预热后 7.4 毫秒。
  3. 自己写的是窗口、负责人、仓库管理员。按角色分开类。
  4. 数据库是另一个程序。SQL 的语句和值分开发送,用 BEGIN 和 COMMIT 夹起来。
  5. Java 服务器第一件慢(0.28 秒 → 0.008 秒)。
  6. 同时到来时,一件一个线程。数据库连接从池子里借来复用。
  7. 本讲的 Java 112 行,由 48 个库支撑。为了了解里面,第 3、4 讲徒手来做。

新井一件申请,竟然有这么多人参与。

古井那留个作业。申请日期发的是 26 日,回复的 Date 头部却是 25 日,日志的时刻是 15 点多,数据库的记录是 0 点多。哪个是对的?

新井……全都对?

古井下一讲是 Java 的准备。从正确地存放钱、日期和时刻开始。

参考

记录来自笔者在云端 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

← 返回文章列表