Hibernate 乐观锁与悲观锁 -- ISS(Ideas Should Spread)
本文是笔者 Java 学习笔记之一,旨在总结个人学习 Java 过程中的心得体会,现将该笔记发表,希望能够帮助在这方面有疑惑的同行解决一点疑惑,我的目的也就达到了。欢迎分享和转载,转载请注明出处,谢谢合作。由于笔者水平有限,文中难免有所错误,希望读者朋友不吝赐教,发现错漏我也会及时更新修改,欢迎斧正。(可在文末评论区说明或索要联系方式进一步沟通。)
乐观锁和悲观锁
在 Java 的多线程环境中,如果有多个线程都要对某些资源进行访问和修改,那么为了防止线程不确定的执行顺序给资源带来不一致的状态,需要对线程进行加锁,也就是说在同一时刻只能有一个线程对资源进行操作,加锁使多个线程对同一个资源的并发操作被串行化。数据库中的数据也是一种资源,并且数据库中的数据对数据一致性也必须得到保障,这可以通过加锁来达到目的,但是串行化访问的方法虽然能够保证资源的安全,但是在并发量非常高的数据库中会导致极高的用户响应时间,对用户来说是不可接受的。
对数据库的加锁方式可以分为两种,悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)。
悲观锁
顾名思义,悲观锁对它在访问数据库的时候总是持有一种悲观的想法,认为在它访问或修改数据库的同时总是会有其它程序也会来对数据库进行访问修改。因此为了保证数据的一致性,悲观锁会在它对数据库进行操作的时候一直锁住它要访问的数据库表(或者锁住其要访问的一条记录),此时其它要对数据库同样位置进行操作的程序将会排队等待获得锁,直到前者操作完毕才释放锁。
悲观锁的使用会导致如上所说的串行化访问的问题,即在一个连接拥有对一个表(或记录)的悲观锁时,其它连接都不可以对该表(或记录)进行操作,因此串行化导致的长响应时间对悲观锁来说同样存在。
乐观锁
同样,乐观锁对它在访问数据库的时候总是持有一种乐观的想法,认为在它访问或修改数据库的时候不会有其它连接会对数据库的同一个位置进行修改,因此不会导致数据不一致的问题。因此乐观锁实际上并没有对数据进行加锁处理。
但是乐观锁也不能盲目乐观,毕竟 “认为在它访问或修改数据库的时候不会有其它连接会对数据库的同一个位置进行修改” 仅仅是一厢情愿的想法,因此乐观锁也必须要对 “在它访问或修改数据库的时候有其它连接会对数据库的同一个位置进行修改” 这一不乐观的的情况做出弥补。一种常见的方法就是使用 版本号(Version) 来进行标记记录。
乐观锁要求数据库在保存记录的时候也要有一个保留该记录的版本的字段,在对记录进行修改的时候,先把数据记录从数据库中读出来,包括版本号(假设此时版本号为 n
),然后对数据进行修改后存回数据库前对版本号加 1 (即 n+1
),然后再存回数据库,因此整个修改过程可能的 sql
语句如下:
select * from user where userId=2; -- 取出要修改的记录(含版本号)
// 程序取出记录中的版本号,假设为 4
update user set username='newUsername', -- ... 还有其它修改的字段
set version=5 -- 把版本号加 1 (即4+1)
where userId=2 and
version=4; -- version 必须是 4,也就是说在程序中读
-- 出该记录时到此时写回去期间没有其它连接对该记录进行修改
会有以下两种情况:
- 读出记录的时候版本号是 4 ,直到写回去的时候版本号仍然为 4 ,因此可以保证在此期间没有其它连接对该记录进行修改(可能会有读取),即乐观的状态,此时修改成功;
- 读出记录的时候版本号是 4 ,写回去的时候版本号不为 4 (比 4 大),因此可以断定在此期间有其它连接对该记录进行了修改,修改完它们将该版本号加 1 ,因此我们的连接由于慢提交,对该记录修改失败,可以选择重试或取消。
Hibernate 中对乐观锁和悲观锁的验证例子
悲观锁的验证
Hibernate 中悲观锁的实现是依靠底层数据库(至少在我测试 MySql 时是这样的)来实现的。为了验证这个想法,我们使用以下的例子:
使用的表
CREATE TABLE `hm_tst_user` (
`userid` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(128) DEFAULT NULL,
PRIMARY KEY (`userid`)
);
使用的 JavaBean
User.java
public class User implements Serializable {
private Long userid;
private String username;
public User (final String username) {
this.username = username;
}
public User () {}
protected void setUserid (final Long userid) {
this.userid = userid;
}
// ... 其它 getter/setter
}
使用的映射文件 User.hbm.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">
<hibernate-mapping auto-import="true">
<class name="User" table="hm_tst_user">
<id name="userid">
<generator class="identity"/>
</id>
<property name="username"/>
</class>
</hibernate-mapping>
使用的测试代码 junit
public class TestVersion extends TestCase {
private SessionFactory sessionFactory;
@Override
protected void setUp () throws Exception {
final StandardServiceRegistry registry = new StandardServiceRegistryBuilder ()
.configure ()
.build ();
try {
sessionFactory = new MetadataSources (registry).buildMetadata ().buildSessionFactory ();
} catch (Exception e) {
StandardServiceRegistryBuilder.destroy (registry);
}
}
@Override
protected void tearDown () throws Exception {
if (sessionFactory != null) {
sessionFactory.close ();
}
}
@SuppressWarnings ("unchecked")
public void test () throws InterruptedException {
Session session = sessionFactory.openSession ();
session.beginTransaction ();
// 以下语句使用了了悲观锁 LockMode.PESSIMISTIC_WRITE
final User user = session.get (User.class, 1L, LockMode.PESSIMISTIC_WRITE);
// 在读出该记录并对其进行悲观锁锁定后,
// 我们跑去 mysql workbench 执行 sql 语句将该记录的 username 修改成其它(如 world)
System.out.println ("Go to update the version field");
// 为了有时间在程序间切换和输入 sql 语句,暂停 10 秒钟
Thread.sleep (10000);
// 修改 username 为 hello 加当前时间戳
user.setUsername ("hello"+ System.currentTimeMillis ());
session.getTransaction ().commit ();
session.close ();
}
}
测试程序中,在将记录以悲观锁的模式读出后,暂停了 10 秒钟(模拟这是一个长事务),在此期间我们从命令行登录 mysql ,将 userId 为 1 的记录的 username 修改成 world;然而在命令行中输入语句 update hm_tst_user set username='world' where userId=1;
回车后,会发现有很长时间的停顿,当程序和命令行两边都执行完毕后,我们发现数据库中最终的结果是命令行中的结果 username=world
。命令行中长时间的停顿是由于我们的测试程序以悲观锁的模式读出,并且在休眠的 10 秒内都锁着这条记录,导致我们从命令行上的语句需要等待测试程序的锁释放后才能进行,也就是说命令行上的语句执行排队在测试程序后面,这可以通过对测试程序中 Thread.sleep()
增加时间相应的命令行等待时间也会增长这一点来判断得到。
乐观锁的验证
Hibernate 中对乐观锁的实现也是基于版本号来实现的,因此我们相应的在数据库表,JavaBean
和配置文件中增加一个字段即可,如下。
修改的数据库表
CREATE TABLE `hm_tst_user` (
`userid` bigint(20) NOT NULL AUTO_INCREMENT,
`version` int(11) DEFAULT NULL, -- 增加一个版本号
`username` varchar(128) DEFAULT NULL,
PRIMARY KEY (`userid`)
);
修改的 JavaBean
public class User implements Serializable {
private Long userid;
private Integer version;
private String username;
// ... 和以上一样
// ... 其它 getter/setter
}
需要注意的是配置文件中对 version 的配置不能当作普通的 property 配置,如下
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE hibernate-mapping PUBLIC "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">
<hibernate-mapping auto-import="true">
<class name="User" table="hm_tst_user">
<id name="userid">
<generator class="identity"/>
</id>
<version name="version"/> <!-- 使用 version 标签 -->
<property name="username"/>
</class>
</hibernate-mapping>
修改的测试代码
public class TestVersion extends TestCase {
// 和以上相同的 tearDown 和 setUp 和字段 sessionFactory
@SuppressWarnings ("unchecked")
public void test () throws InterruptedException {
Session session = sessionFactory.openSession ();
session.beginTransaction ();
// 以下语句使用了了乐观锁 LockMode.OPTIMISTIC
final User user = session.get (User.class, 1L, LockMode.OPTIMISTIC);
// 在读出该记录并对其进行悲观锁锁定后,
// 我们跑去 mysql workbench 执行 sql 语句将该记录的 username 修改成其它(如 world)
// 注意在命令行操作要同时把版本号加1
System.out.println ("Go to update the version field");
// 为了有时间在程序间切换和输入 sql 语句,暂停 10 秒钟
Thread.sleep (10000);
// 修改 username 为 hello 加当前时间戳
user.setUsername ("hello"+ System.currentTimeMillis ());
session.getTransaction ().commit ();
session.close ();
}
}
测试程序中,我们以乐观锁的方法读出记录,并对记录进行修改,然后写回。当程序执行到 System.out.println ("Go to update the version field");
时,此时会有以下几种情况:
- 我们在命令行下将 userId=1 的记录的 username 修改成
world
,并且将版本号 version 加 1,由于是乐观锁,我们的命令行会马上执行并且将结果反映到数据库中,当测试程序从休眠中唤醒时,再去执行更新操作,会发现数据库中版本号比自己大,说明被其它连接修改了,测试程序无法修改成功,在 hibernate 中会抛出一个异常org.hibernate.StaleStateException
,还是很贴切的异常,不新鲜的状态异常
; - 我们在命令行下将 userId=1 的记录的 username 修改成
world
,但是没有将版本号 version 加 ,当测试程序从休眠中被唤醒时,检查到版本号没有变,它会错误地假定在此期间没有其它连接对该记录进行修改,因此对数据库的修改将会进行下去。
乐观锁的局限
从以上第二点可以看出,乐观锁的实现是局限于应用程序内的,也就是说如果其它应用程序不遵循 版本号加 1
的约定,那么乐观锁就可能失效。
其它,附上 Hibernate 的配置文件 hibernate.cfg.xml 和 maven 的 pom.xml 文件,整个测试的代码都全了
hibernate.cfg.xml
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="connection.driver_class">com.mysql.jdbc.Driver</property>
<property name="connection.url">jdbc:mysql://localhost:3306/test?useSSL=false</property>
<property name="connection.username">k</property>
<property name="connection.password">k</property>
<property name="connection.pool_size">1</property>
<property name="dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="cache.provider_class">org.hibernate.cache.internal.NoCacheProvider</property>
<property name="show_sql">true</property>
<mapping resource="User.hbm.xml"/>
</session-factory>
</hibernate-configuration>
pom.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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ml.kezhenxu.train</groupId>
<artifactId>hibernate</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<dependencies>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.1.0.Final</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.5</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.38</version>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<filtering>false</filtering>
<directory>src/test/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</testResource>
<testResource>
<directory>src/test/resources</directory>
</testResource>
</testResources>
</build>
</project>