Mybatis Plus 主键生成器实现思路分析

上一篇我们看到了 Mybatis 拦截器在多租户场景下的使用,这次继续看下在主键生成场景下的应用。

一、背景与问题

在使用 MyBatis-Plus 开发时,通常使用 @TableId(type = IdType.AUTO) 注解来实现主键自动生成。这种方式依赖数据库的自增特性,在 MySQL 等主流数据库上运行良好。

然而,当项目迁移到 OpenGauss 数据库时,遇到了兼容性问题:

  • 问题现象:INSERT 操作时 ID 字段为 NULL,导致插入失败
  • 根本原因:OpenGauss 使用 PostgreSQL 的序列(Sequence)机制,而非自增字段
  • 技术限制 :OpenGauss 驱动对 MyBatis-Plus 的 IdType.AUTO 支持不完善

数据库主键生成机制对比

MySQL 自增主键 (AUTO_INCREMENT)

MySQL 使用 AUTO_INCREMENT 属性实现主键自动生成:

sql 复制代码
-- MySQL 表结构
CREATE TABLE user (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100)
);

-- 插入时无需指定ID
INSERT INTO user (name) VALUES ('张三');
-- MySQL 自动生成 id = 1

特点:

  • 在表结构中定义自增属性
  • INSERT 时可以省略主键字段
  • 数据库引擎自动分配递增的ID值
  • MyBatis-Plus 的 IdType.AUTO 直接支持

PostgreSQL/OpenGauss 序列机制 (SEQUENCE)

PostgreSQL 系列数据库使用序列对象生成主键:

sql 复制代码
-- 创建序列
CREATE SEQUENCE user_id_seq START WITH 1 INCREMENT BY 1;

-- 创建表(不使用AUTO_INCREMENT)
CREATE TABLE user (
    id BIGINT DEFAULT nextval('user_id_seq') PRIMARY KEY,
    name VARCHAR(100)
);

-- 插入方式1:使用默认值
INSERT INTO user (name) VALUES ('张三');

-- 插入方式2:显式调用序列
INSERT INTO user (id, name) VALUES (nextval('user_id_seq'), '李四');

特点:

  • 序列是独立的数据库对象,与表分离
  • 需要显式调用 nextval() 函数获取下一个值
  • 支持更灵活的配置(起始值、步长、缓存等)
  • 可以被多个表共享

MyBatis-Plus 兼容性问题

MyBatis-Plus 的 IdType.AUTO 设计主要针对 MySQL 的自增机制:

java 复制代码
// MyBatis-Plus 期望的行为
@TableId(type = IdType.AUTO)
private Long id;

// 生成的 INSERT SQL(MySQL)
INSERT INTO user (name) VALUES (?)
// MySQL 自动填充 id 字段

// 在 PostgreSQL/OpenGauss 中的问题
INSERT INTO user (name) VALUES (?)
// 没有调用 nextval(),id 字段为 NULL,违反主键约束

失效原因:

  1. SQL 生成差异:MyBatis-Plus 生成的 INSERT 语句不包含序列调用
  2. 驱动支持不足:OpenGauss 驱动无法自动识别并调用对应的序列
  3. 框架假设:MyBatis-Plus 假设数据库会自动处理主键生成,但 PostgreSQL 需要显式操作

为解决这个问题,项目采用了自定义 MyBatis 拦截器的方案,在 INSERT 操作前自动从数据库序列获取 ID 值。

二、MyBatis 拦截器机制

拦截器工作原理

MyBatis 提供了插件(Plugin)机制,允许在 SQL 执行的关键节点进行拦截和增强。拦截器基于 JDK 动态代理实现,可以拦截以下四种对象的方法:

拦截对象 作用 常见拦截方法
Executor SQL 执行器 update, query, commit, rollback
StatementHandler SQL 语句处理器 prepare, parameterize, batch, update, query
ParameterHandler 参数处理器 getParameterObject, setParameters
ResultSetHandler 结果集处理器 handleResultSets, handleOutputParameters

拦截器执行流程

sequenceDiagram participant App as Application participant MyBatis participant Interceptor participant Executor participant DB as Database App->>MyBatis: Execute Mapper method MyBatis->>Interceptor: Call intercept() Interceptor->>Interceptor: Pre-processing Interceptor->>Executor: invocation.proceed() Executor->>DB: Execute SQL DB-->>Executor: Return result Executor-->>Interceptor: Return result Interceptor->>Interceptor: Post-processing Interceptor-->>MyBatis: Return final result MyBatis-->>App: Return result

拦截器接口定义

java 复制代码
public interface Interceptor {
    // 拦截目标方法的执行
    Object intercept(Invocation invocation) throws Throwable;
    
    // 为目标对象创建代理
    Object plugin(Object target);
    
    // 设置拦截器属性
    void setProperties(Properties properties);
}

三、ID 主键生成拦截器实现

整体架构

项目中的 ID 主键生成方案由三个核心组件构成:

flowchart TD A[INSERT Operation] --> B[AutoIdGeneratorInterceptor] B --> C{Check @TableId} C -->|type=AUTO| D{ID is null?} C -->|Other types| E[Skip processing] D -->|Yes| F[PostgresSequenceGenerator] D -->|No| E F --> G[Get table name] G --> H[Build sequence name] H --> I[Execute SELECT nextval] I --> J[Set ID field value] J --> K[Continue INSERT] E --> K

组件 1:AutoIdGeneratorInterceptor

核心职责:

  • 拦截所有 INSERT 操作
  • 检查实体类的 @TableId 注解配置
  • IdType.AUTO 类型且值为 null 的字段生成 ID

核心实现:

java 复制代码
@Component
@Intercepts({
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class AutoIdGeneratorInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        
        // 只处理INSERT操作
        if (SqlCommandType.INSERT.equals(ms.getSqlCommandType())) {
            // 反射检查@TableId(type=AUTO)字段,如果为null调用PostgresSequenceGenerator的nextId生成ID
            processEntity(parameter);
        }
        
        return invocation.proceed();
    }
}

关键技术点:

  1. 拦截 Executor.update 方法 :INSERT/UPDATE/DELETE 都会调用此方法,通过 SqlCommandType 区分操作类型
  2. 反射访问字段 :使用 field.setAccessible(true) 访问私有字段
  3. 注解检查 :只处理 @TableId(type = IdType.AUTO) 标注的字段
  4. 空值判断:仅在 ID 为 null 时生成,避免覆盖已有值

组件 2:PostgresSequenceGenerator

核心职责:

  • 根据实体类确定数据库表名
  • 按约定构造序列名({table_name}_id_seq
  • 执行 SQL 获取序列的下一个值

核心实现:

java 复制代码
@Component
public class PostgresSequenceGenerator implements IKeyGenerator {
    
    public Long nextId(Object entity) {
        String tableName = getTableName(entity);
        String sequenceName = tableName + "_id_seq";
        
        // OpenGauss要求nextval()参数为常量,不能使用PreparedStatement
        String sql = "SELECT nextval('" + sequenceName + "')";
        
        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(sql)) {
            return rs.next() ? rs.getLong(1) : null;
        } catch (Exception e) {
            throw new RuntimeException("Failed to generate ID from sequence", e);
        }
    }
}

关键技术点:

  1. 表名获取策略 :优先使用 @TableName 注解,否则根据类名转换
  2. SQL 注入防护 :序列名必须匹配 ^[a-zA-Z0-9_]+$ 正则表达式
  3. OpenGauss 兼容性 :使用 Statement 而非 PreparedStatement,因为 OpenGauss 要求 nextval() 参数为常量

序列机制深入解析

序列的创建与管理:

sql 复制代码
-- 创建序列(OpenGauss)
CREATE SEQUENCE xxx_id_seq
    START WITH 1          -- 起始值
    INCREMENT BY 1        -- 步长
    MINVALUE 1           -- 最小值
    MAXVALUE 9223372036854775807  -- 最大值(BIGINT最大值)
    CACHE 1              -- 缓存大小
    NO CYCLE;            -- 不循环

-- 查看序列信息
SELECT * FROM information_schema.sequences 
WHERE sequence_name = 'xxx_id_seq';

-- 获取序列当前值(不消耗)
SELECT currval('xxx_id_seq');

-- 获取序列下一个值(消耗一个值)
SELECT nextval('xxx_id_seq');

-- 设置序列当前值
SELECT setval('xxx_id_seq', 1000);

序列 vs 自增字段对比:

特性 PostgreSQL 序列 MySQL 自增
独立性 独立对象,可被多表共享 绑定到特定表的特定列
灵活性 支持复杂配置(步长、缓存等) 配置选项有限
性能 可配置缓存提升性能 数据库引擎优化
事务安全 序列值不会因事务回滚而回收 自增值可能因回滚产生间隙
跨表使用 一个序列可供多个表使用 每个表独立的自增计数器
重置操作 可以随时重置序列值 需要 ALTER TABLE 操作

OpenGauss 特殊限制:

OpenGauss 对序列操作有特殊要求,这也是 MyBatis-Plus 兼容性问题的根源:

java 复制代码
// ❌ 错误:OpenGauss 不支持参数化的 nextval()
PreparedStatement ps = conn.prepareStatement("SELECT nextval(?)");
ps.setString(1, "user_id_seq");

// ✅ 正确:必须使用字符串常量
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT nextval('user_id_seq')");

这个限制要求我们:

  • 必须在 SQL 中硬编码序列名
  • 需要严格验证序列名防止 SQL 注入
  • 无法使用 MyBatis 的参数绑定机制

组件 3:MybatisPlusConfig

核心职责:

  • 在 Spring 容器初始化后注册拦截器
  • 支持多数据源场景,为每个 SqlSessionFactory 注册拦截器

核心实现:

java 复制代码
@Configuration
public class MybatisPlusConfig {
    
    @PostConstruct
    public void addInterceptor() {
        // 为所有SqlSessionFactory注册拦截器
        for (SqlSessionFactory factory : sqlSessionFactoryList) {
            Configuration config = factory.getConfiguration();
            if (!config.getInterceptors().contains(autoIdGeneratorInterceptor)) {
                config.addInterceptor(autoIdGeneratorInterceptor);
            }
        }
    }
}

关键技术点:

  1. @PostConstruct 时机:确保在 Spring 容器初始化后、应用启动前注册拦截器
  2. 多数据源支持 :遍历所有 SqlSessionFactory 实例,逐一注册
  3. 重复注册检查:避免同一个拦截器被重复注册
  4. 注入 List :Spring 会自动注入所有 SqlSessionFactory 类型的 Bean

四、使用方式

实体类配置

使用标准的 MyBatis-Plus 注解即可,无需额外配置:

java 复制代码
@Data
@TableName("xxx")
public class xxx {
    
    @TableId(type = IdType.AUTO)
    private Long id;
}

数据库序列创建

确保数据库中存在对应的序列:

sql 复制代码
-- 创建序列(如果不存在)
CREATE SEQUENCE IF NOT EXISTS xxx_id_seq
    START WITH 1
    INCREMENT BY 1
    NO MINVALUE
    NO MAXVALUE
    CACHE 1;

-- 设置序列的当前值(可选)
SELECT setval('xxx_id_seq', 1000, false);

Mapper 接口

无需特殊处理,使用标准的 MyBatis-Plus 方法:

java 复制代码
@Mapper
public interface xxxMapper extends BaseMapper<xxx> {
    // 继承BaseMapper即可,无需额外配置
}

Service 层调用

java 复制代码
public void addCategory(String categoryName) {
    xxx category = new xxx();
    category.setCategoryName(categoryName);
    // 无需手动设置ID,拦截器会自动生成
    
    categoryMapper.insert(category);
    // 插入后,category.getId() 已经有值
}

五、技术方案对比

主键生成方案对比

方案 实现方式 优势 劣势 适用场景
数据库自增 AUTO_INCREMENT 简单,性能好 分布式问题,迁移困难 单机MySQL应用
MyBatis-Plus AUTO @TableId(type=AUTO) 配置简单,框架支持 数据库兼容性问题 标准MySQL/PostgreSQL
自定义拦截器 Interceptor + Sequence 完全控制,兼容性好 实现复杂,需维护 特殊数据库、复杂规则
UUID UUID.randomUUID() 全局唯一,无依赖 存储空间大,无序 分布式系统
雪花算法 Snowflake 高性能,趋势递增 依赖时钟,配置复杂 高并发分布式系统
Redis自增 INCR命令 高性能,集中管理 依赖Redis,单点问题 中小规模分布式

拦截器方案 vs MyBatis-Plus 原生方案

对比维度 自定义拦截器 MyBatis-Plus 原生
数据库兼容性 ✅ 支持 OpenGauss 等魔改数据库 ⚠️ 部分数据库不支持
实现复杂度 ⚠️ 需要编写拦截器代码 ✅ 配置即可使用
灵活性 ✅ 可自定义生成逻辑 ❌ 依赖框架实现
性能开销 ⚠️ 每次INSERT额外查询序列 ✅ 数据库原生支持
维护成本 ⚠️ 需要维护拦截器代码 ✅ 框架统一维护
调试难度 ⚠️ 需要理解拦截器机制 ✅ 问题较少

六、注意事项与最佳实践

1. SQL 注入防护

序列名必须进行严格验证:sequenceName.matches("^[a-zA-Z0-9_]+$"),避免直接拼接用户输入。

2. 性能考虑

每次 INSERT 都会额外执行一次序列查询,高并发场景需要:

  • 使用高性能连接池(HikariCP)
  • 考虑批量获取序列值并缓存
  • 监控序列查询的响应时间

3. 事务一致性

序列生成在 INSERT 事务外执行,事务回滚时序列值会被消耗,导致序列不连续。这是序列机制的正常行为,不影响数据一致性。

4. 多数据源场景

拦截器会自动为所有 SqlSessionFactory 注册,需确保每个数据源的表都有对应的序列。

5. 序列命名约定

统一使用 {table_name}_id_seq 格式,如 xxx_id_seq

七、常见问题排查

问题 1:ID 仍然为 NULL

可能原因:

  1. 拦截器未正确注册
  2. 实体类注解配置错误
  3. 数据库序列不存在

排查步骤:

  1. 检查拦截器是否注册:查看 Configuration.getInterceptors() 列表
  2. 检查实体类注解:确保 @TableId(type = IdType.AUTO)
  3. 检查数据库序列:SELECT * FROM information_schema.sequences WHERE sequence_name = 'table_id_seq'

问题 2:序列不存在错误

错误信息:

vbnet 复制代码
ERROR: relation "xxx_id_seq" does not exist

解决方案:

sql 复制代码
-- 创建缺失的序列
CREATE SEQUENCE xxx_id_seq START WITH 1;

-- 或者批量创建所有表的序列
DO $$
DECLARE
    r RECORD;
BEGIN
    FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public'
    LOOP
        EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %I_id_seq', r.tablename);
    END LOOP;
END $$;

问题 3:性能下降

现象: INSERT 操作变慢

分析:

  • 每次 INSERT 额外执行一次序列查询
  • 数据库连接池配置不当
  • 序列缓存设置过小

优化方案:

  1. 增加序列缓存:ALTER SEQUENCE table_id_seq CACHE 100
  2. 优化连接池配置:调整 HikariCP 的 maximum-pool-size 和 minimum-idle
  3. 考虑批量获取序列值并缓存,减少数据库交互

八、总结

自定义 MyBatis 拦截器实现主键自动生成是一种灵活且强大的解决方案,特别适用于:

  1. 数据库兼容性问题:如 OpenGauss、达梦等国产数据库
  2. 复杂 ID 生成规则:需要自定义生成逻辑的场景
  3. 框架限制:MyBatis-Plus 原生方案无法满足需求

核心优势:

  • ✅ 完全控制 ID 生成逻辑
  • ✅ 良好的数据库兼容性
  • ✅ 对业务代码透明,无侵入

需要注意:

  • ⚠️ 实现和维护成本较高
  • ⚠️ 性能开销需要评估
  • ⚠️ 需要理解 MyBatis 拦截器机制

通过合理的设计和实现,自定义拦截器可以成为解决特定场景问题的有效工具。

相关推荐
程序喵大人6 小时前
SQLITE问题整理
开发语言·数据库·c++·sqlite
菜鸟小九6 小时前
redis实战(缓存)
数据库·redis·缓存
lionliu05196 小时前
数据库的乐观锁和悲观锁的区别
java·数据库·oracle
快乐就去敲代码@!6 小时前
Boot Cache Star ⭐(高性能两级缓存系统)
spring boot·redis·后端·缓存·docker·压力测试
晴天¥6 小时前
Oracle中的表空间
运维·数据库·oracle
小高求学之路6 小时前
Neo4j - 为什么需要图数据库
数据库·neo4j
阿杆.6 小时前
如何在 Spring Boot 中接入 Amazon ElastiCache
java·spring boot·后端
Tjohn96 小时前
前后端分离项目(Vue-SpringBoot)迁移记录
前端·vue.js·spring boot
rocksun6 小时前
Rust 异步编程:Futures 与 Tokio 深度解析
数据库·rust