Old-School Backend / 2026 ・ No.04
いにしえのバックエンド入門
第4回 素手で DB と話す ── JDBC、SQL インジェクション、トランザクション
公開
前回は JDK だけで HTTP を受けました。今回は DB の番です。第1回では Spring の JdbcClient に SQL を1行渡すだけでしたが、その下では JDBC というJava の標準の窓口が、接続を開き、SQL を送り、結果を1行ずつ受け取っていました。
今回はドライバー1つだけで PostgreSQL と話します。接続の重さ、閉じ忘れ、文字をつなげた SQL、型と null、まとめ書き、途中で失敗したときを、自分の DB で実測します。
素手で DB と話す
新井SQL は書けます。select、insert、update。
古井では、利用者が入力した名前で申請を探す SQL を、Java でどう作る?
新井文字列をつなげて……「where applicant = '」+名前+「'」。
古井今日は、その1行で私の寿司代が君の画面に出るところから始めよう。
全15回の路線図(いまここ:第4回)
- 接続 ── 1本つなぐまでに往復 6 回。select 1 を 100 回、毎回つなぐと 1,582 ms、使い回すと 29 ms(実測と Wireshark)
- 閉じ忘れ ── 97 本で DB が断った(実測)
- SQL インジェクション ── 文字をつなげると、他人の申請まで出た。プレースホルダーなら 0 件(実測、DB のログと Wireshark)
- 型と null ── 合計がないとき getLong は 0 を返す(実測)
- まとめ書きとトランザクション ── 1,000 行で 385 ms → 22 ms。失敗すると、半分だけの申請が残った(実測)
舞台は第1回の3台です。申請サーバー(192.168.1.20)から DB サーバー(192.168.1.30、名前は db.internal)の PostgreSQL 16 へつなぎます。使うのは PostgreSQL の JDBC ドライバー(postgresql-42.7.14.jar、1.2 MB)だけ。練習用のプログラムは Lesson4.java(空行を除いて 238 行)です。今回のために、申請の表に申請者(applicant)の列を足し、新井さんと古井さんの申請を3件ずつ入れ直しました。だれがログインしているかを確かめる話は、第11回です。
つなぐ:接続1本の重さ
JDBC は、Java から DB を使うための標準の窓口(java.sql パッケージ)です。窓口の形は決まっていて、中身は DB ごとのドライバーが作ります。いにしえのコードには Class.forName("org.postgresql.Driver") という呪文がよく残っていますが、いまのドライバーは jar の中に自分の名前を書いておく(META-INF/services/java.sql.Driver)ので、DriverManager.getConnection だけでつながります。
# 接続1本の重さ(同じ 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
接続1本の重さ(往復は Wireshark、時間は実測)
1回目の 375 ms は、ドライバーのクラスを読みこむ時間も入っています。3回目でも 14.6 ms。select 1 そのものは 0.3 ms で終わるのに、つなぐだけでその 50 倍かかります。中身を Wireshark で数えると、TCP のあいさつのあと、SSL を使うかの問い合わせ、名前と DB 名の送信、パスワードの確かめ(SCRAM-SHA-256。パスワードそのものは送らず、4,096 回ハッシュした値で確かめ合う)が2往復、ドライバーの設定と続き、7 往復目でやっと SQL が届きました。しかも DB 側は、接続1本ごとにプロセスを1つ作ります。
だから業務のアプリは、つないだ接続を捨てずに池(コネクションプール)にためて使い回します。第1回の Spring Boot で HikariCP が 10 本の接続を持っていたのは、このためです。
閉じ忘れると、DB が断る
使い終わった接続は、close() で返します。返し忘れるとどうなるか。接続を開けては、閉じずに持ち続けました。
97 本目までは開けましたが、98 本目で DB に断られました。PostgreSQL は同時につなげる数の上限が既定で 100 本で、そのうち 3 本は管理者のために取っておかれます。上限は DB 全体で1つなので、どこかのアプリが接続を漏らすと、同じ DB を使うほかのアプリまでつなげなくなります。コネクションプールでも同じで、借りたまま返さなければ、池はすぐ空になります。
返し忘れを防ぐ書き方が、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 件
アポストロフィ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 の本も、ちゃんと見つかりました。DB サーバーのログで、届いたものを比べます。
# DB サーバーのログ(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'
つなげると値が文に化ける。プレースホルダーは文と値を別々に送る
つなげた版は、入力が SQL の文の中に溶けこんで届きました。DB から見れば、利用者の入力と、プログラマーが書いた SQL の区別がつきません。プレースホルダー版では、文は applicant = $1 のまま、値は別の箱(Bind)で届きます。DB は値を値としてしか使わないので、引用符がいくつ入っていても文は変わりません。第1回の JdbcClient が ? と .params(…) で書いていたのも、中でこれをやっているからです。
新井古井さん、接待費(寿司)48,000 円って……。
古井見なかったことにしなさい。それと、今日から SQL に入力をつなげたら、寿司はおごらない。
? だけでは足りないところ
プレースホルダーにも、守備範囲があります。まず LIKE です。
# LIKE の % と _ は、? に入れても「何でも」の意味のまま item like ? ← "%%%" → 3 件 item like ? escape '\' ← "%\%%" → 0 件 item like ? ← "%_%" → 3 件 item like ? escape '\' ← "%\_%" → 0 件
検索語に % や _ を入れると、? に入れていても LIKE の中では「何でも」「どれか1文字」の意味のままで、3 件全部が当たりました。SQL インジェクションではありませんが、「100%」を探したら全部出る、という不具合になります。\ で打ち消し(エスケープ)てから渡すと 0 件。PostgreSQL は既定でも \ が打ち消しの文字ですが、DB によって違うので 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 なら配列を1つの ? に入れて = any(?) と書けます。
型と null
結果は ResultSet から、型を選んで取り出します。申請が1件もない人(中井さん)の合計を、いろいろな取り出し方で読みました。
# 申請のない人(中井)の合計を、いろいろな 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 のまま DB で計算すれば 3630.000 と正確でした。
まとめて書く
月末に、1,000 件の交通費をまとめて登録する処理を考えます。同じ insert を、4 つのやり方で 3 回ずつ書きました。
# 1,000 行を書く(同じことを3回)
1件ずつ(自動コミット) 424 ms / 385 ms / 375 ms
1件ずつ(1つのトランザクション) 60 ms / 55 ms / 54 ms
addBatch 29 ms / 40 ms / 30 ms
addBatch+reWriteBatchedInserts 23 ms / 22 ms / 22 ms
1,000 行を書く:コミットの回数と往復の回数(実測)
JDBC は最初、1文ごとに自動でコミットします。コミットのたびに、DB は「確かに記録した」と言えるまでディスクへの書きこみを待つので、1,000 行なら 1,000 回待ちます。1つのトランザクションにまとめるだけで 385 ms が 55 ms になりました。さらに addBatch でまとめて送ると往復が減り、ドライバーの reWriteBatchedInserts=true を付けると、ドライバーが複数行の insert に書きかえて 22 ms。実験の DB は同じ機械の中で往復 0.04 ms ほどなので、本物のネットワーク越しなら、往復を減らす効果はもっと大きく出ます。
途中で失敗したら
出張の申請は、見出し(合計)と明細(新幹線、ホテル)の2つの表に分けて書きます。明細の金額の列は 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 行
自動コミットのままだと、半分だけの申請が残る(実測)
自動コミットのままだと、失敗する前の2行はもう確定していて、見出しと明細1行だけの申請が残りました。合計 1,012,860 円の申請に、明細は新幹線の 12,860 円だけ。経理の人が見たら卒倒します。setAutoCommit(false) で始め、全部うまくいったら commit、1つでも失敗したら rollback すれば、何も残りません。
c.setAutoCommit(false); // ここから先は、まとめて1つ
try {
header.executeUpdate(); // 見出し
for (…) line.executeUpdate(); // 明細
c.commit(); // 全部うまくいったら、確定
} catch (SQLException e) {
c.rollback(); // 1つでも失敗したら、全部なかったことに
throw e;
}
# DB サーバーのログ(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
DB のログでも、失敗のあとに ROLLBACK が届いています。ひとまとまりの仕事を「全部やるか、全部やらないか」にするのがトランザクションで、Spring の @Transactional の中では、この setAutoCommit(false)、commit、rollback が動いています。同時に2人が同じ申請を書きかえたときの話は、第9回で扱います。
自分の PC で確かめる
| 試したいこと | コマンド・書き方 |
|---|---|
| ドライバーを付けてソースのまま動かす | java -cp postgresql-42.7.14.jar Lesson4.java conn |
| DB に届いた SQL をログに出す | alter system set log_statement = 'all'; select pg_reload_conf(); |
| いまの接続の数 | select usename, count(*) from pg_stat_activity group by 1; |
| 接続の上限 | show max_connections; |
| NULL だったかを確かめる | long n = r.getLong(1); if (r.wasNull()) … |
| まとめ書きを複数行の insert に | jdbc:postgresql://…/expenses?reWriteBatchedInserts=true |
ログの設定は練習用の DB だけで。本番で log_statement = 'all' にすると、ログがすぐにあふれ、入力された値もそのまま残ります。PostgreSQL がない場合は、Docker の postgres:16 イメージで手軽に立てられます(第15回で使います)。
結 — 一枚まとめと、次回
- JDBC は Java の標準の窓口。ドライバーの jar を1つ置けば DriverManager.getConnection でつながる。
- 接続1本は重い(SQL の前に 6 往復、DB 側に1プロセス)。池にためて使い回す。
- 閉じ忘れると、97 本で DB 全体が断る。try-with-resources で必ず返す。
- 入力を SQL の文につなげない。つなげると他人の申請が出て、O'Reilly で文法が壊れた。プレースホルダーで文と値を分ける。
- ? に入るのは値だけ。LIKE の % と _ は打ち消す。列名は許可した一覧から。
- NULL を getLong すると 0。お金は getBigDecimal、日付は getObject(LocalDate.class)。
- 1,000 行はトランザクションとバッチで 385 → 22 ms。ひとまとまりの仕事は、全部やるか全部やらないか。
新井素手、そろそろ手が痛いです。
古井よく耐えた。次回はいよいよ Spring Boot に戻る。第3回と今回、素手でやった仕事を、だれがどう引き受けてくれるのか。DI と、窓口・担当者・倉庫番の3層を見ていこう。
参考
- Java SE 25 API — java.sql(Connection、PreparedStatement、ResultSet)
- The Java Tutorials — The try-with-resources Statement
- PostgreSQL JDBC Driver — Initializing the Driver(reWriteBatchedInserts)
- PostgreSQL 16 — Connections and Authentication(max_connections)
- PostgreSQL 16 — SASL Authentication(SCRAM-SHA-256)
- PostgreSQL 16 — Message Flow(Parse、Bind、Execute)
- PostgreSQL 16 — Pattern Matching(LIKE と ESCAPE)
- OWASP — SQL Injection Prevention Cheat Sheet
記録は、筆者がクラウド上の Linux(Ubuntu 24.04、カーネル 6.18)で、OpenJDK 25.0.4、PostgreSQL JDBC 42.7.14、PostgreSQL 16.15、Wireshark 4.2.2 を使って 2026年10月30日に取ったものです。申請サーバーと DB サーバーは、1台の中に作った別々のネットワーク空間(network namespace)で、往復は 0.04 ms ほどです。時間はそのときの値で、動かすたびに少し変わります。まとめ書きは3回、ほかは1回の値です。表の数字や出力はすべて、そのときのコマンドの出力から読んだ実際の値です。登場人物は架空の人物で、寿司代も架空です。
いにしえのバックエンド入門 第4回 — 素手で DB と話す / 2026.10