JavaWeb 复习讲义
原始讲义
第五部分 进阶与实战:从能跑到跑得好

第10章 JDBC 与数据库访问

难度:★★★☆☆ | 前置知识:第5章 Tomcat 与 Servlet、第6章 Request 与 Response、MySQL 基础(建库建表、增删改查、事务) | 本章解决:让 Servlet 真正读写 MySQL:用 JDBC 打通 Java 与数据库之间的那条链路,再把连接、SQL、结果集收敛成可复用的工具类与 DAO

本章导读

浏览器  ──HTTP 请求──▶  Servlet(第 5~7 章的 request / response / session)
                          │
                          │  JDBC(本章):DriverManager → Connection
                          │                → PreparedStatement → ResultSet
                          ▼
                       MySQL(数据真正住的地方:db1 库、tb_brand 表)
【考点】

本章在卷子上的形态非常固定:一道"JDBC 六步/七步流程"的简答或填空,一道"Statement 与 PreparedStatement 区别"的对比题,一道"SQL 注入原理与防范"的问答题,一道"连接池好处"的概念题,最后是一道 tb_brand 的编程题(补全 DAO 方法或找错)。流程与两套 API 的对比是必拿分,事务与连接池是拉开差距的地方。

10.1 JDBC 概述

10.1.1 为什么需要 JDBC(原文 1)

在开发中我们使用的是 Java 语言,那么势必要通过 Java 语言操作数据库中的数据,这就是接下来要学习的 JDBC。

先把"痛点"讲清楚。假设没有 JDBC,我们要用 Java 连 MySQL,会发生什么?MySQL 的客户端协议是二进制协议,握手、认证、发包、解析结果集,每一步都有自己的一套规则;换成 Oracle,这套规则从头到尾都不一样,连"包"的格式都不同。如果每个数据库厂商都自己定义一套 Java 调用方式,那么"换数据库"就等于"重写整个数据访问层"。

这在公司里是真实会发生的场景:开发阶段图省事用 MySQL,上线时公司统一采购的是 Oracle,于是就要对代码进行大批量修改,这显然并不是我们想看到的。我们要做到的是同一套 Java 代码操作不同的关系型数据库,而此时 Sun 公司就指定了一套标准接口(JDBC),JDBC 中定义了所有操作关系型数据库的规则。

这里有一个关键的类比:JDBC 是一张"插座标准",驱动是"各国插头"。墙上的插座孔位是标准(接口),中国、美国、欧洲的电器各自做适配的插头(实现类),你换一台电器不需要砸墙重装插座。接口本身无法直接使用,我们需要使用接口的实现类,而这套实现类(称之为:驱动)就由各自的数据库厂商给出。

10.1.2 JDBC 的概念与本质(原文 1.1、1.2)

JDBC(全称:Java DataBase Connectivity,Java 数据库连接)就是使用 Java 语言操作关系型数据库的一套 API。

它的本质可以拆成三句话,这三句话几乎就是简答题的标准答案(原文 1.2):

第三句话是理解 JDBC 的钥匙。你写的 conn.createStatement(),编译时看到的是 java.sql.Connection 这个接口,运行时跑的是 MySQL 驱动里 ConnectionImpl 的 createStatement()。这就是"面向接口编程"最典型的一次落地:你的代码依赖抽象,具体实现由 jar 包在运行时注入。

10.1.3 JDBC 的好处(原文 1.3)

以后编写操作数据库的代码只需要面向 JDBC(接口),操作哪个关系型数据库就需要导入该数据库的驱动包。如需要操作 MySQL 数据库,就需要在项目中导入 MySQL 数据库的驱动包。

【对比】

"面向 JDBC 编程"和"面向 MySQL 编程"的区别,考试常拿来出判断题。

维度面向 JDBC 接口编程(正确)面向 MySQL 实现编程(错误)
声明的类型Connection conn、PreparedStatement pstmtcom.mysql.cj.jdbc.ConnectionImpl
导入的包java.sql.*、javax.sql.DataSourcecom.mysql.cj.jdbc.*
换数据库时只换驱动 jar 与 url整个数据访问层重写
编译期依赖只依赖 JDK 自带的 java.sql强依赖厂商 jar,jar 缺失就编译不过

结论是:变量类型永远写接口,只有 Class.forName 里的那个字符串是厂商名。

10.1.4 本章要用的依赖(补充 pom)

原文用 IDEA 的"Add as Library"把 jar 包挂进项目,今天更常见的做法是用 Maven 管依赖。两种方式等价,本质都是把驱动 jar 放进 classpath。若用 Maven,pom.xml 里至少要有这三样:

<dependencies>
    <!-- MySQL 驱动:8.x 版本,驱动类为 com.mysql.cj.jdbc.Driver -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.30</version>
    </dependency>

    <!-- Druid 连接池 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.1.12</version>
    </dependency>

    <!-- 单元测试,@Test 要靠它 -->
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.13.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

如果你跟原文一样用"普通 Java 项目 + lib 目录",那么把 mysql-connector-java-8.0.30.jar、druid-1.1.12.jar、junit-4.13.2.jar 拷进模块下的 lib 目录,全选后右键"Add as Library"即可。

【易错点】

jar 包放进了 lib 目录不等于加进了 classpath。只把文件拖进目录、没有执行"Add as Library",现象是编译期报 java: 程序包com.mysql.cj.jdbc不存在,或者运行时抛 java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。判断方法:IDEA 里 lib 目录下的 jar 图标上有一个小箭头(表示已作为库),外部库(External Libraries)里能看到它。

10.2 JDBC 快速入门

10.2.1 先看流程,再写代码(原文 2)

先来看看通过 Java 操作数据库的流程,一共四步:

这四步里,第二步和第四步是 JDBC 干的活,第三步是 MySQL 干的活,第一步是我们干的活。把这个流程和 10.1 节的链路图对上:Java 代码(Servlet 或测试类)→ JDBC 驱动 → MySQL 服务端 → 结果集原路返回。

10.2.2 编写代码的七个步骤(原文 2.1)

原文把入门代码拆成七步,这七步是整章的主干,后面所有代码都是它的变体:

  1. 创建工程,导入驱动 jar 包;
  2. 注册驱动:Class.forName("com.mysql.jdbc.Driver");
  3. 获取连接:Java 代码需要发送 SQL 给 MySQL 服务端,就需要先建立连接。
Connection conn = DriverManager.getConnection(url, username, password);
  1. 定义 SQL 语句:
String sql = "update...";
  1. 获取执行 SQL 对象:执行 SQL 语句需要 SQL 执行对象,而这个执行对象就是 Statement 对象。
Statement stmt = conn.createStatement();
  1. 执行 SQL:
stmt.executeUpdate(sql);
  1. 处理返回结果,然后释放资源。
【考点】

这七步的顺口溜:注册、连接、定 SQL、取对象、执行、处理、关闭。注意"定义 SQL"在"获取执行对象"之前:因为 PreparedStatement 的获取需要把 SQL 传进去(conn.prepareStatement(sql)),顺序反了编译就过不去。原文入门代码里第 3 步确实排在 createStatement() 之前,这正是为 10.4 节埋伏笔。

10.2.3 具体操作:建工程与导驱动包(原文 2.2)

在添加为库文件的时候,有如下三个选项:

【补充】

这三个选项的实际区别,很多同学按默认一路"OK"过去,进了公司才发现这里最容易出错。Global Library 把路径写进 IDEA 的全局配置,换一台机器、或者把项目发给同学,jar 路径就失效了;Project Library 记在项目的 .idea 配置里,跟着项目走,但一个项目有多个模块时所有模块共享同一份;Module Library 只对当前模块生效,最不容易互相污染。课程作业建议选 Module Library,因为一个项目里常常有多个模块各自依赖不同版本的驱动。

另外,Maven 项目根本不需要手动选这个:pom.xml 里写 dependency,Maven 自动下载到本地仓库并加进 classpath,scope 决定它是编译期、测试期还是运行期可见。

10.2.4 入门案例完整代码(原文 2.2)

在 src 下创建类,编写代码如下。这段代码的作用是把 db1 库 account 表里 id 为 1 的记录的 money 改成 2000,并把受影响的行数打印出来:

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

/**
 * JDBC 快速入门
 */
public class JDBCDemo {

    public static void main(String[] args) throws Exception {

        //1. 注册驱动
        //Class.forName("com.mysql.jdbc.Driver");
        //2. 获取连接
        String url = "jdbc:mysql://127.0.0.1:3306/db1";
        String username = "root";
        String password = "1234";
        Connection conn = DriverManager.getConnection(url, username, password);
        //3. 定义sql
        String sql = "update account set money = 2000 where id = 1";
        //4. 获取执行sql的对象 Statement
        Statement stmt = conn.createStatement();
        //5. 执行sql
        int count = stmt.executeUpdate(sql);//受影响的行数
        //6. 处理结果
        System.out.println(count);
        //7. 释放资源
        stmt.close();
        conn.close();
    }
}

跑通这段代码,需要三个前置条件同时成立,缺一个就报错,而且报的错完全不一样,正好可以当排错清单用:

缺什么报错现象怎么补
驱动 jar 没进 classpathjava.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver把 jar 加为库文件,或补 pom 依赖
MySQL 服务没启动Communications link failure / Connection refused: connect启动 MySQL 服务(net start mysql)
库或表不存在Unknown database 'db1' / Table 'db1.account' doesn't exist先建库建表,表里要有 id=1 这行数据
账号密码错Access denied for user 'root'@'localhost'改 password,确认 MySQL 的认证方式
【易错点】

入门代码里 Class.forName("com.mysql.jdbc.Driver") 被注释掉了,而 count 打印出来是 1,程序照跑。很多同学由此得出"注册驱动这一步可以删"的结论。结论对,理由错。10.3.1 节会讲清楚:能删是因为 MySQL 5 之后的驱动包自带 SPI 自动注册,不是因为"这行代码没用"。而且如果你用的是 MySQL 8 的驱动包,com.mysql.jdbc.Driver 这个类根本不存在,写上去反而抛 ClassNotFoundException,正确写法是 com.mysql.cj.jdbc.Driver。

10.2.5 连接 URL 的写法与两个常见错误

入门代码里 url 写的是完整形式 jdbc:mysql://127.0.0.1:3306/db1。它的通用语法是:

jdbc:mysql://ip地址(域名):端口号/数据库名称?参数键值对1&参数键值对2...

两个细节(原文 3.1):

String url = "jdbc:mysql:///db1?useSSL=false";

连接时有两个参数,几乎人人都会在这里出错:

【易错点】

useSSL 与 serverTimezone 这两个参数,是 JDBC 连接报错的两大元凶。

第一个参数:useSSL。MySQL 5.7/8.0 默认要求 SSL 加密连接,而驱动在没配证书时会打印一大段红色警告:WARN: Establishing SSL connection without server's identity verification is not recommended...。它只是警告,程序照跑,但看着吓人。加上 useSSL=false 就不打印了。注意它是"关掉加密",生产环境不应该关,学习环境图省事关掉没问题。

第二个参数:serverTimezone。MySQL 8 的驱动(com.mysql.cj.jdbc.Driver)要求明确时区,不配就抛 java.sql.SQLException: The server time zone value '�й���׼ʱ��' is unrecognized or represents more than one time zone(那段乱码是中文"中国标准时间"被按错误编码解出来的)。解决办法是在 url 后面加 serverTimezone=Asia/Shanghai 或 serverTimezone=UTC。

两个参数用 & 连接,写成一条完整的 url:

String url = "jdbc:mysql:///db1?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8";

还有一个容易被忽略的错误:& 在 XML 里是特殊字符。如果你把这条 url 写进 druid.properties 没问题,但写进 web.xml、pom.xml 或 MyBatis 的 XML 映射文件时,必须写成 &amp;,否则 XML 解析器会报 "The reference to entity ... must end with the ';' delimiter"。

10.3 JDBC 核心 API

入门案例里出现了三个名字:DriverManager、Connection、Statement,加上查询时才会出现的 ResultSet,这四个对象就是 JDBC 的全部家当。本节按"谁来创建谁"的顺序讲:DriverManager 造 Connection,Connection 造 Statement,Statement 造 ResultSet。

DriverManager ──getConnection()──▶ Connection ──createStatement()──────▶ Statement ──executeQuery()──▶ ResultSet
                                              └─prepareStatement(sql)──▶ PreparedStatement

10.3.1 DriverManager:注册驱动

DriverManager(驱动管理类)有两个作用:注册驱动、获取数据库连接。

先看第一个。DriverManager 里确实有一个 registerDriver 方法用于注册驱动,但是我们之前做的入门案例没有这样写,它用的是 Class.forName("com.mysql.jdbc.Driver")。为什么?

因为 MySQL 驱动类 com.mysql.jdbc.Driver 自己的静态代码块里,已经调用了 DriverManager.registerDriver()。 它的源码长这样:

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    static {
        try {
            java.sql.DriverManager.registerDriver(new Driver());
        } catch (SQLException E) {
            throw new RuntimeException("Can't register driver!");
        }
    }

    public Driver() throws SQLException {
        // Required for Class.forName().newInstance()
    }
}

在该类中的静态代码块中已经执行了 DriverManager 对象的 registerDriver() 方法进行驱动的注册了,那么我们只需要加载 Driver 类,该静态代码块就会执行。而 Class.forName("com.mysql.jdbc.Driver") 的作用正是加载这个类:它把类加载进 JVM,触发静态代码块执行,注册就完成了。

【补充】

那为什么后来这行代码可以删掉?MySQL 5 之后的驱动包,可以省略注册驱动的步骤,因为驱动 jar 包里有 SPI 机制:jar 包的 META-INF/services/java.sql.Driver 文件里写着一行驱动类的全限定名,DriverManager 的静态代码块会通过 ServiceLoader 自动扫描 classpath 下所有 jar 的这个文件,把驱动类加载进来。"加载驱动类"这件事从"你手动做"变成了"DriverManager 自动做"。

这就解释了两件事:

  1. 为什么入门代码把它注释掉还能跑:SPI 兜住了;
  2. 为什么 Class.forName("com.mysql.jdbc.Driver") 在 MySQL 8 下会抛 ClassNotFoundException:com.mysql.jdbc 这个包名在 8.x 里已经改成 com.mysql.cj.jdbc 了。能删就删,别留着这行过时的代码。

【易错点】 用 Druid 时配置文件里的 driverClassName 写错,现象很迷惑:程序不报"类找不到",而是报 java.sql.SQLException: config error 或连接直接失败。原因是 Druid 解析配置时对驱动类名做了宽容处理。正确写法:MySQL 5.x 用 com.mysql.jdbc.Driver,MySQL 8.x 用 com.mysql.cj.jdbc.Driver,两者不能混用:用 8 的 jar 配 5 的类名会抛 ClassNotFoundException,用 5 的 jar 配 8 的类名同理。

10.3.2 DriverManager:获取数据库连接

第二个作用是获取连接,方法签名是 DriverManager.getConnection(url, username, password),三个参数的含义如下(原文 3.1):

参数含义说明
url连接路径语法:jdbc:mysql://ip地址(域名):端口号/数据库名称?参数键值对1&参数键值对2...
user用户名一般是 root
password密码安装 MySQL 时设的那个

url 的两个细节在 10.2.5 节已经展开:本机 + 默认端口可以简写成 jdbc:mysql:///db1?参数;useSSL=false 用于禁用安全连接、消掉警告。

【考点】

"Class.forName 在 JDBC 4 之后为什么还能删掉?"标准答法两句:一是 JDBC 4.0 起支持 SPI 自动加载,驱动 jar 的 META-INF/services/java.sql.Driver 会被 DriverManager 自动读取;二是删掉之后仍建议保留 Class.forName 的唯一理由是"想尽早暴露驱动缺失":类加载失败会在启动时立刻报错,而不是等到第一次 getConnection 才报。 现代项目普遍靠连接池和框架管这件事,手写代码里基本都删了。

10.3.3 Connection:获取执行 SQL 的对象

Connection(数据库连接对象)有两个作用:获取执行 SQL 的对象、管理事务。先看第一个,它有三个方法,对应三种执行对象(原文 3.2.1):

Statement createStatement()

入门案例中就是通过该方法获取的执行对象。

PreparedStatement prepareStatement(String sql)

通过这种方式获取的 PreparedStatement 是我们一会重点要进行讲解的,它可以防止 SQL 注入。

CallableStatement prepareCall(String sql)

通过这种方式获取的 CallableStatement 执行对象是用来执行存储过程的,而存储过程在 MySQL 中不常用,所以这个我们将不进行讲解。

【对比】

三个执行对象的分工,一张表记住:

对象获取方式用来执行是否预编译本章地位
Statementconn.createStatement()静态 SQL(SQL 已拼好)否入门用,之后被取代
PreparedStatementconn.prepareStatement(sql)带 ? 参数的 SQL是主力,10.4 节主角
CallableStatementconn.prepareCall(sql)存储过程 {call proc(?)}是了解即可

注意三者是继承关系:PreparedStatement extends Statement,CallableStatement extends PreparedStatement。所以 PreparedStatement 能调 Statement 的所有方法,反过来不行。

10.3.4 Connection:事务管理

先回顾一下 MySQL 事务管理的操作(原文 3.2.2):

MySQL 默认是自动提交事务。你执行一条 update,它立刻落盘,没有"反悔"的机会。所以 JDBC 里要手动控制事务,第一步就是把自动提交关掉。

Connection 接口中定义了 3 个对应的方法:

方法作用说明
setAutoCommit(boolean autoCommit)开启事务参数表示是否自动提交事务,true 表示自动提交,false 表示手动提交。开启事务需要将该参数设为 false
commit()提交事务程序运行到此处,说明没有出现任何问题,则提交事务
rollback()回滚事务程序在出现异常时会执行到这个地方,此时就需要回滚事务

具体代码实现如下(原文 JDBCDemo3_Connection)。这段代码的业务是"把 id=1 和 id=2 两个账户的余额都改成 3000",中间故意插了一句 int i = 3/0; 制造异常,用来验证"要么全成功、要么全失败":

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

/**
 * JDBC API 详解:Connection
 */
public class JDBCDemo3_Connection {

    public static void main(String[] args) throws Exception {

        //1. 注册驱动
        //Class.forName("com.mysql.jdbc.Driver");
        //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
        String url = "jdbc:mysql:///db1?useSSL=false";
        String username = "root";
        String password = "1234";
        Connection conn = DriverManager.getConnection(url, username, password);
        //3. 定义sql
        String sql1 = "update account set money = 3000 where id = 1";
        String sql2 = "update account set money = 3000 where id = 2";

        //4. 获取执行sql的对象 Statement
        Statement stmt = conn.createStatement();

        try {
            // ============开启事务==========
            conn.setAutoCommit(false);
            //5. 执行sql
            int count1 = stmt.executeUpdate(sql1);//受影响的行数
            //6. 处理结果
            System.out.println(count1);
            int i = 3 / 0;
            //5. 执行sql
            int count2 = stmt.executeUpdate(sql2);//受影响的行数
            //6. 处理结果
            System.out.println(count2);

            // ============提交事务==========
            //程序运行到此处,说明没有出现任何问题,则需求提交事务
            conn.commit();
        } catch (Exception e) {
            // ============回滚事务==========
            //程序在出现异常时会执行到这个地方,此时就需要回滚事务
            conn.rollback();
            e.printStackTrace();
        }

        //7. 释放资源
        stmt.close();
        conn.close();
    }
}

运行结果很关键:控制台打印了 1,然后抛出 ArithmeticException: / by zero,但数据库里 id=1 的 money 仍然是原值,不是 3000。因为第一条 update 虽然执行成功了,但它处在事务里、还没 commit,异常一来就 rollback 把它撤销了。

【补充】

把这段代码改造成"转账",就是事务最经典的应用场景。需求:张三(id=1)给李四(id=2)转 1000 元。它必须由两条 SQL 组成:张三减 1000、李四加 1000。这两条必须同生共死。中途断电、抛异常、数据库连接断了,只要有一条没执行,钱就凭空消失或凭空多出来。

public void transfer(int fromId, int toId, double money) {
    Connection conn = null;
    PreparedStatement pstmt1 = null;
    PreparedStatement pstmt2 = null;
    try {
        conn = JDBCUtils.getConnection();
        // 开启事务:关掉自动提交
        conn.setAutoCommit(false);

        String sql1 = "update account set money = money - ? where id = ?";
        String sql2 = "update account set money = money + ? where id = ?";

        pstmt1 = conn.prepareStatement(sql1);
        pstmt1.setDouble(1, money);
        pstmt1.setInt(2, fromId);
        pstmt1.executeUpdate();

        // 模拟转账途中的意外:网络超时、服务宕机、余额不足的判断失败……
        // int i = 1 / 0;

        pstmt2 = conn.prepareStatement(sql2);
        pstmt2.setDouble(1, money);
        pstmt2.setInt(2, toId);
        pstmt2.executeUpdate();

        // 两条都成功,才提交
        conn.commit();
    } catch (Exception e) {
        // 任何一条失败,整体回滚
        if (conn != null) {
            try {
                conn.rollback();
            } catch (SQLException ex) {
                ex.printStackTrace();
            }
        }
        e.printStackTrace();
    } finally {
        // 先开后关:pstmt2 → pstmt1 → conn
        JDBCUtils.close(pstmt2, null);
        JDBCUtils.close(pstmt1, null);
        JDBCUtils.close(null, conn);
    }
}

事务要满足的四个特性就是 ACID,用转账这个例子最好记:

特性含义转账里的体现
原子性 Atomicity事务里的操作要么全做、要么全不做张三减钱和李四加钱不能只成功一半
一致性 Consistency事务前后数据满足业务约束两人余额之和不变,总数不能凭空多出 1000
隔离性 Isolation并发事务之间互不干扰转账进行中,别人查张三余额不能看到"减了但没到账"的中间态
持久性 Durability提交后永久生效,宕机也不丢commit() 返回后,即使 MySQL 立刻断电,钱也确实转了

只有 commit() 成功返回,事务才算真正落地;rollback() 之后一切回到 setAutoCommit(false) 那一刻的状态。

【易错点】 事务代码有三个高频错误:

  1. setAutoCommit(false) 写在 try 外面,一旦它自己抛异常,catch 里的 rollback 会再抛一个异常,把真实错误盖掉。正确做法是它也放进 try,rollback 前先判空。
  2. commit() 之后又 rollback():finally 里无脑回滚是经典错误,会把已经提交的事务"再回滚一次",直接抛 SQLException: Can't call rollback when autocommit=true(连接池回收时已重置)。正确做法是只在 catch 里回滚。
  3. 连接池的 close() 会重置事务状态:conn.close() 归还给连接池时,池子会把 autoCommit 恢复成 true。所以不要指望"不 commit 就不生效"来偷懒,该 commit 就 commit。

顺带一提:finally 里 close() 一旦抛异常,try 里的 return 结果就被吞了。所以资源关闭最好用 try-with-resources(见 10.3.10)。

10.3.5 Statement:执行 DDL 与 DML

Statement 对象的作用就是用来执行 SQL 语句。而针对不同类型的 SQL 语句使用的方法也不一样(原文 3.3):

方法用于返回值
int executeUpdate(String sql)执行 DDL、DML 语句受影响的行数(DDL 可能是 0)
ResultSet executeQuery(String sql)执行 DQL 语句ResultSet 结果集对象

executeQuery 涉及到了 ResultSet 对象,而这个对象我们还没有学习,一会再重点讲解。

执行 DML 语句的代码如下:

import org.junit.Test;

/**
 * 执行DML语句
 * @throws Exception
 */
@Test
public void testDML() throws Exception {

    //1. 注册驱动
    //Class.forName("com.mysql.jdbc.Driver");
    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    String url = "jdbc:mysql:///db1?useSSL=false";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);
    //3. 定义sql
    String sql = "update account set money = 3000 where id = 1";
    //4. 获取执行sql的对象 Statement
    Statement stmt = conn.createStatement();
    //5. 执行sql
    int count = stmt.executeUpdate(sql);//执行完DML语句,受影响的行数
    //6. 处理结果
    //System.out.println(count);
    if (count > 0) {
        System.out.println("修改成功~");
    } else {
        System.out.println("修改失败~");
    }
    //7. 释放资源
    stmt.close();
    conn.close();
}

执行 DDL 语句的代码如下,注意 SQL 换成了 drop database db2,而且 count 可能是 0:

/**
 * 执行DDL语句
 * @throws Exception
 */
@Test
public void testDDL() throws Exception {

    //1. 注册驱动
    //Class.forName("com.mysql.jdbc.Driver");
    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    String url = "jdbc:mysql:///db1?useSSL=false";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);
    //3. 定义sql
    String sql = "drop database db2";
    //4. 获取执行sql的对象 Statement
    Statement stmt = conn.createStatement();
    //5. 执行sql
    int count = stmt.executeUpdate(sql);//执行完DDL语句,可能是0
    //6. 处理结果
    System.out.println(count);

    //7. 释放资源
    stmt.close();
    conn.close();
}

注意: 以后开发很少使用 Java 代码操作 DDL 语句。建表、建库、删库这类操作属于"运维/部署"的范畴,用 SQL 脚本在 MySQL 客户端里执行更安全、可追溯;Java 代码的职责是操作数据(DML)和读取数据(DQL)。想象一下:线上程序里藏着一句 drop database db2,某天误跑一次,整个库就没了。

10.3.6 Statement:批量操作 addBatch 与 executeBatch

原文没有讲批量操作,但它在"添加 100 条品牌数据"这种场景里是必选项,也是 Statement 与 PreparedStatement 共有的能力,这里补上。

【补充】

批量操作指的是把多条 SQL 攒在客户端,一次性发给 MySQL 执行,而不是发一条等一条。两个方法:

  • addBatch(String sql)(Statement)或 addBatch()(PreparedStatement,无参):把当前 SQL 加入批次;
  • int[] executeBatch():一次性提交整批,返回每条语句影响的行数数组;
  • clearBatch():清空批次(可省略,executeBatch 之后批次会自动清空)。
// 用 PreparedStatement 批量插入 1000 条品牌
String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
for (int i = 1; i <= 1000; i++) {
    pstmt.setString(1, "品牌" + i);
    pstmt.setString(2, "公司" + i);
    pstmt.setInt(3, i);
    pstmt.setString(4, "描述" + i);
    pstmt.setInt(5, 1);
    pstmt.addBatch();   // 攒起来,不执行
    if (i % 500 == 0) {
        pstmt.executeBatch(); // 每 500 条发一次,避免内存爆掉
        pstmt.clearBatch();
    }
}
pstmt.executeBatch(); // 收尾

想让批量插入真正快起来,url 里还要加一个参数 rewriteBatchedStatements=true。不加的话,MySQL 驱动会把 1000 条 insert 拆成 1000 次网络往返,批量等于没批量;加上之后,驱动会把它们改写成一条 insert into ... values (...),(...),(...),速度能差几倍到几十倍。这是面试里很爱问的一个细节。

10.3.7 ResultSet:结果集对象

ResultSet(结果集对象)的作用是:封装了 SQL 查询语句的结果。而执行了 DQL 语句后就会返回该对象,对应的方法是 ResultSet executeQuery(String sql):执行 DQL 语句,返回 ResultSet 对象。

那么我们就需要从 ResultSet 对象中获取我们想要的数据。ResultSet 对象提供了操作查询结果数据的方法,如下(原文 3.4):

boolean next()

方法返回值说明:

xxx getXxx(参数):获取数据

光标的行为值得单独说清楚。一开始光标指向第一行之前(表头那一行),此时不能取数据;当我们调用了 next() 方法后,光标就下移到第一行数据,并且方法返回 true,此时就可以通过 getInt("id") 获取当前行 id 字段的值,也可以通过 getString("name") 获取当前行 name 字段的值。如果想获取下一行的数据,继续调用 next() 方法,以此类推。当 next() 返回 false 时,说明已经没有下一行了,循环结束。

用一张示意图表示光标的位置变化:

                  id    name      money
光标初始位置 →    (表头)  ——  此时 getXxx() 会抛异常
next() 第1次 →    1    张三      1000     ← 返回 true,可取数据
next() 第2次 →    2    李四      2000     ← 返回 true,可取数据
next() 第3次 →   (无)   ——      ——      ← 返回 false,循环结束
【易错点】

三个和 ResultSet 有关的常见错误,每一个都能让程序"看起来正常但结果是错的":

  1. 不调用 next() 就直接 getXxx():抛 SQLException: Before start of result set。ResultSet 一创建光标就在第一行之前,必须先 next()。
  2. getXxx(1) 以为是从 0 开始:JDBC 的列编号从 1 开始,写 getInt(0) 抛 SQLException: Column Index out of range。而数组、List 是从 0 开始,两个习惯打架,这是最高频的低级错误。
  3. ResultSet 用完必须关:rs 底层持有 MySQL 返回的结果集游标和服务端资源,不关就会一直占着连接。更隐蔽的是:rs.close() 不写,conn.close() 一执行,rs 自动失效;但如果用了连接池,conn.close() 只是"归还",rs 没关会导致这条连接下次被借出时状态异常。所以释放顺序必须是 rs → stmt → conn。

10.3.8 ResultSet:遍历查询结果

完整代码如下(原文 3.4.2)。注释掉的那段是用列编号取值,下面那段是用列名取值,两种写法效果一样,推荐用列名(改表结构时不容易错位):

/**
 * 执行DQL
 * @throws Exception
 */
@Test
public void testResultSet() throws Exception {

    //1. 注册驱动
    //Class.forName("com.mysql.jdbc.Driver");
    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    String url = "jdbc:mysql:///db1?useSSL=false";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);
    //3. 定义sql
    String sql = "select * from account";
    //4. 获取statement对象
    Statement stmt = conn.createStatement();
    //5. 执行sql
    ResultSet rs = stmt.executeQuery(sql);
    //6. 处理结果, 遍历rs中的所有数据
    /* // 6.1 光标向下移动一行,并且判断当前行是否有数据
    while (rs.next()){
        //6.2 获取数据 getXxx()
        int id = rs.getInt(1);
        String name = rs.getString(2);
        double money = rs.getDouble(3);

        System.out.println(id);
        System.out.println(name);
        System.out.println(money);

        System.out.println("--------------");
    }*/
    // 6.1 光标向下移动一行,并且判断当前行是否有数据
    while (rs.next()) {

        //6.2 获取数据 getXxx()
        int id = rs.getInt("id");
        String name = rs.getString("name");
        double money = rs.getDouble("money");

        System.out.println(id);
        System.out.println(name);
        System.out.println(money);

        System.out.println("--------------");
    }

    //7. 释放资源
    rs.close();
    stmt.close();
    conn.close();
}

10.3.9 getInt 与 getObject 对 NULL 的差异

这是原文没提、但一写真实业务就会撞上的问题:数据库里的字段允许为 NULL,而 Java 的 int 不允许为 null。

【易错点】

rs.getInt("status") 遇到数据库里是 NULL 的 status 时,不会抛异常,而是返回 0。这个 0 和"数据库里真的存了 0"完全无法区分,业务上会出大错:比如 status 的语义是"0 禁用、1 启用",一个"没填状态"的商品会被当成"禁用"。更危险的是给实体类的 Integer 字段赋值:brand.setStatus(rs.getInt("status")) 会把 null 变成 0。

正确写法是用 getObject 或 wasNull():

// 写法一:getObject 返回包装类型,NULL 就是 null
Integer status = (Integer) rs.getObject("status");
brand.setStatus(status);   // 数据库是 NULL,Java 里也是 null

// 写法二:getInt 之后用 wasNull() 判断
int statusInt = rs.getInt("status");
brand.setStatus(rs.wasNull() ? null : statusInt);

这也是实体类的基本数据类型建议使用其对应的包装类型(Integer 而不是 int)的根本原因:Brand 类的注释里写了这句话,原因就在这里。表字段可空,Java 侧就必须用能表达"空"的类型。

10.3.10 案例:把结果封装成对象集合

需求:查询 account 账户表数据,封装为 Account 对象中,并且存储到 ArrayList 集合中。这是"数据库行 → Java 对象"的第一次转换,也是后面所有 DAO 方法的原型。

思路分三步:定义实体类 Account、查询数据封装到 Account 对象中、将 Account 对象存入 ArrayList 集合中。

/**
 * 查询account账户表数据,封装为Account对象中,并且存储到ArrayList集合中
 * 1. 定义实体类Account
 * 2. 查询数据,封装到Account对象中
 * 3. 将Account对象存入ArrayList集合中
 */
@Test
public void testResultSet2() throws Exception {

    //1. 注册驱动
    //Class.forName("com.mysql.jdbc.Driver");
    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    String url = "jdbc:mysql:///db1?useSSL=false";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);

    //3. 定义sql
    String sql = "select * from account";

    //4. 获取statement对象
    Statement stmt = conn.createStatement();

    //5. 执行sql
    ResultSet rs = stmt.executeQuery(sql);

    // 创建集合
    List<Account> list = new ArrayList<>();

    // 6.1 光标向下移动一行,并且判断当前行是否有数据
    while (rs.next()) {

        Account account = new Account();

        //6.2 获取数据 getXxx()
        int id = rs.getInt("id");
        String name = rs.getString("name");
        double money = rs.getDouble("money");

        //赋值
        account.setId(id);
        account.setName(name);
        account.setMoney(money);

        // 存入集合
        list.add(account);
    }

    System.out.println(list);

    //7. 释放资源
    rs.close();
    stmt.close();
    conn.close();
}

对应实体类 Account:

/**
 * 账户实体类
 */
public class Account {
    private Integer id;
    private String name;
    private Double money;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Double getMoney() {
        return money;
    }

    public void setMoney(Double money) {
        this.money = money;
    }

    @Override
    public String toString() {
        return "Account{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", money=" + money +
                '}';
    }
}

注意 list.add(account) 的位置在 while 循环内部,而 new Account() 也在循环内部。如果把 new Account() 提到循环外面,所有元素会指向同一个对象,最后集合里全是最后一行的数据。这是集合遍历封装最经典的 bug。

10.3.11 资源释放顺序与 try-with-resources

到这里,每个方法结尾都是三行 close()。它们为什么必须是这个顺序?原文没有解释,这里补上。

【补充】

释放顺序:先开后关,后开先关(像叠盘子,后放上去的先拿下来)。理由有两条:

  1. 依赖关系:ResultSet 由 Statement 产生,Statement 由 Connection 产生。关掉 Connection 之后,它上面的 Statement 和 ResultSet 都会失效,此时再调 rs.close() 可能抛 SQLException: Operation not allowed after ResultSet closed。
  2. 资源层级:每个 Statement 在 MySQL 服务端都有对应的预处理语句缓存,连接关掉时这些缓存才被统一清理。先关小的、再关大的,能保证每一步都关在"还有效"的状态。

所以顺序固定为 rs → stmt/pstmt → conn,且必须放在 finally 里:否则中间一抛异常,后面的 close() 全被跳过,连接就泄漏了。手写 finally 的完整版本是这样的:

Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
    conn = DriverManager.getConnection(url, username, password);
    String sql = "select * from tb_brand";
    pstmt = conn.prepareStatement(sql);
    rs = pstmt.executeQuery();
    while (rs.next()) {
        // 处理结果
    }
} catch (SQLException e) {
    e.printStackTrace();
} finally {
    // 后开先关,每个 close 单独 try-catch,避免一个失败影响后面
    if (rs != null) {
        try { rs.close(); } catch (SQLException e) { e.printStackTrace(); }
    }
    if (pstmt != null) {
        try { pstmt.close(); } catch (SQLException e) { e.printStackTrace(); }
    }
    if (conn != null) {
        try { conn.close(); } catch (SQLException e) { e.printStackTrace(); }
    }
}

三层 if + try-catch 嵌套,写完二十行,谁写谁烦。JDK 7 之后有了 try-with-resources,前提是资源实现了 AutoCloseable 接口:Connection、Statement、ResultSet 全都实现了。上面的代码可以压缩成:

String sql = "select * from tb_brand";
try (Connection conn = DriverManager.getConnection(url, username, password);
     PreparedStatement pstmt = conn.prepareStatement(sql);
     ResultSet rs = pstmt.executeQuery()) {

    while (rs.next()) {
        System.out.println(rs.getString("brand_name"));
    }
} catch (SQLException e) {
    e.printStackTrace();
}

三个资源写在 try 的圆括号里,用分号隔开。关闭顺序由编译器自动按"倒序"生成:先关 rs、再关 pstmt、最后关 conn,正好就是"先开后关"。而且即使 pstmt 的关闭抛了异常,也不会影响 conn 的关闭(编译器生成的代码里做了 addSuppressed 处理)。

注意:try-with-resources 里的变量在 try 块结束后就不可见了,所以如果需要把 conn 传出去(比如事务要在 try 外面 commit),就不能用它。事务场景下,conn 通常还是手写 finally 关闭,因为 commit/rollback 的时机比资源作用域更复杂。

10.4 PreparedStatement 与 SQL 注入

10.4.1 SQL 注入是什么

SQL 注入是通过操作输入来修改事先定义好的 SQL 语句,用以达到执行代码对服务器进行攻击的方法。

这句话有点绕,翻译成大白话:用户输入的内容,被当成了 SQL 语法的一部分。 你本来只想让用户填一个"密码值",结果他填的字符串里带了引号和 or,把你精心写的 where 条件整个改写了。

原文给了一个现成的演示程序,步骤是这样的:

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234
create database test;

这就是 SQL 注入漏洞,也是很危险的。当然现在市面上的系统都不会存在这种问题了,所以也不要尝试用这种方式去试其他的系统。未经授权对他人系统做注入测试是违法行为。

那么该如何解决呢?这里就可以将 SQL 执行对象 Statement 换成 PreparedStatement 对象。

10.4.2 用代码模拟 SQL 注入

先不看 PreparedStatement,用一段代码把注入"复现"出来,你才会真正理解它为什么能成功(原文 3.6.2):

@Test
public void testLogin() throws Exception {

    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    String url = "jdbc:mysql:///db1?useSSL=false";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);

    // 接收用户输入 用户名和密码
    String name = "sjdljfld";
    String pwd = "' or '1' = '1";
    String sql = "select * from tb_user where username = '" + name + "' and password = '" + pwd + "'";

    // 获取stmt对象
    Statement stmt = conn.createStatement();
    // 执行sql
    ResultSet rs = stmt.executeQuery(sql);
    // 判断登录是否成功
    if (rs.next()) {
        System.out.println("登录成功~");
    } else {
        System.out.println("登录失败~");
    }

    //7. 释放资源
    rs.close();
    stmt.close();
    conn.close();
}

上面代码是将用户名和密码拼接到 SQL 语句中,拼接后的 SQL 语句如下:

select * from tb_user where username = 'sjdljfld' and password = ''or '1' = '1'

从上面语句可以看出,条件 username = 'sjdljfld' and password = '' 不管是否满足,而 or 后面的 '1' = '1' 是始终满足的,最终整个条件成立,就可以正常地进行登录了。

把拼接过程拆开看,注入的每一步都清清楚楚:

原始 SQL 模板:
  select * from tb_user where username = '___' and password = '___'

用户输入 pwd = ' or '1' = '1 ,拼进去之后:
  select * from tb_user where username = 'sjdljfld' and password = '' or '1' = '1'
                                                                  └──┬──┘
                          密码变成了空字符串,or 后面恒为真 ──────────┘

最终 WHERE 等价于:(username='sjdljfld' AND password='') OR TRUE
由于 OR TRUE,整个条件恒成立,查出了表里所有行,rs.next() 返回 true。

注意 rs.next() 只判断"有没有返回行",只要有任意一行就认为登录成功。这正是漏洞被放大的地方。

【补充】

除了 ' or '1' = '1,注入还有很多变种,认识它们才知道危害边界:

输入拼接后的效果后果
' or '1' = '1条件恒成立绕过登录
admin' -- -- 把后面的密码判断注释掉只要知道用户名就能登录
' ; drop table tb_user; -- 在支持多语句时执行第二条 SQL删表
' union select username, password from tb_user -- 联合查询把别的表数据带出来拖库

防御不只靠 PreparedStatement 一条:数据库账号最小权限(业务账号不给 drop 权限)、输入校验(id 必须是数字)、错误信息不暴露给前端(不要把 SQL 异常原文打印到页面)。但第一道防线永远是"不要把用户输入拼进 SQL"。

10.4.3 PreparedStatement 概述

PreparedStatement 的作用是:预编译 SQL 语句并执行,预防 SQL 注入问题。

获取 PreparedStatement 对象:SQL 语句中的参数值,使用 ? 占位符替代。

// SQL语句中的参数值,使用?占位符替代
String sql = "select * from user where username = ? and password = ?";
// 通过Connection对象获取,并传入对应的sql语句
PreparedStatement pstmt = conn.prepareStatement(sql);

设置参数值:上面的 SQL 语句中参数使用 ? 进行占位,在执行之前肯定要设置这些 ? 的值。PreparedStatement 对象提供了 setXxx(参数1, 参数2) 给 ? 赋值:

执行 SQL 语句:

注意: 调用这两个方法时不需要传递 SQL 语句,因为获取 SQL 语句执行对象时已经对 SQL 语句进行预编译了。这是 PreparedStatement 和 Statement 在方法签名上最直观的区别:stmt.executeQuery(sql) 要传 SQL,pstmt.executeQuery() 不传。

10.4.4 用 PreparedStatement 改进登录

@Test
public void testPreparedStatement() throws Exception {

    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    String url = "jdbc:mysql:///db1?useSSL=false";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);

    // 接收用户输入 用户名和密码
    String name = "zhangsan";
    String pwd = "' or '1' = '1";

    // 定义sql
    String sql = "select * from tb_user where username = ? and password = ?";
    // 获取pstmt对象
    PreparedStatement pstmt = conn.prepareStatement(sql);
    // 设置?的值
    pstmt.setString(1, name);
    pstmt.setString(2, pwd);
    // 执行sql
    ResultSet rs = pstmt.executeQuery();
    // 判断登录是否成功
    if (rs.next()) {
        System.out.println("登录成功~");
    } else {
        System.out.println("登录失败~");
    }
    //7. 释放资源
    rs.close();
    pstmt.close();
    conn.close();
}

执行上面代码就可以发现不会出现 SQL 注入漏洞问题了,控制台打印的是"登录失败~"。注意:注入的字符串还是那个 ' or '1' = '1,代码里一个字都没改,只是换了执行对象。

那么 PreparedStatement 又是如何解决的?原文的说法是:它是将特殊字符进行了转义,转义后的 SQL 如下:

select * from tb_user where username = 'sjdljfld' and password = '\'or \'1\' = \'1'

看这条语句:pwd 里的三个单引号全都被加上了反斜杠变成 \',于是它们在 MySQL 眼里只是字符串内部的一个普通字符,不再具备"闭合字符串字面量"的语法能力。整个 or '1' = '1 变成了密码值的一部分,条件变成 password = "' or '1' = '1",自然查不到人。

10.4.5 为什么能防住:预编译与参数分离

原文用"转义"解释,这是结果层面的解释;面试如果追问"为什么转义就安全",就要讲到机制层面。

【补充】

PreparedStatement 防注入的真正机制是两个动作,缺一不可:

第一,SQL 模板先被"预编译",语法结构在参数到达之前就已经固定。 conn.prepareStatement(sql) 这一步,驱动就把带 ? 的 SQL 模板发给了 MySQL。MySQL 对它做语法检查、编译成可执行计划,此时这条 SQL 有几个条件、哪些是字符串、哪些是数字,已经全部定死了。注意:这时候参数还没来。

第二,参数是"另一条通道"送进去的,永远只被当作值,不会被当作语法。 pstmt.setString(2, pwd) 设置的值,走的是协议里的"参数绑定"字段,不是拼进 SQL 文本里。MySQL 收到它时,只知道"第 2 个占位符的值是这串字符",不会再去解析这串字符里有没有引号、有没有 or。

用一张图对比两条路径:

Statement(拼接):  Java 字符串拼接 → 一整条 SQL 文本 → MySQL 解析语法 → 执行
                      ↑ 用户输入在这一步就混进了语法里,能被"读懂"

PreparedStatement:  ① SQL 模板(含 ?) → MySQL 解析语法、编译 → 结构固定
                     ② 参数值           → 走绑定通道 → 只当值用
                     两路分开,用户输入永远进不了"语法层"

这就解释了原文那句"将敏感字符进行转义"是从哪来的:驱动在把参数值编码进协议时,会对引号、反斜杠等字符做转义处理,保证它们不会破坏外层结构。转义是"参数绑定"这个机制在实现层面的一个细节,而不是"先拼好再替换敏感字符":顺序完全不同,效果天差地别。

顺带说清一个常被混淆的点:默认情况下 MySQL 的预编译是"客户端预编译",useServerPrepStmts=false。此时 ? 的替换在驱动内部完成,服务端收到的仍是一条完整 SQL。不过驱动会负责正确地转义参数,所以注入依然防得住。开了 useServerPrepStmts=true 才是"服务端预编译",SQL 模板与参数分两次发给 MySQL。两者安全性相同,性能特性不同。

10.4.6 PreparedStatement 的性能:预编译为什么更快

PreparedStatement 的好处有两条:预编译 SQL,性能更高;防止 SQL 注入。防注入已经讲透,这里讲性能。

Java 代码操作数据库的流程是这样的:

原因是:检查 SQL 和编译 SQL 花费的时间比执行 SQL 的时间还要长。 如果我们只是重新设置参数,那么检查 SQL 语句和编译 SQL 语句将不需要重复执行,这样就提高了性能。

接下来通过查询日志来看一下原理。

第一步,开启预编译功能。 在代码中编写 url 时需要加上参数 useServerPrepStmts=true。而我们之前根本就没有开启预编译功能,只是解决了 SQL 注入漏洞。这句话很重要,防注入和预编译性能是两个独立的收益,不开这个参数也防得住注入。

// useServerPrepStmts=true 参数开启预编译功能
String url = "jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true";

第二步,配置 MySQL 执行日志(重启 MySQL 服务后生效)。在 MySQL 配置文件(my.ini)中添加如下配置:

log-output=FILE
general-log=1
general_log_file="D:\mysql.log"
slow-query-log=1
slow_query_log_file="D:\mysql_slow.log"
long_query_time=2

第三步,Java 测试代码如下(原文 3.6.5):

/**
 * PreparedStatement原理
 * @throws Exception
 */
@Test
public void testPreparedStatement2() throws Exception {

    //2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
    // useServerPrepStmts=true 参数开启预编译功能
    String url = "jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true";
    String username = "root";
    String password = "1234";
    Connection conn = DriverManager.getConnection(url, username, password);

    // 接收用户输入 用户名和密码
    String name = "zhangsan";
    String pwd = "' or '1' = '1";

    // 定义sql
    String sql = "select * from tb_user where username = ? and password = ?";

    // 获取pstmt对象
    PreparedStatement pstmt = conn.prepareStatement(sql);

    Thread.sleep(10000);
    // 设置?的值
    pstmt.setString(1, name);
    pstmt.setString(2, pwd);
    ResultSet rs = null;
    // 执行sql
    rs = pstmt.executeQuery();

    // 设置?的值
    pstmt.setString(1, "aaa");
    pstmt.setString(2, "bbb");
    // 执行sql
    rs = pstmt.executeQuery();

    // 判断登录是否成功
    if (rs.next()) {
        System.out.println("登录成功~");
    } else {
        System.out.println("登录失败~");
    }

    //7. 释放资源
    rs.close();
    pstmt.close();
    conn.close();
}

执行 SQL 语句,查看 D:\mysql.log 日志,会看到类似这样的三行:

Prepare  select * from tb_user where username = ? and password = ?
Execute  select * from tb_user where username = 'zhangsan' and password = '\'or \'1\' = \'1'
Execute  select * from tb_user where username = 'aaa' and password = 'bbb'

其中第一行 Prepare 是对 SQL 语句进行预编译;后面两行是执行了两次 SQL 语句,而第二次执行前并没有对 SQL 进行预编译。中间那个 Thread.sleep(10000) 就是为了让你在日志里清楚地看到"编译"和"执行"被时间隔开了。

小结:

所以在 for 循环里插入一万条数据时,PreparedStatement 只编译一次、执行一万次;而 Statement 会编译一万次。这就是性能差距的来源。

10.4.7 Statement 与 PreparedStatement 的完整对比

【对比】

这张表是本章最该背下来的一张表,简答、选择、面试全覆盖。

维度StatementPreparedStatement
获取方式conn.createStatement()conn.prepareStatement(sql)
SQL 形式完整 SQL,参数靠字符串拼接SQL 模板,参数用 ? 占位
执行方法executeUpdate(sql)、executeQuery(sql),要传 SQLexecuteUpdate()、executeQuery(),不传 SQL
是否预编译否,每次执行都检查 + 编译是,模板只编译一次
SQL 注入存在风险可防止(参数只当值)
性能(循环执行)差,N 次编译好,1 次编译 + N 次执行
可读性拼接长 SQL 时可读性差、易漏引号模板清晰,参数集中设置
参数类型安全无,全靠手拼有,setInt 传字符串会直接报错
二进制/大文本不支持 setBinaryStream支持 setBinaryStream、setCharacterStream
适用场景一次性执行的静态 SQL(如建表脚本)绝大多数业务 SQL,尤其是带用户输入的

结论:除了"执行不带参数的静态 SQL"这种场景,其他一律用 PreparedStatement。

【易错点】 PreparedStatement 有两个能力边界,误用就会出错:

边界一:? 只能占位"值",不能占位"标识符"。 下面两种写法都是错的:

// 错误:想动态指定表名
String sql = "select * from ? ";
// 错误:想动态指定排序字段
String sql = "select * from tb_brand order by ? ";

setString(1, "tb_brand") 执行后会变成 select * from 'tb_brand',MySQL 把表名当成字符串字面量,直接报语法错误。表名、列名、order by 的字段、asc/desc 只能拼接,拼接前必须用白名单校验(只允许约定的几个值),绝不能把用户输入直接拼进去。

边界二:like 查询的 % 要放在参数值里,不能放模板里。

// 错误:? 在单引号内部,会被当成普通字符
String sql = "select * from tb_brand where brand_name like '%?%'";

// 正确:模板里只有一个 ?,% 拼在参数值上
String sql = "select * from tb_brand where brand_name like ?";
pstmt.setString(1, "%" + keyword + "%");

10.5 数据库连接池与工具类封装

到这里,每个测试方法都以这四行开头:

Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
Connection conn = dataSource.getConnection();

这段代码会在本章出现六次。出现三次以上就该抽取,这是工程上的一条基本纪律。本节先讲清楚连接池是什么、为什么快,再把这段模板代码收进一个 JDBCUtils 工具类。

10.5.1 数据库连接池的概念(原文 4.1)

数据库连接池是个容器,负责分配、管理数据库连接(Connection)。

它的三条核心行为是:

之前我们代码中使用连接都是"没有使用就创建一个 Connection 对象,使用完毕就会将其销毁"。这样重复创建销毁的过程是特别耗费计算机性能的,也消耗时间。

而数据库使用了数据库连接池后,就能达到 Connection 对象的复用。连接池是在一开始就创建好了一些连接(Connection)对象存储起来。用户需要连接数据库时,不需要自己创建连接,而只需要从连接池中获取一个连接进行使用,使用完毕后再将连接对象归还给连接池;这样就可以起到资源重用,也节省了频繁创建连接销毁连接所花费的时间,从而提升了系统响应的速度。

用图表示"没有池"与"有池"的区别:

没有连接池(每次请求都新建):
  请求1 → 创建连接(TCP 握手 + 认证)→ 执行 SQL → 销毁连接
  请求2 → 创建连接(TCP 握手 + 认证)→ 执行 SQL → 销毁连接
  请求3 → 创建连接(TCP 握手 + 认证)→ 执行 SQL → 销毁连接

有连接池(启动时建好,之后反复借还):
  启动    → 预先创建 5 个连接,放进池子
  请求1  → 借出 conn1 → 执行 SQL → 归还 conn1
  请求2  → 借出 conn2 → 执行 SQL → 归还 conn2
  请求3  → 借出 conn1(复用) → 执行 SQL → 归还 conn1

10.5.2 连接池为什么快:被省掉的那几次往返

原文说"节省了频繁创建连接销毁连接所花费的时间",但没说清这些时间花在哪。这正是面试追问的点。

【补充】

建立一个 MySQL 连接的完整开销分三段,每一段都是网络往返(RTT):

  1. TCP 三次握手:客户端发 SYN、服务端回 SYN+ACK、客户端回 ACK,1.5 个 RTT 之后连接才建立。局域网内一次 RTT 约 0.1~0.5 ms,跨机房就是几毫秒到几十毫秒。
  2. MySQL 握手与认证:服务端先发握手包(带协议版本、salt),客户端回认证包(用户名 + 密码哈希 + 库名),服务端回 OK。这是至少 2 个 RTT,而且密码要做哈希运算(MySQL 8 默认 caching_sha2_password,比 5.7 的 mysql_native_password 更慢,首次认证还可能多一次公钥交换往返)。
  3. 会话初始化:设置字符集、时区、autocommit、sql_mode 等,每项都是一次往返。

加起来,一次"从零建连"通常要 3~10 个 RTT,耗时 5~50 ms;而一次简单的 select 执行可能只要 1 ms。建连的开销是执行 SQL 的几十倍。在一个 QPS 1000 的网站上,如果每次都新建连接,光建连就要吃掉几十毫秒的延迟和大量 CPU(TLS 握手、密码哈希都是 CPU 密集的)。

连接池把这笔开销从"每次请求"摊销成"每个连接一次":启动时建好 N 个连接,之后所有请求只是"借"和"还",借还是内存操作,纳秒级。这就是"提升系统响应速度"的真实含义。

顺带解释另外两个好处:

  • 资源重用:每个 MySQL 连接在服务端都对应一个线程和一块会话内存。不池化的话,高并发时连接数暴涨,MySQL 的 max_connections(默认 151)很快被打满,直接报 Too many connections。
  • 避免连接遗漏:手写代码时,一旦 close() 漏了(比如异常路径没走到 finally),连接就永久泄漏。连接池能检测"借出超过 removeAbandonedTimeout 还没还"的连接并强制回收,还能按 minEvictableIdleTimeMillis 淘汰空闲太久的连接。这就是原文说的"释放空闲时间超过最大空闲时间的数据库连接"。

10.5.3 标准接口 DataSource(原文 4.2)

Connection getConnection()

那么以后就不需要通过 DriverManager 对象获取 Connection 对象,而是通过连接池(DataSource)获取 Connection 对象。

这句话是理解连接池的关键:DataSource 是 javax.sql 包下的接口,和 java.sql.Connection 一样属于 JDBC 标准的一部分。换连接池实现(Druid 换 C3P0)只需要换一行 DruidDataSourceFactory.createDataSource(prop),业务代码一行不用改。

常见的数据库连接池:

连接池出品方特点
DBCPApache老牌,Tomcat 内置的就是它,性能一般
C3P0开源社区老牌,配置项多,Hibernate 早期默认
Druid阿里巴巴功能强大、性能优秀,自带监控页面,国内主流

我们现在使用更多的是 Druid,它的性能比其他两个会好一些。Druid(德鲁伊) 是阿里巴巴开源的数据库连接池项目,功能强大、性能优秀,是 Java 语言最好的数据库连接池之一。

【考点】

"连接池的 close() 到底关了什么?"它没有真的关闭 TCP 连接,它只是把连接归还给池子。 连接池返回给你的 Connection 是一个代理对象(Druid 里是 DruidPooledConnection),它的 close() 方法被改写成了"回收"逻辑:重置 autoCommit、清空会话状态、把连接放回空闲队列。所以用了连接池之后,conn.close() 照常写,但不会真的断开数据库,这也是为什么关闭资源依然不能省。

10.5.4 Druid 的使用步骤(原文 4.3)

使用 Druid 一共五步:

  1. 导入 jar 包:druid-1.1.12.jar;
  2. 定义配置文件;
  3. 加载配置文件;
  4. 获取数据库连接池对象;
  5. 获取连接。

首先需要先将 Druid 的 jar 包放到项目下的 lib 下并添加为库文件。项目结构大致如下:

jdbc-demo
├── lib
│   ├── druid-1.1.12.jar
│   └── mysql-connector-java-8.0.30.jar
└── src
    ├── druid.properties          ← 配置文件放在 src 根目录
    └── com.itheima.jdbc
        └── DruidDemo.java

编写配置文件 druid.properties 如下:

driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true
username=root
password=1234
# 初始化连接数量
initialSize=5
# 最大连接数
maxActive=10
# 最大等待时间
maxWait=3000

使用 Druid 的代码如下(原文 DruidDemo):

import com.alibaba.druid.pool.DruidDataSourceFactory;

import javax.sql.DataSource;
import java.io.FileInputStream;
import java.sql.Connection;
import java.util.Properties;

/**
 * Druid数据库连接池演示
 */
public class DruidDemo {

    public static void main(String[] args) throws Exception {

        //1.导入jar包
        //2.定义配置文件
        //3. 加载配置文件
        Properties prop = new Properties();
        prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
        //4. 获取连接池对象
        DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);

        //5. 获取数据库连接 Connection
        Connection connection = dataSource.getConnection();
        System.out.println(connection); //获取到了连接后就可以继续做其他操作了

        //System.out.println(System.getProperty("user.dir"));
    }
}

注意最后注释掉的那行 System.getProperty("user.dir"),它是用来排查路径问题的:相对路径 jdbc-demo/src/druid.properties 是相对于"工作目录"的,而工作目录在 IDEA 里默认是项目根目录,在命令行里是你执行 java 命令的目录。 两者不一致,FileInputStream 就抛 FileNotFoundException。打印一下 user.dir 就知道到底从哪开始找。

10.5.5 配置文件为什么必须放在 src 根目录

这是原文简单带过、但考试特别爱问的一个细节。

【补充】

druid.properties 放在 src 根目录,原因有两层:

第一层:为了用 classpath 相对路径读取,而不是文件系统绝对路径。 项目编译后,src 根目录下的非 Java 文件会被原样复制到 target/classes(或 IDEA 的 out/production/classes)的根目录下。这个目录就是 classpath 的根。于是可以这样读:

InputStream is = DruidDemo.class.getClassLoader().getResourceAsStream("druid.properties");

用 getResourceAsStream("druid.properties")(前面不能加 /,加了就是从 classpath 根开始的绝对路径,效果一样,但语义不同)读,与项目在哪个盘、哪个目录、谁的工作目录无关,打成 jar 包之后照样能读到,因为配置文件也被打进 jar 了。

而原文的 new FileInputStream("jdbc-demo/src/druid.properties") 是文件系统路径,一旦项目改名、换目录、打包成 jar 就失效了。生产代码不要这么写。

第二层:约定优于配置。 Druid 本身不强制文件名和位置,但"配置文件放 src 根目录、取名 druid.properties"是整个生态(包括后面的 MyBatis、Spring Boot)的通行约定,工具类里硬编码这个名字时不用解释。

补充一个打包后容易出错的地方:如果配置文件放在 src 外面的目录(比如项目根目录),它不会被拷进 target/classes,getResourceAsStream 就返回 null,接着抛 NullPointerException。判断方法:编译后在 target/classes 里看一眼有没有这个文件。

10.5.6 封装 JDBCUtils 工具类

现在把"加载配置、拿连接、关资源"这三件事收进一个类。这个类本章后面所有 DAO 都要用,也是第 11 章的基础设施。

import com.alibaba.druid.pool.DruidDataSourceFactory;

import javax.sql.DataSource;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;

/**
 * JDBC 工具类
 * 1. 提供获取连接的方法
 * 2. 提供释放资源的方法
 * 3. 提供获取连接池对象的方法
 */
public class JDBCUtils {

    // 连接池对象:整个应用只需要一个,用 static 保存
    private static DataSource dataSource;

    // 静态代码块:类加载时执行一次,只加载一次配置文件
    static {
        try {
            // 1. 加载配置文件(classpath 相对路径,配置在 src 根目录)
            Properties prop = new Properties();
            InputStream is = JDBCUtils.class.getClassLoader()
                    .getResourceAsStream("druid.properties");
            prop.load(is);
            // 2. 获取连接池对象
            dataSource = DruidDataSourceFactory.createDataSource(prop);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取连接池对象
     */
    public static DataSource getDataSource() {
        return dataSource;
    }

    /**
     * 获取连接
     */
    public static Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }

    /**
     * 释放资源(先开后关:rs → stmt → conn)
     * 三个参数都可以传 null,方法内部做了判空
     */
    public static void close(ResultSet rs, Statement stmt, Connection conn) {
        if (rs != null) {
            try {
                rs.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
        if (conn != null) {
            try {
                conn.close();   // 用连接池时,这里是把连接归还给池子
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 重载:只关连接(增删改场景没有 ResultSet)
     */
    public static void close(Statement stmt, Connection conn) {
        close(null, stmt, conn);
    }
}

三个设计要点值得说清楚:

10.5.7 Druid 常用配置参数

druid.properties 里除了原文那五项,还有几个参数值得知道:

参数含义参考值说明
driverClassName驱动类名com.mysql.cj.jdbc.DriverMySQL 8 的包名,别写成 5.x 的
url连接地址jdbc:mysql:///db1?useSSL=false参数用 & 连接
username / password账号密码root / 1234
initialSize初始化连接数量5池子一创建就先建 5 个
maxActive最大连接数10借完 10 个后再借就要等
maxWait最大等待时间(毫秒)3000等 3 秒还借不到就抛 GetConnectionTimeoutException
minIdle最小空闲连接数5空闲少于它就开始补
timeBetweenEvictionRunsMillis检测空闲连接的间隔60000配合 minEvictableIdleTimeMillis 淘汰空闲连接
【易错点】

三个连接池相关的报错,看到就知道该改哪个参数:

报错信息原因改法
GetConnectionTimeoutException: wait millis 3000, active 10, maxActive 10连接全被借走没归还(多半是漏了 close())查 close(),或临时调大 maxActive
FileNotFoundException: druid.properties路径不对,或没放 src 根目录改用 getResourceAsStream,确认编译后 classes 下有这个文件
ClassNotFoundException: com.mysql.cj.jdbc.DriverdriverClassName 与 jar 版本不匹配5.x jar 配 com.mysql.jdbc.Driver,8.x 配 com.mysql.cj.jdbc.Driver

特别注意第一条:active 10, maxActive 10 的意思是"池子里 10 个连接全借出去了,一个都没还"。这说明代码里有连接泄漏,调大 maxActive 只是掩盖问题,正确做法是检查每个方法是否在 finally 里关了连接。

10.6 综合练习:tb_brand 的增删改查

10.6.1 需求(原文 5.1)

完成商品品牌数据的增删改查操作:

这四件事对应 SQL 里的 select、insert、update、delete,也就是常说的 CRUD。它们是所有业务系统里出现频率最高的四种操作,把这四个方法写熟,后面无论换什么框架都是在重复这个模式。

10.6.2 环境准备:建表与初始数据(原文 5.2.1)

数据库表 tb_brand:

-- 删除tb_brand表
drop table if exists tb_brand;
-- 创建tb_brand表
create table tb_brand (
    -- id 主键
    id int primary key auto_increment,
    -- 品牌名称
    brand_name varchar(20),
    -- 企业名称
    company_name varchar(20),
    -- 排序字段
    ordered int,
    -- 描述信息
    description varchar(100),
    -- 状态:0:禁用 1:启用
    status int
);
-- 添加数据
insert into tb_brand (brand_name, company_name, ordered, description, status)
values ('三只松鼠', '三只松鼠股份有限公司', 5, '好吃不上火', 0),
       ('华为', '华为技术有限公司', 100, '华为致力于把数字世界带入每个人、每个家庭、每个组织,构建万物互联的智能世界', 1),
       ('小米', '小米科技有限公司', 50, 'are you ok', 1);

三个细节值得注意:

10.6.3 实体类 Brand(原文 5.2.1)

在 pojo 包下定义实体类 Brand。实体类的作用是承载一行数据:一个 Brand 对象对应 tb_brand 表里的一行记录,对象的每个属性对应一个字段。

/**
 * 品牌
 * alt + 鼠标左键:整列编辑
 * 在实体类中,基本数据类型建议使用其对应的包装类型
 */
public class Brand {
    // id 主键
    private Integer id;
    // 品牌名称
    private String brandName;
    // 企业名称
    private String companyName;
    // 排序字段
    private Integer ordered;
    // 描述信息
    private String description;
    // 状态:0:禁用 1:启用
    private Integer status;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getBrandName() {
        return brandName;
    }

    public void setBrandName(String brandName) {
        this.brandName = brandName;
    }

    public String getCompanyName() {
        return companyName;
    }

    public void setCompanyName(String companyName) {
        this.companyName = companyName;
    }

    public Integer getOrdered() {
        return ordered;
    }

    public void setOrdered(Integer ordered) {
        this.ordered = ordered;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    @Override
    public String toString() {
        return "Brand{" +
                "id=" + id +
                ", brandName='" + brandName + '\'' +
                ", companyName='" + companyName + '\'' +
                ", ordered=" + ordered +
                ", description='" + description + '\'' +
                ", status=" + status +
                '}';
    }
}

注释里那句"在实体类中,基本数据类型建议使用其对应的包装类型"是这一节的精华,原因在 10.3.9 节已经讲过:数据库字段可以为 NULL,int 表达不了 NULL。Integer 的默认值是 null,正好对应"数据库里没值";而 int 的默认值是 0,会和真实的 0 混淆。

10.6.4 查询所有(原文 5.2.2)

先想清楚三件事再动手,这是原文每个案例开头都写的注释格式,非常值得学:

/**
 * 查询所有
 * 1. SQL:select * from tb_brand;
 * 2. 参数:不需要
 * 3. 结果:List<Brand>
 */
@Test
public void testSelectAll() throws Exception {

    //1. 获取Connection
    //3. 加载配置文件
    Properties prop = new Properties();
    prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
    //4. 获取连接池对象
    DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);

    //5. 获取数据库连接 Connection
    Connection conn = dataSource.getConnection();
    //2. 定义SQL
    String sql = "select * from tb_brand;";
    //3. 获取pstmt对象
    PreparedStatement pstmt = conn.prepareStatement(sql);
    //4. 设置参数
    //5. 执行SQL
    ResultSet rs = pstmt.executeQuery();
    //6. 处理结果 List<Brand> 封装Brand对象,装载List集合
    Brand brand = null;
    List<Brand> brands = new ArrayList<>();
    while (rs.next()) {

        //获取数据
        int id = rs.getInt("id");
        String brandName = rs.getString("brand_name");
        String companyName = rs.getString("company_name");
        int ordered = rs.getInt("ordered");
        String description = rs.getString("description");
        int status = rs.getInt("status");
        //封装Brand对象
        brand = new Brand();
        brand.setId(id);
        brand.setBrandName(brandName);
        brand.setCompanyName(companyName);
        brand.setOrdered(ordered);
        brand.setDescription(description);
        brand.setStatus(status);

        //装载集合
        brands.add(brand);
    }
    System.out.println(brands);
    //7. 释放资源
    rs.close();
    pstmt.close();
    conn.close();
}
【易错点】

这段代码里 Brand brand = null; 声明在 while 外面,brand = new Brand(); 在 while 里面,位置不能换。如果 new Brand() 也提到外面,brands 里存的六个元素会指向同一个对象,打印出来是六个"小米"。这是集合封装最经典的 bug,判卷时一眼就能看出来。反过来,如果在 while 里写 Brand brand = new Brand();(声明在内部),也是对的,而且更不容易错。

10.6.5 添加品牌(原文 5.2.3)

/**
 * 添加
 * 1. SQL:insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?);
 * 2. 参数:需要,除了id之外的所有参数信息
 * 3. 结果:boolean
 */
@Test
public void testAdd() throws Exception {

    // 接收页面提交的参数
    String brandName = "香飘飘";
    String companyName = "香飘飘";
    int ordered = 1;
    String description = "绕地球一圈";
    int status = 1;

    //1. 获取Connection
    //3. 加载配置文件
    Properties prop = new Properties();
    prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
    //4. 获取连接池对象
    DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
    //5. 获取数据库连接 Connection
    Connection conn = dataSource.getConnection();
    //2. 定义SQL
    String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?);";
    //3. 获取pstmt对象
    PreparedStatement pstmt = conn.prepareStatement(sql);

    //4. 设置参数
    pstmt.setString(1, brandName);
    pstmt.setString(2, companyName);
    pstmt.setInt(3, ordered);
    pstmt.setString(4, description);
    pstmt.setInt(5, status);

    //5. 执行SQL
    int count = pstmt.executeUpdate(); // 影响的行数
    //6. 处理结果
    System.out.println(count > 0);

    //7. 释放资源
    pstmt.close();
    conn.close();
}

count > 0 打印 true 表示插入成功。注意添加、修改、删除用的是 executeUpdate() 而不是 executeQuery(),因为它们是 DML 语句,不返回结果集,只返回受影响的行数。

如果想知道新插入记录的 id(比如插入订单后要拿订单号去做后续操作),用这个写法:

PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
pstmt.executeUpdate();
ResultSet keys = pstmt.getGeneratedKeys();   // 取出数据库生成的主键
if (keys.next()) {
    int newId = keys.getInt(1);
    System.out.println("新插入的品牌 id 是:" + newId);
}

10.6.6 修改品牌(原文 5.2.4)

update tb_brand
set brand_name  = ?,
    company_name = ?,
    ordered      = ?,
    description  = ?,
    status       = ?
where id = ?
/**
 * 修改
 * 1. SQL:update tb_brand set brand_name = ?, company_name = ?, ordered = ?, description = ?, status = ? where id = ?
 * 2. 参数:需要,所有数据
 * 3. 结果:boolean
 */
@Test
public void testUpdate() throws Exception {

    // 接收页面提交的参数
    String brandName = "香飘飘";
    String companyName = "香飘飘";
    int ordered = 1000;
    String description = "绕地球三圈";
    int status = 1;
    int id = 4;

    //1. 获取Connection
    //3. 加载配置文件
    Properties prop = new Properties();
    prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
    //4. 获取连接池对象
    DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
    //5. 获取数据库连接 Connection
    Connection conn = dataSource.getConnection();
    //2. 定义SQL
    String sql = " update tb_brand\n" +
            "           set brand_name = ?,\n" +
            "           company_name= ?,\n" +
            "           ordered               = ?,\n" +
            "           description = ?,\n" +
            "           status                = ?\n" +
            "  where id = ?";
    //3. 获取pstmt对象
    PreparedStatement pstmt = conn.prepareStatement(sql);

    //4. 设置参数
    pstmt.setString(1, brandName);
    pstmt.setString(2, companyName);
    pstmt.setInt(3, ordered);
    pstmt.setString(4, description);
    pstmt.setInt(5, status);
    pstmt.setInt(6, id);

    //5. 执行SQL
    int count = pstmt.executeUpdate(); // 影响的行数
    //6. 处理结果
    System.out.println(count > 0);

    //7. 释放资源
    pstmt.close();
    conn.close();
}
【易错点】

修改数据最容易犯的错是忘了写 where,这一条比 SQL 写错更常见。update tb_brand set status = 0 少一个 where id = ?,整张表的品牌全部被禁用,而且 MySQL 默认自动提交、没有回滚的机会。三个习惯可以救命:

  1. 写 update / delete 时先写 where,再回头写 set;
  2. 在测试库执行前,先把同一条 where 套进 select count(*) 里看一眼命中几行;
  3. 重要操作包在事务里(setAutoCommit(false) + 确认后 commit)。

还有一个隐蔽的错:pstmt.setInt(6, id) 的编号写成了 5,与 status 撞车,MySQL 不报错,但会抛 SQLException: No value specified for parameter 6。参数个数必须与 ? 的个数严格一致。

10.6.7 根据 id 删除(原文 5.2.5)

/**
 * 删除
 * 1. SQL:delete from tb_brand where id = ?
 * 2. 参数:需要,id
 * 3. 结果:boolean
 */
@Test
public void testDeleteById() throws Exception {

    // 接收页面提交的参数
    int id = 4;
    //1. 获取Connection
    //3. 加载配置文件
    Properties prop = new Properties();
    prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
    //4. 获取连接池对象
    DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
    //5. 获取数据库连接 Connection
    Connection conn = dataSource.getConnection();
    //2. 定义SQL
    String sql = " delete from tb_brand where id = ?";
    //3. 获取pstmt对象
    PreparedStatement pstmt = conn.prepareStatement(sql);
    //4. 设置参数
    pstmt.setInt(1, id);
    //5. 执行SQL
    int count = pstmt.executeUpdate(); // 影响的行数
    //6. 处理结果
    System.out.println(count > 0);

    //7. 释放资源
    pstmt.close();
    conn.close();
}

到这里,四个方法都写完了,但把它们放在一起看就会发现一个问题:每个方法开头都有那四行连接池代码,结尾都有两到三行 close(),中间才是真正干活的十几行。重复率超过 50%。 而且每个方法都 DruidDataSourceFactory.createDataSource(prop) 一次,等于每次都新建一个连接池。这正是 10.5.6 节 JDBCUtils 要解决的问题。

10.6.8 用 JDBCUtils 改写

用工具类重写"查询所有",对比一下:

/**
 * 查询所有(工具类版本)
 */
@Test
public void testSelectAllByUtils() {

    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    List<Brand> brands = new ArrayList<>();
    try {
        //1. 获取连接(工具类一行搞定)
        conn = JDBCUtils.getConnection();
        //2. 定义SQL
        String sql = "select * from tb_brand";
        //3. 获取pstmt对象
        pstmt = conn.prepareStatement(sql);
        //4. 执行SQL
        rs = pstmt.executeQuery();
        //5. 处理结果
        while (rs.next()) {
            Brand brand = new Brand();
            brand.setId(rs.getInt("id"));
            brand.setBrandName(rs.getString("brand_name"));
            brand.setCompanyName(rs.getString("company_name"));
            brand.setOrdered(rs.getInt("ordered"));
            brand.setDescription(rs.getString("description"));
            brand.setStatus(rs.getInt("status"));
            brands.add(brand);
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        //6. 释放资源:先开后关
        JDBCUtils.close(rs, pstmt, conn);
    }
    System.out.println(brands);
}

"添加"用工具类改写后,可以短到这样:

@Test
public void testAddByUtils() {
    String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?)";
    try (Connection conn = JDBCUtils.getConnection();
         PreparedStatement pstmt = conn.prepareStatement(sql)) {

        pstmt.setString(1, "香飘飘");
        pstmt.setString(2, "香飘飘");
        pstmt.setInt(3, 1);
        pstmt.setString(4, "绕地球一圈");
        pstmt.setInt(5, 1);

        int count = pstmt.executeUpdate();
        System.out.println(count > 0);
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

对比一下:原来 40 多行,现在 15 行;而且没有任何一行是"连接管理"的样板代码,全部注意力都在 SQL 和参数上。这就是封装的价值:把"不变的"抽走,只留下"会变的"。

【对比】

三处改进前后对照,一眼看出工具类的收益:

关注点改进前(每个方法手写)改进后(JDBCUtils)
获取连接4 行(加载配置 + 建池 + 取连接)1 行 JDBCUtils.getConnection()
连接池创建次数每个方法 1 次(严重错误)全应用 1 次(静态代码块)
释放资源2~3 行 close(),异常时不执行1 行 JDBCUtils.close(...),在 finally 里
配置文件路径硬编码文件系统路径,打包后失效getResourceAsStream,jar 内可读
方法行数约 45 行约 15 行

10.7 分层与 DAO 模式

10.7.1 问题:SQL 该住在哪儿

10.6 节写完,四个测试方法都能跑通。但请想象第 11 章的场面:品牌管理要做"查询所有""添加""修改""回显""删除"五个功能,每个功能对应一个 Servlet。如果按 10.6 的写法,每个 Servlet 里都会出现这么一段:

String sql = "select * from tb_brand";
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
    Brand brand = new Brand();
    brand.setId(rs.getInt("id"));
    // ……再抄 12 行赋值
}

五个功能 = 五份几乎一样的复制粘贴。问题随之而来:

这就是"分层"要解决的问题:把"数据怎么存"和"页面怎么展示"彻底分开。

10.7.2 DAO 模式是什么

DAO(Data Access Object,数据访问对象)模式:把对某一张表的全部数据库操作(增删改查)集中到一个类里,外界只调用这个类的方法,不关心 SQL 长什么样、连接怎么关。

它由两部分组成,接口 + 实现类:

com.itheima
├── pojo
│   └── Brand.java                 ← 实体类:一行数据
├── dao
│   ├── BrandDao.java              ← 接口:定义"能做什么"
│   └── impl
│       └── BrandDaoImpl.java      ← 实现类:定义"怎么做"(SQL 全在这里)
└── util
    └── JDBCUtils.java             ← 工具类:连接与释放

为什么要有接口?因为第 11 章里 Servlet 只依赖 BrandDao 这个接口。将来如果换成 MyBatis 实现、或者换一个假的实现做单元测试,Servlet 一行都不用改。依赖抽象而不是依赖实现,这是 10.1 节 JDBC 本身就在示范的道理:你面向 Connection 接口编程,MySQL 驱动在运行时注入。

10.7.3 BrandDao 接口

接口里只写"能做什么",方法签名要能覆盖 10.6 节的全部需求:

import com.itheima.pojo.Brand;

import java.util.List;

/**
 * 品牌 Dao 接口
 * 针对 tb_brand 表的 CRUD 操作
 */
public interface BrandDao {

    /**
     * 查询所有品牌
     * @return 品牌列表,查不到时返回空集合而不是 null
     */
    List<Brand> selectAll();

    /**
     * 根据 id 查询品牌(修改前的回显要用)
     * @param id 主键
     * @return 品牌对象,查不到返回 null
     */
    Brand selectById(int id);

    /**
     * 添加品牌
     * @param brand 品牌对象(id 由数据库自增生成,不用传)
     * @return 是否添加成功
     */
    boolean add(Brand brand);

    /**
     * 修改品牌
     * @param brand 品牌对象(必须带 id)
     * @return 是否修改成功
     */
    boolean update(Brand brand);

    /**
     * 根据 id 删除品牌
     * @param id 主键
     * @return 是否删除成功
     */
    boolean deleteById(int id);
}

方法签名有三个约定,值得记住:

10.7.4 BrandDaoImpl 实现类

实现类的每个方法都是 10.6 节测试方法的"去重版":去掉 @Test,把写死的参数换成方法入参,把 System.out.println 换成返回值,把连接释放交给 finally。

import com.itheima.pojo.Brand;
import com.itheima.util.JDBCUtils;

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

/**
 * 品牌 Dao 实现类
 * 所有 SQL 集中在这里,Servlet 不需要知道表结构
 */
public class BrandDaoImpl implements BrandDao {

    @Override
    public List<Brand> selectAll() {
        List<Brand> brands = new ArrayList<>();
        String sql = "select * from tb_brand";
        try (Connection conn = JDBCUtils.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql);
             ResultSet rs = pstmt.executeQuery()) {

            while (rs.next()) {
                brands.add(rsToBrand(rs));
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return brands;   // 查不到时是空集合,不是 null
    }

    @Override
    public Brand selectById(int id) {
        Brand brand = null;
        String sql = "select * from tb_brand where id = ?";
        try (Connection conn = JDBCUtils.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setInt(1, id);
            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    brand = rsToBrand(rs);
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return brand;
    }

    @Override
    public boolean add(Brand brand) {
        String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?)";
        try (Connection conn = JDBCUtils.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setString(1, brand.getBrandName());
            pstmt.setString(2, brand.getCompanyName());
            pstmt.setInt(3, brand.getOrdered());
            pstmt.setString(4, brand.getDescription());
            pstmt.setInt(5, brand.getStatus());

            return pstmt.executeUpdate() > 0;
        } catch (SQLException e) {
            e.printStackTrace();
            return false;
        }
    }

    @Override
    public boolean update(Brand brand) {
        String sql = "update tb_brand set brand_name = ?, company_name = ?, ordered = ?, description = ?, status = ? where id = ?";
        try (Connection conn = JDBCUtils.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setString(1, brand.getBrandName());
            pstmt.setString(2, brand.getCompanyName());
            pstmt.setInt(3, brand.getOrdered());
            pstmt.setString(4, brand.getDescription());
            pstmt.setInt(5, brand.getStatus());
            pstmt.setInt(6, brand.getId());

            return pstmt.executeUpdate() > 0;
        } catch (SQLException e) {
            e.printStackTrace();
            return false;
        }
    }

    @Override
    public boolean deleteById(int id) {
        String sql = "delete from tb_brand where id = ?";
        try (Connection conn = JDBCUtils.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {

            pstmt.setInt(1, id);
            return pstmt.executeUpdate() > 0;
        } catch (SQLException e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 把结果集当前行封装成 Brand 对象
     * 抽成私有方法,避免五处重复
     */
    private Brand rsToBrand(ResultSet rs) throws SQLException {
        Brand brand = new Brand();
        brand.setId(rs.getInt("id"));
        brand.setBrandName(rs.getString("brand_name"));
        brand.setCompanyName(rs.getString("company_name"));
        brand.setOrdered(rs.getObject("ordered") == null ? null : rs.getInt("ordered"));
        brand.setDescription(rs.getString("description"));
        brand.setStatus(rs.getObject("status") == null ? null : rs.getInt("status"));
        return brand;
    }
}

这个实现类里有五个设计细节,都直接影响第 11 章的顺利程度:

【补充】

这里刻意没有把 SQLException 继续往上抛,是"教学版"的选择,好处是调用方简单,坏处是丢失了错误细节。工程上更规范的做法是:DAO 层把 SQLException 包成自定义运行时异常再抛,比如 throw new RuntimeException("查询品牌失败", e),由最外层的 Filter 或全局异常处理器统一处理。"不吞异常、不裸抛受检异常" 是一条值得记住的工程准则。本章为了和第 11 章的 JSP 直连风格衔接,采用"捕获 + 兜底返回值"。

10.7.5 DAO 在三层架构里的位置

加上 DAO 之后,整条链路变成四层,每一层只跟相邻的层说话:

浏览器
  │  HTTP 请求(brandName=华为&ordered=100)
  ▼
Servlet(第 5~7 章)        ← 只管取参数、调方法、转发页面
  │  Brand brand = new Brand(); …… brandDao.add(brand);
  ▼
BrandDao(本章 10.7)       ← 只管 SQL 与结果集映射,不管请求不管页面
  │  insert into tb_brand(...) values(?,?,?,?,?)
  ▼
MySQL(数据)

用代码看 Servlet 那一侧的变化。没有 DAO 时,Servlet 要自己写 SQL、自己关连接,六十行里四十行是 JDBC;有了 DAO 之后,Servlet 的 doPost 长这样:

protected void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {

    // 1. 接收参数
    String brandName = request.getParameter("brandName");
    String companyName = request.getParameter("companyName");
    String orderedStr = request.getParameter("ordered");
    String description = request.getParameter("description");
    String statusStr = request.getParameter("status");

    // 2. 封装对象
    Brand brand = new Brand();
    brand.setBrandName(brandName);
    brand.setCompanyName(companyName);
    brand.setOrdered(Integer.parseInt(orderedStr));
    brand.setDescription(description);
    brand.setStatus(Integer.parseInt(statusStr));

    // 3. 调 DAO(一行)
    BrandDao brandDao = new BrandDaoImpl();
    boolean success = brandDao.add(brand);

    // 4. 根据结果跳转
    if (success) {
        response.sendRedirect(request.getContextPath() + "/brand?method=selectAll");
    } else {
        request.setAttribute("error", "添加失败");
        request.getRequestDispatcher("/addBrand.jsp").forward(request, response);
    }
}

注意 request.getParameter 拿到的是 String,而 ordered、status 是 int,所以需要 Integer.parseInt 转换。这里有一个必然会遇到的错误:如果用户没填 ordered,getParameter 返回 null,Integer.parseInt(null) 直接抛 NumberFormatException。第 11 章会在 Servlet 里做空值校验,这也是"参数校验属于 Servlet 层、类型转换属于 Servlet 层、SQL 属于 DAO 层"的分工体现。

10.7.6 这套 DAO 在第 11 章怎么被复用

第 11 章"从页面到数据库贯通"会把本章所有零件拼起来,复用关系如下:

第 11 章要做的功能直接复用本章的什么是否需要改
品牌列表页BrandDaoImpl.selectAll() + Brand 实体不用改,直接调
添加品牌BrandDaoImpl.add(brand)不用改
修改品牌(回显)BrandDaoImpl.selectById(id)不用改
修改品牌(提交)BrandDaoImpl.update(brand)不用改
删除品牌BrandDaoImpl.deleteById(id)不用改
所有 Servlet 取连接JDBCUtils.getConnection()不用改
页面上的 ${brand.brandName}Brand 的 getter(EL 靠 getter 取值)不用改
过滤器统一编码与本章无关,第 9 章内容无

第 11 章真正要新写的只有三样:Servlet(接收参数、调 DAO、转发)、JSP(用 EL/JSTL 渲染)、过滤器(解决中文乱码)。数据访问层一行都不用动。这就是分层的回报。

【考点】

"为什么要分层 / DAO 模式的好处"标准答法四条:职责单一(Servlet 管请求,DAO 管数据)、代码复用(同一张表的操作只写一次)、易于维护(改表结构只改 DAO 一处)、便于测试与替换(面向接口,可换实现、可 mock)。答题时每条配一句具体场景,比干背词条得分高。

10.7.7 从测试方法到工程代码:迁移清单

如果你想把 10.6 的四个 @Test 方法"升级"成 10.7 的 DAO,按这个清单逐条改,一条都不会漏:

  1. 删掉 @Test 注解,方法改成 public 并加 @Override;
  2. 参数从"方法内写死"改成"方法入参"(int id、Brand brand);
  3. 返回值从 System.out.println(count > 0) 改成 return count > 0;
  4. 连接代码从四行换成 JDBCUtils.getConnection();
  5. 结尾的 close() 换成 try-with-resources(或 finally 里的 JDBCUtils.close);
  6. 结果集遍历的赋值代码抽成 rsToBrand(rs);
  7. 异常从 throws Exception 改成 catch (SQLException e) + 兜底返回;
  8. 把所有方法搬进 BrandDaoImpl,再补一个 BrandDao 接口。

本章小结

本章从"数据在 MySQL 里"这个事实出发,把 Java 与数据库之间的桥搭了起来。用一张表收口:

知识点结论关键 API / 写法
JDBC 是什么Sun 定义的操作关系型数据库的接口标准,驱动是厂商给的实现java.sql.*
七步流程注册、连接、定 SQL、取对象、执行、处理、关闭DriverManager → Connection → Statement
注册驱动MySQL 5+ 可省略(SPI 自动加载);Class.forName 只是触发静态代码块com.mysql.cj.jdbc.Driver
连接 URLjdbc:mysql://ip:port/库名?参数,本机可写 jdbc:mysql:///库名useSSL=false、serverTimezone
事务setAutoCommit(false) 开启,commit() 提交,rollback() 回滚转账案例、ACID
Statement执行静态 SQL,executeUpdate 管 DDL/DML,executeQuery 管 DQL有注入风险
ResultSet光标在首行之前,next() 下移并判有效,getXxx 取值(列号从 1 开始)rs.getInt("id")
PreparedStatement? 占位 + setXxx 赋值 + 执行时不传 SQL防注入 + 预编译
SQL 注入用户输入被当成语法;' or '1'='1 让条件恒真参数分离才能根治
连接池DataSource 标准接口,复用连接,避免反复握手认证Druid、initialSize/maxActive/maxWait
工具类连接池 static 一次创建,getResourceAsStream 读配置JDBCUtils
DAO一张表一个接口 + 一个实现,SQL 与页面彻底分离BrandDao / BrandDaoImpl

再用六条要点把"最容易忘"的钉住:

  1. 释放顺序是"先开后关":rs → pstmt → conn,且必须在 finally 或 try-with-resources 里。
  2. 列编号从 1 开始,不是 0。数组习惯在这里会害你。
  3. getInt 遇到 NULL 返回 0,要区分 NULL 就用 getObject 或 wasNull();实体类用包装类型。
  4. PreparedStatement 防注入靠"预编译 + 参数分离",转义只是实现细节;? 不能占位表名和列名。
  5. 连接池的 close() 是归还不是断开,但依然必须关,否则池子会被借空(active 10, maxActive 10)。
  6. druid.properties 放 src 根目录,是为了用 classpath 相对路径读,打包后仍可读。

最后回答"这章的知识在下章哪里被用到":第 11 章的品牌管理综合案例,每一个功能都是本章零件的重新组装:JDBCUtils 提供连接,Brand 承载数据,BrandDao 执行 SQL,Servlet 只负责把 request 里的参数封装进 Brand、把 Brand 交给 DAO、把 List<Brand> 放进 request 域,最后 JSP 用 <c:forEach> 渲染成表格。本章写的是"基础层",第 11 章盖的是"业务层"。

自测

基础题

1. 写出 JDBC 操作数据库的七个步骤,并说明每一步用到的核心类或方法。

2. 下面这行代码在 MySQL 5 之后为什么可以注释掉?注释掉之后驱动是怎么被注册的?

Class.forName("com.mysql.jdbc.Driver");

3. 阅读代码,写出运行结果,并说明数据库里 account 表发生了什么变化:

String url = "jdbc:mysql:///db1?useSSL=false";
Connection conn = DriverManager.getConnection(url, "root", "1234");
String sql = "update account set money = 3000 where id = 1";
Statement stmt = conn.createStatement();
int count = stmt.executeUpdate(sql);
System.out.println(count);
stmt.close();
conn.close();

4. 判断对错并说明理由:ResultSet 的 getXxx(int) 方法中,列编号从 0 开始,和数组一样。

5. 写出 Statement 与 PreparedStatement 在"获取方式、执行方法、是否预编译、能否防注入"四个方面的区别。

进阶题

6. 下面的登录代码存在 SQL 注入漏洞,请指出漏洞点、写出攻击者会输入的密码,并改成安全的写法:

String sql = "select * from tb_user where username = '" + name + "' and password = '" + pwd + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
    System.out.println("登录成功");
}

7. 找错:下面这段事务代码有三处问题,请指出并改正。

Connection conn = DriverManager.getConnection(url, "root", "1234");
conn.setAutoCommit(false);
Statement stmt = conn.createStatement();
stmt.executeUpdate("update account set money = money - 1000 where id = 1");
int i = 1 / 0;
stmt.executeUpdate("update account set money = money + 1000 where id = 2");
conn.commit();
conn.rollback();
stmt.close();
conn.close();

8. 简答:数据库连接池为什么能提升系统响应速度?请从"建立一条 MySQL 连接要花哪些网络往返"的角度说明。

9. 阅读代码,指出 brands 集合最终有几个元素、每个元素是什么,并说明原因:

List<Brand> brands = new ArrayList<>();
Brand brand = new Brand();
while (rs.next()) {
    brand.setId(rs.getInt("id"));
    brand.setBrandName(rs.getString("brand_name"));
    brands.add(brand);
}

10. 设计题:tb_brand 表新增一个字段 brand_logo varchar(200),请说明用 DAO 分层的情况下,需要改哪几个文件、每个文件改什么;如果不分层(SQL 写在 Servlet 里),又要改几处?

参考答案

1. ① 导入驱动 jar;② 注册驱动(Class.forName,MySQL 5+ 可省);③ 获取连接(DriverManager.getConnection(url, user, password));④ 定义 SQL(String sql);⑤ 获取执行对象(conn.createStatement() 或 conn.prepareStatement(sql));⑥ 执行 SQL(executeUpdate / executeQuery);⑦ 处理结果并释放资源(rs/stmt/conn 依次 close())。

2. 因为 MySQL 5 之后的驱动 jar 里有 META-INF/services/java.sql.Driver 文件,DriverManager 通过 SPI(ServiceLoader)自动发现并加载驱动类,静态代码块自动完成注册。Class.forName 的作用只是"手动加载类以触发静态代码块",SPI 已经代劳了。注意 MySQL 8 的类名是 com.mysql.cj.jdbc.Driver。

3. 打印 1(受影响 1 行)。数据库里 id=1 的记录 money 被改成 3000,且因为 MySQL 默认自动提交,立即生效、无法回滚。

4. 错。JDBC 的列编号从 1 开始,getInt(0) 会抛 SQLException: Column Index out of range。数组和集合才是从 0 开始。

5. 获取方式:createStatement() vs prepareStatement(sql);执行方法:executeUpdate(sql) 要传 SQL vs executeUpdate() 不传;预编译:否 vs 是(模板只编译一次);防注入:不能 vs 能(参数只当值,不参与语法解析)。

6. 漏洞点是用字符串拼接把用户输入混进 SQL 语法。攻击者密码输入 ' or '1' = '1,拼接后变成 ... and password = '' or '1' = '1',or '1'='1' 恒真,rs.next() 返回 true,绕过登录。修法:改成 ? 占位 + setString:

String sql = "select * from tb_user where username = ? and password = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, name);
pstmt.setString(2, pwd);
ResultSet rs = pstmt.executeQuery();

7. ① setAutoCommit(false) 和两条 update 没放在 try 里,1/0 抛异常后直接跳出方法,没有回滚,第一条 update 会随连接关闭而悬空(连接池场景下可能被自动提交);② commit() 之后又调 rollback(),逻辑矛盾,会抛异常;③ 没有 catch/finally,异常时 stmt、conn 都不关,连接泄漏。改法:try { setAutoCommit(false); ... commit(); } catch (Exception e) { rollback(); } finally { close(); }。

8. 一条连接从零建立要经历:TCP 三次握手(约 1.5 个 RTT)、MySQL 握手与密码认证(至少 2 个 RTT,MySQL 8 的 caching_sha2_password 更慢)、会话初始化(字符集、时区等,各 1 个 RTT),合计 3~10 个 RTT、5~50 ms;而执行一条简单 SQL 可能只要 1 ms。连接池在启动时一次性建好 N 条连接,之后请求只是"借/还"(内存操作),把建连开销从"每次请求"摊销成"每个连接一次",所以响应更快,也避免了连接数暴涨打满 max_connections。

9. 集合里有 N 个元素(等于结果集行数),但全部指向同一个 Brand 对象,最终每个元素的内容都是最后一行的数据。因为 new Brand() 写在 while 外面,循环里反复 set 的是同一个对象,add 进去的是同一个引用。修法:把 Brand brand = new Brand(); 移到 while 循环内部。

10. 分层时改 3 个文件:tb_brand 建表 SQL(加字段)、Brand.java(加 brandLogo 属性 + getter/setter)、BrandDaoImpl.java(selectAll/selectById 的取值加一行、add/update 的 SQL 与 setString 各加一处;由于封装已抽成 rsToBrand,实际只改这一处映射方法)。不分层时,每个涉及品牌的 Servlet 都要改(查询列表、添加、修改回显、修改提交至少 4 处),漏一处就是 bug。这正是 DAO 分层的价值。