欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

记一次Spring Data JPA死锁分析

程序员文章站 2022-07-08 10:17:27
笔者公司目前使用的ORM为Spring Data JPA,其底层基于Hibernate,Hibernate是一个重量级的ORM框架,在不了解Hibernate机制的情况下使用Spring Data JPA时,可能会遇到很多比较奇怪的问题。笔者最近在公司的业务中就碰到了一个有关死锁的奇怪问题,Repository中的find查询语句竟然引发了死锁:还原死锁现场程序死锁日志截取部分程序日志如下: [ WARN ] SqlExceptionHelper:137 - SQL Error: 1213, SQ...

笔者公司目前使用的ORM为Spring Data JPA,其底层基于Hibernate,Hibernate是一个重量级的ORM框架,在不了解Hibernate机制的情况下使用Spring Data JPA时,可能会遇到很多比较奇怪的问题。笔者最近在公司的业务中就碰到了一个有关死锁的奇怪问题:Repository中的find查询语句竟然引发了死锁。

还原死锁现场

程序死锁日志

截取部分程序日志如下:

 [ WARN  ] SqlExceptionHelper:137 - SQL Error: 1213, SQLState: 40001
 [ ERROR ] SqlExceptionHelper:142 - Deadlock found when trying to get lock; try restarting transaction
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
        at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:287) ~[spring-orm-5.1.9.RELEASE.jar!/:5.1.9.RELEASE]
        ...
        at com.sun.proxy.$Proxy185.findByProductId(Unknown Source) ~[?:?]
        at com.*.*.service.impl.OrderServiceImpl.transferOrReleaseLock(OrderServiceImpl.java:1086) ~[classes!/:1.3.0-SNAPSHOT]
        at com.*.*.service.impl.OrderServiceImpl.manualComplete(OrderServiceImpl.java:1078) ~[classes!/:1.3.0-SNAPSHOT]
        at com.*.*.service.impl.OrderServiceImpl$$FastClassBySpringCGLIB$$7a54b92d.invoke(<generated>) ~[classes!/:0.3.0-SNAPSHOT]
        ...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123) ~[mysql-connector-java-8.0.17.jar!/:8.0.17]
        at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:97) ~[mysql-connector-java-8.0.17.jar!/:8.0.17]
        at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122) ~[mysql-connector-java-8.0.17.jar!/:8.0.17]
        at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953) ~[mysql-connector-java-8.0.17.jar!/:8.0.17]
        ...

程序代码

对应OrderServiceImpl发生错误处的代码:

List<Order> orders = orderRepo.findByProductId(productId);

MySQL死锁日志

使用show engine innodb status命令查看死锁信息,死锁信息在其LATEST DETECTED DEADLOCK部分:

*** (1) TRANSACTION:
TRANSACTION 9139673, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 2
MySQL thread id 35014, OS thread handle 140228539574016, query id 65585521 172.17.1.2 user updating
update product set ... where id=*
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7035 page no 9 n bits 120 index PRIMARY of table `test`.`product` trx id 9139673 lock_mode X locks rec but not gap waiting
Record lock, heap no 50 PHYSICAL RECORD: n_fields 39; compact format; info bits 0

*** (2) TRANSACTION:
TRANSACTION 9139671, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
6 lock struct(s), heap size 1136, 6 row lock(s), undo log entries 5
MySQL thread id 34874, OS thread handle 140228542277376, query id 65585523 172.17.1.2 user updating
update order set ... where id=*
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 7035 page no 9 n bits 120 index PRIMARY of table `test`.`product` trx id 9139671 lock_mode X locks rec but not gap
Record lock, heap no 50 PHYSICAL RECORD: n_fields 39; compact format; info bits 0
...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7002 page no 47 n bits 112 index PRIMARY of table `test`.`order` trx id 9139671 lock_mode X locks rec but not gap waiting
Record lock, heap no 42 PHYSICAL RECORD: n_fields 30; compact format; info bits 0

死锁分析

MySQL死锁日志分析

关于死锁的信息,MySQL 只保留了最后一个死锁的现场,但这个现场还是不完备的。根据MySQL死锁日志,可以得出一下信息:

  1. 事务1使用主键索引更新product表,等待product表主键上锁模式为写锁的行锁。
  2. 事务2持有product表中主键索引上模式为写锁的行锁,也就是事务1正在等待的锁。
  3. 事务2等待order表中主键索引上模式为写锁的行锁。

仅仅根据以上信息并不能得出死锁发生的原因,因为事务2等待的锁被谁持有是未知的,只能结合业务代码来推断谁持有order表中对应记录的锁。

关于MySQL的锁机制以及死锁分析可以学习极客时间《MySQL实战45讲》以及某大佬的博客aneasystone’s blog

程序日志分析和业务代码分析

看看错误日志中记录死锁发生的代码:orderRepo.findByProductId(productId)。WTF,find就是普通的select语句啊,又不会加锁,为什么会死锁呢?
联系到Spring Data JPA底层使用的是Hibernate,而Hibernate默认并不是调用Repository的save方法就立即执行更新或插入:Hibernate的持久化上下文会跟踪实体的状态,提交的更新或插入并不会立即flush到数据库,只有在特定的时间才会flush到数据库中。默认的flush模式下情况下,Hibernate会在以下三种情况下将持久化上下文中的“脏”实体flush到数据库:

  1. 事务提交前。
  2. 执行和”脏“实体有关的JPQL/HQL查询前。
  3. 执行任何没有注册同步flush的原生SQL查询前。

有关Hibernate的flush可以参阅Hibernate的Flush机制

再看看product实体和order实体的关联关系:

@Data
@Table(name = "product")
public class Product{
    @Id
    private String id;

    @Column
    private String name;
}


@Table(name = "order")
public class Order{
    @Id
    private String id;

    @JoinColumn(name = "product_id", referencedColumnName = "id", updatable = false, insertable = false)
    @ManyToOne
    private Procuct product;
}

以及@ManyToOne的源码:

public @interface ManyToOne {
...
    /** 
     * (Optional) Whether the association should be lazily 
     * loaded or must be eagerly fetched. The EAGER
     * strategy is a requirement on the persistence provider runtime that 
     * the associated entity must be eagerly fetched. The LAZY 
     * strategy is a hint to the persistence provider runtime.
     */
    FetchType fetch() default EAGER;
...
}

其中Order关联了Product,并且默认是EAGER关联的。也就是说,查询Order的时候,会先查询order表,然后查询product表。有了这些知识准备,再联系业务代码,就可以还原死锁过程了(这里我们假设product表中有一条记录id=p1,order表中有一条记录id=o1,且对应order记录的product_id=p1):

事务2 事务1
调用save更新product(未flush) 调用save更新product(未flush)
调用findById查询order :由于关联关系,触发EAGER加载,查询product表;由于flush机制,执行update product where id=p1语句,拿到product主键为p1的记录的X锁 调用save更新order表 (未flush)
调用save更新order表 (未flush) 调用findById查询order表: 先查询order,由于flush机制,触发执行update order where id=o1,拿到order主键为o1的记录的X锁
查询order表:由于flush机制,触发执行update order where id=o1语句,等待order主键为o1的记录的X锁 由于关联关系,后查询product表,由于flush机制,触发执行update product where id=p1语句,product主键为p1的记录的X锁

简化后如下:

事务2 事务1
持有主键为p1的product记录的X锁 持有主键为o1的order记录的X锁
等待主键为01的order记录的X锁 等待主键为p1的product记录的X锁

除了事务1持有的order记录的X锁,其他信息和MySQL死锁日志一致。事务1和事务2互相持有对方需要获取的锁,引起死锁。了解了死锁发生的原因,也就能避免死锁了。通常死锁可以通过两种方式避免:

  1. 尽量按照相同的顺序加锁。
  2. 尽量减小持有锁的时间。

对应的业务代码解决方式为:

  1. 修改关联关系@ManyToOne(fetch=FetchType.LAZY),这样事务会在最后提交的时候flush对应的product和order,更新顺序和获取锁顺序是一致的;或者Repository中的save修改为saveAndFlush,也就是立即将product或order的记录flush到数据库中,代码中更新的先后顺序就是更新顺序和获取锁顺序。
  2. 减少事务中两个save或者saveAndFlush之间的代码,减少持有锁的时间。

save和saveAndFlash的区别可以参考Difference Between save() and saveAndFlush() in Spring Data JPA>

本文地址:https://blog.csdn.net/qq_32734365/article/details/107406320

相关标签: spring java mysql