PostgreSQL jsonb 写入踩坑:一条 \u0000 如何让整批同步失败,还把异常伪装成"数据完整性冲突"
前言
最近在做企业微信会话存档同步时,遇到一次很"迷惑"的故障:
- 日志里抛的是 Spring 的
DataIntegrityViolationException - 第一反应是主键 / 唯一键冲突
- 表上确实有
msg_id唯一约束,SQL 也写了ON CONFLICT DO UPDATE - 测试环境把同一条 SQL 连跑两遍,却完全不报错
真正的根因,和约束一点关系都没有。
是 PostgreSQL 的 jsonb 拒绝接受 Unicode NUL(\u0000)。再加上 JDBC 批量写入,一条脏数据就能把整批 INSERT 打回,同步位点卡住。
这篇文章按排查顺序把这件事讲清楚,方便以后再碰到同类问题,少走弯路。
现象
会话记录同步任务在批量 upsert 时失败,核心栈大致是:
text
org.springframework.dao.DataIntegrityViolationException:
xxx.mapper.WeWorkChatRecordMapper.upsert (batch index #1) failed.
Cause: java.sql.BatchUpdateException:
Batch entry 40 INSERT INTO ... msg_content_json ... was aborted:
ERROR: unsupported Unicode escape sequence
详细:\u0000 cannot be converted to text.
在位置:JSON data, line 1: {"content":...
业务影响很直接:
- 批次里只要有一条消息的 JSON 内容带
\u0000,整批写入回滚 - 同步任务失败,位点无法推进
- 后续几天的会话数据都会停在这里,下游分析 / 跟进记录自然也空了
为什么第一眼会看错方向
DataIntegrityViolationException 在 Spring 里通常对应:
- 主键冲突
- 唯一约束冲突
- 外键约束
- NOT NULL 等完整性规则
所以排查时很容易先去看表结构:
id自增主键msg_id唯一键(上游明确保证消息 ID 唯一)- 写入用的是
INSERT ... ON CONFLICT (msg_id) DO UPDATE
按这个设计,重复消息应该走更新,不该炸。测试环境把同一条 SQL 执行两次,也确实不报错。
这说明:异常类型被翻译过了,不能当根因。
真正有用的,是被包在最里面的 PostgreSQL 原文:
text
unsupported Unicode escape sequence
\u0000 cannot be converted to text
日志一旦被截断,就只剩外层的 DataIntegrityViolationException,排查会非常痛苦。建议:
- 先找
Caused by最底层的 SQL 错误 - 必要时在测试环境复现,拿到完整 JDBC / MyBatis 日志
- 不要只凭 Spring 翻译后的异常名下结论
根因:PostgreSQL 的 text / jsonb 存不下 NUL
消息内容来自企业微信会话存档 SDK 的原始文本。某条文本消息的 JSON 里出现了:
json
{"content":"...完成【期末预测卷】\u0000,\u0000🔥这套试卷..."}
\u0000 是 Unicode NUL(码点 U+0000)。它不可见,日志里通常以转义形式打印。这里还夹着零宽连接符(U+200D),很像表情序列在拷贝 / 编辑过程中被"插坏"了。
对照下面这张表更容易看清:\u0000 就是十进制 0、十六进制 0x00 的 NUL 空字符 。它和后面的 \n、\t 一样,都属于 ASCII 控制字符,只是 NUL 在 PostgreSQL 的 text / jsonb 里无法落库。

从空格(十进制 32 / 0x20)开始才是可见字符。NUL 不可见、也没有字形,日志里只能靠转义形式打印出来。

jsonb 比 json 更严
PostgreSQL 官方文档写得很清楚(JSON Types):
- RFC 允许 JSON 字符串里出现
\uXXXX json类型主要检查语法:\u后面是不是四个十六进制数字jsonb更严格:- 数据库编码里表示不了的字符,不允许用 Unicode 转义蒙混过关
- 直接拒绝
\u0000,因为 PostgreSQL 的text存不了这个字符 - 合法的 Unicode 转义会在入库时转成真实字符存储
也就是说:
普通 JSON 字符串里可以写
"\u0000"; 但把它塞进 PostgreSQL 的jsonb,会被拒绝。
为什么 MySQL 能、PG 不能
| 数据库 | VARCHAR / TEXT / JSON 是否允许 NUL | 底层原因 |
|---|---|---|
| MySQL 5.7+ | 通常允许 | 字符串按长度前缀 存储,中间出现 0x00 不会被当成结束符 |
| PostgreSQL | 不允许 | 内部按 C 风格 \0 结尾字符串处理,NUL 会被当成字符串结束 |
可以用下面两组 SQL 自己验证。
PostgreSQL(会失败):
sql
DROP TABLE IF EXISTS test_nul;
CREATE TABLE test_nul (
id SERIAL PRIMARY KEY,
content VARCHAR(100),
content_text TEXT
);
INSERT INTO test_nul(content) VALUES ('normal text');
-- 会报错
INSERT INTO test_nul(content) VALUES (E'\\x00abc');
INSERT INTO test_nul(content) VALUES (CONCAT('ab', CHR(0), 'cd'));
MySQL(通常能插入):
sql
CREATE TABLE test_nul (
id INT PRIMARY KEY AUTO_INCREMENT,
content VARCHAR(100),
content_text TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO test_nul(content) VALUES (0x00616263); -- \0abc
INSERT INTO test_nul(content) VALUES (CONCAT('ab', CHAR(0), 'cd'));
INSERT INTO test_nul(content) VALUES ('normal text');
如果业务是从 MySQL 迁到 PostgreSQL,或本地用 MySQL、生产用 PG,这个差异特别容易漏。
异常是怎么被"翻译"歪的
调用链大致如下:
text
batchUpdateOrInsert()
└─ SqlSessionTemplate.flushStatements()
└─ SqlSessionInterceptor.invoke()
└─ DefaultSqlSession.flushStatements()
└─ BatchExecutor.doFlushStatements()
└─ stmt.executeBatch() ← PG 抛 BatchUpdateException
└─ MyBatis 包装成 BatchExecutorException
└─ MyBatisExceptionTranslator
└─ SQLErrorCodeSQLExceptionTranslator
└─ DataIntegrityViolationException
PostgreSQL 拒绝 \u0000 时,JDBC 拿到的是 BatchUpdateException。 Spring 的 SQL 异常翻译器会按 SQLState / error code 把它归到"数据完整性"一类。
所以日志看起来像唯一约束冲突,其实只是 翻译映射过粗 。 这类问题建议把 PG 的 SQLState、ERROR: 原文一起打进日志,不要只保留 Spring 异常类名。
影响被放大的原因:批量写入
写入用的是批量:
sql
INSERT INTO wechat_chat_record (
seq, msg_id, msg_action, msg_from, msg_to_json,
room_id, msg_time, msg_type, msg_content_json,
create_time, update_time
) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?, ?::jsonb, NOW(), NOW())
ON CONFLICT (msg_id) DO UPDATE SET
seq = EXCLUDED.seq,
msg_action = EXCLUDED.msg_action,
msg_from = EXCLUDED.msg_from,
msg_to_json = EXCLUDED.msg_to_json,
room_id = EXCLUDED.room_id,
msg_time = EXCLUDED.msg_time,
msg_type = EXCLUDED.msg_type,
msg_content_json = EXCLUDED.msg_content_json,
update_time = NOW();
JDBC / MyBatis 批处理里,一条失败,整批 abort 。 日志里的 Batch entry 40 ... was aborted 就是这个意思:第 40 条是毒丸,前面已发送但未提交的语句也会跟着失败。
对同步任务来说,这比"丢一条脏消息"严重得多:
- 位点无法前进
- 后续正常消息全部卡住
- 故障窗口会被拉成"连续几天没数据"
5Why
-
为什么会抛
DataIntegrityViolationException? 因为往jsonb列写入了包含\u0000的 JSON,PG 拒绝转换,JDBC 批处理失败,再被 Spring 翻译成这个异常。 -
为什么消息里会有这个字符? 内容来自企微会话存档 SDK 的原始文本。上游没有保证字符集边界,客户端复制表情、零宽字符、异常转义都可能带进来。
-
为什么入库前没清洗? 对 PostgreSQL
text/jsonb不支持 NUL 这件事认知不足,默认信任了上游数据。 -
为什么一条脏数据能让整批失败? 使用了批量
INSERT ... ON CONFLICT,单条异常会放大成整批回滚。 -
根本原因 外部文本入库时,没有把"数据库字符集边界"当成存储约束的一部分。
修复思路
原则很简单:入库前清洗,不要让数据库帮你做字符集校验。
1. 写库前去掉 NUL
Java 示例:
java
public static String stripNul(String raw) {
if (raw == null || raw.isEmpty()) {
return raw;
}
// 同时覆盖真实 NUL 和 JSON 转义残留
return raw.replace("\u0000", "")
.replace("\\u0000", "");
}
入库前对 msg_content_json(以及所有可能进 text / jsonb / varchar 的外部字符串)统一过一遍。
如果用的是 Jackson,也可以在序列化后、拼 SQL 前再扫一次:
java
String json = objectMapper.writeValueAsString(content);
json = json.replace("\\u0000", "");
注意:有的链路会先把真实 \0 转成 \\u0000 再交给 PG。两种形态都要防。
2. 批量不要"一颗老鼠屎坏一锅粥"
可选策略:
- 单条失败时从批次中剔除,记录
msg_id,其余继续提交 - 失败批次自动降级为逐条写入,定位毒丸后再跳过
- 同步位点按"成功写入的最大 seq"推进,而不是整批失败就停
会话存档这类任务通常可以做成幂等:修好后重跑即可补数。即便如此,也要避免"一条脏数据卡死后续全部数据"。
3. 日志不要截断关键信息
至少保留:
- PostgreSQL
ERROR/SQLState - 批次下标(
batch index/Batch entry N) - 出问题的
msg_id - 清洗前后是否包含
\u0000的标记(不要把完整用户聊天打到日志里)
可复现的最小实验
sql
-- jsonb 拒绝 \u0000
SELECT '{"content":"hello\u0000world"}'::jsonb;
-- ERROR: unsupported Unicode escape sequence
-- DETAIL: \u0000 cannot be converted to text.
-- json 类型往往能先存进去(只做语法检查)
SELECT '{"content":"hello\u0000world"}'::json;
对比一下就能记住这个差异:json 更像"原文快照",jsonb 会解析并转成内部二进制,顺便执行更严的字符约束。
后续建议
- 团队内同步:PostgreSQL 的
text/varchar/jsonb都不能存 NUL - 所有外部接口(IM、SDK、Excel、爬虫、用户输入)进 PG 前,统一做控制字符清洗
- 看到
DataIntegrityViolationException,先看底层 SQL 原文,再判断是不是真的约束冲突 - 批量写入要有毒丸隔离,避免同步任务被单条脏数据卡死
小结
这次故障可以压成三句话:
- 表象:Spring 说数据完整性冲突。
- 本质 :PostgreSQL
jsonb拒绝\u0000。 - 放大器:JDBC 批处理让一条脏数据毁掉整批同步。
外部数据进 PostgreSQL 时,不要默认"JSON 合法就能入库"。json 合法,不代表 jsonb 合法,更不代表 text 合法。