3. 1 概 述 业界存在许多数据库,且每种数据库所使用的协议和底层机制也各不相同。尽管从一 开始Java开发人员就意识到Java在数据库应用方面潜力巨大,想通过扩展Java的标准类 库就可以使用“纯”Java语言与任何数据库进行通信,但这显然是一个无法完成的任务。所 以,数据库供应商和开发商都认为,如果Java能够提供一套“纯”JavaAPI,同时提供一个驱 动管理器来允许第三方驱动程序可以连接到特定的数据库,数据库供应商就可以提供自己的 驱动程序来插入注册到驱动管理器中。针对此,a提供了JDBC(y), JavJavadatabaseconnectivit 用以与数据库连接。JDBC 是一个规范,提供了一整套接口,允许以一种可移植的方式访问底 层数据库。 JDBC 通过标准的SQL 语句,甚至是专门的SQL 扩展访问数据库。数据库供应商和数 据库工具提供商可以提供底层的驱动程序。本章将介绍具有广泛应用的关系型数据库,以 及通过JDBC 访问、操作数据库的方法。 3.数据库基础 2 日常生产、生活产生了海量数据,纸质存储已经远远不能满足要求。随着信息时代到 来,计算机提供了另一种存储和处理数据的方法。那么计算机是如何存储与管理这些数据 的呢? 人们在计算机上建立了一套类似于现实生活中的仓库的数据仓库,把数据有组织地 存储在数据仓库中,并提供相应的管理软件来管理这些数据,这个仓库就称为数据库。其中 最重要的一种数据库被称为关系数据库。 3.2.1 关系数据库 关系数据库(relationaldatabase,RDB)是基于集合代数发展而来的数据种类,采用关系 扫一扫模型来组织数据。关系模型可以简单理解为二维表格模型,而关系型数据库就是由二维表 及其之间的关系组成的一个数据组织。二维表格模型又称为关系。二维表格由列头和多行 数据组成,列头定义了关系由哪些属性组成(这些属性称为字段),每行数据包含一个唯一的 数据实体(称为记录),每个数据实体的每个字段(属性)有确定的值。 用来管理关系数据的软件被称为关系数据库管理系统( relationaldatabasemanagement system,RDBMS),常见的RDBMS有Oracle、IBMDB2 、SQLServer、MySQL 、Microsoft Aces 。MySQL是一种开放源码,它是免费的关系数据库管理系统,具有体积小、速度 快、总体拥有成本低等特点,广泛地被使用(注意:MySQL的社区版免费,但商业版是收 费的)。 关系数据库有以下常用概念。 (1)表。 表(是以行和列表示的数据的集合,每个表在数据库中都有一个名称。一个数据 table) 库可以包含任意多个数据表。表又被称为关系,这就是关系数据库名称的来源。表中的行 被称为记录,或者元组;表中的列被称为字段,或者属性。 (2)记录或元组。 rcr也称为元组,表中的每一行称为记录(eod), 它是表中存在的每个单独数据项。 (3)列。 列(column)是表中的垂直实体,其中包含与表中特定字段关联的所有信息。 (4)字段。 每个表都被分解为更小的实体,称为字段。字段是表中的列名称,用于维护有关表中每 条记录的特定信息。 (5)域。 表中属性的一组允许值。属性不能接收域外的值,例如一个整型字段不能接收浮点数 类型或者字符类型的值。 (6)空值。 表中的空值(nul)是字段中显示为空的值,表示在创建记录时留空的字段,非常重要的 一点是空值不同于零值或包含空格的字。 (7)索引。 使用索引可快速访问数据库表中的特定信息。索引是对数据库表中一列或多列的值进 行排序的一种结构。类似书籍的目录。 (8)键。 键在RDBMS中起着重要作用,它可以在表中标识唯一行,还可以建立表之间的关系。 以下列举常用的键。 ① 主键(primarykey):表中的一列或一组列,用于唯一标识该表中的元组(行)。其中 主键包含多列,又称为超键,主键包含一列,又称为候选键。 ② 外键(oegnky) frie:外键是表的列指向另一个表的主键。它们充当表之间的交叉 76 引用。 比如,需要存储和处理学生以及课程信息。在关系数据库中,为学生和课程各建立一张 表,表中的每一条记录代表一个学生或课程的具体信息,如表3-1和表3-2所示。 表3学生(表 - 1 student) 学号(id) 姓名(name) 年龄(age) 1001 张三18 1002 李四17 表3- 2 课程(course)表 课程编号(no) 课程名称(name) 学时(hours) 001 数学32 002 哲学48 课程表中有两条记录,分别是数学课程信息和哲学课程信息,学生表中也有两条记录, 分别是张三和李四的基本信息。现在要表示学生的选课信息,在关系数据库中如何表示呢? 一般的做法是新建一张表,这张表用于描述学生表和课程表的关系,这张表称为关系表,关 系表中的每条记录都要关联着关系的双方。比如在这个例子中,需要建立一张学生成绩表, 用于表示学生的选课信息,表的结构如表3-3所示。 表3学生成绩(_g表 - 3 studentrade) 学号(student_id) 课程编号(course_no) 成绩(grade) 1001 001 80 1001 002 70 1002 002 90 表3-3中的每行记录有三个字段,分别用于表示学生的学号、课程编号以及学生的该门 课成绩。其中学号即为外键,与学生表产生关联。通过这种关联,学生与课程之间就建立了 关系。例如此例中,学号为1001 的学生选择了编号为001 和002 的课程,学号为1002 的学 生只选择了编号为002 的课程。 关系数据因简单灵活、安全健壮、高效率等特性得到了广泛应用,当今的主流数据库厂 商生产的数据库仍然是以关系数据库为主,比如MySQL 、Oracle、DB2 等。 3.2.2 结构化查询语言 为了满足复杂的数据库操作,人们为关系数据库专门设计了一种数据处理语言,即结构 化查询语言SQL(structuredquerylanguage)。最早提出结构化查询语言的是IBM 公司, 随后该语言得到广泛应用,并发布了相应的标准。现如今,虽然各种关系数据库的查询语言 有一些差异,但大多数都遵从了ANSISQL 标准。 SQL 语言分成以下4部分。 77 78 (1)数据定义语言(DDL):create、drop、alter等语句。 (2)数据操作语言(DML):insert、update、delete等语句。 (3)数据查询语言(DQL):select等语句。 (4)数据控制语言(DCL):grant、revoke、commit、rollback等语句。 表3-4所示为SQL语言使用方法。 表3-4 SQL语言使用方法 操 作SQL 语句 创建表 create table student(id int,name varchar(50),age int); create table course(no VARCHAR(50),name VARCHAR(50),hours int) create table student_grade (student_id int,course_no VARCHAR(50),grade double) 删除记录delete from student where id=1001 插入记录insert into student(id,name,age) values(100,'张三',18); 修改记录update student set name='王五',age=24 where id=1001 查询数据 select id,name,age from student; select id,name,age from student where id=1001; Select s.NAME,c.NAME,sg.grade 以上列出了一些简单的SQL操作,SQL是一种简单但功能强大的语言,有兴趣的同学 请阅读SQL方面的专业书籍。 注意:在Java程序设计中,数据库SQL执行相对于Java来说非常耗费时间,因此不要 滥用SQL。可以通过关系数据的索引来提高SQL的执行效率。 3.2.3 MySQL 数据库 MySQL是当下最流行的关系型数据库管理系统之一,在Web 应用方面是最好的 RDBMS应用软件之一。它采用双授权政策,分为社区版和商业版,由于其体积小、速度快、 总体拥有成本低,尤其是开放源码这一特点,一般中小型网站的开发都选择MySQL作为网 站数据库。随着MySQL的不断成熟,它也逐渐用于更多大规模网站和应用,比如维基百 科、Google和Facebook等网站。它的典型特性如下。 (1)使用SQL语言作为访问数据库的语言,优化的SQL 查询算法有效提高了查询 速度。 (2)为多种语言提供API。这些编程语言包括C、C++、C#、VB.NET、Java、Perl、 PHP、Python、Ruby和Tcl等。 (3)支持多线程,充分利用CPU 资源支持多用户。可以处理拥有上千万条记录的大型 数据库。 (4)既能够作为一个单独的应用程序在客户—服务器网络环境中运行,也能够作为一 个程序库而嵌入到其他的软件中。同时提供了用于管理、检查、优化数据库操作的管理 工具。 (5)提供TCP/IP、ODBC和JDBC等多种数据库连接途径。 3.JDBC 3 本书使用的数据库是MySQL 数据库,因此需要先安装MySQL 数据库,下载MySQL 数据库的驱动包,并把驱动包导入到项目中。 程序在运行的过程中会产生和处理大量的数据,因此数据库成为了多数程序必不可少 的一部分。那么Java是如何对数据库进行操作的呢? 这就是本节将要讨论的内容——— JDBC 。 3.3.1 数据库驱动 JDBC 是Java设计者为数据库编程提供的一组接口。 如图3-1所示,对于开发者来说,这组接口是访问数据库的 扫一扫 工具;对于数据库提供商来说,是驱动程序的编写规范,从 而保证Java可以访问他们的数据库产品。因此使用 JDBC 后,开发者可以更加专注于业务开发,而不必为特定 的数据库编写程序。 JDBC 面向的是两个方向:开发者和数据库提供商。 对于开发者来说,只要使用数据库提供商提供的驱动程 序,就可以方便地访问数据库了;对于数据库提供商来说, 他们的职责就是根据JDBC 规范编写正确的驱动程序。那 么JDBC 如何让数据库操作运转起来呢? 首先来看看 JDBC 的架构,如图3-2所示。 图3-1JDBC驱动 图3- 2 JDBC 架构 从图3-2可以看出,使用JDBC 的用户很多,访问的数据库也各不相同,但JDBC 使用这 种可插拔的方式,让用户以同一种方式访问数据库。图3-2中只列出了3种数据库的驱动, 其他的数据库厂商都可以根据JDBC 规范来编写自己的驱动程序,从而让Java开发者可以 访问他们的数据库产品。 上面提到了可插拔的方式,那么Java是如何实现驱动程序的可插拔呢? 让我们先来看 一下JDBC 的生命周期,图3-3显示了JDBC 的生命周期。 第1步:注册驱动(作用:告诉Java程序即将要连接的是哪个品牌的数据库)。 79 图3- 3 JDBC 的生命周期 第2步:获取连接(表示JVM 的进程和数据库进程之间的通道打开了,这属于进程之 间的通信,重量级的,使用完之后一定要关闭通道)。 第3步:获取数据库操作对象(专门执行SQL 语句的对象)。 第4步:执行SQL 语句(DQLDML …)。 第5步:处理查询结果集(只有当第4步执行的是select语句的时候,才有第5步处理 查询结果集。) 第6步:释放资源(使用完资源之后一定要关闭资源。Java和数据库属于进程之间的 通信,开启之后一定要关闭)。 在JDBC 的生命周期中,首先要加载需要用到的数据驱动,然后将加载的驱动注册到 JDBC 中,然后用户就可通过JDBC 获取数据库连接会话了。获取会话后,用户就可以使用 该会话进行数据库操作,操作完成后,即可关闭释放连接,一个JDBC 的使用周期结束。 在JDBC 的生命周期中,需要用到几个重要的类或接口,比如Connection、Driver、 DriverManager以及一些具体的驱动类。下面来看看这些类或接口之间的关系,图3-4显示 了JDBC 核心类的架构。 图3- 4 JDBC 核心类的架构 从图3-4可以看出,JDBC 使用了接口Driver和接口Connection,JDBC 设计者并未对 其进行实现,具体的实现留给数据库提供商,正是这种面向接口的编程使得JDBC 的扩展更 加灵活健壮,可以对JDBC 进行插拔操作。 3.3.2 JDBC 核心组件 常见的JDBC 组件API 提供以下接口和类。 (1)DriverManager:此类管理数据库驱动程序。使用通信子协议将来自Java应用程 序的连接请求与适当的数据库驱动程序进行匹配。在JDBC 下识别某个子协议的第一个驱 动程序将用于建立数据库连接。 (2)Driver:此接口处理与数据库服务器的通信。很少会直接与Driver对象进行交互。 80 81 但会使用DriverManager对象来管理这种类型的对象。它还提取与使用Driver对象相关 的信息。 (3)Connection:此接口有连接数据库的所有方法。连接(Connection)对象表示通信 上下文,即与数据库的所有通信仅通过连接对象。 (4)Statement:使用从此接口创建的对象将SQL语句提交到数据库。除了执行存储 过程之外,一些派生接口还接收参数。 (5)ResultSet:在使用Statement对象执行SQL查询后,ResultSet对象保存从数据库 检索的数据。 (6)SQLException:此类处理数据库应用程序中发生的任何错误。 3.3.3 建立连接 Java.sql.Connection接口的实现类负责维护Java开发者与数据库之间的会话。特定的 数据库需要实现该接口,以便开发者能正确地操作数据库。开发者拥有该类的实例后,就可 以访问数据库,并可以执行特定操作。下面列出了该接口中一些常用的重要方法。 (1)StatementcreateStatement()throwsSQLException。 该方法返回一个用于执行SQL 的Statement对象,通过该方法获得Statement实例 后,即可通过该实例执行SQL语句,并获取返回结果。 (2)PreparedStatementprepareStatement(Stringsql)throwsSQLException。 该方法返回一个SQL命令执行器PreparedStatement,PreparedStatement与Statement的 区别是,该类在初始化时需要传入一个SQL,SQL需要的条件值可通过参数的方式设置,该 类会预编译SQL 命令,因此在执行效率上高于Statement,但是它只能执行特定的SQL 语句。 (3)voidcommit()throwsSQLException。 提交数据库事务,默认情况下connection会自动提交事务,即执行每条SQL语句后都会 自动提交,如果取消了自动提交,则必须使用此方法进行提交,否则对数据库的操作将无效。 (4)voidrollback()throwsSQLException。 取消当前事务的所有数据库操作,将已经修改的数据还原成初始状态。 想要进行数据库连接,首先需要建立与数据库之间的连接会话,所有操作都是基于这个 会话基础上进行的。建立连接的代码如下。 01 public class MySqlDAO { 02 public static Connection getConnection() throws Exception{ 03 String driverName = "com.mysql.jdbc.Driver"; 04 String url = "jdbc:mysql://localhost:3306/simple"; 05 String userName = "root"; 06 String password = "111111"; 07 Class.forName(driverName); 08 Connection con = DriverManager.getConnection(url, userName, password); 09 return con; 10 } 11 } 82 这段代码的主要功能是建立与数据库之间的连接会话。下面来逐一分析这段代码,首 先是连接参数定义,代码如下。 01 String driverName = "com.mysql.jdbc.Driver"; 02 String url = "jdbc:mysql://localhost:3306/simple"; 03 String userName = "root"; 04 String password = "111111"; 这段代码块首先定义了几个变量,分别是驱动类名称、数据库的连接字符串、数据库用 户名以及数据库密码,请注意以下几点。 (1)驱动名称必须为全名,而且请确保路径正确。 (2)连接字符串分成以下3部分。 ① 连接协议jdbc:mysql://。 ② 数据库地址localhost:3306/,包含主机地址和数据库端口号。 ③ 数据库名称simple。 (3)请确保用户名和密码正确,否则会拒绝连接。 接着是加载数据库的驱动,代码如下。 01 Class.forName(driverName); 加载数据库驱动的时候,驱动程序会自动调用DriverManager中的registerDriver (Driverdriver)方法,将自身注册到管理器中。 接下来是创建并获取连接,代码如下。 01 Connection con = DriverManager.getConnection(url,userName, 02 password); 此处调用了驱动管理器的getConnection的三参数方法,驱动管理器对该方法有多个 重载。该方法会根据传入的参数选择合适的连接会话返回给用户。至此已经获取了数据库 的连接会话,可以在此会话基础上进行数据库操作了。注:执行这段代码前,请确保将数据 库驱动jar包加入到了项目中。Connection很昂贵,需要及时关闭。 3.3.4 执行数据查询语言 下面介绍如何从数据库中读取数据。 请看以下代码。 01 public class SelectTester { 02 public static void main(String[] args) throws Exception{ 03 Connection con = MySqlDAO.getConnection(); 04 Statement stmt = con.createStatement(); 05 String sql = "select id, name, age from student where no >= 1001"; 06 ResultSet rs = stmt.executeQuery(sql); 07 while(rs.next()){ 扫一扫 83 08 System.out.print("学号:" + rs.getInt("no")); 09 System.out.println(" 姓名:" + rs.getString(2)); 10 } 11 stmt.close(); 12 con.close(); 13 } 14 } 这段代码的功能是读取表student中no大于或等于1001的数据,并打印出来。首先 仍然是先获取数据库命令执行对象Statement的实例,接着定义一条查询SQL语句,执行 这条语句会返回一个结果集,结果集中包含了student表中no大于等于1001的学生信息。 JDBC使用游标来访问结果集中的数据。游标可以理解为指向结果集中一条数据的指 针,指针可以向后移动指向下一条数据(游标也可以向前移动,但使用比较少),调用结果集 ResultSet的next()方法就可以向后移动游标。 游标的工作原理如图3-5 所示。游标最开始指向结果集的第1 行数据之前,调用 ResultSet.next()方法就可以移动游标到第1行,并且next()方法返回true(如果移动后指 向数据,就返回true,否则返回false)。同理,可以循环调用next()方法,让游标指向结果集 中的每一条数据。如果游标指向最后一条数据,再调用next(),游标将指向最后一条数据 之后,并且next()方法返回false,表示已经没有可以访问的数据了,上面代码第7行的循环 将退出,结束数据集的访问。 图3-5 游标的工作原理 如果游标当前指向数据,可以使用ResultSet的方法访问当前行的数据。ResultSet提 供了一系列形如getXXX()的方法访问当前行各个字段的值,例如用getInt()方法访问int 类型的字段。所有getXXX()方法都有两个重载方法,一个传入一个字符串,表示字段的名 称,一个是传入整数,表示字段的索引。例如第8行的rs.getInt("no")访问名为no的字段, 并返回整数;第9行的rs.getString(2)访问第2个字段的值,即name字段的值,并返回字符 串(请注意,字段的索引是根据select语句中列出的字段顺序,并且索引是从1开始计数的, 因此前面的getString(2)表示访问第2个字段name,而不是第3个字段,这与Java通常从0 开始计数不一样)。强烈建议通过字段的名称获取字段的值,不要通过索引。需要注意,字 段的类型必须正确,不能通过getInt的方法获取字符串字段的值。 JDBC驱动程序在将Java数据发送到数据库之前,会将其转换为相应的JDBC类型。 对于大多数数据类型都采用了默认的映射关系。例如,一个Javaint数据类型转换为SQL INTEGER 。通过默认的映射关系来提供驱动程序之间的一致性,如表3-5所示。 表3- 5 JDBC字段类型映射表 SQL类型JDBC/Java类型setXXX getXXX VARCHAR java.lang.String setString getString CHAR java.lang.String setString getString LONGVARCHAR java.lang.String setString getString BIT boolean setBoolean getBoolean NUMERIC java.math.BigDecimal setBigDecimal getBigDecimal TINYINT byte setByte getByte SMALLINT short setShort getShort INTEGER int setInt getInt BIGINT long setLong getLong REAL float setFloat getFloat FLOAT float setFloat getFloat DOUBLE double setDouble getDouble VARBINARY byte[] setBytes getBytes BINARY byte[] setBytes getBytes DATE java.sql.Date setDate getDate TIME java.sql.Time setTime getTime TIMESTAMP java.sql.Timestamp setTimestamp getTimestamp CLOB java.sql.Clob setClob getClob BLOB java.sql.Blob setBlob getBlob ARRAY java.sql.Aray setARRAY getARRAY REF java.sql.Ref SetRef getRef STRUCT java.sql.Struct SetStruct getStruct JDBC为什么要通过游标来访问数据集中的数据,而不是像访问数组中的数据那样,通 过索引来访问呢? 这是因为JDBC通过Statement执行SQL语句,创建了ResultSet对象, elecfetcesultSe JDBC并不是把st查询到的所有数据都检索(h)出来,并存储在Rt中,而是 只返回查询结果的前面一部分数据和查询结果的数据总数。没有返回的数据在可能需要访 问的时候再从数据库中检索出来,并存入ResultSet供访问。JDBC能根据访问的情况异步 检索后面的数据,例如访问到结果集的第50行时,JDBC可能会把100~200行的数据都异 步检索出来,供后面访问。如果使用游标方式顺序访问结果集中的数据,JDBC可以预测将 84 85 要访问的数据,并预先加载,如果采用索引方式,可以随机访问结果集中的数据,这种预测就 不可能实现了,这是采用游标方式的主要原因。采用游标方式还有其他原因,例如如果查询 的结果集比较大(数万条数据),Java内存可能不足以存放这么多数据,导致内存溢出,采用 游标方式可以从内存中移除访问过的数据,以存放后面的数据。 3.3.5 处理null 值 SQL使用null值和Java使用null是不同的概念。那么,可以使用三种策略来处理 Java中的SQLnull值。 (1)避免使用返回基本数据类型的getXXX()方法。 (2)使用包装类的基本数据类型,并使用ResultSet对象的wasNull()方法来测试收到 getXXX()方法返回的值是否为null,如果是null,该包装类变量则被设置为null。 (3)使用基本数据类型和ResultSet对象的wasNull()方法来测试通过getXXX()方法 返回的值,如果是null,则基本变量应设置为可接收的值来代表NULL。 下面是一个处理NULL值的示例,代码如下。 01 Connection con = MysqlDAO.getConnection(); 02 Statement stmt = conn.createStatement(); 03 String sql = "SELECT id, first, last, age FROM Employees"; 04 ResultSet rs = stmt.executeQuery(sql); 05 int id = rs.getInt(1); 06 if(rs.wasNull()) { 07 id = 0; 08 } 3.3.6 执行数据操作语句 本小节将介绍获取连接会话后,如何通过Statement对数据库进行增、删、改、查操作。 在对数据库进行操作前,首先需要获取Statement对象,用于执行数据操作命令。 下面代码演示了在数据库中插入一条记录的操作。 01 public class InsertTester { 02 public static void main(String[] args) throws Exception{ 03 Connection con = MySqlDAO.getConnection(); 04 Statement stmt = con.createStatement(); 05 String sql = "insert into student(id,name,age) values(1007, 06 '小亮',28)"; 07 int count = stmt.executeUpdate(sql); 08 System.out.println("成功插入了"+count+" 条数据"); 09 stmt.close(); 10 con.close(); 11 } 12 } 代码第7行通过executeUpdate()执行insert语句。executeUpdate()返回SQL语句 扫一扫 86 影响的行数,如果是insert,表示新增的行数,如果执行的是update、delete语句,分别返回修 改的行数和删除的行数。修改(update)、删除(delete)语句与插入(insert)操作类似,在此就 不赘述了。 3.3.7 执行数据定义语句 通过JDBC可以操作数据,还可以执行定义数据库结构的数据定义语言(DDL)。以下 代码演示了通过JDBC在数据库中创建一张数据表,代码如下。 01 public class CreateTableTester { 02 public static void main(String[] args) throws Exception{ 03 Connection con = MySqlDAO.getConnection(); 04 Statement stmt = con.createStatement(); 05 String sql = "create table student(no int primary key, name 06 varchar(50),age int)"; 07 stmt.execute(sql); 08 stmt.close(); 09 con.close(); 10 } 11 } 第03行代码首先通过MySqlDAO 获取用于数据库连接的Connection实例,第04行 代码获取了数据库命令执行对象Statement的实例,然后定义了一条数据库语句。该语句 的作用是创建一张student表,表中有3个字段:整型的no(学号)、字符串类型的name(姓 名)、整型的age(年龄)。接着调用Statement对象的execute(Stringsql)方法,执行这条数 据库语句后,将在数据库中创建一张student表。 3.3.8 预编译Statement 上面的Statement可以用来执行SQL语句,JDBC为了提高执行效率,提供了PrepareStatement 类来执行需要多次重复执行的语句。例如前面的insert语句,如果要一次性插入10名学 生,除了后面的值不一样,整个语句的样式是一样的。PrepareStatement类能提高这种语句 的总体执行效率。 与Statement类似,需要获取一个PreparedStatement对象,并为其指定SQL语句。 01 PreparedStatement ps = 02 MySqlDAO.getConnection().prepareStatement(sql); 下面的代码演示了如何使用PreparedStatement。 01 public class PrepareStatement Tester { 02 public static void main(String[] args) throws Exception{ 03 Connection con = MySqlDAO.getConnection(); 04 String sql = "select * from student where no = ? "; 05 PreparedStatement ps = con.prepareStatement(sql); 扫一扫 87 06 ps.setInt(1, 1); 07 ResultSet rs = ps.executeQuery(); 08 while(rs.next()){ 09 System.out.print("学号" + rs.getInt("no")); 10 System.out.println(" 姓名" + rs.getString(2)); 11 } 12 ps.close(); 13 con.close(); 14 } 15 } 与3.3.6节通过Statement对象操作数据库的代码类似,这段代码仍然是在数据库连接 实例上获取PreparedStatement实例,并指定一条SQL 语句,但这条语句不同的是它采 用”?”代替了具体的值,PreparedStatement对象允许在执行SQL语句时才进行参数指定。 PreparedStatement对象有多个设置参数值的方法,设置参数时,需要知道参数的值类型,比 如int类型可以使用setInt(intparamIndex,intvalue)进行参数值设定,前一个参数表示参 数的索引,即它代表着第几个问号,后面的参数为属性值。 PreparedStatement对象会预先编译SQL语句,因此它的执行效率高于Statement,但 它只能执行预先设定的SQL,因此多用于指定特定SQL的场景中。PreparedStatement的 其他数据库操作与此类似,因此不再赘述。 下面代码展示了一个批量插入的例子,可以看出PreparedStatement对象与Statement 对象两者在执行效率上面的差别。 01 public class CompareStatement { 02 static final int SIZE = 5000; 03 public static void main(String[] args) { 04 Connection con = null; 05 try { 06 //Statement 07 String sql = "INSERT student(id,name) 08 VALUES(1,'test')"; 09 con = MySqlDAO.getConnection(); 10 System.out.println(con.toString()); 11 long startTime=System.currentTimeMillis(); 12 Statement stmt = con.createStatement(); 13 for (int j = 0; j < SIZE; j++) { 14 stmt.execute(sql); 15 } 16 stmt.close(); 17 System.out.println("Statement 运行时间: 18 "+(System.currentTimeMillis() - startTime)+"ms"); 19 //PreparedStatement 20 String psql = "INSERT student(id,name) 88 21 VALUES(? ,? )"; 22 startTime=System.currentTimeMillis(); 23 PreparedStatement ps = 24 con.prepareStatement(psql); 25 for (int i=0; i < SIZE; i++) { 26 ps.setInt(1, 1); 27 ps.setString(2, "test"); 28 ps.execute(); 29 } 30 ps.close(); 31 System.out.println("PreparedStatement 运行时 32 间: "+(System.currentTimeMillis() - startTime)+"ms"); 33 } catch (Exception e) { 34 e.printStackTrace(); 35 }finally { 36 if(con != null) 37 try { 38 con.close(); 39 } catch (SQLException e) { 40 e.printStackTrace(); 41 } 42 } 43 } 44 } 运行结果如下: 01 Statement 运行时间: 1942ms 02 PreparedStatement 运行时间: 1783ms 从上面的结果可以看出,PreparedStatement要比Statement快。在数据库模型更加复 杂的实际生产应用中,PreparedStatement的优势更加明显。 通过JDBC执行一条SQL语句,通常需要数毫秒、数百毫秒,比较复杂的语句可能用数秒。 需要注意,对于一次请求,数毫秒并不能被认为是微不足道的,相对于普通的Java代码,数毫 秒已经是很长的时间了。因此,使用JDBC访问数据库的时候,要有效率意识,也就是尽量减 少SQL语句的执行时间,例如创建数据库索引、使用PreparedStatement、批量更新等。 值得注意的是,MySQL本身是具有预编译功能的,但是PreparedStatement默认不会 帮我们开启。只有使用了useServerPrepStmts=true才能开启MySQL的预编译,其他代 码不变,修改连接数据库的URL的代码如下。 01 String url = 02 "jdbc:mysql://localhost:3306/simple? useServerPrepStmts= 03 true"; 如果useServerPrepStmts=true,Connection实例在PrepareStatement处会产生一个 89 ServerPrepareStatement对象,在此对象开始构造时首先会把当前SQL 语句发送到 MySQL,进行预编译,然后将返回的结果缓存起来。其中包括预编译的名称(可以看作是当 前SQL语句编译后的函数名)、签名(参数列表)。执行时会直接把参数传递给这个函数,请 求MySQL执行这个函数。 此外,如果在代码中使用不同的PrepareStatement句柄,就会发现同一个SQL语句发 生了两次预编译,这不是我们想要的效果。若想要对同一SQL语句多次执行,而不是每次 都预编译,就要使用cachePrepStmts=true。这个选项可以让JVM 端缓存每个SQL语句 的预编译结果。简单说就是以SQL语句为key,将预编译结果缓存起来。下次遇到相同的 SQL语句时,作为key去获得结果。修改连接数据库的URL的代码如下。 01 String url = 02 "jdbc:mysql://localhost:3306/simple? useServerPrepStmts=true 03 &cachePrepStmts=true"; 当然,配置参数不止这两个,此处不赘述。更多说明可以参阅MySQL 官方文档的 Connector/J部分。 使用PreparedStatement的优势如下。 . 防止注入攻击。 . 防止烦琐的字符串拼接和错误。 . 直接设置对象,而不需要转换为字符串。 . PreparedStatement使用预编译速度相对Statement较快。 通常,一条SQL语句在数据库中从接收到最终执行完毕返回,可以分为下面3个过程。 (1)词法和语义解析。 (2)优化SQL语句,制订执行计划。 (3)执行并返回结果。 这种普通语句被称作ImmediateStatements。 但是在很多情况,一条SQL语句可能会反复执行,或者每次执行的时候只有个别的值 不同(比如query的where子句值不同,update的set子句值不同,insert的values值不同)。 如果每次都需要经过上面的词法语义解析、语句优化、制定执行计划等,则效率就明显 不高了。 所谓预编译语句,就是将这类语句中的值用占位符替代,可以视为将SQL语句模板化 或参数化,一般称这类语句为PreparedStatements或ParameterizedStatements。 预编译语句的优势可归纳为:一次编译、多次运行,省去了解析优化等过程。此外,预 编译语句能防止SQL注入。 当然,就优化来说,很多时候最优的执行计划不是光靠知道SQL语句的模板就能决定 了,往往就是需要通过具体值来预估出成本代价。 为了防止SQL注入,采用预编译的方法,先将SQL语句中可被客户端控制的参数集进 行编译,生成对应的临时变量集,再使用对应的设置方法,为临时变量集里面的元素赋值,赋 值函数为setString(),对传入的参数进行强制类型检查和安全检查,避免了SQL注入的产 生。下面具体分析。 90 (1)为什么Statement会被SQL注入? Statement之所以会被SQL注入,是因为SQL语句结构发生了变化。比如: 01 "select*from user where username='"+uesrname+ 02 "'and password='"+password+"'" 用户输入'ortrueor'之后,SQL语句结构改变,代码如下。 01 select*from user where username=''or true or'' and 02 password='' 上面的SQL语句本意是查询用户名和密码匹配的人员信息,但如果用户注入ortrue or之后,原来查询条件中就包含了ortrue的部分,这就意味着表中的所有数据记录都会满 足条件,会返回到程序中。如果这个语句用于登录,就意味着所有的用户都可以登录,这是 严重的安全漏洞。 (2)为什么Preparement可以防止SQL注入? 01 select*from user where username=? and password=? 该SQL语句会在得到用户的输入之前先用数据库进行预编译,这样不管用户输入什么 用户名和密码,判断始终都是“并”的逻辑关系,防止了SQL注入。 简单总结,参数化能防止注入的原因在于:语句是语句,参数是参数,参数的值并不是 语句的一部分,数据库只按语句的语义执行。 3.3.9 批量更新 批量执行SQL操作,建议使用addBatch()方法,此方法将若干SQL语句装载到一起, 然后一次送到数据库执行,执行只需要很短的时间。上一小节的批量操作是一条一条发往 数据库执行的,部分时间消耗在数据库连接的传输上面。数据量越大,addBatch()方法的优 势越明显。 Statement接口中包括如下两个方法。 (1)voidaddBatch(Stringsql)throwsSQLException; 将给定的SQL命令添加到此Statement对象的命令列表中,通过调用executeBatch() 可以批量执行此列表中的命令。 (2)int[]executeBatch()throwsSQLException; 将一批命令提交给数据库来执行,返回一个整形数组int[],数组中每个数字对应一条 命令的影响行数。 以下展示的是用addBatch()方法进行批量更新的例子,代码如下。 01 public class AddBatchTester { 02 static final int SIZE = 5000; 03 public static void main(String[] args) { 04 Connection con = null; 05 try { 91 06 con = MySqlDAO.getConnection(); 07 con.setAutoCommit(false); 08 Statement stmt = con.createStatement(); 09 for (int i = 0; i < SIZE; i++) { 10 stmt.addBatch("INSERT student(no,name) 11 VALUES(1,'test')"); 12 } 13 stmt.executeBatch(); 14 con.commit(); 15 } catch (Exception e) { 16 e.printStackTrace(); 17 }finally { 18 if(con != null){ 19 try { 20 con.close(); 21 } catch (SQLException e) { 22 e.printStackTrace(); 23 } 24 } 25 } 26 } 27 } 在默认环境下,SQL操作会被自动提交到数据库,无法回滚事务。为了完成在一次数 据库连接中完成所有SQL 语句的执行,第7 行代码中首先设置con.setAutoCommit (false),表示SQL命令的提交由应用程序负责,程序必须调用con.commit(),将先前执行 的语句一起提交到数据库,或者调用con.rollback()方法,取消在当前事务中进行的更改,并 且释放Connection对象持有的所有数据库锁。关于事务操作等JDBC高级操作,将在3.4 节详细说明。 值得一提的是,跟JDBC 连接中设置参数类似,添加rewriteBatchedStatements=true 参数,可以很大程度地提高操作效率。通过tcpdump抓包,并在wireshark下做分析发现, 若不使用参数,SQL语句在一次连接中被逐条提交到服务器,该操作一共执行了5000次。 而加上参数后,JDBC 将5000 条insert语句分成若干条报文,将SQL 语句分批发送到 MySQL服务器。每发送一次报文,便插入一批数据进入数据库,实现了批量操作。 3.4 JDBC进阶 本节将讨论一些JDBC高级操作,这些操作包括事务、存储过程、数据库连接池、元数 据、分页等等。 3.4.1 事务 数据库的事务(transaction)是保证数据库完整性、一致性的一种机制,即把多个相关联扫一扫 的数据库操作当作一个原子性的操作对待,要么同时成功,要么同时失败,不允许同一个事 务中的部分操作成功,这有点类似Java的synchronized机制。 数据库系统保证在一个事务中的所有SQL操作要么全部执行成功,要么全部不执行, 事务具有ACID特性。 .原子性(atomicity):表示事务包含的操作要么全部成功,要么失败回滚(恢复到初 始状态)。 .一致性(consistency):事务执行前后必须保持一致,比如在转账时,转出的数量需要 等于转入的数量。 隔离性(n):当多个事务并发性访问数据库时,每个事务不能被其他事务干 . isolatio 扰。比如数据库中有两个事务,记为T1 、T2,两个事务同时更新一份共享数据,T1 事务应该等待T2事务结束后执行,反之亦可。 .持久性(durability):表示一个事务一旦被提交了,对数据库的改变是永久的,即使 数据库发生了故障,也可以恢复 。 事务的通常工作过程如下 。 (1)开启事务(BeginTransaction)。 (2)执行数据库操作1。 (3)执行数据库操作2,以及其他操作。 状态 ( 。 4)如果发生错误,回滚事务(rolbacktransaction),即把数据库恢复到事务开启前的 (5)如果没有错误,提交事务(committransaction),即持久化前面的所有操作。 数据库事务可以并发执行。并发执行会带来一系列的问题:脏读(dirtyread)、不可重 复读(nonrepeatableread)、幻读(phantomread)。下面举例说明这些问题。 脏读(dirtyread)。例如有两个事务T1 、T2,T1开启事务,然后修改学生张三的年龄为 19岁,这时T2开启事务,读取学生张三的年龄,T2读取的年龄应该是原来的18岁,还是 T1修改后的19岁呢? 从时效性来说,应该读取到最新的数据19岁,但是如果事务T1没 有正常提交,而是回滚了,即T1把数据恢复到开启事务前的18岁,那么T2读出的19岁就 是错误的(这个错误的数据被称为脏数据)。 不可重复读(nonrepeatableread)。例如T1读取年龄小于20岁的学生,读取到张三和 李四的信息,此时T2修改张三的年龄为21岁,如果此时T1重新读取年龄小于20岁的学 生,只能读取到李四,这种在一个事务中两次读到的数据不一样的问题称为不可重复读。 幻读(phantomread)。例如T1读取年龄大于等于18岁的学生,将读取到张三,此时 T2修改李四的年龄为21岁。如果此时T1重新读取大于等于18岁的学生,将读取到张三 和李四。这种在事务执行过程中,当两个完全相同的查询语句执行得到不同的结果集的现 象被称为幻读。 导致上面3种问题的原因是事务之间没有有效的隔离,即同时执行的两个事务相互影 响。为了解决以上3种问题,数据库采用了事务隔离机制。根据事务隔离的程度可以分为 不同的事务界别(isolationlevel),高级别的隔离能解决以上的所有问题,但是数据库的执行 效率将降低。SQL标准定义了4种隔离级别,分别对应可能出现的数据不一致的情况,如 表3-6所示。 92 93 表3-6 事务隔离 隔离级别(isolationlevel) 存在的问题 脏读 (dirtyread) 不可重复读 (nonrepeatableread) 幻读 (phantomread) 未提交读(readuncommitted) 存在存在存在 提交读(readcommitted) — 存在存在 可重复读(repeatableread) — — 存在 可序列化(serializable) — — — 对应用程序来说,数据库事务非常重要,很多运行着关键任务的应用程序都必须依赖数 据库事务,保证程序的结果正常。举个例子:假设小明准备给小红支付100元,两人在数据 库中的记录主键分别是123和456,那么用两条SQL语句的操作代码如下。 01 UPDATE accounts SET balance = balance - 100 WHERE id=123; 03 UPDATE accounts SET balance = balance + 100 WHERE id=456; 这两条语句必须以事务方式执行才能保证业务的正确性。因为一旦第一条SQL执行 成功而第二条SQL失败,系统的钱就会凭空减少100元,而有了事务,要么这笔转账成功, 要么转账失败,双方账户的钱都不变。 要在JDBC中执行事务,本质上就是如何把多条SQL包裹在一个数据库事务中执行。 JDBC中使用事务机制的代码结构如下所示。 01 Connection con = openConnection(); 02 try { 03 //关闭自动提交: 04 con.setAutoCommit(false); 05 //执行多条SQL 语句: 06 insert(); update(); delete(); 07 //提交事务: 08 con.commit(); 09 } catch (SQLException e) { 10 //回滚事务: 11 con.rollback(); 12 } finally { 13 con.setAutoCommit(true); 14 con.close(); 15 } 其中,开启事务的关键代码是con.setAutoCommit(false),表示关闭自动提交(JDBC默认是 执行每条SQL语句自动开启事务,并自动提交事务)。提交事务的代码在执行完指定的若 干条SQL语句后调用con.commit()提交事务。要注意,事务不是总能成功,如果事务提交 失败,会抛出SQL异常(也可能在执行SQL语句的时候就抛出了),此时必须捕获并调用 con.rollback()回滚事务。最后,在finally中通过con.setAutoCommit(true)把Connection 94 对象的状态恢复到初始值。 实际上,默认情况下,我们获取到Connection连接后,总是处于“自动提交”模式,也就 是每执行一条SQL都是作为事务自动执行的,这也是为什么前面几节更新操作总能成功的 原因:因为默认有这种“隐式事务”。只要关闭了Connection的autoCommit,就可以在一个 事务中执行多条语句,事务以commit()方法结束。 如果要设定事务的隔离级别,可以使用如下代码。 01 //设定隔离级别为READ COMMITTED: 02 con.setTransactionIsolation(Connection.TRANSACTION_READ_ 03 COMM ITTED); 如果没有调用上述方法,会使用数据库的默认隔离级别。MySQL的默认隔离级别是 REPEATABLEREAD。 JDBC对事务的操作主要通过Connection类的rollback()、commit()以及setAutoCommit (booleanautoCommit)等方法进行事务回滚、提交操作。 下面为一个事务实例的代码。 01 public class DBTest7 { 02 public static void main(String[] args){ 03 Connection con = null; 04 try{ 05 con = MySqlDAO.getConnection(); 06 con.setAutoCommit(false); 07 Statement stmt = con.createStatement(); 08 String sql1 = "select max(no) from student"; 09 ResultSet rs = stmt.executeQuery(sql1); 10 int no = 0; 11 while(rs.next()){ 12 no = rs.getInt(1) + 1; 13 } 14 String sql2 = "insert into student values(" + no + ", 15 'wahaha')"; 16 stmt.execute(sql2); 17 con.commit(); 18 stmt.close(); 19 con.close(); 20 }catch(Exception e){ 21 try { 22 con.rollback(); 23 con.close(); 24 } catch (SQLException e1) { 25 e1.printStackTrace(); 26 }