Seata AT模式:MySQL自增ID的场景下个人推荐使用 useGeneratedKeys

2,030 阅读7分钟

我是石页兄,朋友不因远而疏,高山不隔友谊情;偶遇美羊羊,我们互相鼓励

欢迎关注微信公众号「架构染色」交流和学习

一、上下文

Seata AT 模式在 构建 insert 操作的 afterImage 时,如果是自增 ID 的情况下,需要获取刚插入记录的自增 ID 值是什么。在《Seata-AT 模式+TDDL:排查 构建 Insert 操作的 afterImage 时执行 SELECT LAST_INSERT_ID()报错》 中有描述因为上下文环境中没有激活 useGeneratedKeys ,复用 insert 操作对应的PreparedStatement在执行 SELECT LAST_INSERT_ID()时,因 TDDL 内还会执行 insert 遗留的三个占位符对应参数设置的逻辑,而导致了报错。对于报错之处statementProxy.getTargetStatement().executeQuery("SELECT LAST_INSERT_ID()");来说以下两种修复方案似乎都可以考虑:

  1. 执行 SELECT LAST_INSERT_ID()时,新建一个PreparedStatement
  2. 若复用PreparedStatement,也可用clearParameters()方法将 insert 时设置的三个参数清除掉

当然从 Seata AT 下的源码中梳理可知,采用useGeneratedKeys,可完全规避 Seata 内置的SELECT LAST_INSERT_ID()相关逻辑的执行。

二、什么是 useGeneratedKeys

useGeneratedKeys参数表示支持返回自动生成主键,当然这个特性需要 JDBC 驱动程序兼容(一些驱动可能不兼容)。也依赖支持自动生成记录主键的数据库,如 MySQL 和 SQL Server。如果参数值设置为 true,则进行 INSERT 操作后,数据库自动生成的主键会填充到 Java 实体属性中。

三、 mybatis 中使用 useGeneratedKeys

3.1 理论基础

在 Mybatis 中,useGeneratedKeys该属性仅对 <update><insert> 标签有用,属性值为 true 时,MyBatis 使用 JDBC Statement 对象的 getGeneratedKeys()方法来取出由数据库内部生成的键值,例如 MySQL 自增主键。另外配套的还有两个参数,描述如下:

  • keyProperty:该属性仅对<update><insert>标签有用,用于将数据库自增主键或者<insert>标签中<selectKey>标签返回的值填充到实体的属性中,如果有多个属性,则使用逗号分隔。
  • keyColumn:该属性仅对<update><insert>标签有用,通过生成的键值设置表中的列名,这个设置仅在某些数据库(例如 PostgreSQL)中是必需的,当主键列不是表中的第一列时需要设置,如果有多个字段,则使用逗号分隔。

useGeneratedKeys 的场景下,keyColumn配置为 DB 中的自增 ID 的字段名称,keyProperty配置为 Java 对象中对应的自增 ID 的属性名称,关系如下图: image.png

keyProperty与keyColumn的关系图示(来自网络).png

从 Mybatis 内的实现来看, MyBatis 内的主键生成器是 KeyGenerator,MyBatis 中提供了 3 种KeyGenerator

  • Jdbc3KeyGenerator

    • Jdbc3KeyGenerator实现的processAfter方法,是将数据库自动生成的主键填充到 Java 实体属性中,这种用法只能是在有自增主键的数据库中
  • NoKeyGenerator

    • 无自增主键,处理逻辑是空
  • SelectKeyGenerator

    • SelectKeyGenerator的源码实现中就是执行 select last_insert_id() as id 这条 sql 语句,获得结果并赋值给 id

在 MySQL + useGeneratedKeys的场景下,使用到的就是Jdbc3KeyGenerator

3.2 代码示例

  1. 实体类
    public class Stock {
        private Long id;
        private Long skuId;
        private Integer stockNum;
        private Date gmtCreated;
        ...
        //省略 getxxx setxxx
    
  2. interface mapper 中的 insert 方法
    @Insert("INSERT INTO tstock (`sku_id`,`stock_num`,`gmt_created`) VALUES(#{stock.skuId},#{stock.stockNum},#{stock.gmtCreated});") 
    @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") 
    public int insert(@Param("stock") Stock stock); 
    

其他上下文非核心,忽略不提。

3.2 结果

基于以上示例进行测试,悲剧的是 insert stock 记录后,stock 的 id 属性还是 null,跟预期不一致。

先把正确的方式同步给大家,需调整配置 将keyProperty = "id"调整为keyProperty = "stock.id",完整示例如下:

@Insert("INSERT INTO tstock (`sku_id`,`stock_num`,`gmt_created`) VALUES(#{stock.skuId},#{stock.stockNum},#{stock.gmtCreated});")
@Options(useGeneratedKeys = true, keyProperty = "stock.id", keyColumn = "id")
public int insert(@Param("stock") Stock stock);

四、梳理源码查找原因

Mybatis 中useGeneratedKeys将数据库自动生成的主键填充到 Java 实体自增 ID 属性中的逻辑就发生在org.apache.ibatis.executor.statement.PreparedStatementHandler#update中的keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject)环节中。

public int update(Statement statement) throws SQLException {
  PreparedStatement ps = (PreparedStatement) statement;
  ps.execute();
  int rows = ps.getUpdateCount();
  Object parameterObject = boundSql.getParameterObject();
  KeyGenerator keyGenerator = mappedStatement.getKeyGenerator();
  //这里,将数据库自动生成的主键填充到Java实体自增 ID 属性中
  keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject);
  return rows;
}

在第二部分有提到 Jdbc3KeyGenerator实现了关键的processAfter方法,出问题的地方就是在keyGenerator#processAfter这个方法的子逻辑方法populateKeys中,debug 的堆栈如下:

populateKeys:142, Jdbc3KeyGenerator (org.apache.ibatis.executor.keygen)
processBatch:79, Jdbc3KeyGenerator (org.apache.ibatis.executor.keygen)
processAfter:57, Jdbc3KeyGenerator (org.apache.ibatis.executor.keygen)
update:50, PreparedStatementHandler (org.apache.ibatis.executor.statement)
update:74, RoutingStatementHandler (org.apache.ibatis.executor.statement)
doUpdate:50, SimpleExecutor (org.apache.ibatis.executor)
update:117, BaseExecutor (org.apache.ibatis.executor)
update:76, CachingExecutor (org.apache.ibatis.executor)
update:198, DefaultSqlSession (org.apache.ibatis.session.defaults)
insert:185, DefaultSqlSession (org.apache.ibatis.session.defaults)

populateKeys 方法中根据调试情况确认了是给 id 赋值的环节,出了问题,方法和关键环节的注释说明如下:

private void populateKeys(ResultSet rs, MetaObject metaParam, String[] keyProperties, TypeHandler<?>[] typeHandlers) throws SQLException {
  for (int i = 0; i < keyProperties.length; i++) {
    String property = keyProperties[i];
    TypeHandler<?> th = typeHandlers[i];// 居然是 null
    if (th != null) {
      Object value = th.getResult(rs, i + 1);
      metaParam.setValue(property, value);
    }
  }
}

populateKeys方法中出问题的时候关键参数信息如下,看到 TypeHandlers 是 null 后就意识到不对了,对 Mybatis 了解的话会知道 没有 typeHandlers 的话,是无法完成属性赋值的。

keyProperties = {String[1]@14656} ["id"]
 > 0 = "id"
typeHandlers = {TypeHandler[1]@15132}
 > All elements are null

补充一下TypeHandler的信息,TypeHandler是类型转换器,在 Mybatis 中用于实现 Java 类型和 JDBC 类型的相互转换。Mybatis 使用prepareStatement来进行参数设置的时候,需要通过TypeHandler将传入的 Java 参数设置成合适的 JDBC 类型参数,这个过程实际上是通过调用prepareStatement不同的set方法实现的;在获取结果返回之后,也需要将返回的结果转换成我们需要的 java 类型。

所以,接下来只要找出为什么 typeHandlers 为空,为什么 没有创建 id 对应的 TypeHandler,应该就能找到解决办法了。上层方法processBatch中搜集关键线索,关键逻辑如下:

final MetaObject metaParam = configuration.newMetaObject(parameter);
if (typeHandlers == null) {
  // 获取 typeHandlers
  typeHandlers = getTypeHandlers(typeHandlerRegistry, metaParam, keyProperties, rsmd);
}
// 通过typeHandlers给属性赋值
populateKeys(rs, metaParam, keyProperties, typeHandlers);

getTypeHandlers返回的typeHandlers 是空,直接进入到问题发生的关键逻辑方法org.apache.ibatis.reflection.wrapper.MapWrapper#getSetterType中,代码逻辑以及调试时上下文参数如下:

@Override
public Class<?> getSetterType(String name) {
PropertyTokenizer prop = new PropertyTokenizer(name);
if (prop.hasNext()) {
  MetaObject metaValue = metaObject.metaObjectForProperty(prop.getIndexedName());
  if (metaValue == SystemMetaObject.NULL_META_OBJECT) {
    return Object.class;
  } else {
    return metaValue.getSetterType(prop.getChildren());
  }
} else {
  //代码执行到此处,map中,有`[stock, param1]`,没有 ID
  if (map.get(name) != null) {
    return map.get(name).getClass();
  } else {
    return Object.class;
  }
}
}

从源码调试情况,发现了问题进入了 else 逻辑,且 map 变量里有 [stock, param1]param1非本篇关注点,可忽略),但是没有 ididstock的属性,所以正常情况下,应该是要执行属性相关的操作,从方法名也可看出是跟 metaObject.metaObjectForProperty有关。根据经验盲猜一下,id 是stock 的属性,所以是不是应该使用 stock.id呢 ?

调整配置,将keyProperty的值设置为stock.id,完整情况如下:

@Insert("INSERT INTO tstock (`sku_id`,`stock_num`,`gmt_created`) VALUES(#{stock.skuId},#{stock.stockNum},#{stock.gmtCreated});")
@Options(useGeneratedKeys = true, keyProperty = "stock.id", keyColumn = "id")
public int insert(@Param("stock") Stock stock);

再次调试,可以看到org.apache.ibatis.reflection.wrapper.MapWrapper#getSetterType中,关键变量PropertyTokenizer的值有明显的变化,如下: prop = {PropertyTokenizer@14306}

  • name = "stock"
  • indexedName = "stock"
  • index = null
  • children = "id"

因为参数值有变化,所以org.apache.ibatis.reflection.wrapper.MapWrapper#getSetterType的运行情况跟上一次进入 else 环节不同,(结合下边代码看)就是:

  1. 先获取 stock 的 MetaObject; 因为 prop.getIndexedName() = "stock"
  2. 再获取 stock 中 id 属性的 class 信息,之后通过此信息即可构建 TypeHandler,调试时留意这个方法递归调用
//1. 先获取stock的MetaObject; prop.getIndexedName()   = "stock"
MetaObject metaValue = metaObject.metaObjectForProperty(prop.getIndexedName());
if (metaValue == SystemMetaObject.NULL_META_OBJECT) {
  return Object.class;
} else {
  // 2. 再获取stock 中 id属性的class信息,之后通过此信息即可构建TypeHandler,这是个递归调用哈
  return metaValue.getSetterType(prop.getChildren());
}

至此,从相关源码中探究了为何将keyProperty = "id"调整为keyProperty = "stock.id"的逻辑的真相。读者老师的环境也可能有所不同,需结合自己的组件代码上下文进行确认。

五、最后说一句

我是石页兄,如果这篇文章对您有帮助,或者有所启发的话,欢迎关注笔者的微信公众号【 架构染色 】加群进行交流和学习。您的支持是我坚持写作最大的动力。