Hibernate 10 事务管理

Hibernate 10 事务管理

事务管理一直都比较复杂, 经过仔细研读, 现在基本上看明白了原理, 也知道一般都是基于读已提交这个隔离级别, 然后使用乐观锁来上升到Repeatable Read可重复读级别, 最后使用悲观锁来继续上升到Serializable串行化级别. 不过每次遇到事务都是一堆理论. 事务这东西估计要看个几次才

事务管理一直都比较复杂, 经过仔细研读, 现在基本上看明白了原理, 也知道一般都是基于读已提交这个隔离级别, 然后使用乐观锁来上升到Repeatable Read可重复读级别, 最后使用悲观锁来继续上升到Serializable串行化级别. 不过每次遇到事务都是一堆理论. 事务这东西估计要看个几次才能看懂.
  1. 事务的四种隔离级别
  2. 解决更新丢失 - 乐观并发控制
  3. 悲观锁 - 锁行
  4. 无事务情况的操作

事务的四种隔离级别

既然允许一个事务要么全部完成, 要么全部不完成, 最大的问题就是并发, 如果串行操作, 则事务不会有任何问题. 正常的操作, 哪怕是并发, 只要先后顺序能够保证而且不回滚, 那么就正常. 关键就在于回滚的时候的并发问题, 以及并不是完整的将事务隔离成串行操作. 串行操作主要带来如下几种问题:
  1. 丢失更新如果完全没有并发控制, 那么可能A事务先更新, B事务再更新, 然后A事务或者B事务回滚, 另外一个事务的更新就会丢失, 即事务提交成功, 但一致性没有得到保证.
  2. 脏读事务A读到事务B的未提交数据, B之后回滚, A读到的就是一个脏数据
  3. 不可重复读事务A两次读取到不同的结果, 因为中间被另外一个事务给更新了, 此外两个事务先后提交, 如何保证一致性, 让后提交覆盖之前的提交会出问题.
  4. 幻象读, 指第二次查询中包含了第一次没有见到的数据或者更少的数据, 这个很显然是查询多个元素的时候会碰到.
其实上边这些问题的根源, 不外乎是多线程操作下的冲突问题. 而且不像简单的多线程, 同时操作一个共享变量, 一个事务中会涉及多个共享变量. 对于竞争, 当然是要加锁. 但是锁加在哪里,如何加, 就很关键了. ANSI SQL规定了四种事务隔离级别:
  1. Serializable(串行化):避免上述所有问题
  2. Repeatable read(可重复读):避免脏读和不可重复读
  3. Read committed(读已提交):避免脏读
  4. Read uncommitted(读未提交):避免不了上述任何问题
JPA的标准默认是read committed, 一般数据库的默认级别也是读已提交, 即避免脏读, 读未提交隔离级别几乎无法保证一致性, 所以很少使用. 也就是A不会读到一个未提交数据. 现在来看看Hibernate如何在这个基础上, 继续通过不同的手段来提升隔离级别.

解决更新丢失 - 乐观并发控制

所谓乐观, 就是假设在最后写入之前, 没人来更改过数据, 所以读取的时候并不加锁, 而是在写入的时候去判断一下写入的是否是最新的数据, 如果遇到了更新的数据, 就报异常并回退. 可见, 这个操作不会对写入加锁, 但是要对读取加锁, 需要使用额外的东西来控制判断是不是最新的. 由于Hibernate的一级缓存机制, 实际上有效的隔离了可重复读的问题.

写入版本控制

乐观的方案就是加上一个版本控制, 如下:
@Entity
@Table
public class MessageVersion {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Version
    private long version;
}
加上了这个注解之后, Hibernate会在数据表中存储额外的一列, 然后会自动管理版本, 只要每次更新, 就会对比版本, 如果版本不是比当前对象的版本多1的话, 就会拒绝写入. 这个值其实就相当于一个并发控制器, 如果到了数字的尽头, Hibernate会自动重新开始, 一开始默认是0. 来写点测试看看:
//一个写入的10个带版本的Message的测试:
@Test
public void testVersion() throws InterruptedException {
    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();

    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
    transaction.begin();

    for (int i = 0; i < 10; i++) {
        MessageVersion messageVersion = new MessageVersion();
        messageVersion.setText( String.valueOf(System.currentTimeMillis())+"Good");
        Thread.sleep(1200);
        em.persist(messageVersion);
    }
    transaction.commit();
}
可以发现message多了一列叫做version. 然后取出的时候看一下:
@Test
public void testWrite() throws InterruptedException {
    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();

    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
    transaction.begin();

    MessageVersion messageVersion = em.find(MessageVersion.class, 5L);

    long version = messageVersion.getVersion();

    //先查询当前的版本
    System.out.println("当前版本是: " + version);

    //睡眠10秒, 手工修改当前版本, 模拟被其他事务修改
    System.out.println("请手工修改版本为: " + (version + 1));
    Thread.sleep(10000);

    messageVersion.setText("尝试写入版本: " + version + 1);
    //尝试持久化的时候报错
    em.persist(messageVersion);

    transaction.commit();
}
如果版本管理全部交给Hibernate, 在版本不一致的时候, 就会提示:
OptimisticLockException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)
可以看到这是一个乐观锁. 可以捕捉这个异常, 在回滚当前事务的时候, 其实可以提示用户重新进行操作.
public static void main(String[] args) throws InterruptedException {

    boolean goOn = true;

    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
    while (goOn) {
        Scanner in = new Scanner(System.in);
        System.out.print("请输入id=5的数据要更新成的内容: ");
        String input = in.nextLine();
        if (input.equals("!!!quit!!!")) {
            goOn = false;
            System.out.println("输入了退出密码, 退出, 不更新");
            continue;
        }
        try {
            transaction.begin();
            MessageVersion messageVersion = em.find(MessageVersion.class, 5L);
            long version = messageVersion.getVersion();
            System.out.println("当前版本是: " + version);
            Thread.sleep(6000);
            System.out.println("尝试写入版本: " + (version + 1));
            messageVersion.setText(input);
            em.persist(messageVersion);
            transaction.commit();
        } catch (Exception ex) {
            System.out.println("最新数据已经被别人修改, 请重试");
            transaction.rollback();
        }
    }
    em.close();
}
这里编写了一个简单的程序, 提示用户输入一段文字, 然后将id=5的Message更新成这段文字, 中间睡眠了6秒钟. 这6秒钟的时候可以去更新一下数据库中的版本, 只要版本出现问题, 就可以提示用户数据已经被别人修改, 让其重新输入. 除了使用数值, 还可以使用时间作为版本控制,但是不太推荐. 除了在字段上标明@Version, 还可以不用显式指定, 而是在类上加注解:
@Entity
@Table
@org.hibernate.annotations.OptimisticLocking(type = OptimisticLockType.ALL)
@org.hibernate.annotations.DynamicUpdate
public class MessageV {
}
注意使用了乐观锁之后, 必须搭配使用DynamicUpdate. OptimisticLockType一般就使用ALL, 不推荐使用DIRTY, DIRTY仅仅检查改变的字段, 即可以并发改一个数据库不同字段, 这不是很推荐. 如果希望纯手动版本检查, 则应该在每次查询的时候设置乐观锁, 这样每次刷新.flush()的时候, 都会再查一次, 如果两次对比不一致, 就会抛异常. 手动设置版本检查的方式是 em.createQuery().setLockMode(LockModeType.OPTMISTIC) .

读取版本控制

由于写入版本控制仅仅只在写入的时候对比, 然后只知道数据不是自己当时读的情况. 但如果事务A在读取到写入之间都是同一版本, 但是这个过程中被另外一个事务B读取, 这个事务B的业务逻辑发现必须阻止事务A, 就需要强制版本递增. 相比写, 这实际上连读也锁了, 如果一个事务读的时候就发现了版本变更, 说明有其他东西控制了这个数据, 必须再回头去读, 而不用等到写. 使用强制版本变更的方式是:
@Test
public void force() {
    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();

    transaction.begin();
    MessageVersion messageVersion =
            em.find(MessageVersion.class, 6L, LockModeType.OPTIMISTIC_FORCE_INCREMENT);

    messageVersion.setText("当前id是:" + messageVersion.getVersion());
    transaction.commit();
}
橙色的部分用于控制强制版本变更, 在读出来的时候会更新一次, 写入的时候再根据读出来的版本进行控制, 一次会递增两个版本. 有了强制版本变更, 等于就锁了一整行. 不过版本控制其实并不是在数据库层面加锁, 而是Hibernate提供的机制. 如果并发修改很多, 而且不能延后判断, 就要使用悲观锁, 悲观锁就类似于普通多线程上的锁, 必须获得锁之后才能读写. 这会锁住数据库的一行.

悲观锁 - 锁行

先来看看悲观锁的配置:
MessageVersion messageVersion =
        em.find(MessageVersion.class, 6L, LockModeType.PESSIMISTIC_READ);
生成的SQL语句是:
Hibernate:
    select
        messagever0_.id as id1_0_0_,
        messagever0_.currentDate as currentD2_0_0_,
        messagever0_."text" as text3_0_0_,
        messagever0_."version" as version4_0_0_
    from
        MessageVersion messagever0_
    where
        messagever0_.id=? for share
为何刚才没有展示SQL语句, 是因为乐观锁本来就没有在数据库层面加锁. 而悲观锁就是在读取的时候直接加了锁. 这里的语句FOR SHARE是针对PgSQL的, 其获取的是一个ROW SHARE锁, 是一个行级锁, 只锁数据. PESSIMISTIC_WRITE则对应FOR UPDATE子句. 既然是一个行级锁, 所以锁住一个数据对象(以及其关联的其他行). 从读取的时候加锁, 到事务提交的时候,才会释放锁. 相比乐观锁, 悲观锁只要能够读取, 这个事务里就可以放心操作了, 在无法获取锁的时候抛异常. 而乐观锁要等到最后写入的那一刻, 也就是提交事务的时候才知道. 如果并发非常多, 而且不能发生冲突, 就必须要放置悲观锁. JPA对于悲观锁的规范是:
  1. PESSIMISTIC_READ模式需要确保不出现可重复读
  2. PESSIMISTIC_WRITE模式不出现可重复读问题, 和幻象读, 也就是最高级的隔离级别.
注意, 有一些旧规范的锁LockModeType.READ和LockModeType.WRITE不要再使用. 这些其实对应乐观锁, 就采用乐观锁专门的类即可.

总结

总结一下就是, 数据库默认是读已提交级别. 使用乐观锁可以在不对数据库加锁的情况下用于较少的并发读写. 使用悲观锁中的PESSIMISTIC_READ可以将事务隔离级别提高到不可重复读级别. 使用悲观锁中的PESSIMISTIC_WRITE可以将事务隔离级别提高到串行化级别.

无事务情况的操作

在使用Hibernate自动建表的时候, Hibernate会有如下提示:
INFO: HHH10001501:
    Connection obtained from JdbcConnectionAccess [org.hibernate.engine.jdbc.env.internal.JdbcEnvironmentInitiator$ConnectionProviderJdbcConnectionAccess@53c99b09]
    for (non-JTA) DDL execution was not in auto-commit mode;
    the Connection 'local transaction' will be committed and the Connection will be set into auto-commit mode.
这是因为JDBC Connection默认是auto-commit模式. 在这个模式下, 每一条SQL语句就是一个事务.在自动提交模式下的效率也很高, 因为没有原子性和事务隔离的影响(只要每一条SQL语句是原子的). 看一个例子:
@Test
public void testNoTX() {
    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();
    EntityManager em = emf.createEntityManager();

    MessageVersion messageVersion = em.find(MessageVersion.class, 6L);
    System.out.println(messageVersion);
    messageVersion.setText(String.valueOf(System.currentTimeMillis()) + "Good");

    //抛异常
    em.flush();
}
创建的em对象处于非同步模式, 没有开启事务. 一直执行到em.flush()就会报javax.persistence.TransactionRequiredException异常, 因为无法回滚. 在没有事务的情况下, 可以手工回滚:
MessageVersion messageVersion = em.find(MessageVersion.class, 6L);
System.out.println(messageVersion);
messageVersion.setText(String.valueOf(System.currentTimeMillis()) + "Good");
System.out.println(messageVersion);
em.refresh(messageVersion);
System.out.println(messageVersion);
执行refresh()之后, 会将messageVersion重新变回原来未更新的样子, 这是通过再执行一个SELECT语句做到的. 根据JPA的限制, 在无事务环境中可以进行查询, 使用find(), getReference()和.refresh(). 想刷新持久化上下文写入数据库是不允许的. 由于无事务环境下的操作特点, 不手动刷新不会去刷新, 所以无事务的情况下的操作主要用于一系列不需要回滚的操作排队, 最后一起提交给某个事务. 比如例子:
@Test
public void joinTX() throws InterruptedException {
    EntityManagerFactory emf =
            CaveatEmptorUtil.getEntityManagerFactory();
    EntityManager em = emf.createEntityManager();

    //连续创建10个对象
    for (int i = 0; i < 10; i++) {
        MessageVersion messageVersion = new MessageVersion();
        messageVersion.setText(String.valueOf(System.currentTimeMillis()) + "Good");
        Thread.sleep(100);
        em.persist(messageVersion);
    }

    //detach一批对象用于处理
    List<MessageVersion> messages = em.createQuery("select m FROM MessageVersion m", MessageVersion.class).getResultList();

    MessageVersion message16 = messages.get(15);

    em.detach(message16);
    message16.setText("其实是第16行");
    //合并的时候会触发一个SELECT
    em.merge(message16);

    //删除几个数据
    em.remove(messages.get(18));
    em.remove(messages.get(19));

    //上边的所有操作, 都没有影响到数据库, 因为没有同步

    //直到这里才开启一个事务
    em.getTransaction().begin();

    //如果em没有被连结到事务, 就连接
    if (!em.isJoinedToTransaction()) {
        em.joinTransaction();
    }

    //此时提交事务, 会把上边的一系列全部都写入
    em.getTransaction().commit();
}
上边到开启和提交事务之前, 大量操作可以在之前先做掉, 尤其是查询和处理数据, 效率会比较高. 如果仅仅查询, 有的时候使用不同步的模式更方便. 所有需要的操作执行过之后, 相当于给这些操作排了队, 最后开启事务, 保证当前线程的em在一个事务模式中, 然后提交, 上边的批量操作就全部执行了. 事务这玩意看的多了终于明白了一些. 后边看一下在编写查询之前还要了解的一些东西,就可以进军编写查询了.
LICENSED UNDER CC BY-NC-SA 4.0
Comment