Mybatis 使用ON DUPLICATE KEY UPDATE时自增主键返回值问题

写在前面

本文是自己的一次踩坑经历,结合网上其他文章,在此总结。个人踩坑时的Mybatis版本为3.5.5,JDK版本为11,Mysql版本为5.7.32和8.0.16。个人能力有限,以下论述若有纰漏,欢迎和感谢指出。

参考链接:

关于Mybatis使用useGeneratedKeys获取自增主键

MyBatis返回自增主键实验

在MyBatis中,一般使用useGenerateKeys属性来返回自增主键。但在配合含有ON DUPLICATE KEY UPDATE的SQL时,自增主键需要根据以下几种情况分类讨论。

使用情况分类

单条记录插入

数据库能正确返回受影响行数1以及增加的主键,Mybatis可以从中可以拿到正确的主键并返回

单条记录更新

更新前后不一致

数据库确实发生了数据更新,并且只有单条记录情况下,会正确返回更新的主键和受影响行数2。此时Mybatis也能拿到正确的主键并返回

更新前后一致

数据库实际数据并没有发生更新,并且只有单条记录情况下,Mysql会根据连接配置参数useAffectedRows返回受影响行数,

  • 默认是false,受影响行数返回0
  • 设置成true,受影响行数返回1

补充一点:MySQL官方并不推荐设置为true,原因是不符合JDBC规范。参考链接

以上两种情况都由于没有发生数据更新,不会返回目标行的主键信息,此时Mybatis不能拿到主键信息,不能返回正确的自增主键

批量记录插入

记录带有主键信息插入

数据库返回第一条插入记录的主键,并返回正确的受影响行数,Mybatis拿到后会写入入参集合的第一个实体中,但由于ON DUPLICATE KEY UPDATE的存在,不计算后续实体的主键

记录不带有主键信息插入

同上

批量记录更新

数据库返回最后一条更新记录的主键,但不能返回正确的受影响行数,Mybatis拿到后会设置在入参集合的第一个实体中,但由于ON DUPLICATE KEY UPDATE的存在,不计算后续实体的主键(实际上也不能正确计算)

批量记录插入和更新

数据库返回第一条插入记录的主键,但不能返回正确的受影响行数,Mybatis拿到后设置在入参集合的第一个实体中,但由于ON DUPLICATE KEY UPDATE的存在,不计算后续实体的主键(实际上也不能正确计算)

原理分析

首先,需要知道MySQL对于自增主键返回值的几个前提条件:

  1. MySQL对于自增主键,只会返回第一条插入或者最后一条更新(无插入的情况)的主键值;
  2. 更新前后一致情况,数据库并不会返回主键值;
  3. MySQL在对ON DUPLICATE KEY UPDATE的受影响行数的计算规则:
    • 插入成功,行数为1
    • 更新:数据变化,行数为2;数据无变化,根据连接参数设置,默认是0,设置useAffectedRows = true后为1

而Mybatis设置了useuseGeneratedKeys=true后,实际上是基于JDBC客户端的getGeneratedKeys()方法来获取、生成返回集合的自增主键的。

// JDBC中的获取自增主键方法
public ResultSet getGeneratedKeys() throws SQLException {
        try {
            synchronized(this.checkClosed().getConnectionMutex()) {
                if (!this.retrieveGeneratedKeys) {
                    throw SQLError.createSQLException(Messages.getString("Statement.GeneratedKeysNotRequested"), "S1009", this.getExceptionInterceptor());
                } else if (this.batchedGeneratedKeys == null) {
                    // 执行SQL后会进入此处,设置自增主键
                    /* 
                    这里先判断了SQL时候使用了ON DUPLICATE KEY UPDATE,若有,使用带参数的getGeneratedKeysInternal()方法,并传入参数1;
                    若无,则会使用同名无参方法
                    */
                    return this.lastQueryIsOnDupKeyUpdate ? (this.generatedKeysResults = this.getGeneratedKeysInternal(1L)) : (this.generatedKeysResults = this.getGeneratedKeysInternal());
                // 以下省略部分无关处理
    }

getGeneratedKeysInternal()的两个重载方法中,有参方法的参数为受影响行数,无参方法会先获取受影响行数,并接着传入调用有参方法

// 无参方法
protected ResultSetInternalMethods getGeneratedKeysInternal() throws SQLException {
    // 获取受影响行数
        long numKeys = this.getLargeUpdateCount();
        return this.getGeneratedKeysInternal(numKeys);
}
// 最后运行的有参方法
protected ResultSetInternalMethods getGeneratedKeysInternal(long numKeys) throws SQLException {
            synchronized(this.checkClosed().getConnectionMutex()) {
                String encoding = this.session.getServerSession().getCharacterSetMetadata();
                int collationIndex = this.session.getServerSession().getMetadataCollationIndex();
                Field[] fields = new Field[]{new Field("", "GENERATED_KEY", collationIndex, encoding, MysqlType.BIGINT_UNSIGNED, 20)};
                ArrayList<Row> rowSet = new ArrayList();
                // 获取了数据库返回的主键
                long beginAt = this.getLastInsertID();
                if (this.results != null) {
                    String serverInfo = this.results.getServerInfo();
                	// Only parse server info messages for 'REPLACE' queries
                    if (numKeys > 0L && this.results.getFirstCharOfQuery() == 'R' && serverInfo != null && serverInfo.length() > 0) {
                        numKeys = this.getRecordCountFromInfo(serverInfo);
                    }
                    // 当自增主键不为0,并且受影响行数>0时就会计算返回数据的实体主键
                    if (beginAt != 0L && numKeys > 0L) {
                        for(int i = 0; (long)i < numKeys; ++i) {
                            byte[][] row = new byte[1][];
                            if (beginAt > 0L) {
                                row[0] = StringUtils.getBytes(Long.toString(beginAt));
                            } else {
                                byte[] asBytes = new byte[8];
                            	asBytes[7] = (byte) (beginAt & 0xff);
                            	asBytes[6] = (byte) (beginAt >>> 8);
                            	asBytes[5] = (byte) (beginAt >>> 16);
                            	asBytes[4] = (byte) (beginAt >>> 24);
                            	asBytes[3] = (byte) (beginAt >>> 32);
                            	asBytes[2] = (byte) (beginAt >>> 40);
                            	asBytes[1] = (byte) (beginAt >>> 48);
                            	asBytes[0] = (byte) (beginAt >>> 56);
                                BigInteger val = new BigInteger(1, asBytes);
                                row[0] = val.toString().getBytes();
                            }
                            rowSet.add(new ByteArrayRow(row, this.getExceptionInterceptor()));
                            // 这里实际上就是根据数据库自增值,逐行计算
                            beginAt += (long)this.connection.getAutoIncrementIncrement();
                        }
                    }
                }
                ResultSetImpl gkRs = this.resultSetFactory.createFromResultsetRows(ResultSet.CONCUR_READ_ONLY, ResultSet.TYPE_SCROLL_INSENSITIVE, new ResultsetRowsStatic(rowSet, new DefaultColumnDefinition(fields)));
                return gkRs;
            }
    }

所以,由上面代码可以知道,JDBC在发现SQL中含有ON DUPLICATE KEY UPDATE后。尽管在某些情况下,数据库可以返回正确的受影响行数,也不再考虑。JDBC客户端只会当受影响行数为1,然后接收从数据库返回主键值,并返回给调用者Mybatis。

这里有种特殊情况就是当发生了更新操作,但是数据并未发生变化,此时JDBC依然会传入受影响行数1,但是获取不到数据库返回的主键值(默认是0)

// 这个方法此时会返回0
long beginAt = this.getLastInsertID();

测试样例debug

测试样例DEBUG

总结

对于Mybatis中使用ON DUPLICATE KEY UPDATE

  1. 单条记录的插入、更新均可以返回主键,除了,当发生更新但数据未发生改变时,实体对象主键不会更新。

    当发生更新但数据未发生改变时的情况,还需要表中存在另一个主键以外的唯一索引,来使得入参时不需要主键信息

  2. 批量插入、更新、插入并更新等操作均无法正确返回自增主键

综上所述,Mybatis中使用useGenerateKeys获取计算ON DUPLICATE KEY UPDATESQL的主键时

  • 单条记录插入更新时,可以生成自增主键,但在实际代码中需要对返回实体主键判空,以排除更新前后一致情况的影响。

  • 批量插入更新时,不要返回主键,不要设置自动生成主键

posted @ 2022-07-06 11:47  xiaobaoor  阅读(274)  评论(0)    收藏  举报