DEBUG.WIKI
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
雨云;给项目一台云服务器
RackNerd;$21.99/年 · 1GB KVM

PostgreSQL identity 列插入 NULL 报 violates not-null constraint

PostgreSQL identity 列显式插入 NULL 不会触发自动编号。通过省略列或 DEFAULT 修正 SQL,并为 JDBC 自动编号与指定编号分别绑定参数。

Java 程序往自增编号列传了 null,预期数据库生成编号,实际却收到:

ERROR: null value in column "id" violates not-null constraint

表结构里的这一列可能已经是 bigint GENERATED BY DEFAULT AS IDENTITY。显式传入 SQL NULL 不等于没有提供值。BY DEFAULT 接受调用方提供的值,而 identity 列本身要求非空,所以这个空值会被拒绝。

需要自动生成编号时,应从插入列清单里省略该列,或在 SQL 中写 DEFAULT。这适用于使用 identity 列的 PostgreSQL 插入操作;下面以普通表、单个 identity 列说明,具体语义按 PostgreSQL 18 核对,PostgreSQL 11 的插入语法也支持这两种写法。升级 JDBC 驱动不会把 SQL NULL 自动改成 DEFAULT。

核对列定义和真正发送的 INSERT

先在报错所用的数据库查询。示例表名是 public.tasks,编号列是 id,应替换为实际对象:

SELECT column_name, data_type, is_nullable,
       is_identity, identity_generation
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'tasks'
ORDER BY ordinal_position;

如果 id 的 is_identity 为 YES、identity_generation 为 BY DEFAULT,本文的主要处理路径适用。若结果是 ALWAYS,显式值通常会先触发“不允许插入非 DEFAULT 值”的错误;若不是 identity 列,就要另查默认值和表定义。

接着查看应用生成的 SQL 与绑定参数,注意不要把敏感业务值输出到公开日志。下面两种形式都在向 id 提供空值:

INSERT INTO public.tasks (id, title)
VALUES (NULL, 'demo task');

以及 JDBC 的 VALUES (?, ?),第一个参数通过 setNull 或相应的空值绑定方法传入。检查对象中的 id == null 还不够:关键是 ORM 最后省略了这一列,还是把它写进 SQL 并绑定为 NULL。

自动编号时,省略 id 或写 DEFAULT

在新的隔离测试数据库中,下面的表定义可以作为示例;已有业务表不要为排错而重建:

CREATE TABLE public.tasks (
    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title text NOT NULL
);

普通新增记录只传业务字段:

INSERT INTO public.tasks (title)
VALUES ('demo task')
RETURNING id;

如果固定列清单必须保留 id,则将 SQL 写成:

INSERT INTO public.tasks (id, title)
VALUES (DEFAULT, 'demo task')
RETURNING id;

这两个示例是二选一的写法,不要为同一条业务记录连续执行。RETURNING id 返回本次实际写入的编号,调用方应使用它关联后续操作,不要用查询全表最大编号的方式猜测自己插入的是哪一行。

DEFAULT 是 SQL 关键字。把字符串 "DEFAULT" 绑定给一个 ? 参数,不会让它变成关键字;setNull 也不会请求默认值。可以把 DEFAULT 直接写进预编译 SQL,只把业务字段保留为参数。

同时支持自动编号和指定编号的 JDBC 写法

如果业务确实有两种入口,一种生成新编号,另一种保留已审核的编号,可以分别使用两条固定 SQL。下面的方法放在项目的 DAO 类中,使用调用方提供的 Connection;事务的提交和回滚仍由调用方管理。它只适用于上面的 BY DEFAULT 表定义。

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

// Place this method inside your DAO class.
public static long insertTask(Connection connection,
                              Long suppliedId,
                              String title) throws SQLException {
    String sql = suppliedId == null
        ? "INSERT INTO public.tasks (title) VALUES (?) RETURNING id"
        : "INSERT INTO public.tasks (id, title) VALUES (?, ?) RETURNING id";

    try (PreparedStatement statement = connection.prepareStatement(sql)) {
        int parameter = 1;
        if (suppliedId != null) {
            statement.setLong(parameter++, suppliedId);
        }
        statement.setString(parameter, title);
        try (ResultSet result = statement.executeQuery()) {
            if (!result.next()) {
                throw new SQLException("Insert returned no id");
            }
            return result.getLong(1);
        }
    }
}

这里空编号分支不会把 id 放进插入列清单,因此触发自动生成;非空分支才绑定编号。所有业务值继续使用参数绑定,不需要把编号或标题拼进 SQL。指定编号仍受主键唯一性约束,重复编号应作为数据冲突处理。

若实际列定义为 GENERATED ALWAYS,自动编号分支仍可省略 id;指定编号分支则需要另外设计显式覆盖操作。不要仅为了让一条插入语句通过,就把全表改为 BY DEFAULT 或删除 identity 定义。

OVERRIDING SYSTEM VALUE 用于接受提供的 identity 值,不能把 NULL 变成自动编号。相反,OVERRIDING USER VALUE 会忽略所提供的 identity 值,但它也会忽略非空的指定编号,因此不适合用作“有编号就保留、没有就生成”的条件开关。

确认修复没有改变编号语义

先在隔离副本检查自动编号入口:不提供编号时插入成功,返回非空 id,并能用该编号查到对应业务字段。需要指定编号的入口,应另选不会冲突的测试编号,确认实际保存的就是该值。还应检查失败路径没有吞掉异常或把重复提交当成成功。

如果使用事务回滚测试,表里的插入可以撤销,但序列已经分配的值不会因此退回。编号出现间隔不代表插入修复失败,也不应为了填补间隔而重置序列。

同样,手工提供编号不能理解为自动序列已随之对齐。若入口用于导入旧数据,后续自动编号是否会撞上导入值,需要作为导入流程的一部分另外核对,不能拿这次解决空值错误来代替序列管理。

这次修改通常只需要调整插入语句和参数绑定。发布前保留旧 DAO 或 ORM 配置,确认所有新增入口的字段行为;回退代码不会自动删除已经提交的记录。遇到连接中断等结果不明的情况,先核对业务唯一标识对应的记录是否已存在,再决定是否重试,避免产生重复任务。