Old-School Backend / 2026 ・ No.04

古法后端入门

第4讲 徒手和数据库对话 ── JDBC、SQL 注入、事务

发布

终端画面。给 java Lesson4 concat 传入 新井' OR '1'='1,SQL 变成 where applicant = '新井' OR '1'='1',除了新井的 3 件,还出现了古井的接待费(寿司)48,000 日元等共 6 件。把同样的文字传给 prepared,SQL 保持 where applicant = ?,结果 0 件
在申请人名字里输入带引号的文字,用拼接字符串造的 SQL(上)返回了所有人的 6 件申请,占位符版(下)是 0 件。古井的寿司费 48,000 日元出现在了新井的画面上(笔者于 2026 年 10 月 30 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

上一讲只用 JDK 接收了 HTTP。这次轮到数据库。第 1 讲里只是把一行 SQL 交给 Spring 的 JdbcClient,而在它下面,Java 的标准窗口 JDBC 打开连接、发送 SQL、一行一行地接收结果。
这次只用一个驱动和 PostgreSQL 对话。连接的分量、忘记关闭、拼接字符串的 SQL、类型与 null、批量写入、中途失败时会怎样,都在自己的数据库上实测。

一

徒手和数据库对话

新井SQL 我会写。select、insert、update。

古井那么,按用户输入的名字查申请的 SQL,你在 Java 里怎么造?

新井把字符串拼起来……“where applicant = '”+名字+“'”。

古井今天就从这一行让我的寿司费出现在你的画面上开始。

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

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 连接 ── 建立一个连接要往返 6 次。select 1 执行 100 次,每次连接 1,582 ms,复用 29 ms(实测与 Wireshark)
  • 忘记关闭 ── 到 97 个时数据库拒绝了(实测)
  • SQL 注入 ── 拼接字符串,连别人的申请都出来了。用占位符则是 0 件(实测、数据库日志与 Wireshark)
  • 类型与 null ── 没有合计时 getLong 返回 0(实测)
  • 批量写入与事务 ── 1,000 行从 385 ms 到 22 ms。失败时留下了只有一半的申请(实测)

舞台是第 1 讲的三台机器。从申请服务器(192.168.1.20)连接数据库服务器(192.168.1.30,名字是 db.internal)上的 PostgreSQL 16。用到的只有 PostgreSQL 的 JDBC 驱动(postgresql-42.7.14.jar,1.2 MB)。练习程序是 Lesson4.java(不计空行 238 行)。为了这一讲,给申请表加了申请人(applicant)列,重新放入新井和古井各 3 件申请。确认是谁登录的话题,在第 11 讲。

二

连接:一个连接有多重

JDBC 是 Java 使用数据库的标准窗口(java.sql 包)。窗口的形状是定好的,里面由各个数据库的驱动来实现。古老的代码里常留着 Class.forName("org.postgresql.Driver") 这句咒语,但现在的驱动会在 jar 里写上自己的名字(META-INF/services/java.sql.Driver),只用 DriverManager.getConnection 就能连上。

# 一个连接有多重(同一 JVM 里连 3 次),以及 select 1 执行 100 次
getConnection 1 回目  375.0 ms
getConnection 2 回目  34.5 ms
getConnection 3 回目  14.6 ms
select 1 を 100 回:毎回つなぐ 1582 ms / 1本を使い回す 29 ms

一个连接有多重(往返用 Wireshark,时间为实测)

建立一个连接之前(用 Wireshark 数的往返) 1 TCP 握手 SYN → SYN,ACK 2 用 SSL 吗? SSLRequest → N(不用) 3 用户名和数据库名 Startup → 请用 SASL 4 确认密码(1) SCRAM-SHA-256、i=4096 5 确认密码(2) → 认证 OK 与 14 个设置 6 驱动的设置 SET application_name 7 终于是 SQL Parse/Bind/Execute select 1 执行 100 次 每次都连接 1,582 ms 复用一个连接 29 ms 数据库为每个连接创建一个进程(照片里的 103 个) 所以连接要放进池里复用(第 1 讲的 HikariCP 是 10 个)

第 1 次的 375 ms 还包括加载驱动类的时间。第 3 次也要 14.6 ms。select 1 本身 0.3 ms 就结束了,光是连接就要它的 50 倍。用 Wireshark 数里面的内容:TCP 握手之后,询问是否使用 SSL,发送用户名和数据库名,确认密码(SCRAM-SHA-256。不发送密码本身,而是用哈希 4,096 次后的值互相确认)2 次往返,接着是驱动的设置,到第 7 次往返 SQL 才送到。而且数据库这边,每个连接都会创建一个进程。

所以业务应用不会丢掉连好的连接,而是把它放进池(连接池)里复用。第 1 讲的 Spring Boot 里 HikariCP 持有 10 个连接,就是这个原因。

三

忘记关闭,数据库就会拒绝

用完的连接要用 close() 还回去。忘记还会怎样?我们一直打开连接,不关闭、一直拿着。

终端画面。上方在申请服务器上运行 Lesson4 leak,保持打开 97 个连接后第 98 个失败,SQLState 53300 FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute。下方在数据库服务器上统计 pg_stat_activity,app 为 97,用 ps 数的 postgres 进程为 103
不关闭连接、一直打开下去,到第 98 个时被数据库拒绝。此时数据库服务器上运行着 app 的 97 个连接、103 个 postgres 进程(97 个+数据库自身的 6 个)(笔者于 2026 年 10 月 30 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

开到第 97 个都没问题,第 98 个被数据库拒绝了。PostgreSQL 同时连接数的上限默认是 100 个,其中 3 个留给管理员。上限是整个数据库共用的,所以只要某个应用泄漏连接,使用同一个数据库的其他应用也连不上了。连接池也一样,借了不还,池子很快就空了。

防止忘记归还的写法,是 Java 7 起的 try(try-with-resources)。

try (Connection c = DriverManager.getConnection(URL, USER, PASS);   // 在 ( ) 里打开的东西
     PreparedStatement ps = c.prepareStatement(sql);
     ResultSet r = ps.executeQuery()) {
    …
}                                       // 在这里按相反顺序 close(即使出了异常)

在 ( ) 里打开的东西,离开代码块时一定会被关闭,包括因异常跳出的情况。从连接池借来的连接,close() 的意思不是断开,而是“还回池里”。

四

拼接字符串的 SQL

用最直白的方式,写新井查看自己申请的画面。把申请人的名字拼进 SQL 的字符串里。

String sql = "select id, applicant, item, amount from expense where applicant = '" + who + "' order by id";
try (Connection c = DriverManager.getConnection(URL, USER, PASS);
     Statement s = c.createStatement();
     ResultSet r = s.executeQuery(sql)) {
    while (r.next()) { … r.getString("item") … r.getBigDecimal("amount") … }
}
# 用拼接字符串造 SQL 的版本,传入申请人的名字
SQL: select id, applicant, item, amount from expense where applicant = '新井' order by id
  EXP-2026-0101  新井  交通費                       1,280
  EXP-2026-0102  新井  会議費                       3,300
  EXP-2026-0103  新井  書籍(O'Reilly)             4,180
  → 3 件
SQL: select id, applicant, item, amount from expense where applicant = '古井' order by id
  EXP-2026-0104  古井  接待費(寿司)              48,000
  EXP-2026-0105  古井  交通費                       2,860
  EXP-2026-0106  古井  消耗品費(万年筆のインク)     980
  → 3 件
SQL: select id, applicant, item, amount from expense where applicant = '新井' OR '1'='1' order by id
  EXP-2026-0101  新井  交通費                       1,280
  EXP-2026-0102  新井  会議費                       3,300
  EXP-2026-0103  新井  書籍(O'Reilly)             4,180
  EXP-2026-0104  古井  接待費(寿司)              48,000
  EXP-2026-0105  古井  交通費                       2,860
  EXP-2026-0106  古井  消耗品費(万年筆のインク)     980
  → 6 件

“新井”就是新井的 3 件,“古井”就是古井的 3 件。到这里都正常。可是在名字栏里输入 新井' OR '1'='1,6 件全都出来了,连古井的接待费(寿司)48,000 日元也在。输入中的 ' 把 SQL 的字符串闭合了,后面的 OR '1'='1' 被当作“SQL 语句”来读。“申请人是新井,或者 1 等于 1”对任何一行都成立,于是返回了所有人的申请。这就是 SQL 注入。

即使没有恶意也会坏。在按项目查找的画面里,查新井买的 O'Reilly 的书。

# 按项目查找(拼接版)。查 O'Reilly 的书时
SQL: select id, applicant, item, amount from expense where applicant = '新井' and item like '%O'Reilly%' order by id
  PSQLException: Unterminated string literal started at position 96 in SQL select id, applicant, item, amount from expense where applicant = '新井' and item like '%O'Reilly%' order by id. Expected  char
SQL: select id, applicant, item, amount from expense where applicant = '新井' and item like '%Reilly%' order by id
  EXP-2026-0103  新井  書籍(O'Reilly)             4,180
  → 1 件

一个撇号,就让 SQL 的语法坏掉了。人名(O'Brien)和公司名里,这是很普通的字符。而且这个例子只是“被看到”,从同一个漏洞还能改写条件,修改或删除别人的数据。不要把输入直接拼进 SQL 语句。这不是喜好,而是规矩。

五

占位符:把语句和值分开

改法是在放值的地方写 ?(占位符),值用 setString 等另外传入。

String sql = "select id, applicant, item, amount from expense where applicant = ? order by id";
try (Connection c = DriverManager.getConnection(URL, USER, PASS);
     PreparedStatement ps = c.prepareStatement(sql)) {
    ps.setString(1, who);                 // 放进第 1 个 ? 的“值”
    try (ResultSet r = ps.executeQuery()) { … }
}
# 占位符(?)版,传入同样的文字
SQL: select id, applicant, item, amount from expense where applicant = ? order by id   ? = 新井
  EXP-2026-0101  新井  交通費                       1,280
  EXP-2026-0102  新井  会議費                       3,300
  EXP-2026-0103  新井  書籍(O'Reilly)             4,180
  → 3 件
SQL: select id, applicant, item, amount from expense where applicant = ? order by id   ? = 新井' OR '1'='1
  → 0 件
SQL: select id, applicant, item, amount from expense where applicant = ? and item like ? order by id   ? = 新井, %O'Reilly%
  EXP-2026-0103  新井  書籍(O'Reilly)             4,180
  → 1 件

传入同样的 新井' OR '1'='1,结果是 0 件。因为没有“名叫 新井' OR '1'='1 的人”。O'Reilly 的书也好好地找到了。用数据库服务器的日志,比较送达的内容。

# 数据库服务器日志(log_statement=all)。上面是拼接版,下面是占位符版
23:53:40 LOG:  execute <unnamed>: select id, applicant, item, amount from expense where applicant = '新井' OR '1'='1' order by id
23:53:40 LOG:  execute <unnamed>: select id, applicant, item, amount from expense where applicant = $1 order by id
23:53:40 DETAIL:  parameters: $1 = '新井'' OR ''1''=''1'
Wireshark 画面。只显示 PostgreSQL 的交互,打开第 37 个包。Parse 的 Query 是 select id, applicant, item, amount from expense where applicant = $1 order by id,选中了 Bind 的 Parameter values 里的 Data(17 字节),下方字节显示中 新井' OR '1'='1 对应的部分呈蓝色
占位符版发给数据库的内容。语句(Parse)里只写着 $1,输入的文字作为值(Bind)以 17 字节单独送达。上面的 11 行,是这一个连接从握手到结束的全部(笔者于 2026 年 10 月 30 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

拼接时值变成了语句。占位符把语句和值分开发送

拼接字符串:输入进入了 SQL 语句 ... where applicant = '新井' order by id 3 件(自己的) ... where applicant = '新井' OR '1'='1' order by id 6 件(所有人的) 输入中的 ' 把字符串闭合,之后的部分被当作“SQL 语句”来读 占位符:语句和值分开发送 ... where applicant = $1 order by id 语句(Parse) $1 = 新井' OR '1'='1 值(Bind) 值始终只是“名字”。没有叫这个名字的人,所以 0 件 SQL 注入,就是值变成了语句。防止的办法,是不把值放进语句 实测:同样的输入,拼接版 6 件,占位符版 0 件

拼接版的输入溶进了 SQL 语句里送达。从数据库来看,分不清哪些是用户输入、哪些是程序员写的 SQL。占位符版的语句保持 applicant = $1,值装在另一个箱子(Bind)里送达。数据库只把值当作值来用,所以不管放了多少引号,语句都不会变。第 1 讲的 JdbcClient 用 ? 和 .params(…) 来写,也是因为里面在做这件事。

新井古井前辈,接待费(寿司)48,000 日元……

古井就当没看见。还有,从今天起谁再把输入拼进 SQL,寿司就不请了。

六

只靠 ? 还不够的地方

占位符也有它管不到的地方。先看 LIKE。

# LIKE 的 % 和 _,放进 ? 里依然表示“任意”
item like ?  ← "%%%"         → 3 件
item like ? escape '\'  ← "%\%%" → 0 件
item like ?  ← "%_%"         → 3 件
item like ? escape '\'  ← "%\_%" → 0 件

在搜索词里放 % 或 _,即使放进了 ?,在 LIKE 里依然表示“任意”“任意一个字符”,3 件全部命中。这不是 SQL 注入,但会变成“搜 100% 就全部出来”的 bug。用 \ 转义后再传入就是 0 件。PostgreSQL 默认的转义字符也是 \,但各个数据库不同,用 escape 写明比较稳妥。

# 排序的列名不能用 ?。列表用 any(?)
order by ?  ← "amount desc"  → [EXP-2026-0101 1280, EXP-2026-0102 3300, EXP-2026-0103 4180]
許可した列名だけつなぐ order by amount desc → [EXP-2026-0103 4180, EXP-2026-0102 3300, EXP-2026-0101 1280]
id = any(?)  ← [EXP-2026-0101, EXP-2026-0103] → 2 件

? 里只能放值。把排序的列名写成 ?,就变成按“amount desc”这串文字排序,排序不起作用,也不报错。想让画面选择列名、表名或 asc/desc 时,只把从允许的名字清单里选出来的拼进 SQL。用多个编号查找的 in (…),在 PostgreSQL 里可以把数组放进一个 ?,写成 = any(?)。

七

类型与 null

结果要从 ResultSet 里选好类型取出。用各种方式读取一件申请都没有的人(中井)的合计。

# 用各种 get 读取没有申请的人(中井)的合计
新井 の合計
  getBigDecimal → 8760.00
  getLong       → 8760   wasNull() = false
  getDouble     → 8760.0
  getObject(LocalDate) → 2026-10-28
中井 の合計
  getBigDecimal → null
  getLong       → 0   wasNull() = true
  getDouble     → 0.0
  getObject(LocalDate) → null
会議費 3300 の税込(numeric のまま DB で計算)
  getBigDecimal → 3630.000   getDouble → 3630.0   getString(amount) → 3300.00

中井的合计在 SQL 里是“什么都没有”(NULL)。getBigDecimal 返回了 null,getLong 却返回 0,getDouble 返回 0.0。long 和 double 是不能存 null 的类型,所以 JDBC 返回 0,“其实是 NULL”只能在紧接着的 wasNull() 里知道。“合计 0 日元”和“还没有申请”在业务上是两回事。钱用 getBigDecimal(第 2 讲),日期用 getObject(…, LocalDate.class) 来读,是基本做法。含税的计算,也是保持 numeric 在数据库里算,得到准确的 3630.000。

八

批量写入

设想月底把 1,000 件交通费一次性登记的处理。同样的 insert,用 4 种方式各写 3 次。

# 写入 1,000 行(同样做 3 次)
    逐条(自动提交)                  424 ms / 385 ms / 375 ms
    逐条(提交 1 次)                  60 ms /  55 ms /  54 ms
    addBatch                           29 ms /  40 ms /  30 ms
    addBatch+reWriteBatchedInserts    23 ms /  22 ms /  22 ms

写入 1,000 行:提交次数与往返次数(实测)

写入 1,000 行(3 次的中间值) 逐条・自动提交 385 ms 逐条・提交 1 次 55 ms addBatch 30 ms + reWriteBatchedInserts 22 ms 最有效的,是减少提交的次数(385 → 55 ms) 每次提交,数据库都要等记录写到磁盘。是等 1,000 次,还是等 1 次 再减少往返的是 addBatch。数据库离得越远,这个差距越大

JDBC 一开始是每条语句自动提交。每次提交,数据库都要等写入磁盘,直到能说“确实记录了”,1,000 行就要等 1,000 次。只是放进一个事务里,385 ms 就变成了 55 ms。再用 addBatch 一起发送,往返减少;加上驱动的 reWriteBatchedInserts=true,驱动会改写成多行 insert,22 ms。实验的数据库在同一台机器里,往返约 0.04 ms,如果隔着真正的网络,减少往返的效果会更明显。

九

中途失败时

出差的申请分成抬头(合计)和明细(新干线、酒店)两张表来写。明细的金额列最多 8 位(含小数 2 位),酒店费 1,000,000 日元放不进去。就这样写写看。

# 1 行抬头+2 行明细的申请。第 2 行的金额超出列的位数
自動コミットのまま
  見出し 1 行 OK
  明細 1(新幹線 12860)OK
  失敗: ERROR: numeric field overflow
  → 残ったもの:見出し 1 行、明細 1 行
setAutoCommit(false) と rollback
  見出し 1 行 OK
  明細 1(新幹線 12860)OK
  失敗: ERROR: numeric field overflow
  rollback
  → 残ったもの:見出し 0 行、明細 0 行

保持自动提交,会留下只有一半的申请(实测)

保持自动提交 抬头:出差 1,012,860 日元 留下了 明细1:新干线 12,860 日元 留下了 明细2:酒店 1,000,000 日元 位数溢出 → 留下抬头 1 行・明细 1 行 setAutoCommit(false) 与 rollback 抬头:出差 1,012,860 日元 已撤销 明细1:新干线 12,860 日元 已撤销 明细2:酒店 1,000,000 日元 位数溢出 → 什么都不留 自动提交是“每条语句都确定”。中途失败,就会留下只有一半的申请 明细合计(12,860 日元)与抬头金额(1,012,860 日元)对不上的申请,留在了数据库里 一组工作要放进一个事务。Spring 的 @Transactional 里面就是这个(第 9 讲)

保持自动提交时,失败之前的 2 行已经确定了,留下了只有抬头和 1 行明细的申请。合计 1,012,860 日元的申请,明细只有新干线的 12,860 日元。财务看到会晕过去。用 setAutoCommit(false) 开始,全部成功就 commit,有一个失败就 rollback,什么都不会留下。

c.setAutoCommit(false);                 // 从这里开始,合成一个整体
try {
    header.executeUpdate();             // 抬头
    for (…) line.executeUpdate();       // 明细
    c.commit();                         // 全部成功了,就确定
} catch (SQLException e) {
    c.rollback();                       // 只要有一个失败,就全部当作没发生
    throw e;
}
# 数据库服务器日志(setAutoCommit(false) 的那次)
23:53:45 LOG:  execute <unnamed>: insert into expense (id, applicant, applied_on, item, amount, status) values ('EXP-2026-0107', '新井', '2026-10-30', '出張', $1, 'submitted')
23:53:45 DETAIL:  parameters: $1 = '1012860'
23:53:45 LOG:  execute <unnamed>: insert into expense_line (expense_id, line_no, item, amount) values ('EXP-2026-0107', $1, $2, $3)
23:53:45 DETAIL:  parameters: $1 = '1', $2 = '新幹線', $3 = '12860'
23:53:45 ERROR:  numeric field overflow
23:53:45 DETAIL:  A field with precision 8, scale 2 must round to an absolute value less than 10^6.
23:53:45 STATEMENT:  insert into expense_line (expense_id, line_no, item, amount) values ('EXP-2026-0107', $1, $2, $3)
23:53:45 LOG:  execute S_1: ROLLBACK

数据库日志里,失败之后也送达了 ROLLBACK。让一组工作“要么全做、要么全不做”,就是事务;Spring 的 @Transactional 里面,运行的正是这些 setAutoCommit(false)、commit、rollback。两个人同时修改同一件申请的话题,在第 9 讲讨论。

十

在自己的电脑上验证

想试的事命令・写法
带上驱动直接运行源文件java -cp postgresql-42.7.14.jar Lesson4.java conn
把送达数据库的 SQL 写进日志alter system set log_statement = 'all'; select pg_reload_conf();
当前的连接数select usename, count(*) from pg_stat_activity group by 1;
连接上限show max_connections;
确认是否为 NULLlong n = r.getLong(1); if (r.wasNull()) …
把批量写入改成多行 insertjdbc:postgresql://…/expenses?reWriteBatchedInserts=true

日志设置只在练习用的数据库上做。在生产环境设成 log_statement = 'all',日志很快就会爆满,输入的值也会原样留下。没有 PostgreSQL 的话,可以用 Docker 的 postgres:16 镜像轻松搭一个(第 15 讲会用到)。

结 — 一页总结与下一讲

  1. JDBC 是 Java 的标准窗口。放一个驱动 jar,就能用 DriverManager.getConnection 连接。
  2. 一个连接很重(SQL 之前要往返 6 次,数据库那边要一个进程)。放进池里复用。
  3. 忘记关闭,到 97 个整个数据库就拒绝连接。用 try-with-resources 一定归还。
  4. 不要把输入拼进 SQL 语句。拼接后出现了别人的申请,O'Reilly 让语法坏掉。用占位符把语句和值分开。
  5. ? 里只能放值。LIKE 的 % 和 _ 要转义。列名从允许的清单里选。
  6. 对 NULL 用 getLong 得到 0。钱用 getBigDecimal,日期用 getObject(LocalDate.class)。
  7. 1,000 行用事务和批量从 385 ms 降到 22 ms。一组工作,要么全做、要么全不做。

新井徒手,手差不多要疼了。

古井坚持得不错。下一讲终于回到 Spring Boot。第 3 讲和这一讲徒手做的工作,由谁、怎样接手?来看 DI,以及窗口・负责人・仓库管理员这三层。

参考

记录是笔者于 2026 年 10 月 30 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 OpenJDK 25.0.4、PostgreSQL JDBC 42.7.14、PostgreSQL 16.15、Wireshark 4.2.2 取得的。申请服务器和数据库服务器是在一台机器里建立的不同网络空间(network namespace),往返约 0.04 ms。时间是当时的值,每次运行都会略有不同。批量写入取 3 次,其他取 1 次的值。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构,寿司费也是虚构的。

古法后端入门 第4讲 — 徒手和数据库对话 / 2026.10

← 返回文章列表