Seata-AT模式:MySQL自增ID的場景下推薦在 Mybatis 中使用 useGeneratedKeys

碼農談IT發表於2023-01-11

一、上下文

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 的屬性名稱,關係如下圖:Seata-AT模式:MySQL自增ID的場景下推薦在 Mybatis 中使用 useGeneratedKeys

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"的邏輯的真相。讀者老師的環境也可能有所不同,需結合自己的元件程式碼上下文進行確認。



來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2931755/,如需轉載,請註明出處,否則將追究法律責任。

相關文章