JDBCとは、JavaからデータベースへSQLを送るための標準APIで、MySQLにはドライバ(MySQL Connector/J)を依存関係に追加し、DriverManager.getConnection() で接続して PreparedStatement でSQLを実行します。 接続・SQL実行・後片付けは try-with-resources でまとめて書くのが基本形です。

この記事では、Java 21.0.10 と MySQL 8.0.46 で実際に動かしたコードと出力をそのまま載せています。接続確認から、PreparedStatement による登録・検索・更新・削除(CRUD)、自動採番キーの取得、トランザクション、バッチ登録の速度比較(1万件で最大約16倍の差)、Statement と PreparedStatement の違いを示すSQLインジェクションの再現、接続エラーのメッセージ別の直し方まで、1ページで用が足りるように整理しました。


Java JDBC × MySQLの早見表

やりたいこと 書き方(要点) この記事の節
ドライバを入れる Maven com.mysql:mysql-connector-j:9.7.0 準備
接続する DriverManager.getConnection(url, user, pass) 接続
接続URL jdbc:mysql://ホスト:3306/DB名?オプション 接続
閉じ忘れを防ぐ try (Connection con = ...; PreparedStatement ps = ...) 接続
値を安全に渡す ? + ps.setString(1, v) / setInt / setLong CRUD
SELECT ps.executeQuery() → while (rs.next()) CRUD
INSERT/UPDATE/DELETE ps.executeUpdate()(戻り値=影響行数) CRUD
採番されたIDを取る prepareStatement(sql, Statement.RETURN_GENERATED_KEYS) → getGeneratedKeys() CRUD
日時を取り出す rs.getObject("col", LocalDateTime.class) CRUD
まとめて確定・取り消し setAutoCommit(false) → commit() / rollback() トランザクション
大量INSERTを速く addBatch() + executeBatch() + rewriteBatchedStatements=true バッチ
接続を使い回す HikariCP などのコネクションプール プール
Public Key Retrieval is not allowed TLSで接続する、または allowPublicKeyRetrieval=true エラー一覧
Communications link failure ホスト・ポート・起動状態・ファイアウォールを確認 エラー一覧

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java JDBCでMySQL接続|PreparedStatementとCRUD」


動作確認した環境とドライバの違い

記事のコードは MySQL Connector/J 9.x(ドライバクラス com.mysql.cj.jdbc.Driver)向けに書いています。 検証用のコンテナでは Maven Central に接続できなかったため、OS のパッケージで入る MariaDB Connector/J 2.7.6(MySQL サーバーにも接続できるJDBCドライバ)で全コードを実行しました。JDBC は標準APIなので、コードは1行も変えずにどちらのドライバでも動きます。 違いが出るのはエラーメッセージの文面や一部オプションの既定値です。

項目 検証で使ったもの 記事が想定する本番構成
Java OpenJDK 21.0.10 Java 21(LTS)/Java 25(LTS)
MySQL サーバー 8.0.46 8.4 LTS/9.7 でも同じ手順
JDBCドライバ MariaDB Connector/J 2.7.6 MySQL Connector/J 9.7.0
接続URL jdbc:mysql://...(MariaDB 2.x はこの形式も受け付ける) jdbc:mysql://...
TLSの既定 明示しないと非TLS(検証では useSSL=true を付与) sslMode=PREFERRED(使えればTLS)

MySQL 8.0 は2026年4月でサポートが終了しています。新しく作る環境では 8.4 LTS 以降を選ぶとよいでしょう。この記事の手順(URL、PreparedStatement、トランザクション、バッチ)は 8.4/9.7 でも同じです。8.4 以降では mysql_native_password 認証プラグインが既定で無効になっているため、ユーザーは caching_sha2_password(8.0以降の既定)で作っておくと、この記事のエラー一覧にある認証まわりの挙動がそのまま当てはまります。

Java 25 との差分:Java 25 の java.sql パッケージは Java 21 と同じ JDBC 4.3 で、この記事で使うAPIに変更はありません(Java SE 25 API ドキュメントで確認)。

Connector/J のバージョンについて:公式リリースノートでは 9.7.0(2026-04-22)が9.x系の最新で、2026-07-29 に後継の 26.7.0(バージョン体系を変更、対応サーバーは MySQL 8.4 以降)が出ています。MySQL 8.0 に接続する場合は 9.x を、8.4 以降なら 26.x も選べます。 依存関係の書き方は同じで、バージョン番号を差し替えるだけです。


準備:MySQL Connector/J を依存関係に追加する

Maven(pom.xml)

<dependencies>
  <dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.7.0</version>
    <scope>runtime</scope>
  </dependency>
</dependencies>

Gradle(build.gradle.kts)

dependencies {
    runtimeOnly("com.mysql:mysql-connector-j:9.7.0")
}

groupId は com.mysql、artifactId は mysql-connector-j です。 以前の mysql:mysql-connector-java という座標は 8.0.31 で今の名前に移っています。コードからはドライバのクラスを直接参照しないので、スコープは runtime(Gradle は runtimeOnly)で十分です。

Class.forName() は書かなくてよい

JDBC 4.0(Java 6)以降は、ドライバの jar がクラスパスにあれば自動で登録されます。 以前の解説記事にある com.mysql.jdbc.Driver は 5.1 系のクラス名で、今のドライバでは com.mysql.cj.jdbc.Driver です。ClassNotFoundException が出たら、まず jar が実行時のクラスパスにあるかを確認します。

検証用のテーブル

この記事では次のデータベースとテーブルを使います(MySQL に root でログインして実行)。

CREATE DATABASE shop;
CREATE USER 'app'@'%' IDENTIFIED BY 'app_pass';
GRANT ALL ON shop.* TO 'app'@'%';

USE shop;
CREATE TABLE item (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  price      INT          NOT NULL,
  stock      INT          NOT NULL DEFAULT 0,
  created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE orders (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  item_id    BIGINT NOT NULL,
  qty        INT    NOT NULL,
  ordered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

JavaからMySQLに接続する(try-with-resources)

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConnectTest {
    static final String URL  = "jdbc:mysql://localhost:3306/shop";
    static final String USER = "app";
    static final String PASS = "app_pass";

    public static void main(String[] args) throws SQLException {
        // try-with-resources:ブロックを抜けると自動で close() される
        try (Connection con = DriverManager.getConnection(URL, USER, PASS)) {
            DatabaseMetaData meta = con.getMetaData();
            System.out.println("接続OK");
            System.out.println("DB      : " + meta.getDatabaseProductName() + " " + meta.getDatabaseProductVersion());
            System.out.println("Driver  : " + meta.getDriverName() + " " + meta.getDriverVersion());
            System.out.println("JDBC    : " + meta.getJDBCMajorVersion() + "." + meta.getJDBCMinorVersion());
            System.out.println("Catalog : " + con.getCatalog());
        }
    }
}

実行コマンド(jar をクラスパスに指定して単一ファイル実行):

java -cp mysql-connector-j-9.7.0.jar ConnectTest.java

実行結果(Java 21.0.10、MariaDB Connector/J 2.7.6、MySQL 8.0.46):

接続OK
DB      : MySQL 8.0.46-0ubuntu0.24.04.4
Driver  : MariaDB Connector/J 2.7.6
JDBC    : 4.2
Catalog : shop

Driver の行は使っている jar によって変わります。 Connector/J 9.x なら MySQL Connector/J と表示されます。

接続URLの組み立て方

形式は jdbc:mysql://ホスト:ポート/DB名?キー=値&キー=値 です。

よく使うオプション(Connector/J) 意味 目安
sslMode TLSの使い方。既定は PREFERRED 本番は REQUIRED 以上
connectTimeout 接続確立の待ち時間(ミリ秒) 3000〜10000
connectionTimeZone 日時変換で使うタイムゾーン(既定 LOCAL) JVMとサーバーをそろえる
rewriteBatchedStatements バッチを複数行INSERTに書き換える 大量登録時に true
allowPublicKeyRetrieval 非TLS時にサーバーからRSA公開鍵を取得する 開発環境のみ

ユーザー名とパスワードはソースに直書きせず、環境変数や設定ファイルから読み込むのが一般的です(System.getenv("DB_PASS") など)。


PreparedStatementでCRUD(登録・検索・更新・削除)

PreparedStatement は、SQLの値の部分を ?(プレースホルダ)にしておき、あとから setXxx(番号, 値) で値を渡す仕組みです。 番号は 1始まりです。値は「SQLの一部」ではなく「データ」として送られるため、文字列連結より安全で、同じSQLを繰り返すときに効率も上がります。

コピペで動くCRUD完成形

import java.sql.*;
import java.time.LocalDateTime;
import java.util.Optional;

public class ItemCrud {
    static final String URL  = "jdbc:mysql://localhost:3306/shop";
    static final String USER = "app";
    static final String PASS = "app_pass";

    record Item(long id, String name, int price, int stock, LocalDateTime createdAt) {}

    // INSERT:自動採番された id を返す
    static long insert(Connection con, String name, int price, int stock) throws SQLException {
        String sql = "INSERT INTO item (name, price, stock) VALUES (?, ?, ?)";
        try (PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, name);
            ps.setInt(2, price);
            ps.setInt(3, stock);
            int rows = ps.executeUpdate();              // 影響を受けた行数
            try (ResultSet keys = ps.getGeneratedKeys()) {
                keys.next();
                long id = keys.getLong(1);
                System.out.printf("INSERT %d行 id=%d%n", rows, id);
                return id;
            }
        }
    }

    // SELECT 1件:見つからなければ Optional.empty()
    static Optional<Item> findById(Connection con, long id) throws SQLException {
        String sql = "SELECT id, name, price, stock, created_at FROM item WHERE id = ?";
        try (PreparedStatement ps = con.prepareStatement(sql)) {
            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? Optional.of(toItem(rs)) : Optional.empty();
            }
        }
    }

    // UPDATE
    static int updateStock(Connection con, long id, int stock) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement("UPDATE item SET stock = ? WHERE id = ?")) {
            ps.setInt(1, stock);
            ps.setLong(2, id);
            return ps.executeUpdate();
        }
    }

    // DELETE
    static int delete(Connection con, long id) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement("DELETE FROM item WHERE id = ?")) {
            ps.setLong(1, id);
            return ps.executeUpdate();
        }
    }

    static Item toItem(ResultSet rs) throws SQLException {
        return new Item(
            rs.getLong("id"),
            rs.getString("name"),
            rs.getInt("price"),
            rs.getInt("stock"),
            rs.getObject("created_at", LocalDateTime.class));   // JDBC 4.2 の型指定取得
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = DriverManager.getConnection(URL, USER, PASS)) {
            try (Statement st = con.createStatement()) {
                st.executeUpdate("TRUNCATE TABLE item");      // 何度実行しても同じ結果になるよう初期化
            }
            long pen  = insert(con, "ボールペン", 120, 50);
            long note = insert(con, "ノート A5", 180, 30);
            insert(con, "付箋セット", 350, 12);

            System.out.println("findById(" + note + ") = " + findById(con, note));
            System.out.println("findById(999) = " + findById(con, 999));

            System.out.println("UPDATE " + updateStock(con, pen, 45) + "行 → " + findById(con, pen).map(Item::stock).orElse(-1));
            System.out.println("DELETE " + delete(con, note) + "行");
            System.out.println("DELETE(再実行) " + delete(con, note) + "行");
        }
    }
}

実行結果:

INSERT 1行 id=1
INSERT 1行 id=2
INSERT 1行 id=3
findById(2) = Optional[Item[id=2, name=ノート A5, price=180, stock=30, createdAt=2026-10-02T04:56:52]]
findById(999) = Optional.empty
UPDATE 1行 → 45
DELETE 1行
DELETE(再実行) 0行

読み取りのポイントは3つです。

  • executeUpdate() の戻り値は影響を受けた行数です。DELETE を2回実行すると2回目は 0行 になり、「対象がなかった」ことを判定できます。
  • 自動採番キーは Statement.RETURN_GENERATED_KEYS を指定して getGeneratedKeys() で受け取ります。 指定しないと getGeneratedKeys() が使えません。
  • DATETIME は rs.getObject(列名, LocalDateTime.class) で java.time の型として取れます。 getTimestamp() より扱いやすく、タイムゾーン変換も起きません。

executeQuery / executeUpdate / execute の使い分け

メソッド 使うSQL 戻り値
executeQuery() SELECT ResultSet
executeUpdate() INSERT / UPDATE / DELETE / DDL 影響行数(DDLは0)
executeLargeUpdate() 影響行数が int を超えうる更新 long
execute() 種類が実行時までわからないSQL 結果が ResultSet なら true
executeBatch() addBatch() でためたSQL 各SQLの影響行数の配列

ResultSet から値を取り出すメソッド早見表

MySQLの型 おすすめの取り出し方 NULLのとき
INT / BIGINT getInt / getLong 0 が返る → rs.wasNull() で判定、または getObject(col, Integer.class)
VARCHAR / TEXT getString null
DECIMAL getBigDecimal null
DATE getObject(col, LocalDate.class) null
DATETIME getObject(col, LocalDateTime.class) null
TIMESTAMP getObject(col, LocalDateTime.class) または OffsetDateTime.class null

getInt() は NULL のとき 0 を返します。 0 と NULL を区別したい列は、rs.getObject("stock", Integer.class) のようにラッパー型で受け取ると null がそのまま返ります。書き込み側で NULL を入れるときは ps.setNull(番号, Types.INTEGER) を使います。

列名やORDER BYは?にできない

? は値の位置にしか置けません。 テーブル名・列名・ORDER BY の列は、許可した名前の一覧から選ぶ形で組み立てます。IN句は要素数ぶん ? を並べ、LIKE は ps.setString(1, "%" + keyword + "%") のように値の側にワイルドカードを付けます。

String sortColumn = switch (sortParam) {      // 入力をそのまま連結せず、決まった列名に置き換える
    case "price" -> "price";
    case "stock" -> "stock";
    default -> "id";
};
String sql = "SELECT id, name FROM item ORDER BY " + sortColumn;

トランザクション:commitとrollbackで「全部成功か全部取り消し」

JDBCの接続は、初期状態で自動コミット(1文ごとに確定)です。 複数のSQLをひとまとまりで確定したいときは setAutoCommit(false) にして、最後に commit()、途中で失敗したら rollback() します。

例として「注文を登録し、同時に在庫を減らす」処理を書きます。在庫が足りないときは注文の登録も取り消します。

import java.sql.*;

public class OrderTx {
    static final String URL  = "jdbc:mysql://localhost:3306/shop";
    static final String USER = "app";
    static final String PASS = "app_pass";

    /** 注文を登録し、在庫を減らす。どちらかが失敗したら両方取り消す */
    static long placeOrder(Connection con, long itemId, int qty) throws SQLException {
        boolean oldAutoCommit = con.getAutoCommit();
        con.setAutoCommit(false);                       // ここからトランザクション
        try {
            long orderId;
            try (PreparedStatement ps = con.prepareStatement(
                    "INSERT INTO orders (item_id, qty) VALUES (?, ?)", Statement.RETURN_GENERATED_KEYS)) {
                ps.setLong(1, itemId);
                ps.setInt(2, qty);
                ps.executeUpdate();
                try (ResultSet k = ps.getGeneratedKeys()) { k.next(); orderId = k.getLong(1); }
            }
            try (PreparedStatement ps = con.prepareStatement(
                    "UPDATE item SET stock = stock - ? WHERE id = ? AND stock >= ?")) {
                ps.setInt(1, qty);
                ps.setLong(2, itemId);
                ps.setInt(3, qty);
                if (ps.executeUpdate() == 0) {
                    throw new SQLException("在庫不足: item_id=" + itemId + " qty=" + qty);
                }
            }
            con.commit();                               // 両方成功したら確定
            return orderId;
        } catch (SQLException e) {
            con.rollback();                             // 途中で失敗したら全部取り消す
            throw e;
        } finally {
            con.setAutoCommit(oldAutoCommit);           // 元に戻す(プール利用時に大切)
        }
    }

    static void show(Connection con, String label) throws SQLException {
        try (Statement st = con.createStatement();
             ResultSet rs = st.executeQuery(
                 "SELECT (SELECT stock FROM item WHERE id = 1) AS stock, (SELECT COUNT(*) FROM orders) AS orders")) {
            rs.next();
            System.out.printf("%-10s stock=%d orders=%d%n", label, rs.getInt("stock"), rs.getInt("orders"));
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = DriverManager.getConnection(URL, USER, PASS)) {
            try (Statement st = con.createStatement()) {
                st.executeUpdate("TRUNCATE TABLE orders");
                st.executeUpdate("UPDATE item SET stock = 45 WHERE id = 1");
            }
            show(con, "開始");
            System.out.println("注文1 → order_id=" + placeOrder(con, 1, 40));
            show(con, "注文1の後");
            try {
                placeOrder(con, 1, 10);                 // 残り5個なので失敗する
            } catch (SQLException e) {
                System.out.println("注文2 → " + e.getMessage());
            }
            show(con, "注文2の後");
        }
    }
}

実行結果(トランザクションあり):

開始         stock=45 orders=0
注文1 → order_id=1
注文1の後      stock=5 orders=1
注文2 → 在庫不足: item_id=1 qty=10
注文2の後      stock=5 orders=1

比較のため、setAutoCommit(false)・commit()・rollback() の3行を外して同じ処理を実行した結果です。

開始         stock=45 orders=0
注文1 → order_id=1
注文1の後      stock=5 orders=1
注文2 → 在庫不足: item_id=1 qty=10
注文2の後      stock=5 orders=2

自動コミットのままだと、在庫不足で失敗した注文の行(2件目)が残ります。 トランザクションにすると orders=1 のままで、データの整合性が保たれます。

トランザクションで押さえる4点

ポイント 理由
WHERE stock >= ? で条件付き更新し、影響行数で判定 先にSELECTして判定するより、同時実行に強い
catch で rollback() してから例外を投げ直す 呼び出し元にも失敗を伝える
finally で setAutoCommit を元に戻す プールに返した接続が次の利用者に影響しない
テーブルは InnoDB MyISAM はトランザクションに対応していない

バッチ登録:addBatchとrewriteBatchedStatementsの効果を計測

大量の INSERT は addBatch() でためて executeBatch() でまとめて送ります。 さらに MySQL では接続URLに rewriteBatchedStatements=true を付けると、ドライバが複数の INSERT を1本の複数行 INSERT(VALUES (...), (...), ...)に書き換えて送るため、通信回数が大きく減ります。

import java.sql.*;

public class BatchInsert {
    static final String USER = "app";
    static final String PASS = "app_pass";
    static final int N = 10_000;

    static long run(String url, String mode) throws SQLException {
        try (Connection con = DriverManager.getConnection(url, USER, PASS)) {
            try (Statement st = con.createStatement()) { st.executeUpdate("TRUNCATE TABLE item"); }
            con.setAutoCommit(false);
            String sql = "INSERT INTO item (name, price, stock) VALUES (?, ?, ?)";
            long t0 = System.nanoTime();
            try (PreparedStatement ps = con.prepareStatement(sql)) {
                for (int i = 1; i <= N; i++) {
                    ps.setString(1, "備品-" + i);
                    ps.setInt(2, 100 + i % 900);
                    ps.setInt(3, i % 50);
                    if (mode.equals("1件ずつ")) {
                        ps.executeUpdate();
                    } else {
                        ps.addBatch();
                        if (i % 1000 == 0) ps.executeBatch();   // 1000件ごとに送る
                    }
                }
                if (!mode.equals("1件ずつ")) ps.executeBatch(); // 端数を送る
            }
            con.commit();
            return (System.nanoTime() - t0) / 1_000_000;
        }
    }

    public static void main(String[] args) throws SQLException {
        String base = "jdbc:mysql://localhost:3306/shop";
        String rewrite = "jdbc:mysql://localhost:3306/shop?rewriteBatchedStatements=true";
        for (int round = 1; round <= 3; round++) {
            System.out.printf("round %d: 1件ずつ=%dms  addBatch=%dms  addBatch+rewrite=%dms%n", round,
                run(base, "1件ずつ"), run(base, "batch"), run(rewrite, "batch"));
        }
    }
}

実行結果(1万件、同一マシン上の MySQL 8.0.46、MariaDB Connector/J 2.7.6):

round 1: 1件ずつ=2435ms  addBatch=1297ms  addBatch+rewrite=156ms
round 2: 1件ずつ=874ms  addBatch=849ms  addBatch+rewrite=216ms
round 3: 1件ずつ=801ms  addBatch=744ms  addBatch+rewrite=150ms

rewriteBatchedStatements=true を付けると、2回目以降でも約4〜5倍(1回目は約16倍)速くなりました。 round 1 は JIT コンパイルや接続の初回処理が乗っているため、比較は round 2・3 で見るのが妥当です。サーバーとの距離がある環境(別ホスト・クラウド)では通信1回あたりの待ち時間が長くなるため、差はさらに広がる傾向があります。

サーバーが実際に受け取った INSERT 文の回数も数えました(SHOW SESSION STATUS LIKE 'Com_insert' の増分、1000件のバッチ)。

rewrite=false
  executeBatch 戻り値の長さ=1000 先頭=1  サーバーが受けたINSERT文=1000回
rewrite=true
  executeBatch 戻り値の長さ=1000 先頭=-2  サーバーが受けたINSERT文=1回

書き換えありでは1000件が1本のINSERTになっています。 このとき executeBatch() の戻り値は各行の件数ではなく -2(Statement.SUCCESS_NO_INFO=成功したが件数は不明)になりました。戻り値の中身はドライバによって異なるため、件数を厳密に使う処理では Connector/J の公式ドキュメントで挙動を確認してください。

ドライバによる差の注記:今回の MariaDB Connector/J は書き換えなしの addBatch でも内部で送信をまとめる(パイプライン)ため、「1件ずつ」との差が小さく出ています。Connector/J では書き換えなしの addBatch は1文ずつ送られるのが基本動作で、どちらのドライバでも 大量登録では rewriteBatchedStatements=true を付けるのが定番です。

まとめる件数は500〜5000件ごとが目安です。1本のSQLがサーバーの max_allowed_packet(8.0の既定は64MB)を超えないようドライバが分割します。途中で失敗したときは BatchUpdateException#getUpdateCounts() で成功分を確認し、rollback() します。


SQLインジェクションをStatementで再現し、PreparedStatementで防ぐ

入力値を文字列連結してSQLを作ると、入力に含まれる ' がSQLの一部として解釈されます。 同じ入力を Statement(連結)と PreparedStatement(バインド)に渡して比べました。

import java.sql.*;

public class InjectionDemo {
    static final String URL  = "jdbc:mysql://localhost:3306/shop";
    static final String USER = "app";
    static final String PASS = "app_pass";

    // 直す前:入力を文字列連結して SQL を組み立てる
    static int searchWithStatement(Connection con, String name) throws SQLException {
        String sql = "SELECT id, name FROM item WHERE name = '" + name + "'";
        System.out.println("  実際のSQL: " + sql);
        try (Statement st = con.createStatement(); ResultSet rs = st.executeQuery(sql)) {
            int n = 0;
            while (rs.next()) n++;
            return n;
        }
    }

    // 直した後:プレースホルダ ? に値としてバインドする
    static int searchWithPrepared(Connection con, String name) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement("SELECT id, name FROM item WHERE name = ?")) {
            ps.setString(1, name);
            try (ResultSet rs = ps.executeQuery()) {
                int n = 0;
                while (rs.next()) n++;
                return n;
            }
        }
    }

    public static void main(String[] args) throws SQLException {
        String normal = "ノート A5";
        String crafted = "x' OR '1'='1";
        try (Connection con = DriverManager.getConnection(URL, USER, PASS)) {
            System.out.println("[Statement] 通常入力");
            System.out.println("  件数=" + searchWithStatement(con, normal));
            System.out.println("[Statement] 細工した入力");
            System.out.println("  件数=" + searchWithStatement(con, crafted));
            System.out.println("[PreparedStatement] 通常入力   件数=" + searchWithPrepared(con, normal));
            System.out.println("[PreparedStatement] 細工した入力 件数=" + searchWithPrepared(con, crafted));
        }
    }
}

実行結果(item テーブルに3件ある状態):

[Statement] 通常入力
  実際のSQL: SELECT id, name FROM item WHERE name = 'ノート A5'
  件数=1
[Statement] 細工した入力
  実際のSQL: SELECT id, name FROM item WHERE name = 'x' OR '1'='1'
  件数=3
[PreparedStatement] 通常入力   件数=1
[PreparedStatement] 細工した入力 件数=0

連結版では WHERE 句が OR '1'='1' に書き換わり、全件が返りました。 PreparedStatement では入力全体が「x' OR '1'='1 という名前」として扱われ、該当0件です。ユーザー入力が入る値は必ず ? で渡す、これだけで防げます。攻撃パターンや他言語での対策は、別記事「SQLインジェクション対策」で詳しく扱っています。

観点 文字列連結(Statement) PreparedStatement
入力の扱い SQLの一部になる 値(データ)になる
' を含む名前(例:O'Brien) 構文エラーになる そのまま検索できる
繰り返し実行 毎回SQLを組み立てる SQLを使い回せる
使いどころ 固定のDDLや管理用SQL アプリのSQLはこちら

接続エラー・SQLエラーの一覧と直し方

検証環境で再現したエラーの型・SQLState・エラーコードです。エラーコード(MySQLの番号)とSQLStateはドライバが違っても同じなので、判定にはメッセージ文ではなくこちらを使うと移植しやすくなります。

import java.sql.*;

public class ErrorCases {
    interface Action { void run(Connection c) throws SQLException; }

    static void tryIt(String label, String url, String pass, Action a) {
        try (Connection c = DriverManager.getConnection(url, "app", pass)) {
            a.run(c);
            System.out.println(label + " → 成功");
        } catch (SQLException e) {
            System.out.printf("■ %s%n  %s  SQLState=%s  errorCode=%d%n  %s%n", label,
                e.getClass().getSimpleName(), e.getSQLState(), e.getErrorCode(),
                e.getMessage().lines().findFirst().orElse(""));
        }
    }

    public static void main(String[] args) {
        String ok = "jdbc:mysql://localhost:3306/shop";
        tryIt("起動していないポート", "jdbc:mysql://localhost:3399/shop?connectTimeout=2000", "app_pass", c -> {});
        tryIt("パスワード違い", ok, "wrong", c -> {});
        tryIt("存在しないDB", "jdbc:mysql://localhost:3306/shopx", "app_pass", c -> {});
        tryIt("存在しないテーブル", ok, "app_pass", c -> c.createStatement().executeQuery("SELECT * FROM items"));
        tryIt("主キー重複", ok, "app_pass", c -> c.createStatement().executeUpdate("INSERT INTO item (id, name, price) VALUES (1, 'x', 1)"));
        tryIt("NOT NULL 列に null", ok, "app_pass", c -> c.createStatement().executeUpdate("INSERT INTO item (name, price) VALUES (NULL, 1)"));
    }
}

実行結果(MariaDB Connector/J 2.7.6。検証環境ではポート番号を変えて実行したため、表示ポートは手元の値です。エラー再現用なので Statement の close はConnectionの close に任せています):

■ 起動していないポート
  SQLNonTransientConnectionException  SQLState=08000  errorCode=-1
  Could not connect to address=(host=localhost)(port=7339)(type=master) : Socket fail to connect to host:localhost, port:7339. Connection refused
■ パスワード違い
  SQLInvalidAuthorizationSpecException  SQLState=28000  errorCode=1045
  Could not connect to address=(host=localhost)(port=7336)(type=master) : (conn=52) Access denied for user 'app'@'localhost' (using password: YES)
■ 存在しないDB
  SQLSyntaxErrorException  SQLState=42000  errorCode=1044
  Could not connect to address=(host=localhost)(port=7336)(type=master) : (conn=53) Access denied for user 'app'@'localhost' to database 'shopx'
■ 存在しないテーブル
  SQLSyntaxErrorException  SQLState=42S02  errorCode=1146
  (conn=54) Table 'shop.items' doesn't exist
■ 主キー重複
  SQLIntegrityConstraintViolationException  SQLState=23000  errorCode=1062
  (conn=55) Duplicate entry '1' for key 'item.PRIMARY'
■ NOT NULL 列に null
  SQLIntegrityConstraintViolationException  SQLState=23000  errorCode=1048
  (conn=56) Column 'name' cannot be null

「存在しないDB」は、権限の限られたユーザーでは 1049 ではなく 1044(Access denied ... to database)になりました。 権限の広い root で同じDB名を指定すると ERROR 1049 (42000): Unknown database 'shopx' になります(mysql クライアントで確認)。どちらが出ても「DB名の綴り」と「そのDBへの GRANT」を両方確認します。

エラーメッセージ別の原因と対処表

エラー(Connector/J での表記) 主な原因 直し方
Communications link failure(CommunicationsException) サーバー停止、ホスト・ポート違い、ファイアウォール、bind-address が 127.0.0.1 起動確認、telnet/nc でポート疎通、bind-address とセキュリティグループの見直し
Access denied for user ... (using password: YES)(1045) パスワード違い、'user'@'host' のホスト部が一致しない SELECT user, host FROM mysql.user; で確認し、接続元ホストに合う CREATE USER を作る
Unknown database 'xxx'(1049) DB名の綴り違い・未作成 SHOW DATABASES; で確認
Access denied ... to database 'xxx'(1044) DBへの権限がない GRANT ... ON xxx.* TO ...
Public Key Retrieval is not allowed caching_sha2_password のユーザーで、非TLS接続かつサーバー側に認証キャッシュがない 下の節を参照
The server time zone value 'JST' is unrecognized Connector/J 8.0.22 以前とサーバーのタイムゾーン名の組み合わせ ドライバを更新、または connectionTimeZone=Asia/Tokyo
No suitable driver found for jdbc:mysql://... jar がクラスパスにない、URLの綴り違い 依存関係とURLの先頭 jdbc:mysql:// を確認

Communications link failure について:Connector/J ではサーバーに届かないとき com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure という例外になり、続く行に「最後にパケットを送ってからの時間」が表示されます。今回の MariaDB Connector/J では同じ状況が Socket fail to connect ... Connection refused という文面でした(上の実行結果)。文面は違っても SQLState は 08 で始まる接続エラーのクラスです。長時間放置した接続で出る場合は、サーバーの wait_timeout で切断された接続を使っている可能性があり、コネクションプールの maxLifetime を wait_timeout より短くすると解消します。

Public Key Retrieval is not allowed の正体と対処

MySQL 8.0 以降の既定認証 caching_sha2_password は、TLSを使わない接続で初回ログインするとき、パスワードをRSA公開鍵で暗号化して送ります。 Connector/J は安全のため、公開鍵をサーバーから取りに行く動作を既定で無効(allowPublicKeyRetrieval=false)にしているので、このメッセージで止まります(Connector/J 公式ドキュメントのセキュリティ関連プロパティで確認)。

検証環境でも、TLSを切った接続(useSSL=false)でサーバーの認証キャッシュを消した直後に接続すると、MariaDB Connector/J 版の同じエラーが出ました。

java.sql.SQLTransientConnectionException: Could not connect to address=(host=localhost)(port=7336)(type=master) : RSA public key is not available client side (option serverRsaPublicKeyFile not set)
対処 設定例 向いている環境
TLSで接続する(推奨) sslMode=REQUIRED(Connector/J の既定 PREFERRED でもサーバーがTLS対応なら発生しない) 本番・共有環境
公開鍵ファイルを渡す serverRSAPublicKeyFile=/path/public_key.pem TLSを使えない環境
サーバーから鍵を取得する allowPublicKeyRetrieval=true ローカルの開発環境

「一度 mysql コマンドでログインすると Java からもつながるようになる」のは、サーバー側に認証キャッシュができて公開鍵が不要になるためです。サーバー再起動や FLUSH PRIVILEGES で再び発生するので、上の表のいずれかで恒久的に設定しておきます。

タイムゾーンの設定

Connector/J 8.0.23 以降は connectionTimeZone(既定 LOCAL=JVMのタイムゾーン)で日時の扱いを決めます。 serverTimezone はその別名です(公式ドキュメントで確認)。検証環境の MySQL は @@system_time_zone が JST で、Connector/J 8.0.22 以前ではこの名前を解釈できず「The server time zone value 'JST' is unrecognized」が出る組み合わせでした。今のドライバでは次のどちらかに寄せると素直です。

方針 接続URLのオプション
JVMとMySQLのタイムゾーンをそろえて変換しない(DATETIME中心) connectionTimeZone=LOCAL&forceConnectionTimeZoneToSession=true
サーバーのタイムゾーンを問い合わせて時刻の瞬間を保つ preserveInstants=true&connectionTimeZone=SERVER

コネクションプール(HikariCP)でDataSourceを使う

DriverManager.getConnection() は呼ぶたびにTCP接続と認証を行います。 Webアプリのように何度も接続する場合は、接続を使い回すコネクションプールを使うのが一般的です。Spring Boot は既定で HikariCP を使います。

依存関係は com.zaxxer:HikariCP:7.1.0(HikariCP 公式 README で確認)です。

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/shop");
config.setUsername("app");
config.setPassword("app_pass");
config.setMaximumPoolSize(10);
config.addDataSourceProperty("cachePrepStmts", "true");        // PreparedStatement をドライバ側でキャッシュ
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

try (HikariDataSource ds = new HikariDataSource(config)) {      // アプリ起動時に1つ作り、終了時に close
    try (Connection con = ds.getConnection()) {                 // close() でプールへ返却される
        // ここで PreparedStatement を使う(CRUDのコードと同じ)
    }
}

このHikariCPのコードは、検証環境で HikariCP の jar を取得できなかったため未実行です(設定項目は HikariCP 公式 README で確認)。プールを使っても Connection を try-with-resources で閉じる書き方は同じで、close() が「切断」ではなく「プールへの返却」になります。


よくある質問

Q. com.mysql.jdbc.Driver と com.mysql.cj.jdbc.Driver はどちらを使えばよいですか? A. 今の Connector/J(8.0以降、9.x、26.x)のクラス名は com.mysql.cj.jdbc.Driver です。ただし JDBC 4.0 以降は自動登録されるので、Class.forName() 自体を書かなくて大丈夫です。

Q. MySQL 8.4 や 9.7 でもこの記事のコードは使えますか? A. 使えます。JDBCのAPI、接続URL、PreparedStatement、トランザクション、バッチの書き方は同じです。8.4 以降は mysql_native_password が既定で無効なので、ユーザーは caching_sha2_password で作り、TLS接続にしておくと認証まわりで止まりにくくなります。

Q. Statement を使ってよい場面はありますか? A. 入力値を含まない固定のSQL(TRUNCATE TABLE、DDLなど)なら Statement でも問題ありません。ユーザー入力や外部データが入るSQLは PreparedStatement を使います。


まとめ

  • 依存関係は com.mysql:mysql-connector-j(9.7.0、8.4以降なら後継の26.x も可)、Class.forName() は不要
  • 接続・SQL・結果は try-with-resources で閉じる。プールを使っても書き方は同じ
  • 値は ? + setXxx で渡す。文字列連結では ' OR '1'='1 で全件が返ることを再現で確認
  • 採番キーは RETURN_GENERATED_KEYS + getGeneratedKeys()、影響行数は executeUpdate() の戻り値
  • 複数SQLは setAutoCommit(false) → commit()/rollback()。失敗時にも整合性が保たれる
  • 大量INSERTは addBatch() + rewriteBatchedStatements=true。1万件で約4〜16倍速くなった
  • エラーは SQLState とエラーコードで判定。Public Key Retrieval is not allowed はTLS接続で解消

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java JDBCでMySQL接続|PreparedStatementとCRUD」

株式会社RJCでは、JDBCによるデータベース接続の基本を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?