接手过老系统的同学一定见过这种场面:A 表用 create_time / update_time,B 表用 gmt_create / gmt_modified,C 表用 created_at / updated_at;逻辑删除有的用 del_flag(tinyint 0/1)、有的用 is_deleted、还有的干脆物理删除;审计字段更是五花八门------有的叫 creator、有的叫 create_by、有的存名字、有的存 ID。
这次做多租户微服务重构,旧单体有约 340 张表,我索性把审计字段重新设计成一套统一规范,全部落地。这篇文章把这套设计和踩过的坑讲清楚,你可以直接抄走。
一、统一成哪 6 个字段
所有数据表,至少包含这 6 个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint | 主键,雪花算法 |
create_user_id |
bigint | 创建人 ID |
create_time |
datetime(3) | 创建时间(毫秒精度) |
last_update_user_id |
bigint | 最后修改人 ID |
last_update_time |
datetime(3) | 最后修改时间(毫秒精度) |
delete_time |
datetime(3) | 删除时间(逻辑删除:null 正常,非 null 已删) |
几个要点:
- 主键用雪花算法 (MyBatis-Plus 的
IdType.ASSIGN_ID),分布式下不依赖数据库自增、趋势递增,还能承载多租户和分库分表。 - 人用「ID」不用「名字」:主键是 Long 雪花 ID,外键关联必须同类型;前端要显示名字再 join 用户表,别图省事直接存名字(用户一改名就全废了)。
- 时间用
datetime(3)毫秒精度:秒级在高频写入下区分不了操作先后,毫秒才能看清顺序。
二、三个关键设计决策
1. 用 delete_time 替代 del_flag
传统逻辑删除是 del_flag(tinyint 0/1),它有个天生缺陷:只能知道「删没删」,不知道「什么时候删的」。审计追溯场景下,删除时间往往比删除状态更有价值------对账、恢复数据、排查误删都要它。
改成 delete_time(datetime)后:null 表示正常、非 null 表示已删除。MyBatis-Plus 官方支持 datetime 逻辑删除,查询自动拼 IS NULL、删除自动 SET delete_time = now()。本质上是「把逻辑删除做成了带时间戳的软删除」,一举两得。
2. 用 last_update 而不是 update
字段名用 last_update_time / last_update_user_id,而不是 update_time / update_user。原因很简单:语义更准确------update_time 容易被理解成「最后更新时间」,但没人说清是「谁、什么动作」更新的;last_update 直白表达「最后一次修改」。而且 update 在部分 SQL 方言里是保留字,绕开它省得引号转义。
3. Long 主键,序列化成 String 给前端
主键用 Long 雪花 ID(19 位,约 2e18),但 JS 的 Number 只能精确到 2^53(约 9e15)。19 位雪花 ID 一旦直接返回前端,JSON 解析就被截断(2094436024836198402 变成 ...400),再回传删除/更新就查不到数据、更新 0 行。
所以:后端存储和计算用 Long,序列化给前端时统一转 String。这个要全局配置 ,不是给实体字段加个 @JsonSerialize 就完事------因为 R<Long> 返回值里的裸 Long 覆盖不到(见坑 1)。
三、自动填充,别让业务代码手写
审计字段的填充逻辑绝对不能散落在每个 Service 里手写 setXxx,否则一定会漏。统一用一个基类 + 一个填充器:
java
// 实体基类:所有实体继承它,6 字段统一在这里
@Data
public abstract class BaseEntity implements Serializable {
@TableId(type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
@TableField(fill = FieldFill.INSERT)
private Long createUserId;
@TableField(fill = FieldFill.INSERT)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long lastUpdateUserId;
@TableField(fill = FieldFill.INSERT_UPDATE)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS")
private LocalDateTime lastUpdateTime;
@TableLogic
private LocalDateTime deleteTime;
}
java
// 自动填充器:insert 填 4 字段、update 填 2 字段,用户 ID 从上下文透传拿
@AutoConfiguration
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "lastUpdateTime", LocalDateTime.class, now);
Long userId = UserContextHolder.getUserId();
if (userId != null) {
this.strictInsertFill(metaObject, "createUserId", Long.class, userId);
this.strictInsertFill(metaObject, "lastUpdateUserId", Long.class, userId);
}
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "lastUpdateTime", LocalDateTime.class, LocalDateTime.now());
Long userId = UserContextHolder.getUserId();
if (userId != null) {
this.strictUpdateFill(metaObject, "lastUpdateUserId", Long.class, userId);
}
}
}
关键点:用户 ID 从 UserContextHolder 拿(网关解析 JWT 后透传 header、拦截器塞进 ThreadLocal),业务代码全程不感知审计字段,insert/update 时 MyBatis-Plus 自动填。
四、两个真实踩坑
坑 1:雪花 ID 前端精度丢失
前面说了 19 位 Long 超 JS 精度。给实体字段加 @JsonSerialize(using = ToStringSerializer.class) 只能覆盖实体字段本身,覆盖不到 R<Long> 这种返回值里的裸 Long(比如新增接口返回 R<Long> 的主键)。结果就是新增数据后前端拿到的主键是截断的,删除时传错 id,SQL 打了但 Updates: 0。
正确做法是全局配置:Jackson 注册一个模块,把 Long 类型统一走 ToStringSerializer 序列化。Spring Boot 4 用 Jackson 3,写法要变一下(JsonMapperBuilderCustomizer + JacksonModule,因为 SimpleModule.addSerializer 没了),核心就一句:type 是 Long 或其子类型就返回 ToStringSerializer.instance。
坑 2:@TableLogic 注解传参不生效
MyBatis-Plus 的逻辑删除,直觉上是 @TableLogic(value = "null", delval = "now()") 在注解里传参。但实测 value = null 会报错(GitHub issue #180),注解传参这条路走不通。正确做法是全局配置:
yaml
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleteTime
logic-delete-value: now()
logic-not-delete-value: "null" # YAML 里 null 要加引号,否则解析成 Java null
实体上只留 @TableLogic 无参注解,值全走全局配置。验证方法:看日志里的 SQL,删除应该是 UPDATE ... SET delete_time = now() WHERE id = ? AND delete_time IS NULL,且 Updates: 1。
五、落地清单(照着抄)
- 建
BaseEntity抽象类,6 字段 + 注解。 - 建
MetaObjectHandler,用@AutoConfiguration注册(别用@Component,跨模块扫不到)。 application.yml配逻辑删除全局配置。- 配 Long→String 全局序列化。
- 所有实体继承
BaseEntity,删掉各自的旧审计字段。 - 旧代码里手写的
.eq(delFlag, 0)全删掉(逻辑删除自动处理)。