- JAVA
Java JDBCでMySQL接続|PreparedStatementとCRUD
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期連続の黒字決算。