CRUD và các loại Statement

Xem thử

Một cổng quản trị nội bộ có màn hình đăng nhập viết rất gọn: ghép tên đăng nhập và mật khẩu vào một câu SELECT role FROM app_user WHERE ..., có dòng thì cho vào. Một buổi kiểm tra bảo mật, người kiểm tra gõ vào ô tên đăng nhập đúng bảy ký tự admin' -- và để mật khẩu là gì cũng được. Màn hình chào "Xin chào, ADMIN". Không cần đoán mật khẩu, không cần công cụ nào. Chỉ một dấu nháy đơn và hai gạch đã đủ để câu lệnh bị viết lại bởi người không có quyền viết nó.

SQL injection bị nhắc tới trong mọi khóa học Java, nhưng thường chỉ dừng ở câu "luôn dùng PreparedStatement". Bài này đi xa hơn: tái hiện lỗi trên MySQL thật, xem vì sao PreparedStatement bảo vệ được và chỗ nào nó không bảo vệ được, đo ba chế độ chuẩn bị câu lệnh của Connector/J, và đi qua những chi tiết của executeUpdate, ResultSet, đọc dữ liệu lớn mà cả người có kinh nghiệm cũng hay vấp.

Sau bài này, bạn có thể

  • Chọn đúng giữa Statement, PreparedStatement và CallableStatement, và viết CRUD hoàn chỉnh kèm khóa sinh tự động.
  • Tái hiện và giải thích SQL injection, kể cả kiểu xếp chồng lệnh, và nói được điều kiện để kiểu đó thành công trên MySQL.
  • Giải thích ba chế độ chuẩn bị câu lệnh của Connector/J qua log máy chủ và số đo, và chọn cấu hình hợp lý.
  • Xử lý những thứ tham số ? không làm được: ORDER BY, danh sách IN, và tên cột.
  • Đọc dữ liệu lớn mà không làm tràn bộ nhớ, và biết cái giá của chế độ streaming.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, MySQL 8.4.9, Connector/J 26.7.0). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 02-jdbc/03-crud-va-statement, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Docker + Docker Composebất kỳ bản gần đâyMySQL cho demo và Testcontainers cho test
MySQL8.4 (image mysql:8.4)
MySQL Connector/J26.7.0khai báo trong pom.xml

Cấu trúc

text
03-crud-va-statement/
  docker-compose.yml           MySQL 8.4, database shop, user app (cổng 3307)
  pom.xml                      thuộc tính main.class, main.arg, jvm.heap cho exec:exec
  src/main/java/dev/canhdominich/jdbc/crud/
    DbConfig, Db               cấu hình kết nối, mở kết nối, tạo schema
    Product.java               record ánh xạ một dòng bảng product
    CrudDemo.java              insert, findById, search, update, delete
    SqlInjectionDemo.java      đăng nhập nối chuỗi và đăng nhập PreparedStatement; xếp chồng lệnh
    PreparedModes.java         client-side, server-side, server-side có cache: 5000 truy vấn
    AffectedRows.java          giá trị executeUpdate trả về với và không có useAffectedRows
    OrderByAndIn.java          ORDER BY ?, whitelist cột sắp xếp, danh sách IN
    LargeResult.java           đọc 400.000 dòng: default, stream, cursor, và bị chặn khi đang stream
    StoredProcedureDemo.java   CallableStatement với tham số IN và OUT

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven Java 17 với Connector/J, JUnit, Testcontainers và AssertJ. Ngoài main.class còn có hai thuộc tính main.arg (đối số truyền cho chương trình) và jvm.heap (giới hạn bộ nhớ JVM) để các thí nghiệm có thể chạy bằng -Dmain.arg=... và -Djvm.heap=-Xmx64m.

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>jdbc-crud-statement</artifactId>
    <version>1.0.0</version>
    <name>JDBC - CRUD va Statement</name>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <main.class>dev.canhdominich.jdbc.crud.CrudDemo</main.class>
        <main.arg>none</main.arg>
        <jvm.heap>-Xmx512m</jvm.heap>
        <mysql.connector.version>26.7.0</mysql.connector.version>
        <junit.version>6.0.3</junit.version>
        <testcontainers.version>2.0.5</testcontainers.version>
        <assertj.version>3.27.7</assertj.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>${mysql.connector.version}</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-mysql</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.3</version>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>${jvm.heap}</argument>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                        <argument>${main.arg}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Cấu hình Docker Compose

docker-compose.yml

File này khởi động MySQL 8.4 bằng một lệnh. Nó tạo database shop, tài khoản ứng dụng app (mật khẩu app_pw) và tài khoản quản trị root (mật khẩu root_pw), mở cổng 3307 của máy bạn tới cổng 3306 trong container, và khai báo healthcheck bằng mysqladmin ping để docker compose ps báo healthy khi MySQL đã sẵn sàng nhận kết nối.

yaml
services:
  mysql:
    image: mysql:8.4
    container_name: jdbc-lab-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root_pw
      MYSQL_DATABASE: shop
      MYSQL_USER: app
      MYSQL_PASSWORD: app_pw
    ports:
      - "${MYSQL_PORT:-3307}:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_pw"]
      interval: 3s
      timeout: 3s
      retries: 30

Khởi động và kiểm tra MySQL:

bash
docker compose up -d
docker compose ps

Chờ cột STATUS hiện healthy. MySQL lắng nghe ở cổng 3307 của máy bạn; nếu bạn đang chạy một lab khác dùng cùng cổng, chạy docker compose down ở lab đó trước. Biến môi trường mặc định của chương trình: JDBC_URL=jdbc:mysql://localhost:3307/shop, DB_USER=app, DB_PASSWORD=app_pw.

Ba loại Statement và khi nào dùng

LoạiDùng khiLưu ý
StatementLệnh tĩnh hoàn toàn, không có dữ liệu bên ngoài: CREATE TABLE, DROP TABLE, script khởi tạoMọi chuỗi nối vào đây đều là rủi ro
PreparedStatementMọi lệnh có tham số, kể cả lệnh lặp lại nhiều lầnChỉ tham số hóa được giá trị, không phải tên cột hay tên bảng
CallableStatementGọi stored procedure có tham số IN, OUTLogic nằm trong cơ sở dữ liệu, khó kiểm thử và kiểm soát phiên bản hơn

PreparedStatement là lựa chọn mặc định. Lab dùng Statement đúng hai chỗ: tạo schema và chạy script đã cố định.

CRUD hoàn chỉnh bằng PreparedStatement

Bảng product của lab có khóa sinh tự động, một cột DECIMAL và một cột DATETIME. Một record Java làm mô hình dữ liệu:

src/main/java/dev/canhdominich/jdbc/crud/Product.java

java
package dev.canhdominich.jdbc.crud;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public record Product(Long id, String sku, String name, BigDecimal price, int stock, LocalDateTime createdAt) {
}

Create, lấy khóa do cơ sở dữ liệu sinh ra:

java
static long insert(Connection connection, String sku, String name, BigDecimal price, int stock) throws SQLException {
    String sql = "INSERT INTO product(sku, name, price, stock) VALUES (?, ?, ?, ?)";
    try (PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
        ps.setString(1, sku);
        ps.setString(2, name);
        ps.setBigDecimal(3, price);
        ps.setInt(4, stock);
        ps.executeUpdate();
        try (ResultSet keys = ps.getGeneratedKeys()) {
            keys.next();
            return keys.getLong(1);
        }
    }
}

Read, một dòng và nhiều dòng, dùng chung một hàm ánh xạ:

java
static Optional<Product> findById(Connection connection, long id) throws SQLException {
    try (PreparedStatement ps = connection.prepareStatement("SELECT id, sku, name, price, stock, created_at FROM product WHERE id = ?")) {
        ps.setLong(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            return rs.next() ? Optional.of(map(rs)) : Optional.empty();
        }
    }
}

private static Product map(ResultSet rs) throws SQLException {
    return new Product(
            rs.getLong("id"),
            rs.getString("sku"),
            rs.getString("name"),
            rs.getBigDecimal("price"),
            rs.getInt("stock"),
            rs.getObject("created_at", LocalDateTime.class));
}

Update và Delete trả về số dòng, và chính con số đó là thông tin quan trọng:

java
static int updatePrice(Connection connection, long id, BigDecimal price) throws SQLException {
    try (PreparedStatement ps = connection.prepareStatement("UPDATE product SET price = ? WHERE id = ?")) {
        ps.setBigDecimal(1, price);
        ps.setLong(2, id);
        return ps.executeUpdate();
    }
}

Cả bốn thao tác nằm trong chương trình CrudDemo, cùng hai lớp phụ trợ: DbConfig đọc thông tin kết nối từ biến môi trường (như các bài trước) và Db mở kết nối, đồng thời tạo lại ba bảng product, app_user, audit_log với dữ liệu mẫu để mỗi lần chạy bắt đầu từ cùng một trạng thái.

src/main/java/dev/canhdominich/jdbc/crud/DbConfig.java

java
package dev.canhdominich.jdbc.crud;

public record DbConfig(String url, String user, String password) {

    public static DbConfig fromEnvironment() {
        return new DbConfig(
                env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"),
                env("DB_USER", "app"),
                env("DB_PASSWORD", "app_pw"));
    }

    public DbConfig withUrl(String newUrl) {
        return new DbConfig(newUrl, user, password);
    }

    public DbConfig withPassword(String newPassword) {
        return new DbConfig(url, user, newPassword);
    }

    private static String env(String name, String fallback) {
        String value = System.getenv(name);
        return value == null || value.isBlank() ? fallback : value;
    }
}

src/main/java/dev/canhdominich/jdbc/crud/Db.java

java
package dev.canhdominich.jdbc.crud;

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

final class Db {

    private Db() {
    }

    static Connection open(DbConfig config) throws SQLException {
        return DriverManager.getConnection(config.url(), config.user(), config.password());
    }

    static Connection open(DbConfig config, String urlOptions) throws SQLException {
        String separator = config.url().contains("?") ? "&" : "?";
        return DriverManager.getConnection(config.url() + separator + urlOptions, config.user(), config.password());
    }

    static void resetSchema(Connection connection) throws SQLException {
        try (Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS product");
            statement.execute("DROP TABLE IF EXISTS app_user");
            statement.execute("DROP TABLE IF EXISTS audit_log");
            statement.execute("""
                    CREATE TABLE product (
                        id BIGINT AUTO_INCREMENT PRIMARY KEY,
                        sku VARCHAR(32) NOT NULL UNIQUE,
                        name VARCHAR(100) NOT NULL,
                        price DECIMAL(10,2) NOT NULL,
                        stock INT NOT NULL DEFAULT 0,
                        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                    )""");
            statement.execute("""
                    CREATE TABLE app_user (
                        id BIGINT AUTO_INCREMENT PRIMARY KEY,
                        username VARCHAR(50) NOT NULL UNIQUE,
                        password_hash VARCHAR(100) NOT NULL,
                        role VARCHAR(20) NOT NULL
                    )""");
            statement.execute("CREATE TABLE audit_log (id BIGINT AUTO_INCREMENT PRIMARY KEY, message VARCHAR(200) NOT NULL)");
            statement.execute("INSERT INTO app_user(username, password_hash, role) VALUES ('admin', 'hash-admin', 'ADMIN'), ('an', 'hash-an', 'USER')");
            statement.execute("INSERT INTO audit_log(message) VALUES ('first entry')");
        }
    }
}

src/main/java/dev/canhdominich/jdbc/crud/CrudDemo.java

java
package dev.canhdominich.jdbc.crud;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public final class CrudDemo {

    private CrudDemo() {
    }

    public static void main(String[] args) throws SQLException {
        try (Connection connection = Db.open(DbConfig.fromEnvironment())) {
            Db.resetSchema(connection);
            long id = insert(connection, "KB-01", "Keyboard", new BigDecimal("49.90"), 12);
            System.out.println("generated id = " + id);
            System.out.println("found = " + findById(connection, id).orElseThrow());
            System.out.println("update rows = " + updatePrice(connection, id, new BigDecimal("59.90")));
            System.out.println("search = " + searchByMinPrice(connection, new BigDecimal("20.00")));
            System.out.println("delete rows = " + delete(connection, id));
            System.out.println("delete again rows = " + delete(connection, id));
        }
    }

    static long insert(Connection connection, String sku, String name, BigDecimal price, int stock) throws SQLException {
        String sql = "INSERT INTO product(sku, name, price, stock) VALUES (?, ?, ?, ?)";
        try (PreparedStatement ps = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, sku);
            ps.setString(2, name);
            ps.setBigDecimal(3, price);
            ps.setInt(4, stock);
            ps.executeUpdate();
            try (ResultSet keys = ps.getGeneratedKeys()) {
                keys.next();
                return keys.getLong(1);
            }
        }
    }

    static Optional<Product> findById(Connection connection, long id) throws SQLException {
        try (PreparedStatement ps = connection.prepareStatement("SELECT id, sku, name, price, stock, created_at FROM product WHERE id = ?")) {
            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? Optional.of(map(rs)) : Optional.empty();
            }
        }
    }

    static List<Product> searchByMinPrice(Connection connection, BigDecimal min) throws SQLException {
        try (PreparedStatement ps = connection.prepareStatement("SELECT id, sku, name, price, stock, created_at FROM product WHERE price >= ? ORDER BY price")) {
            ps.setBigDecimal(1, min);
            try (ResultSet rs = ps.executeQuery()) {
                List<Product> result = new ArrayList<>();
                while (rs.next()) {
                    result.add(map(rs));
                }
                return result;
            }
        }
    }

    static int updatePrice(Connection connection, long id, BigDecimal price) throws SQLException {
        try (PreparedStatement ps = connection.prepareStatement("UPDATE product SET price = ? WHERE id = ?")) {
            ps.setBigDecimal(1, price);
            ps.setLong(2, id);
            return ps.executeUpdate();
        }
    }

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

    private static Product map(ResultSet rs) throws SQLException {
        return new Product(
                rs.getLong("id"),
                rs.getString("sku"),
                rs.getString("name"),
                rs.getBigDecimal("price"),
                rs.getInt("stock"),
                rs.getObject("created_at", LocalDateTime.class));
    }
}

Chạy MySQL bằng Docker Compose rồi chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.CrudDemo (xem mục Chuẩn bị môi trường và dự án ở đầu bài để có docker-compose.yml và pom.xml). Kết quả:

text
generated id = 1
found = Product[id=1, sku=KB-01, name=Keyboard, price=49.90, stock=12, createdAt=...]
update rows = 1
delete rows = 1
delete again rows = 0

Lần xóa thứ hai trả về 0, không lỗi. executeUpdate không ném ngoại lệ khi không có dòng nào khớp; muốn phát hiện "không tìm thấy" bạn phải đọc số trả về. Hàm map dùng getObject("created_at", LocalDateTime.class) để lấy thẳng kiểu java.time thay vì java.sql.Timestamp cũ.

Về ResultSet: nó là một con trỏ chỉ tiến, đứng trước dòng đầu tiên khi executeQuery trả về, vì vậy rs.next() phải được gọi trước mỗi lần đọc. Đọc cột theo tên (rs.getLong("id")) rõ hơn theo chỉ số và không vỡ khi ai đó đổi thứ tự cột trong câu SELECT; chỉ số có lợi thế tốc độ rất nhỏ mà ít khi đáng đánh đổi.

Cách đọc hình: năm ô trên cùng là trình tự bắt buộc: tạo câu lệnh với dấu ?, gán giá trị (chỉ số tính từ 1), thực thi, duyệt ResultSet bằng next() trước mỗi lần đọc, rồi đóng bằng try-with-resources theo thứ tự ngược lại. Ô xám nét đứt là nhánh lấy khóa do cơ sở dữ liệu sinh ra sau một lệnh INSERT.

SQL injection: tái hiện trên MySQL thật

Chương trình SqlInjectionDemo đặt hai cách đăng nhập cạnh nhau trên bảng app_user (hai người dùng: admin có vai trò ADMIN và an có vai trò USER). Cách có lỗ hổng, vulnerableLogin, ghép chuỗi SQL. Cách an toàn, safeLogin, dùng WHERE username = ? AND password_hash = ?. Hàm main cho cả hai chạy bốn lần đăng nhập, rồi gọi stacked để thử đòn xếp chồng lệnh DROP TABLE ở hai URL (phần sau của bài):

src/main/java/dev/canhdominich/jdbc/crud/SqlInjectionDemo.java

java
package dev.canhdominich.jdbc.crud;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public final class SqlInjectionDemo {

    private SqlInjectionDemo() {
    }

    public static void main(String[] args) throws SQLException {
        DbConfig config = DbConfig.fromEnvironment();
        try (Connection connection = Db.open(config)) {
            Db.resetSchema(connection);
            String[][] attempts = {
                    {"an", "hash-an"},
                    {"an", "wrong"},
                    {"admin' -- ", "anything"},
                    {"nobody", "x' OR '1'='1"}
            };
            for (String[] attempt : attempts) {
                System.out.println("login(" + attempt[0] + " | " + attempt[1] + ")  concat=" + vulnerableLogin(connection, attempt[0], attempt[1])
                        + "  prepared=" + safeLogin(connection, attempt[0], attempt[1]));
            }
        }
        stacked(config, "default URL");
        stacked(config, "allowMultiQueries=true");
    }

    static String vulnerableLogin(Connection connection, String username, String password) throws SQLException {
        String sql = "SELECT role FROM app_user WHERE username = '" + username + "' AND password_hash = '" + password + "'";
        try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) {
            return rs.next() ? "LOGGED_IN as " + rs.getString(1) : "denied";
        } catch (SQLException e) {
            return "error " + e.getSQLState();
        }
    }

    static String safeLogin(Connection connection, String username, String password) throws SQLException {
        try (PreparedStatement ps = connection.prepareStatement("SELECT role FROM app_user WHERE username = ? AND password_hash = ?")) {
            ps.setString(1, username);
            ps.setString(2, password);
            try (ResultSet rs = ps.executeQuery()) {
                return rs.next() ? "LOGGED_IN as " + rs.getString(1) : "denied";
            }
        }
    }

    static String stacked(DbConfig config, String label) throws SQLException {
        String outcome;
        try (Connection connection = label.startsWith("allow") ? Db.open(config, "allowMultiQueries=true") : Db.open(config)) {
            Db.resetSchema(connection);
            String input = "x'; DROP TABLE audit_log; -- ";
            String sql = "SELECT role FROM app_user WHERE username = '" + input + "'";
            try (Statement statement = connection.createStatement()) {
                statement.execute(sql);
                outcome = "query ran";
            } catch (SQLException e) {
                outcome = "rejected " + e.getClass().getSimpleName() + " SQLState=" + e.getSQLState();
            }
            try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'audit_log'")) {
                rs.next();
                outcome += ", audit_log exists = " + (rs.getInt(1) == 1);
            }
        }
        System.out.println("stacked query with " + label + ": " + outcome);
        return outcome;
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.SqlInjectionDemo. Kết quả:

text
login(an | hash-an)  concat=LOGGED_IN as USER  prepared=LOGGED_IN as USER
login(an | wrong)  concat=denied  prepared=denied
login(admin' --  | anything)  concat=LOGGED_IN as ADMIN  prepared=denied
login(nobody | x' OR '1'='1)  concat=LOGGED_IN as ADMIN  prepared=denied
stacked query with default URL: rejected SQLSyntaxErrorException SQLState=42000, audit_log exists = true
stacked query with allowMultiQueries=true: query ran, audit_log exists = false

Bốn dòng đầu tổng hợp thành bảng:

Tên đăng nhậpMật khẩuNối chuỗiPreparedStatement
anhash-an (đúng)LOGGED_IN as USERLOGGED_IN as USER
anwrongdenieddenied
admin' -- anythingLOGGED_IN as ADMINdenied
nobodyx' OR '1'='1LOGGED_IN as ADMINdenied

Hai hàng cuối cho thấy hai kiểu quen thuộc. Kiểu thứ nhất dùng dấu -- để chú thích phần kiểm tra mật khẩu. Kiểu thứ hai làm điều kiện OR '1'='1' luôn đúng, nên câu lệnh trả về dòng đầu tiên của bảng, tình cờ là tài khoản quản trị.

Cách đọc hình: hàng trên là nối chuỗi: đầu vào được dán vào chính câu SQL, dấu -- biến phần kiểm tra mật khẩu thành chú thích và người dùng đăng nhập với quyền ADMIN. Hàng dưới là PreparedStatement: driver thoát dấu nháy, cả đầu vào chỉ còn là một chuỗi tên, không có người dùng nào như vậy nên bị từ chối.

Đang tải Animation…

Xếp chồng lệnh: vì sao mặc định MySQL đỡ nguy hiểm hơn bạn nghĩ

Kẻ tấn công muốn nhiều hơn là đăng nhập: x'; DROP TABLE audit_log; -- định chèn thêm một lệnh DROP. Phương thức stacked của SqlInjectionDemo ở trên chạy đầu vào này qua đường ghép chuỗi, trên hai URL (hai dòng cuối của kết quả SqlInjectionDemo):

URLKết quảBảng audit_log sau đó
mặc địnhSQLSyntaxErrorException, SQLState 42000còn nguyên
thêm allowMultiQueries=truecâu lệnh chạyđã bị xóa

Mặc định Connector/J chỉ cho một lệnh mỗi lần gọi, nên đòn xếp chồng thất bại. Tùy chọn allowMultiQueries=true bỏ giới hạn đó và biến một lỗ hổng đăng nhập thành khả năng xóa dữ liệu. Có hai bài học: đừng bật tùy chọn này nếu không có lý do chắc chắn; và đừng coi đó là lớp bảo vệ, vì đòn không xếp chồng, như đăng nhập không cần mật khẩu ở bảng trên, vẫn thành công trên cấu hình mặc định.

Vì sao PreparedStatement chống được, và giới hạn của nó

Hình dung phổ biến là "câu lệnh và dữ liệu đi riêng". Điều đó chỉ đúng khi máy chủ nhận lệnh Prepare và Execute. Với cấu hình mặc định của Connector/J, driver tự thay ? bằng giá trị đã được thoát ký tự, rồi gửi một câu Query hoàn chỉnh (bài trước đã đo trong log máy chủ). Cả hai cách đều an toàn vì cùng một lý do cốt lõi: giá trị luôn đi qua một bước xử lý nhận biết nó là dữ liệu, trước khi nằm vào câu lệnh. Ngoài cách này, mọi giá trị người dùng đi vào câu lệnh bằng nối chuỗi đều bỏ qua bước đó.

Hệ quả: PreparedStatement bảo vệ giá trị, không bảo vệ cấu trúc câu lệnh. Phần dưới đây là chỗ nó bất lực.

Những thứ dấu ? không làm được

ORDER BY và tên cột

API thường cho client chọn cách sắp xếp (?sort=price). Dùng dấu ? ở đó là lỗi im lặng. Chương trình OrderByAndIn thử cả hai cách và cả cách sửa đúng, trên ba sản phẩm chèn theo thứ tự Charger, Adapter, Battery với giá 30, 10, 20. Nó cũng chứa kỹ thuật ghép danh sách IN ở mục kế tiếp:

src/main/java/dev/canhdominich/jdbc/crud/OrderByAndIn.java

java
package dev.canhdominich.jdbc.crud;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

public final class OrderByAndIn {

    private static final Map<String, String> SORTABLE = Map.of("price", "price", "name", "name", "stock", "stock");

    private OrderByAndIn() {
    }

    public static void main(String[] args) throws SQLException {
        try (Connection connection = Db.open(DbConfig.fromEnvironment())) {
            Db.resetSchema(connection);
            try (Statement statement = connection.createStatement()) {
                statement.execute("INSERT INTO product(sku, name, price, stock) VALUES ('A', 'Charger', 30.00, 5), ('B', 'Adapter', 10.00, 9), ('C', 'Battery', 20.00, 1)");
            }
            System.out.println("ORDER BY ? with 'price' : " + names(connection, "SELECT name FROM product ORDER BY ?", "price"));
            System.out.println("ORDER BY price           : " + names(connection, "SELECT name FROM product ORDER BY price", null));
            System.out.println("whitelist sort=name      : " + names(connection, "SELECT name FROM product ORDER BY " + sortColumn("name"), null));
            try {
                sortColumn("price; DROP TABLE product");
            } catch (IllegalArgumentException e) {
                System.out.println("whitelist rejects        : " + e.getMessage());
            }
            System.out.println("IN list                  : " + findBySkus(connection, List.of("A", "C")));
        }
    }

    static String sortColumn(String requested) {
        String column = SORTABLE.get(requested);
        if (column == null) {
            throw new IllegalArgumentException("unsupported sort column: " + requested);
        }
        return column;
    }

    static List<String> findBySkus(Connection connection, List<String> skus) throws SQLException {
        String placeholders = String.join(", ", Collections.nCopies(skus.size(), "?"));
        try (PreparedStatement ps = connection.prepareStatement("SELECT name FROM product WHERE sku IN (" + placeholders + ") ORDER BY name")) {
            for (int i = 0; i < skus.size(); i++) {
                ps.setString(i + 1, skus.get(i));
            }
            return readNames(ps);
        }
    }

    static List<String> names(Connection connection, String sql, String parameter) throws SQLException {
        try (PreparedStatement ps = connection.prepareStatement(sql)) {
            if (parameter != null) {
                ps.setString(1, parameter);
            }
            return readNames(ps);
        }
    }

    private static List<String> readNames(PreparedStatement ps) throws SQLException {
        List<String> names = new ArrayList<>();
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                names.add(rs.getString(1));
            }
        }
        return names;
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.OrderByAndIn:

text
ORDER BY ? with 'price' : [Charger, Adapter, Battery]
ORDER BY price           : [Adapter, Battery, Charger]
whitelist sort=name      : [Adapter, Battery, Charger]
whitelist rejects        : unsupported sort column: price; DROP TABLE product
IN list                  : [Battery, Charger]

Tổng hợp hai dòng đầu:

Câu lệnhKết quả
ORDER BY ? với tham số price[Charger, Adapter, Battery], giữ nguyên thứ tự chèn
ORDER BY price[Adapter, Battery, Charger]

Tham số được coi là một chuỗi hằng 'price', không phải cột, nên sắp xếp theo một hằng không đổi gì. Câu lệnh không báo lỗi, người dùng chỉ thấy danh sách không được sắp xếp. Cách đúng là một danh sách cho phép:

java
private static final Map<String, String> SORTABLE = Map.of("price", "price", "name", "name", "stock", "stock");

static String sortColumn(String requested) {
    String column = SORTABLE.get(requested);
    if (column == null) {
        throw new IllegalArgumentException("unsupported sort column: " + requested);
    }
    return column;
}

Giá trị người dùng chỉ chọn một mục trong bản đồ; chuỗi đi vào SQL luôn là chuỗi do bạn viết. Lab thử price; DROP TABLE product và nhận unsupported sort column. Cùng nguyên tắc áp dụng cho tên bảng và hướng sắp xếp (ASC hoặc DESC).

Danh sách IN

Số phần tử thay đổi nên bạn sinh số dấu ? tương ứng, mỗi dấu vẫn là tham số thật:

java
String placeholders = String.join(", ", Collections.nCopies(skus.size(), "?"));
PreparedStatement ps = connection.prepareStatement("SELECT name FROM product WHERE sku IN (" + placeholders + ") ORDER BY name");

Chuỗi nối ở đây chỉ chứa các dấu ? và dấu phẩy do chương trình tự sinh, không có dữ liệu người dùng. Với danh sách rất dài, chia nhỏ theo lô vài trăm phần tử.

Ba chế độ chuẩn bị câu lệnh của Connector/J

Cách đọc hình: mỗi hàng là một cấu hình URL, đi từ trái sang phải đến số lần gọi qua mạng cho mỗi lần chạy. Hàng trên là mặc định, hàng giữa bật useServerPrepStmts=true, hàng dưới thêm cachePrepStmts=true. Con số trong khối cuối là thời gian chạy 5000 lần mở, thực thi, đóng một PreparedStatement, đo ở một máy; hãy đọc tỉ lệ chứ không đọc mili giây.

Chương trình PreparedModes đo thời gian 5000 lần mở, chạy và đóng một PreparedStatement ở ba cấu hình URL, sau 200 lần khởi động:

src/main/java/dev/canhdominich/jdbc/crud/PreparedModes.java

java
package dev.canhdominich.jdbc.crud;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public final class PreparedModes {

    static final int ROUNDS = 5000;

    private PreparedModes() {
    }

    public static void main(String[] args) throws SQLException {
        DbConfig config = DbConfig.fromEnvironment();
        try (Connection connection = Db.open(config)) {
            Db.resetSchema(connection);
            try (Statement statement = connection.createStatement()) {
                statement.execute("INSERT INTO product(sku, name, price, stock) VALUES ('KB-01', 'Keyboard', 49.90, 12)");
            }
        }
        String[] modes = {"", "useServerPrepStmts=true", "useServerPrepStmts=true&cachePrepStmts=true"};
        for (String mode : modes) {
            try (Connection connection = mode.isEmpty() ? Db.open(config) : Db.open(config, mode)) {
                run(connection, 200);
                long start = System.nanoTime();
                run(connection, ROUNDS);
                long ms = (System.nanoTime() - start) / 1_000_000;
                System.out.printf("%-48s %d queries in %d ms%n", mode.isEmpty() ? "default (client-side)" : mode, ROUNDS, ms);
            }
        }
    }

    static void run(Connection connection, int rounds) throws SQLException {
        for (int i = 0; i < rounds; i++) {
            try (PreparedStatement ps = connection.prepareStatement("SELECT name FROM product WHERE sku = ?")) {
                ps.setString(1, "KB-01");
                try (ResultSet rs = ps.executeQuery()) {
                    rs.next();
                }
            }
        }
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.PreparedModes:

text
default (client-side)                            5000 queries in 3049 ms
useServerPrepStmts=true                          5000 queries in 3671 ms
useServerPrepStmts=true&cachePrepStmts=true      5000 queries in 1433 ms

Để biết máy chủ thật sự nhận lệnh gì, bật general_log của MySQL (ghi lại mọi lệnh nhận được) trước khi chạy PreparedModes, rồi đếm theo loại lệnh sau đó:

bash
docker exec jdbc-lab-mysql mysql -uroot -proot_pw -e "SET GLOBAL log_output='TABLE'; TRUNCATE mysql.general_log; SET GLOBAL general_log='ON';"
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.PreparedModes
docker exec jdbc-lab-mysql mysql -uroot -proot_pw -e "SET GLOBAL general_log='OFF'; SELECT thread_id, command_type, COUNT(*) n FROM mysql.general_log WHERE CONVERT(argument USING utf8mb4) LIKE '%FROM product WHERE sku%' OR command_type IN ('Prepare','Execute','Close stmt') GROUP BY thread_id, command_type ORDER BY thread_id, command_type; TRUNCATE mysql.general_log;"

Kết quả đếm (mỗi chế độ chạy 5200 lần kể cả khởi động):

Chế độLệnh máy chủ nhậnThời gian 5000 lần (chạy bình thường)
Mặc địnhQuery: 5200khoảng 3000 ms
useServerPrepStmts=truePrepare 5200, Execute 5200, Close stmt 5200khoảng 3400 đến 4000 ms
useServerPrepStmts=true&cachePrepStmts=truePrepare 1, Execute 5200, không có Close stmtkhoảng 1500 ms

Điều đáng nhớ là hàng giữa: bật chuẩn bị phía máy chủ mà không bật cache làm chậm hơn mặc định, vì mỗi lần mở-chạy-đóng tốn ba chuyến đi. Cái lợi của câu lệnh chuẩn bị sẵn chỉ xuất hiện khi chính PreparedStatement được tái dùng, và cachePrepStmts cho phép tái dùng ngay cả khi code mở và đóng nó mỗi lần. Khi dùng HikariCP, bạn thường đặt hai tùy chọn này ở dataSourceProperties thay vì URL.

Không có kết luận "chế độ nào luôn nhanh nhất". Mặc định là điểm bắt đầu hợp lý cho ứng dụng nhỏ; hãy đo bằng truy vấn thật của bạn trước khi đổi.

executeUpdate trả về gì: số dòng tìm thấy, không phải số dòng đổi

Chương trình AffectedRows cập nhật cột stock của một sản phẩm từ 12 sang chính 12, sang 13, và với một id không tồn tại, ở URL mặc định rồi ở URL có useAffectedRows=true, và in giá trị executeUpdate trả về:

src/main/java/dev/canhdominich/jdbc/crud/AffectedRows.java

java
package dev.canhdominich.jdbc.crud;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;

public final class AffectedRows {

    private AffectedRows() {
    }

    public static void main(String[] args) throws SQLException {
        DbConfig config = DbConfig.fromEnvironment();
        for (String options : new String[]{"", "useAffectedRows=true"}) {
            try (Connection connection = options.isEmpty() ? Db.open(config) : Db.open(config, options)) {
                Db.resetSchema(connection);
                try (Statement statement = connection.createStatement()) {
                    statement.execute("INSERT INTO product(sku, name, price, stock) VALUES ('KB-01', 'Keyboard', 49.90, 12)");
                }
                System.out.println((options.isEmpty() ? "default" : options)
                        + ": same value -> " + setStock(connection, 12)
                        + ", new value -> " + setStock(connection, 13)
                        + ", no such row -> " + setStockWhere(connection, 99, 5));
            }
        }
    }

    static int setStock(Connection connection, int stock) throws SQLException {
        return setStockWhere(connection, 1, stock);
    }

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

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.AffectedRows:

text
default: same value -> 1, new value -> 1, no such row -> 0
useAffectedRows=true: same value -> 0, new value -> 1, no such row -> 0

Tổng hợp:

Cấu hìnhCùng giá trịGiá trị mớiKhông có dòng
mặc định110
useAffectedRows=true010

Mặc định, driver báo số dòng mà điều kiện WHERE khớp, kể cả khi giá trị không thay đổi. Với useAffectedRows=true nó báo số dòng thật sự bị đổi. Trong code có kiểm tra "cập nhật có hiệu lực chưa" qua số trả về, hai cấu hình cho hai kết quả, và code chạy tốt ở cấu hình này có thể lệch khi chuyển sang cấu hình kia. Hãy chọn một cấu hình, ghi vào tài liệu dự án, và đừng đổi mà không chạy lại các test phụ thuộc vào con số này.

Đọc dữ liệu lớn: hành vi mặc định và cái giá của streaming

Chương trình LargeResult nhận một đối số chọn chế độ: seed tạo bảng big_table với 400.000 dòng, mỗi dòng một cột 200 ký tự (tổng 80 triệu ký tự); default, stream và cursor đọc bảng đó theo ba cách; blocked thử chạy một lệnh thứ hai khi đang streaming:

src/main/java/dev/canhdominich/jdbc/crud/LargeResult.java

java
package dev.canhdominich.jdbc.crud;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public final class LargeResult {

    static final int ROWS = 400_000;

    private LargeResult() {
    }

    public static void main(String[] args) throws SQLException {
        String mode = args.length > 0 ? args[0] : "default";
        DbConfig config = DbConfig.fromEnvironment();
        if (mode.equals("seed")) {
            seed(config);
            return;
        }
        if (mode.equals("blocked")) {
            blocked(config);
            return;
        }
        String options = mode.equals("cursor") ? "useCursorFetch=true" : "";
        long start = System.nanoTime();
        try (Connection connection = options.isEmpty() ? Db.open(config) : Db.open(config, options);
             PreparedStatement ps = connection.prepareStatement("SELECT id, payload FROM big_table", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
            switch (mode) {
                case "stream" -> ps.setFetchSize(Integer.MIN_VALUE);
                case "cursor" -> ps.setFetchSize(1000);
                default -> {
                }
            }
            long count = 0;
            long bytes = 0;
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    count++;
                    bytes += rs.getString("payload").length();
                }
            }
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.println(mode + ": rows=" + count + " chars=" + bytes + " time=" + ms + " ms");
        } catch (SQLException e) {
            String cause = e.getCause() == null ? "none" : e.getCause().getClass().getSimpleName();
            System.out.println(mode + ": SQLException message=\"" + e.getMessage() + "\" cause=" + cause);
        }
    }

    static void blocked(DbConfig config) throws SQLException {
        try (Connection connection = Db.open(config);
             PreparedStatement ps = connection.prepareStatement("SELECT id FROM big_table", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
            ps.setFetchSize(Integer.MIN_VALUE);
            try (ResultSet rs = ps.executeQuery()) {
                rs.next();
                try (Statement other = connection.createStatement()) {
                    other.executeQuery("SELECT 1");
                } catch (SQLException e) {
                    System.out.println("second statement while streaming: " + e.getClass().getSimpleName() + " SQLState=" + e.getSQLState() + " message=" + e.getMessage());
                }
            }
        }
    }

    static void seed(DbConfig config) throws SQLException {
        try (Connection connection = Db.open(config); Statement statement = connection.createStatement()) {
            statement.execute("DROP TABLE IF EXISTS big_table");
            statement.execute("CREATE TABLE big_table (id BIGINT PRIMARY KEY, payload VARCHAR(255) NOT NULL)");
            statement.execute("SET SESSION cte_max_recursion_depth = " + ROWS);
            statement.execute("INSERT INTO big_table SELECT n, REPEAT('x', 200) FROM (WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < " + ROWS + ") SELECT n FROM seq) s");
            try (ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM big_table")) {
                rs.next();
                System.out.println("seeded " + rs.getLong(1) + " rows");
            }
        }
    }
}

Tạo dữ liệu rồi đọc với heap giới hạn 64 MB:

bash
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.LargeResult -Dmain.arg=seed
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.LargeResult -Dmain.arg=default -Djvm.heap=-Xmx64m
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.LargeResult -Dmain.arg=stream -Djvm.heap=-Xmx64m
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.LargeResult -Dmain.arg=cursor -Djvm.heap=-Xmx64m
mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.LargeResult -Dmain.arg=blocked
text
default: SQLException message="Java heap space" cause=OutOfMemoryError
stream: rows=400000 chars=80000000 time=2169 ms
cursor: rows=400000 chars=80000000 time=4114 ms

Kết quả tổng hợp:

Chế độCấu hìnhKết quả với -Xmx64m
Mặc địnhkhôngSQLException với thông điệp Java heap space, nguyên nhân OutOfMemoryError, ném ngay ở executeQuery
StreamingsetFetchSize(Integer.MIN_VALUE)đọc đủ 400.000 dòng, khoảng 2200 ms
CursoruseCursorFetch=true và setFetchSize(1000)đọc đủ 400.000 dòng, khoảng 3600 đến 4100 ms
Mặc định với -Xmx1gkhôngđọc đủ, khoảng 2700 ms

Cách đọc hình: mỗi hàng là một cấu hình. Chế độ mặc định tải toàn bộ kết quả vào bộ nhớ nên báo hết bộ nhớ ngay ở executeQuery. Streaming đọc đủ dòng nhanh nhất nhưng khóa kết nối: lệnh thứ hai trên cùng kết nối bị từ chối. Cursor phía máy chủ không khóa kết nối nhưng mỗi lô 1000 dòng là một lần gọi tới máy chủ nên chậm hơn.

Có ba điểm cần nhớ.

Mặc định tải toàn bộ kết quả vào bộ nhớ trước khi trả quyền điều khiển. Lỗi hết bộ nhớ xảy ra ngay ở executeQuery, trước cả lần rs.next() đầu tiên, và còn bị bọc thành SQLException chứ không phải OutOfMemoryError trần, nên một khối catch (SQLException) nuốt mất nguyên nhân thật nếu bạn không ghi cả getCause().

Streaming tiết kiệm bộ nhớ nhưng khóa kết nối. Khi một ResultSet streaming đang mở, mọi câu lệnh khác trên cùng kết nối đều bị từ chối. Lab chạy một lệnh thứ hai và nhận SQLState=S1000 với thông điệp bắt đầu bằng Streaming result set ... is still active. No statements may be issued when any streaming result sets are open and in use on a given connection. Vì vậy trong lúc duyệt, bạn không được chạy truy vấn phụ trên cùng kết nối.

Cursor phía máy chủ không chặn kết nối nhưng chậm hơn. Ở đây mỗi lô 1000 dòng là một chuyến đi, nên tổng thời gian dài hơn streaming trong lần đo này. Trong thực tế, nhiều khi cách đúng không phải cấu hình driver mà là phân trang truy vấn (bài Spring Data sẽ nói tới) hoặc xử lý theo lô có khóa.

CallableStatement: gọi stored procedure

Chương trình StoredProcedureDemo tạo procedure count_products(IN min_price DECIMAL, OUT total INT) (đếm sản phẩm có giá từ min_price trở lên) rồi gọi nó bằng CallableStatement: tham số IN gán bằng setBigDecimal, tham số OUT phải đăng ký kiểu bằng registerOutParameter trước khi execute, rồi đọc bằng getInt:

src/main/java/dev/canhdominich/jdbc/crud/StoredProcedureDemo.java

java
package dev.canhdominich.jdbc.crud;

import java.math.BigDecimal;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;

public final class StoredProcedureDemo {

    private StoredProcedureDemo() {
    }

    public static void main(String[] args) throws SQLException {
        try (Connection connection = Db.open(DbConfig.fromEnvironment())) {
            Db.resetSchema(connection);
            try (Statement statement = connection.createStatement()) {
                statement.execute("INSERT INTO product(sku, name, price, stock) VALUES ('A', 'Charger', 30.00, 5), ('B', 'Adapter', 10.00, 9)");
                statement.execute("DROP PROCEDURE IF EXISTS count_products");
                statement.execute("CREATE PROCEDURE count_products(IN min_price DECIMAL(10,2), OUT total INT) SELECT COUNT(*) INTO total FROM product WHERE price >= min_price");
            }
            System.out.println("count_products(15.00) = " + countAtLeast(connection, new BigDecimal("15.00")));
            System.out.println("count_products(5.00)  = " + countAtLeast(connection, new BigDecimal("5.00")));
        }
    }

    static int countAtLeast(Connection connection, BigDecimal minPrice) throws SQLException {
        try (CallableStatement call = connection.prepareCall("{call count_products(?, ?)}")) {
            call.setBigDecimal(1, minPrice);
            call.registerOutParameter(2, Types.INTEGER);
            call.execute();
            return call.getInt(2);
        }
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.jdbc.crud.StoredProcedureDemo:

text
count_products(15.00) = 1
count_products(5.00)  = 2

Gọi với 15.00 trả 1, với 5.00 trả 2. Cú pháp {call ...} là chuẩn JDBC nên chạy được trên nhiều cơ sở dữ liệu, nhưng nội dung procedure thì không di chuyển được. Trong ứng dụng Java hiện đại, stored procedure thường dành cho những nghiệp vụ buộc phải nằm trong cơ sở dữ liệu vì hiệu năng hoặc phân quyền.

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Truy vấn có dữ liệu ngoàiLuôn PreparedStatementKhông có, trừ chi phí gõ thêm vài dòng
Cột sắp xếp do client chọnDanh sách cho phép, ghép chuỗi do mình viếtThêm một bản đồ phải bảo trì khi thêm cột
Chế độ prepared của MySQLMặc định; chỉ bật cache sau khi đoMặc định không tái dùng câu lệnh đã chuẩn bị giữa các lần gọi
allowMultiQueriesTắtScript nhiều lệnh phải chạy từng lệnh
useAffectedRowsChọn một và ghi vào tài liệuĐổi cấu hình sau này làm đổi ý nghĩa của giá trị trả về
Đọc dữ liệu lớnPhân trang hoặc xử lý theo lô; streaming khi buộc phải quét toàn bảngStreaming khóa kết nối, cursor chậm hơn
Stored procedureChỉ khi có lý do hiệu năng hoặc phân quyềnKhó kiểm thử, khó quản lý phiên bản

Playbook: gặp sự cố liên quan truy vấn thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Đăng nhập được với mật khẩu saiCó nối chuỗi trong câu SQL khôngSQL injectionĐổi sang PreparedStatement, rà soát toàn dự án bằng tìm kiếm "+ trong chuỗi SQL
Danh sách không sắp xếp theo lựa chọn của người dùngCó ORDER BY ? khôngTham số bị coi là hằng chuỗiDanh sách cho phép cột sắp xếp
Java heap space ngay ở executeQuerySố dòng kết quả, getCause()Tải trọn kết quả vào bộ nhớPhân trang hoặc streaming; xem lại có cần đọc hết không
Streaming result set ... is still activeTruy vấn nào chạy giữa lúc duyệtDùng chung kết nối với truy vấn phụMở kết nối thứ hai hoặc đọc xong rồi mới truy vấn
executeUpdate luôn trả 1 dù dữ liệu không đổiuseAffectedRowsMặc định đếm dòng khớpChọn cấu hình rõ ràng, đồng bộ test
Chậm hơn sau khi bật useServerPrepStmtsCó cachePrepStmts khôngBa chuyến đi mỗi lầnBật cache hoặc quay về mặc định
SQLSyntaxErrorException khi chạy script nhiều lệnhDấu ; giữa các lệnhallowMultiQueries mặc định tắtChạy từng lệnh, đừng bật tùy chọn trên máy chủ thật

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Bind for 0.0.0.0:3307 failed: port is already allocatedCổng 3307 đã bị chiếmMYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop
Table 'shop.big_table' doesn't existChưa tạo dữ liệu mẫuChạy LargeResult với -Dmain.arg=seed
LargeResult default không lỗi bộ nhớHeap lớn hơn dữ liệuGiữ -Djvm.heap=-Xmx64m
Could not find a valid Docker environment khi mvn testDocker chưa chạyBật Docker rồi chạy lại

Bài thực hành

Làm trong thư mục 02-jdbc/03-crud-va-statement. Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. CRUD. Chạy CrudDemo. Tiêu chí: thấy generated id = 1, update rows = 1, lần xóa thứ hai trả 0.
  2. Tấn công. Chạy SqlInjectionDemo. Tiêu chí: hai dòng đăng nhập độc hại cho LOGGED_IN as ADMIN ở cột nối chuỗi và denied ở cột PreparedStatement; audit_log bị xóa chỉ khi có allowMultiQueries=true.
  3. Sửa lỗ hổng. Tự viết thêm phương thức findByUsername(String) bằng nối chuỗi, thấy nó bị khai thác, rồi sửa bằng PreparedStatement. Tiêu chí: bốn đầu vào độc hại trong bảng đều bị từ chối.
  4. Đo ba chế độ. Chạy PreparedModes hai lần. Tiêu chí: chế độ có cache nhanh nhất ở cả hai lần; ghi lại ba con số của máy bạn.
  5. Xem lệnh máy chủ. Làm theo hướng dẫn general_log trong README của lab. Tiêu chí: thấy Prepare chỉ xuất hiện 1 lần khi có cachePrepStmts=true.
  6. Dữ liệu lớn. Tạo dữ liệu bằng -Dmain.arg=seed, chạy ba chế độ với -Djvm.heap=-Xmx64m. Tiêu chí: chế độ mặc định lỗi Java heap space, hai chế độ còn lại đọc đủ 400.000 dòng. Chạy blocked và đọc thông điệp lỗi.
  7. Chạy bộ test (tùy chọn). Bộ test tự động của dự án nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 8, Failures: 0, Errors: 0. Dọn dẹp bằng docker compose down -v.

Hiểu lầm và giới hạn

  • "PreparedStatement luôn gửi câu lệnh và tham số riêng cho máy chủ." Với Connector/J mặc định, không: driver thoát ký tự rồi gửi một Query. Bảo vệ vẫn có, nhưng cơ chế khác.
  • "Dùng PreparedStatement là hết SQL injection." Nó chỉ bảo vệ giá trị; tên cột và tên bảng ghép từ đầu vào vẫn là lỗ hổng.
  • "allowMultiQueries=false đã đủ an toàn." Nó chặn đòn xếp chồng, không chặn đòn đăng nhập không cần mật khẩu.
  • "executeUpdate trả 0 khi không có gì đổi." Mặc định nó đếm dòng khớp, nên trả 1.
  • "Bật useServerPrepStmts luôn nhanh hơn." Lab đo thấy chậm hơn khi không có cache.
  • Số đo lấy từ một máy, một lần chạy: máy chủ cục bộ qua Docker nên độ trễ mạng rất nhỏ. Với máy chủ ở xa, chênh lệch giữa chế độ một chuyến đi và ba chuyến đi sẽ lớn hơn.
  • Bài không đo hành vi streaming của driver khác Connector/J, và không thử nghiệm với cơ sở dữ liệu khác MySQL.

Tự kiểm tra

  1. Vì sao admin' -- đăng nhập được khi nối chuỗi, và cùng chuỗi đó không làm gì khi dùng PreparedStatement?
  2. API nhận sort=price. Bạn có dùng ORDER BY ? không? Nếu không thì làm thế nào?
  3. useAffectedRows đổi giá trị trả về của executeUpdate ở trường hợp nào?
  4. Vì sao executeQuery có thể ném Java heap space trước khi bạn gọi rs.next() lần nào?
  5. Bạn bật useServerPrepStmts=true và ứng dụng chậm đi. Hai nguyên nhân có thể và cách kiểm chứng?

Gợi ý đáp án. (1) Nối chuỗi đưa dấu nháy và -- vào phần mã của câu lệnh; ở PreparedStatement chúng bị thoát nên chỉ là một chuỗi tên. (2) Không: tham số được coi là chuỗi hằng, không sắp xếp; dùng danh sách cho phép ánh xạ giá trị sang tên cột. (3) Khi giá trị mới bằng giá trị cũ: mặc định trả 1, bật tùy chọn trả 0. (4) Driver mặc định đọc hết kết quả vào bộ nhớ ngay trong executeQuery. (5) Mỗi lần mở-chạy-đóng tốn ba chuyến đi vì thiếu cachePrepStmts; hoặc câu lệnh chạy ít lần nên chi phí chuẩn bị không được bù. Kiểm chứng bằng general_log và đo thời gian.

Điều cần nhớ

  • Dữ liệu từ bên ngoài đi vào SQL bằng tham số ?; tên cột và tên bảng đi vào bằng danh sách cho phép.
  • executeUpdate trả số dòng, không ném lỗi khi không khớp dòng nào; mặc định đó là số dòng khớp.
  • Connector/J mặc định ghép giá trị ở phía máy khách; useServerPrepStmts chỉ có lợi khi kèm cache.
  • Kết quả lớn mặc định nằm trọn trong bộ nhớ; streaming tiết kiệm bộ nhớ nhưng khóa kết nối.
  • allowMultiQueries=true biến một lỗ hổng nhỏ thành xóa được dữ liệu.

Đọc thêm

Bài tiếp theo trình bày transaction và batch: khi nhiều lệnh phải thành công cùng nhau, và cách gửi hàng nghìn lệnh một lần.