1. 项目概述:为什么需要数据变更记录?
在业务系统开发中,数据变更记录(又称审计日志)是保障数据安全性和可追溯性的重要手段。想象这样一个场景:财务系统中的关键金额字段被修改,如果没有详细的变更记录,当出现问题时我们将无法回答"谁在什么时间修改了什么数据?原值是什么?新值是什么?"这些关键问题。
传统做法是在每个业务Service中手动插入日志代码,但这种方式存在明显弊端:
- 代码侵入性强,业务逻辑与审计逻辑耦合
- 重复劳动,每个修改操作都需要写相似代码
- 容易遗漏,新开发的功能可能忘记添加审计逻辑
而使用SpringBoot+MyBatis-Plus的组合,我们可以通过注解和AOP实现声明式的审计日志,让系统自动记录所有数据变更。这不仅减少了开发工作量,还能确保审计覆盖的全面性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心方案设计
2.1 技术选型分析
选择MyBatis-Plus作为ORM框架的优势在于:
- 内置的BaseMapper提供了通用的CRUD方法
- 支持Lambda表达式,避免硬编码字段名
- 丰富的插件体系(如自动填充、乐观锁等)
- 与SpringBoot无缝集成,减少配置工作
审计方案的核心思路是:
- 使用MyBatis-Plus的
@TableField注解标记需要审计的字段 - 通过AOP拦截Mapper的update方法
- 利用反射获取修改前后的字段值差异
- 将变更记录存入审计日志表
2.2 数据库表设计
需要两张核心表:
- 业务表(以用户表为例):
sql复制CREATE TABLE `sys_user` (
`id` bigint NOT NULL COMMENT '主键',
`username` varchar(64) NOT NULL COMMENT '用户名',
`password` varchar(128) NOT NULL COMMENT '密码',
`status` tinyint DEFAULT '1' COMMENT '状态(1:正常,0:禁用)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) COMMENT='用户表';
- 审计日志表:
sql复制CREATE TABLE `sys_audit_log` (
`id` bigint NOT NULL COMMENT '主键ID',
`table_name` varchar(64) NOT NULL COMMENT '表名',
`record_id` bigint NOT NULL COMMENT '记录ID',
`field_name` varchar(64) NOT NULL COMMENT '字段名',
`old_value` text COMMENT '旧值',
`new_value` text COMMENT '新值',
`operation_type` varchar(16) NOT NULL COMMENT '操作类型(INSERT/UPDATE/DELETE)',
`operator_id` bigint DEFAULT NULL COMMENT '操作人ID',
`operator_name` varchar(64) DEFAULT NULL COMMENT '操作人姓名',
`operation_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
`client_ip` varchar(64) DEFAULT NULL COMMENT '客户端IP',
PRIMARY KEY (`id`),
KEY `idx_table_record` (`table_name`,`record_id`)
) COMMENT='审计日志表';
3. 实现细节解析
3.1 自定义审计注解
首先定义标记字段是否需要审计的注解:
java复制@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditField {
/**
* 字段描述
*/
String value() default "";
/**
* 是否忽略空值变化
*/
boolean ignoreNull() default true;
}
在实体类中使用注解:
java复制@Data
@TableName("sys_user")
public class User {
private Long id;
@AuditField("用户名")
private String username;
@AuditField(value = "状态", ignoreNull = false)
private Integer status;
// 其他字段...
}
3.2 MyBatis-Plus插件实现
通过实现MyBatis-Plus的InnerInterceptor接口,我们可以拦截SQL执行:
java复制@Slf4j
@Component
@RequiredArgsConstructor
public class AuditInterceptor implements InnerInterceptor {
private final AuditLogService auditLogService;
@Override
public void beforeUpdate(Executor executor, MappedStatement ms,
Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) {
// 1. 获取原始记录
Object original = getOriginalRecord(ms, parameter);
if (original == null) return;
// 2. 获取修改后的实体
Object current = getCurrentEntity(parameter);
if (current == null) return;
// 3. 比较字段变化
List<FieldChange> changes = compareFields(original, current);
if (!changes.isEmpty()) {
// 4. 保存审计日志
auditLogService.saveLog(
current.getClass().getSimpleName(),
getIdValue(current),
changes,
"UPDATE"
);
}
}
// 其他方法实现...
}
3.3 字段比较逻辑
核心的字段比较方法实现:
java复制private List<FieldChange> compareFields(Object original, Object current) {
List<FieldChange> changes = new ArrayList<>();
Class<?> clazz = original.getClass();
// 获取所有字段(包括父类)
List<Field> fields = getAllFields(clazz);
for (Field field : fields) {
AuditField auditField = field.getAnnotation(AuditField.class);
if (auditField == null) continue;
try {
field.setAccessible(true);
Object oldValue = field.get(original);
Object newValue = field.get(current);
// 忽略未变化的字段
if (Objects.equals(oldValue, newValue)) continue;
// 如果配置了忽略null且新值为null则跳过
if (auditField.ignoreNull() && newValue == null) continue;
changes.add(new FieldChange(
field.getName(),
auditField.value(),
String.valueOf(oldValue),
String.valueOf(newValue)
));
} catch (IllegalAccessException e) {
log.warn("获取字段值失败: {}", field.getName(), e);
}
}
return changes;
}
4. 高级功能实现
4.1 操作人信息自动填充
通过ThreadLocal保存当前用户信息:
java复制public class AuditContextHolder {
private static final ThreadLocal<Operator> CONTEXT = new ThreadLocal<>();
public static void setOperator(Operator operator) {
CONTEXT.set(operator);
}
public static Operator getOperator() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
@Data
public static class Operator {
private Long id;
private String name;
}
}
在拦截器中获取操作人信息:
java复制Operator operator = AuditContextHolder.getOperator();
if (operator != null) {
auditLog.setOperatorId(operator.getId());
auditLog.setOperatorName(operator.getName());
}
4.2 支持多租户场景
对于多租户系统,需要在审计日志中记录租户信息:
- 修改审计日志表,增加tenant_id字段
- 通过TenantContext获取当前租户ID
- 在保存日志时自动设置租户ID
java复制public class TenantContext {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CONTEXT.set(tenantId);
}
public static String getTenantId() {
return CONTEXT.get();
}
}
// 在拦截器中
String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
auditLog.setTenantId(tenantId);
}
5. 性能优化建议
5.1 异步记录审计日志
直接同步保存审计日志会影响业务操作性能,建议采用异步方式:
java复制@Async
public void saveLogAsync(AuditLog log) {
auditLogMapper.insert(log);
}
注意点:
- 需要启用Spring的异步支持(@EnableAsync)
- 需要考虑日志丢失风险,可引入消息队列确保可靠性
- 异步方法不能与被拦截方法在同一个类中
5.2 批量插入优化
当一次操作修改多条记录时,使用批量插入:
java复制public void batchInsert(List<AuditLog> logs) {
if (logs.isEmpty()) return;
SqlSession session = sqlSessionTemplate.getSqlSessionFactory()
.openSession(ExecutorType.BATCH, false);
try {
AuditLogMapper mapper = session.getMapper(AuditLogMapper.class);
for (AuditLog log : logs) {
mapper.insert(log);
}
session.commit();
} finally {
session.close();
}
}
6. 常见问题与解决方案
6.1 MyBatis-Plus的updateById无法更新null值
问题现象:使用updateById方法时,即使字段显式设置为null,数据库中的值也不会被更新。
解决方案:
- 在字段上添加
@TableField(updateStrategy = FieldStrategy.IGNORED)注解 - 或者使用UpdateWrapper进行更新:
java复制UpdateWrapper<User> wrapper = new UpdateWrapper<>();
wrapper.eq("id", user.getId())
.set("status", null);
userMapper.update(null, wrapper);
6.2 审计日志表数据量过大
随着系统运行,审计日志表会变得非常庞大,建议:
- 按时间分表(如按月分表)
- 定期归档旧数据
- 对不重要的操作减少审计字段
实现按月分表示例:
java复制public String getActualTableName(String logicTableName) {
// 如将sys_audit_log转为sys_audit_log_202301
return logicTableName + "_" + DateFormatUtils.format(new Date(), "yyyyMM");
}
6.3 敏感字段脱敏处理
对于密码等敏感字段,审计日志中应该脱敏存储:
java复制@AuditField(value = "密码", processor = "passwordMask")
private String password;
// 在比较字段时
if ("passwordMask".equals(auditField.processor())) {
newValue = "******";
oldValue = "******";
}
7. 扩展思考
7.1 支持更多操作类型
当前方案主要处理UPDATE操作,可以扩展支持:
- INSERT:记录所有字段的初始值
- DELETE:记录被删除记录的完整数据
- SELECT:记录敏感数据的查询访问
7.2 与Spring Security集成
获取当前登录用户信息更优雅的方式:
java复制Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
AuditContextHolder.setOperator(
new Operator(userDetails.getUserId(), userDetails.getUsername())
);
}
7.3 审计日志查询优化
为方便查询审计记录,可以:
- 添加更多索引(如操作时间、操作人等)
- 实现按字段名模糊查询
- 支持变更内容的关键词搜索
- 提供数据变更图谱展示
实现一个简单的查询DSL:
java复制public List<AuditLog> query(AuditLogQuery query) {
LambdaQueryWrapper<AuditLog> wrapper = new LambdaQueryWrapper<>();
if (query.getTableName() != null) {
wrapper.eq(AuditLog::getTableName, query.getTableName());
}
if (query.getRecordId() != null) {
wrapper.eq(AuditLog::getRecordId, query.getRecordId());
}
if (query.getStartTime() != null && query.getEndTime() != null) {
wrapper.between(AuditLog::getOperationTime,
query.getStartTime(), query.getEndTime());
}
return auditLogMapper.selectList(wrapper);
}
8. 完整源码结构
项目推荐结构:
code复制src/main/java
├── com.example.audit
│ ├── annotation
│ │ └── AuditField.java
│ ├── aspect
│ │ └── AuditAspect.java
│ ├── config
│ │ └── MybatisPlusConfig.java
│ ├── entity
│ │ ├── AuditLog.java
│ │ └── User.java
│ ├── interceptor
│ │ └── AuditInterceptor.java
│ ├── service
│ │ ├── AuditLogService.java
│ │ └── impl
│ │ └── AuditLogServiceImpl.java
│ └── util
│ ├── AuditContextHolder.java
│ └── TenantContext.java
src/main/resources
└── application.yml
关键配置示例(application.yml):
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted # 全局逻辑删除字段
logic-not-delete-value: 0
logic-delete-value: 1
在实际项目中,根据我的经验,审计模块最容易出现的问题是性能影响和字段对比不准确。特别是在处理大对象时,反射获取字段值的性能开销会变得明显。我通常会采用以下优化手段:
- 对频繁变更的实体类缓存Field反射信息
- 对不需要审计的字段添加@AuditField(ignore = true)
- 对大型文本字段(如content、remark)单独处理,只记录摘要信息
