数据库审计字段,别再每张表各写一套了——我统一成这 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) 全删掉(逻辑删除自动处理)。
相关推荐
SelectDB8 小时前
为什么 JSON 正在成为分析数据库新的竞争点?
数据库·json·agent
ClouGence9 小时前
开源数据库管理工具 CloudDM 4.2.0 发布,新增 GoldenDB、KingbaseES 等数据源
数据库·dba·devops
发霉的馒头9 小时前
ORA-00845: MEMORY_TARGET not supported on this system的解决方法
数据库
草莓熊Lotso11 小时前
【Redis 初阶】Set 类型深度解析:去重集合的运算能力与实战场景
linux·网络·数据库·windows·redis·tcp/ip·缓存
煎饼皮皮侠12 小时前
【设计】设计一个web版的数据库管理平台后端(之五) --借鉴mybatis
数据库·mybatis
东风破_19 小时前
danci 2:创建的单词书到底存在哪里?从 Supabase 一路理解 ORM、Drizzle 和 RLS
数据库·后端·node.js
橙子家20 小时前
OSS 文件上传的几个风险点和解决方案
数据库
2601_962066491 天前
【Sql Server】Update中的From语句,以及常见更新操作方式
android·java·数据库
愤怒的苹果ext1 天前
MySQL Shell备份恢复数据库
数据库·mysql·备份恢复·mysqlsh