数据库审计字段,别再每张表各写一套了——我统一成这 6 个字段

接手过老系统的同学一定见过这种场面: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。

五、落地清单(照着抄)

  1. 建 BaseEntity 抽象类,6 字段 + 注解。
  2. 建 MetaObjectHandler,用 @AutoConfiguration 注册(别用 @Component,跨模块扫不到)。
  3. application.yml 配逻辑删除全局配置。
  4. 配 Long→String 全局序列化。
  5. 所有实体继承 BaseEntity,删掉各自的旧审计字段。
  6. 旧代码里手写的 .eq(delFlag, 0) 全删掉(逻辑删除自动处理)。
相关推荐
KIDULT°6 小时前
MySQL系列 (1):数据库原理
运维·数据库·mysql·云计算
我不是程序员三三6 小时前
透明加密软件偶发解密异常排错实录:文件时能打开时乱码
数据库·域智盾
snhna6 小时前
向量数据库深度实战:从 HNSW 到 pgvector、Milvus 选型与 RAG 落地
数据库·人工智能·python·postgresql·milvus
天行健,君子而铎7 小时前
数据分类分级的范式转换:从规则匹配到场景化高准确率一键部署
网络·数据库·安全
牛战士coding7 小时前
MySQL 读写分离完整链路:主从复制、多数据源与 @DS 路由
数据库·mysql
goehou8 小时前
MCP 查数实战:让 LLM 安全地查数据库(只读账号、SQL 校验、参数漂移)
数据库·ai·llm·教程·mcp·function calling
我不会插花弄玉8 小时前
7.函数【由浅入深-MySQL】
android·数据库·mysql
周小董9 小时前
[1369]Windows上安装PostgreSQL
数据库·windows·postgresql
一只积极向上的小咸鱼11 小时前
第一性原理思考:从拆解问题到用实验检验答案
数据库
这个DBA有点耶11 小时前
时序数据库InfluxDB高基数问题深度拆解:内存索引膨胀机制与OOM完整链路
数据库·架构·dba