Mybatis-Plus3.0預設主鍵策略導致自動生成19位長度主鍵id的坑

朱季謙發表於2021-12-10

碼字不易,如果對您有用,求各位看官點贊關注~

原創/朱季謙

目前的Mybatis-Plus版本是3.0,至於最新版本是否已經沒有這個問題,後續再考慮研究。

某天檢查一位離職同事寫的程式碼,發現其對應表雖然設定了AUTO_INCREMENT自增,但頁面新增功能生成的資料主鍵id很詭異,長度達到了19位,且不是從1開始遞增的——
image

我檢查了一下,發現該表目前自增主鍵已經變成從1468844351843872770開始遞增了——
image

這就很奇怪了,目前該表資料量很少,且主鍵是設定AUTO_INCREMENT,正常而言,應該自增id仍在1000範圍內,但目前已經變成一串長數字。

底層ORM框架用的是Mybatis-Plus,我尋思了一下,這看起來像是在插入資料庫就自動生成的id,導致並非預設使用MySql的自增AUTO_INCREMENT來生成id。

因此,決定一步步定位,先給Mybatis-Plus列印出sql日誌,看下其insert語句是否自動生成了一個id後才插入資料庫。

按照網上的教程,我在yaml檔案裡對應的mybatis-plus配置處設定了開啟sql列印日誌——

mybatis-plus:
  mapper-locations: classpath*:mapper/*.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

然而,很詭異的是,執行操作時並沒有列印出sql日誌,故而,某一瞬間,我忽然覺得,這群傢伙可能都是互相抄的,沒有驗證當springboot整合了logback時,單純這樣設定並沒有效果。

最後額外在yaml加了以下配置,才能正常列印MP的sql日誌資訊——

logging:
  level:
    com:
      zhu:
        test:
          mapper: debug   

接下來,驗證一番後,發現,Mybatis-Plus在做insert操作時,確實自動生成一條長19的數字當做該條資料的id插入到MySql,導致雖然MySql表設定了自增,但被Mybatis-Plus生成的id為1468844351843872769所影響,導致下一條資料自動遞增值變成1468844351843872770,這種過長的id值,在做索引維護時,是很影響效率,佔用空間過大,故而,這個問題必須得解決。
image

到這裡,就確定,這個長數字的id,是在程式碼層次就自動生成了,最後進入對應的實體類中,發現該對映資料表的id欄位,並沒有顯示設定對應的主鍵生成策略。

@Data
@TableName("test")
public class Test extends Model<Test> implements Serializable {
    
    private Long id;
    ......
}

Mybatis-Plus主要有以下幾種主鍵生成策略——

@Getter
public enum IdType {
    /**
     * 資料庫ID自增
     */
    AUTO(0),
    /**
     * 該型別為未設定主鍵型別
     */
    NONE(1),
    /**
     * 使用者輸入ID
     * 該型別可以通過自己註冊自動填充外掛進行填充
     */
    INPUT(2),

    /* 以下3種型別、只有當插入物件ID 為空,才自動填充。 */
    /**
     * 全域性唯一ID (idWorker),根據雪花演算法生成19位數字,long型別
     */
    ID_WORKER(3),
    /**
     * 全域性唯一ID (UUID)
     */
    UUID(4),
    /**
     * 字串全域性唯一ID (idWorker 的字串表示),根據雪花演算法生成19位字串,String
     */
    ID_WORKER_STR(5);

    private int key;

    IdType(int key) {
        this.key = key;
    }
}

這裡驗證了一下,當設定成這樣時,就能正常生成資料庫自增的id了,使用資料庫AUTO_INCREMENT從1開始自增的效果了,當然,其實使用IdType.AUTO也是可以的——

@Data
@TableName("test")
public class Test extends Model<Test> implements Serializable {
    @TableId(value = "id", type = IdType.INPUT)
    private Long id;
    ......
}

百度網上的說法,當Mybatis-Plus實體類沒有顯示設定主鍵策略時,將預設使用雪花演算法生成,也就是IdType.ID_WORKER或者IdType.ID_WORKER_STR,具體是long型別的19位還是字串的19位,應該是根據欄位定義型別來判斷。

snowflake演算法是Twitter開源的分散式ID生成演算法,結果是一個long型別的ID 。其核心思想:使用41bit作為毫秒數,10bit作為機器的ID(5bit資料中心,5bit的機器ID),12bit作為毫秒內的流水號(意味著每個節點在每個毫秒可以產生4096個ID),最後還有一個符號位,永遠是0。

接下來,先驗證Mybatis-Plus預設主鍵策略是如何的。

Mybatis-Plus專案在啟動時,會對註解實體類進行初始化,然後快取到系統Map中。

這裡,只需要關注Mybatis-Plus原始碼TableInfoHelper類中的initTableInfo方法即可,這個方法在專案啟動時會被呼叫,然後初始化所有註解@TableName的實體類。與主鍵根據哪種策略來設定的邏輯在方法initTableFields(clazz, globalConfig, tableInfo)當中——

public synchronized static TableInfo initTableInfo(MapperBuilderAssistant builderAssistant, Class<?> clazz) {
    TableInfo tableInfo = TABLE_INFO_CACHE.get(clazz.getName());
    if (tableInfo != null) {
        if (tableInfo.getConfigMark() == null && builderAssistant != null) {
            tableInfo.setConfigMark(builderAssistant.getConfiguration());
        }
        return tableInfo;
    }

    /* 沒有獲取到快取資訊,則初始化 */
    tableInfo = new TableInfo();
    GlobalConfig globalConfig;
    if (null != builderAssistant) {
        tableInfo.setCurrentNamespace(builderAssistant.getCurrentNamespace());
        tableInfo.setConfigMark(builderAssistant.getConfiguration());
        tableInfo.setUnderCamel(builderAssistant.getConfiguration().isMapUnderscoreToCamelCase());
        globalConfig = GlobalConfigUtils.getGlobalConfig(builderAssistant.getConfiguration());
    } else {
        // 相容測試場景
        globalConfig = GlobalConfigUtils.defaults();
    }

    /* 初始化表名相關 */
    initTableName(clazz, globalConfig, tableInfo);

    /* 初始化欄位相關 */
    initTableFields(clazz, globalConfig, tableInfo);

    /* 放入快取 */
    TABLE_INFO_CACHE.put(clazz.getName(), tableInfo);

    /* 快取 Lambda 對映關係 */
    LambdaUtils.createCache(clazz, tableInfo);
    return tableInfo;
}

在初始化欄位相關的initTableFields方法裡,會判斷是否有@TableId 註解,如果沒有,就執行initTableIdWithoutAnnotation方法,連續前文提到的,如果實體類id沒有加@TableId(value = "id", type = IdType.INPUT),那麼就會取預設的主鍵策略。這裡的判斷是否有@TableId 註解,就是判斷是否需要取預設的主鍵策略,至於具體是如何設定預設主鍵的,我們可以直接進入到initTableIdWithoutAnnotation方法當中。

public static void initTableFields(Class<?> clazz, GlobalConfig globalConfig, TableInfo tableInfo) {
    /* 資料庫全域性配置 */
    GlobalConfig.DbConfig dbConfig = globalConfig.getDbConfig();
    List<Field> list = getAllFields(clazz);
    // 標記是否讀取到主鍵
    boolean isReadPK = false;
    // 是否存在 @TableId 註解
    boolean existTableId = isExistTableId(list);

    List<TableFieldInfo> fieldList = new ArrayList<>();
    for (Field field : list) {
        /*
         * 主鍵ID 初始化
         */
        if (!isReadPK) {
            if (existTableId) {
                isReadPK = initTableIdWithAnnotation(dbConfig, tableInfo, field, clazz);
            } else {
                isReadPK = initTableIdWithoutAnnotation(dbConfig, tableInfo, field, clazz);
            }
            if (isReadPK) {
                continue;
            }
        }
       ......
    }
   ......
}

initTableIdWithoutAnnotation方法——

private static final String DEFAULT_ID_NAME = "id";
/**
 * <p>
 * 主鍵屬性初始化
 * </p>
 *
 * @param tableInfo 表資訊
 * @param field     欄位
 * @param clazz     實體類
 * @return true 繼續下一個屬性判斷,返回 continue;
 */
private static boolean initTableIdWithoutAnnotation(GlobalConfig.DbConfig dbConfig, TableInfo tableInfo,
                                                 Field field, Class<?> clazz) {
    //獲取實體類欄位名
    String column = field.getName();
    if (dbConfig.isCapitalMode()) {
        column = column.toUpperCase();
    }
    //當欄位名為id
    if (DEFAULT_ID_NAME.equalsIgnoreCase(column)) {
        if (StringUtils.isEmpty(tableInfo.getKeyColumn())) {
            tableInfo.setKeyRelated(checkRelated(tableInfo.isUnderCamel(), field.getName(), column))
                //設定表策略
                .setIdType(dbConfig.getIdType())
                .setKeyColumn(column)
                .setKeyProperty(field.getName())
                .setClazz(field.getDeclaringClass());
            return true;
        } else {
            throwExceptionId(clazz);
        }
    }
    return false;
}

Debug到這裡,可以看到,如果沒有 @TableId 註解顯示設定主鍵策略情況下,預設設定的是 ID_WORKER(3),即會根據雪花演算法生成19位數字,long型別。
image

可以進一步發現,這裡的 dbConfig是GlobalConfig.DbConfig例項,進入到DbConfig類,可以看到原來實體類對映的資料庫設定在這裡,主鍵型別預設是IdType.ID_WORKER。

@Data
public static class DbConfig {

    /**
     * 資料庫型別
     */
    private DbType dbType = DbType.OTHER;
    /**
     * 主鍵型別(預設 ID_WORKER)
     */
    private IdType idType = IdType.ID_WORKER;
    /**
     * 表名字首
     */
    private String tablePrefix;
    /**
     * 表名、是否使用下劃線命名(預設 true:預設資料庫表下劃線命名)
     */
    private boolean tableUnderline = true;
    /**
     * String 型別欄位 LIKE
     */
    private boolean columnLike = false;
    /**
     * 大寫命名
     */
    private boolean capitalMode = false;
    /**
     * 表關鍵詞 key 生成器
     */
    private IKeyGenerator keyGenerator;
    /**
     * 邏輯刪除全域性值(預設 1、表示已刪除)
     */
    private String logicDeleteValue = "1";
    /**
     * 邏輯未刪除全域性值(預設 0、表示未刪除)
     */
    private String logicNotDeleteValue = "0";
    /**
     * 欄位驗證策略
     */
    private FieldStrategy fieldStrategy = FieldStrategy.NOT_NULL;
}

至於如何生成雪花演算法id,這裡就不一一詳細介紹,具體邏輯是在MybatisDefaultParameterHandler類populateKeys方法裡,核心程式碼如下——

protected static Object populateKeys(MetaObjectHandler metaObjectHandler, TableInfo tableInfo,
                                     MappedStatement ms, Object parameterObject, boolean isInsert) {
    if (null == tableInfo) {
        /* 不處理 */
        return parameterObject;
    }
    /* 自定義元物件填充控制器 */
    MetaObject metaObject = ms.getConfiguration().newMetaObject(parameterObject);
    // 填充主鍵
    if (isInsert && !StringUtils.isEmpty(tableInfo.getKeyProperty())
        && null != tableInfo.getIdType() && tableInfo.getIdType().getKey() >= 3) {
        Object idValue = metaObject.getValue(tableInfo.getKeyProperty());
        /* 自定義 ID */
        if (StringUtils.checkValNull(idValue)) {
            if (tableInfo.getIdType() == IdType.ID_WORKER) {
                metaObject.setValue(tableInfo.getKeyProperty(), IdWorker.getId());
            } else if (tableInfo.getIdType() == IdType.ID_WORKER_STR) {
                metaObject.setValue(tableInfo.getKeyProperty(), IdWorker.getIdStr());
            } else if (tableInfo.getIdType() == IdType.UUID) {
                metaObject.setValue(tableInfo.getKeyProperty(), IdWorker.get32UUID());
            }
        }
    }
   ......
}

前邊提到,預設的主鍵策略是IdType.ID_WORKER,這裡有一個判斷tableInfo.getIdType() == IdType.ID_WORKER,對程式碼Debug可以看到,metaObject的setValue(tableInfo.getKeyProperty(), IdWorker.getId())程式碼的作用,是對註解id進行了值填充。

image
填充的值為IdWorker.getId()返回的1468970800437465089,剛好是19位長度,這就意味著,這裡產生的id值,就是我們最後要找的。

IdWorker.getId()實現本質,正好是基於Snowflake實現64位自增ID演算法,而Snowflake,正是引用了雪花演算法——

/**
 * <p>
 * 高效GUID產生演算法(sequence),基於Snowflake實現64位自增ID演算法。 <br>
 * 優化開源專案 http://git.oschina.net/yu120/sequence
 * </p>
 *
 * @author hubin
 * @since 2016-08-01
 */
public class IdWorker {

    /**
     * 主機和程式的機器碼
     */
    private static final Sequence WORKER = new Sequence();

    public static long getId() {
        return WORKER.nextId();
    }

    public static String getIdStr() {
        return String.valueOf(WORKER.nextId());
    }

    /**
     * <p>
     * 獲取去掉"-" UUID
     * </p>
     */
    public static synchronized String get32UUID() {
        return UUID.randomUUID().toString().replace(StringPool.DASH, StringPool.EMPTY);
    }

}

相關文章