Old-School Backend / 2026 ・ No.04

いにしえのバックエンド入門

第4回 素手で DB と話す ── 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 円が、新井さんの画面に出ています(筆者がクラウド上の Linux で、2026年10月30日に撮影。ウインドウの上の帯は説明のために描き足したもの)

前回は 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 全体図 2 Java の準備 3 素の HTTP 4 素の JDBC 5 Spring Boot 6 入力チェック 7 例外と返事 8 DB アクセス 9 トランザクション 10 状態遷移 11 認証・認可 12 ファイル 13 ログと監視 14 テスト 15 動かす いまここ 基礎 素手で 受け口 データ 業務と守り 運用
  • 接続 ── 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本つなぐまでに(Wireshark で数えた往復) 1 TCP のあいさつ SYN → SYN,ACK 2 SSL を使う? SSLRequest → N(使わない) 3 名前と DB 名 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 1本を使い回す 29 ms DB は接続1本ごとにプロセスを1つ作る(写真の 103 個) だから接続は池にためて使い回す(第1回の HikariCP は 10 本)

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() で返します。返し忘れるとどうなるか。接続を開けては、閉じずに持ち続けました。

端末の画面。上の申請サーバー側で Lesson4 leak を実行し、開いたままの接続 97 本で 98 本目が失敗、SQLState 53300 FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute。下の DB サーバー側で pg_stat_activity を数えると app が 97、ps で数えた postgres のプロセスは 103
接続を閉じずに開け続けると、98 本目で DB に断られました。そのとき DB サーバーでは、app の接続が 97 本、postgres のプロセスが 103 個(97 本+DB 自身の 6 個)動いていました(筆者がクラウド上の Linux で、2026年10月30日に撮影。ウインドウの上の帯は説明のために描き足したもの)

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'
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 に当たる部分が青くなっている
プレースホルダー版が DB に送ったもの。文(Parse)には $1 としか書かれず、入力した文字は値(Bind)として 17 バイトで別に届いています。上の 11 行は、この1本の接続のあいさつから終わりまでです(筆者がクラウド上の Linux で、2026年10月30日に撮影。ウインドウの上の帯は説明のために描き足したもの)

つなげると値が文に化ける。プレースホルダーは文と値を別々に送る

文字をつなげる:入力が 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 の文の中に溶けこんで届きました。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 行を書く:コミットの回数と往復の回数(実測)

1,000 行を書く(3回の真ん中の値) 1件ずつ・自動コミット 385 ms 1件ずつ・コミット1回 55 ms addBatch 30 ms + reWriteBatchedInserts 22 ms いちばん効くのは、コミットの回数を減らすこと(385 → 55 ms) コミットのたびに、DB は記録がディスクに届くのを待つ。1,000 回待つか、1 回待つか さらに往復を減らすのが addBatch。DB が別の建物にあるほど、この差は大きくなる

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 行

自動コミットのままだと、半分だけの申請が残る(実測)

自動コミットのまま 見出し:出張 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 円 桁あふれ → 何も残らない 自動コミットは「1文ごとに確定」。途中で失敗すると、半分だけの申請ができる 明細の合計(12,860 円)と見出しの金額(1,012,860 円)が合わない申請が、DB に残った ひとまとまりの仕事は1つのトランザクションに(@Transactional、第9回)

自動コミットのままだと、失敗する前の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回で使います)。

結 — 一枚まとめと、次回

  1. JDBC は Java の標準の窓口。ドライバーの jar を1つ置けば DriverManager.getConnection でつながる。
  2. 接続1本は重い(SQL の前に 6 往復、DB 側に1プロセス)。池にためて使い回す。
  3. 閉じ忘れると、97 本で DB 全体が断る。try-with-resources で必ず返す。
  4. 入力を SQL の文につなげない。つなげると他人の申請が出て、O'Reilly で文法が壊れた。プレースホルダーで文と値を分ける。
  5. ? に入るのは値だけ。LIKE の % と _ は打ち消す。列名は許可した一覧から。
  6. NULL を getLong すると 0。お金は getBigDecimal、日付は getObject(LocalDate.class)。
  7. 1,000 行はトランザクションとバッチで 385 → 22 ms。ひとまとまりの仕事は、全部やるか全部やらないか。

新井素手、そろそろ手が痛いです。

古井よく耐えた。次回はいよいよ Spring Boot に戻る。第3回と今回、素手でやった仕事を、だれがどう引き受けてくれるのか。DI と、窓口・担当者・倉庫番の3層を見ていこう。

参考

記録は、筆者がクラウド上の 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

← 記事一覧へ