当前位置: 移动技术网 > IT编程>开发语言>Java > 记一次Spring Data JPA死锁分析

记一次Spring Data JPA死锁分析

2020年07月18日  | 移动技术网IT编程  | 我要评论

笔者公司目前使用的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

如对本文有疑问, 点击进行留言回复!!

相关文章:

验证码:
移动技术网