JPA 使用过程中问题汇总(持续更新...)

有事务时,JPA save方法无法捕获异常

问题:
在类上有事务注解,在该类中,有一个方法中调用JPA save方法,但因为各种原因save应该报错,此时想捕获该异常,并不进行回滚和向上层抛出异常,但发现异常无法被捕获

情景再现:
entity实体类如下:

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "employee", uniqueConstraints = {@UniqueConstraint(columnNames={"department_id","company_id"})})
public class Employee {
    @Id
    @Column(name = "id", columnDefinition = "varchar(64)")
    @GenericGenerator(name="idGenerator", strategy="uuid")
    @GeneratedValue(generator = "idGenerator")
    private String id;

    @Column(name = "name", columnDefinition = "varchar(128) default null")
    private String name;

    @Column(name = "department_id", columnDefinition = "varchar(32) default null")
    private String departmentId;

    @Column(name = "company_id", columnDefinition = "varchar(32) default null")
    private String companyId;
}

service层如下:

@Service
@Transactional(rollbackFor = Exception.class)
public class EmpServiceImpl implements EmpService {

    @Resource
    private EmployeeRepository employeeRepository;

    @Override
    public void updateName(String name, String companyId, String departmentId) {
        Employee employee = employeeRepository.findByDepartmentIdAndCompanyId(departmentId, companyId);
        if(Objects.isNull(employee)){
            employee = Employee.builder().name(name).companyId(companyId).departmentId(departmentId).build();
        } else {
            employee.setName(name);
        }
        try {
            employeeRepository.save(employee);
        }catch (Exception e){
            System.out.println("exception = " + e.getMessage());
        }
    }
}

controller层就是对该service层方法进行调用
当库里没有数据时,使用jmeter并发调用该接口,会存在并发问题,即都需要save一个新的Employee对象,当save同companyId和departmentId时,由于唯一索引的限制,会save失败。使用try-catch捕获异常,却未成功。
并发调用5次接口,有4次失败了,从输出看并没有打印catch中的语句,接口也报错了。
在这里插入图片描述![在这里插入图片描述](https://img-blog.csdnimg.cn/4a349374d4f242e5a7e2b2f1717eaf7b.png#pic_center

原因分析
当employeeRepository.save时,JPA并没有马上把数据实体存储到数据库中,而是先存储在缓存中,然后正常执行后面代码逻辑,因为没将缓存中的数据刷到数据库,此时不会报错,try-catch没有异常可捕获,在提交事务时,尝试刷新缓存中的数据到数据库,发生联合索引重复的异常,此时异常无法被save的那个try-catch捕获。从打印的异常堆栈来看,异常是发生在flush后,提交事务时会先flush,把缓存中数据刷到数据库中时报错了。

在这里插入图片描述

** 解决**
去除该方法的事务,在方法前加上@Transactional(propagation = Propagation.NOT_SUPPORTED),事务传播属性改为不使用事务
再次用jmeter测试,发现接口都不再报错,并且异常也被catch住了

在这里插入图片描述
在这里插入图片描述

注意
之前看到网上的解决该问题的方式是把save改成saveAndFlush,经过测试,虽然try-catch能捕获异常,但是会报错回滚异常,接口仍然是会报回滚异常的错误.

在这里插入图片描述

经查询,saveAndFlush应该只是把sql数据刷到库里,并不提交事务,怀疑可能当时刷库时有问题,已经标记了一次需要回滚,而由于异常被catch住了,外层service提交事务时导致报错了。

JPA引起cpu过高,转换成entity时太慢,使用原生JDBC查询

遇到问题是CPU过高,通过火焰图看到是某些jpa的方法占用大量cpu。对此进行优化,改为使用原生JDBC查询,并自己转化成entity。
举个例子:
entity代码如下:

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "user_info")
@DynamicInsert
@DynamicUpdate
public class User {

    @Id
    @Column(name = "id")
    @GenericGenerator(name = "idGenerator", strategy = "uuid")
    @GeneratedValue(generator = "idGenerator")
    private String id;

    @Column(name = "name")
    private String name;

    @Column(name = "age")
    private Integer age;
}

根据group id 查用户的jpa方法:

@Repository
public interface UserRepository extends JpaRepository<User, Serializable>, JpaSpecificationExecutor<User> {

    List<User> findAllByGroupId(String groupId);
}

改为使用原生JDBC查询

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public List<User> findUsers(String groupId) {
            StringBuilder sql = new StringBuilder();
            sql.append("select a.id, a.name name, a.age age, a.groupId groupId "
                    "from site_info a " +
                    "where a.group_id = :groupId");

            NamedParameterJdbcTemplate npj = new NamedParameterJdbcTemplate(jdbcTemplate.getDataSource());
            List<User> list = npj.query(sql.toString(), MapBuilder.create(new HashMap()).put("groupId", groupId).build(), (resultSet, rowNum) ->
                    Site.builder()
                            .id(resultSet.getString("id"))                       
                            .name(resultSet.getString("name"))
                            .age(resultSet.getInt("age"))
                            .groupId(resultSet.getString("groupId"))                       
                            .build());

            return list;
    }

注意
值得注意的是,接口ResultSet中的getInt,如果库里数据是null,则使用该方法会返回0。方法官方说明如下:

/**
     * Retrieves the value of the designated column in the current row
     * of this <code>ResultSet</code> object as
     * an <code>int</code> in the Java programming language.
     *
     * @param columnLabel the label for the column specified with the SQL AS clause.  If the SQL AS clause was not specified, then the label is the name of the column
     * @return the column value; if the value is SQL <code>NULL</code>, the
     * value returned is <code>0</code>
     * @exception SQLException if the columnLabel is not valid;
     * if a database access error occurs or this method is
     *            called on a closed result set
     */
    int getInt(String columnLabel) throws SQLException;

若不想取默认值0,想取null,则需要使用getObject方法判断。如果不为空,再使用getInt。

resultSet.getObject("age") != null ? resultSet.getInt("age") : null

JPA将已持久化的对象在开启事务时调用set方法重新设置某些属性字段值时,库里数据会发生改变

开启了数据库事务时,查询一个已持久化对象,调用set方法设置属性,还并未调用save方法更新属性,但库里的属性会被更新。查询以及对查询后的实体set值都必须在一个事务里,并且在方法执行结束,事务提交前,不管有没有显示调用Update,JPA都会自动调用Update。

如下例,数据库中姓名和createtime会更新

    @Transactional
    @Override
    public void testSet() {
        User before = userRepository.findById("1");
        before.setName("李四");
        before.setCreateTime(System.currentTimeMillis());
    }

若只是想set属性,并不更新库里的数据的话,可以:

  • 提交事务之前执行EntityManager.clear()方法,清理缓存对象
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    @Override
    public void testSet() {
        User before = userRepository.findById("1");
        before.setName("李四");
        before.setCreateTime(System.currentTimeMillis());
        entityManager.clear();
    }
  • 避免直接对查询对象进行set操作,使用BeanUtils.copyProperties或mapper先转换成另一个类对象,再对该对象进行属性set。
    @Transactional
    @Override
    public void testSet() {
        User user = userRepository.findById("1");
        UserVo res = new UserVo();
        BeanUtils.copyProperties(user, res);
        res.setName("新名字");
        System.out.println(res);
    }
  • 使用EntityManager.evict()方法清理指定对象
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    @Override
    public void testSet() {
        User before = userRepository.findById("1");
        before.setName("李四");
        before.setCreateTime(System.currentTimeMillis());
        HibernateEntityManager hibernateEntityManager = (HibernateEntityManager) entityManager;
        Session session = hibernateEntityManager.getSession();
        session.evict(before);
    }

使用jpa自动生成表

配置文件增加:spring.jpa.hibernate.ddl-auto=update
写Entity实体类

@Entity(name = "user")
@Data
public class User {

    @Id
    @Column(name = "id", columnDefinition = "varchar(64)")
    @GenericGenerator(name="idGenerator", strategy="uuid")
    @GeneratedValue(generator = "idGenerator")
    private String id;

    @Column(name = "name", columnDefinition = "varchar(128) default null")
    private String name;

    @Basic
    private Integer age;

    @Column(name = "create_time")
    private Long createTime;

    @Column(name = "remark", columnDefinition = "varchar(255) default null")
    private String remark;

}

启动工程,会自动创建表格
开启打印sql,在控制台输出可以看到sql语句:

Hibernate: create table user (id varchar(64) not null, age integer, create_time bigint, name varchar(128) default null, remark varchar(255) default null, primary key (id)) engine=InnoDB

注意,create表格时可能会报如下错误:Specified key was too long; max key length is 767 bytes
是因为MySQL的InnoDB引擎表索引字段长度的限制为767字节,因此对于多字节字符集的大字段或者多字段组合,创建索引时会出现该问题。

以utf8mb4字符集字符串类型字段为例。utf8mb4是4字节字符集,默认支持的索引字段最大长度是191字符(767字节/4字节每字符≈191字符),因此在varchar(255)或char(255)类型字段上创建索引会失败。详情请参见MySQL官网文档。

所以在主键id上增加columnDefinition = “varchar(64)”,设置主键长度为64。避免出现该问题,可以自动创建表格成功。

jpa.generate-ddl和jpa.hibernate.ddl-auto

  jpa:
    database: mysql           #数据库类型MySQL
    show-sql: false           #不打印sql语句
    generate-ddl: false
    hibernate:
      ddl-auto: none          #不执行datasource.schema脚本

jpa.generate-ddl和jpa.hibernate.ddl-auto都可以控制是否执行datasource.schema脚本,来初始化数据库结构,只要有一个为可执行状态就会执行,比如ddl为true,或者update。他们相互没有制约关系。
必须二者都为false或none,才能不执行schema脚本。

数据库中的Schema是什么?

JPA save数据时,如果数据没有赋值,即使数据库层面设置了default值,但仍然会插入null

增加表中一个字段时的sql如下:

ALTER TABLE account ADD status tinyint(1) DEFAULT b'1' COMMENT '是否加入黑名单';

调用jpa的save方法时,并没有build实体类中status,即没有给该字段赋值,导致入库的值是空值,而不是预想中的1。

所以,修改为save时传true。sql保证的是原生sql insert时会设置默认值,使用JPA时是不行的。

JPA的save和saveAndFlush可以返回存储的实例,以获得存入数据的主键

通常主键都自动生成,我们存数据时是不设置主键的,但有业务要拿到插入数据的主键,需要接收save的返回的实例,再getId

Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1

解决Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1的一种方法

javax.persistence.TransactionRequiredException: Executing an update/delete query

解决javax.persistence.TransactionRequiredException: Executing an update/delete query的一种方法

JPA修改属性后再查询未生效问题

JPA的Repository提供一种非常易用的机制用于ORM方式处理数据,但是如果需要一次性更新一批数据的部分字段,构造所有实体并逐个修改字段再存回数据库就显得有些臃肿。在spring-data-JPA中提供了@Query注解用于使用JPQL执行数据库操作(可以使用nativeQuery属性改用原生sql),如果数据库操作是修改数据而非查询数据,则需要再额外使用@Modifying注解提示JPA该操作是修改操作。

当进行 find 操作时,JPA 在 EntityManager 中缓存了 find 生成的对象,当再次 find 时会直接返回该对象。于是可能会出现下面这种情况:
实体类是User类,包含id,姓名,年龄等属性。
用 @Query 定义一个修改姓名的方法

@Repository
public interface UserRepository extends JpaRepository<User, Serializable> {

    User findById(String id);

    @Transactional
    @Modifying
    @Query(value = "update user set name = :name where id = :id", nativeQuery = true)
    int updateNameById(String name, String id);
}

user对象id为1,name为王五,先读取该对象,再修改对象状态,再次读取对象,使用save方法修改对象属性,再读取对象

User before = userRepository.findById("1");
System.out.println("before " + before);

userRepository.updateNameById("张三","1");
User after = userRepository.findById("1");
System.out.println("after " + after);

after.setName("李四");
userRepository.save(after);
User afterSave = userRepository.findById("1");
System.out.println("afterSave " + afterSave);

控制台输入如下:

before User(id=1, name=王五, age=18, createTime=null, remark=null)
after User(id=1, name=王五, age=18, createTime=null, remark=null)
afterSave User(id=1, name=李四, age=18, createTime=null, remark=null)

在这里插入图片描述

结果会发现before和after中虽然id相同,但name并没有修改过来。修改后使用save方法更新,才得以成功。
原因是@Query跟find和save系列方法是两套不同的体系,@Query引起的数据库变更,但EntityManager并不能发现,不能及时反应到JPA的find系列方法上来,因此find时仍取的缓存中的数据,也就是修改前的数据。

解决方式:
在需要时显式清理EntityManager的缓存,@Modifying(clearAutomatically = true),clearAutomatically性为true时,执行完modifying query之后就会清理缓存,从而在下次find时就可以读取到数据库中的最新值。
注意
自动清理之后还会带来一个新的问题,clear操作清理的缓存中,还包括提交后未flush的数据,例如调用 save 而不是saveAndFlush就有可能不会立即将修改内容更新到数据库中,在save后flush前进行了clearAutomatically,有可能导致修改丢失。可以使用另一个属性@Modifying(clearAutomatically = true, flushAutomatically = true),@Modifying 的flushAutomatically属性为 true 时,执行modifying query前会先调用flush操作,从而避免数据丢失问题。

存疑:使用junit test测试时,不会出现此问题

JPA使用find自定义方法时,只能查出entity,不能查指定字段

当只想查询数据库里某个字段的值时,不能自己写findXXXByXXX方法,JPA会报类型转换错误,因为JPA的find只能查出entity,不能指定查出某字段。
示例如下,想查出指定姓名的全部备注数据:
entity类

@Entity(name = "user")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@DynamicInsert
@DynamicUpdate
public class User {

    @Id
    @Column(name = "id", columnDefinition = "varchar(64)")
    @GenericGenerator(name="idGenerator", strategy="uuid")
    @GeneratedValue(generator = "idGenerator")
    private String id;

    @Column(name = "name", columnDefinition = "varchar(128) default null")
    private String name;

    @Basic
    private Integer age;

    @Column(name = "create_time")
    private Long createTime;

    @Column(name = "remark", columnDefinition = "varchar(255) default null")
    private String remark;
}

repository接口

public interface UserRepository extends JpaRepository<User, Serializable>, JpaSpecificationExecutor<User> {
    List<User> findByName(String name);
	// 这样写会报错
    List<String> findRemarkByName(String name);
}

上层调用

// 本意是想直接调用方法查出remark list
userRepository.findRemarkByName("Mary");

// 先查出所有符合的entity,再用stream处理返回
userRepository.findByName("Mary").stream().map(User::getRemark).collect(Collectors.toList());

结果:
调用findRemarkByName,会报错,如下图,而另外一个方法不会报错。
在这里插入图片描述

解决方法
改为原生sql查询

@Query(value = "SELECT remark FROM user WHERE name = :name ", nativeQuery = true)
List<String> findRemarkByName(String name);