上一篇我们看到了 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,违反主键约束
失效原因:
- SQL 生成差异:MyBatis-Plus 生成的 INSERT 语句不包含序列调用
- 驱动支持不足:OpenGauss 驱动无法自动识别并调用对应的序列
- 框架假设: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 |
拦截器执行流程
拦截器接口定义
java
public interface Interceptor {
// 拦截目标方法的执行
Object intercept(Invocation invocation) throws Throwable;
// 为目标对象创建代理
Object plugin(Object target);
// 设置拦截器属性
void setProperties(Properties properties);
}
三、ID 主键生成拦截器实现
整体架构
项目中的 ID 主键生成方案由三个核心组件构成:
组件 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();
}
}
关键技术点:
- 拦截 Executor.update 方法 :INSERT/UPDATE/DELETE 都会调用此方法,通过
SqlCommandType区分操作类型 - 反射访问字段 :使用
field.setAccessible(true)访问私有字段 - 注解检查 :只处理
@TableId(type = IdType.AUTO)标注的字段 - 空值判断:仅在 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);
}
}
}
关键技术点:
- 表名获取策略 :优先使用
@TableName注解,否则根据类名转换 - SQL 注入防护 :序列名必须匹配
^[a-zA-Z0-9_]+$正则表达式 - 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);
}
}
}
}
关键技术点:
- @PostConstruct 时机:确保在 Spring 容器初始化后、应用启动前注册拦截器
- 多数据源支持 :遍历所有
SqlSessionFactory实例,逐一注册 - 重复注册检查:避免同一个拦截器被重复注册
- 注入 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
可能原因:
- 拦截器未正确注册
- 实体类注解配置错误
- 数据库序列不存在
排查步骤:
- 检查拦截器是否注册:查看 Configuration.getInterceptors() 列表
- 检查实体类注解:确保
@TableId(type = IdType.AUTO) - 检查数据库序列:
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 额外执行一次序列查询
- 数据库连接池配置不当
- 序列缓存设置过小
优化方案:
- 增加序列缓存:
ALTER SEQUENCE table_id_seq CACHE 100 - 优化连接池配置:调整 HikariCP 的 maximum-pool-size 和 minimum-idle
- 考虑批量获取序列值并缓存,减少数据库交互
八、总结
自定义 MyBatis 拦截器实现主键自动生成是一种灵活且强大的解决方案,特别适用于:
- 数据库兼容性问题:如 OpenGauss、达梦等国产数据库
- 复杂 ID 生成规则:需要自定义生成逻辑的场景
- 框架限制:MyBatis-Plus 原生方案无法满足需求
核心优势:
- ✅ 完全控制 ID 生成逻辑
- ✅ 良好的数据库兼容性
- ✅ 对业务代码透明,无侵入
需要注意:
- ⚠️ 实现和维护成本较高
- ⚠️ 性能开销需要评估
- ⚠️ 需要理解 MyBatis 拦截器机制
通过合理的设计和实现,自定义拦截器可以成为解决特定场景问题的有效工具。