先交代一下事情起因。上周一个晚上,负责的数据同步服务突然告警,接口成功率从 99.9% 掉到 97% 左右,看监控不是数据库慢查询,也不是依赖超时,翻日志清一色都是同一个异常:JSON parse error: Illegal unquoted character ((CTRL-CHAR, code 10)): has to be escaped using backslash。当时群里有人把报错贴出来,末尾还被日志系统截断成了backsla,乍一看像个拼写错误,实际上这是 Jackson 在“控诉”JSON 字符串里出现了一个没转义的换行符。
这类问题我遇到过不止一次,但每次排查链路都值得整理一遍:错误信息每一段都在告诉你线索,只是很多人被“JSON parse error”挡住了视线,直接去改解析器配置,反而错过了真正的数据问题。这篇文章就围绕这个报错展开,从根因、复现、定位到修复和预防,把完整链路讲清楚,适合正在被同类问题折磨的后端开发、数据开发,也适合那些手拼 JSON 对接第三方接口时不放心的人。
1. 半夜的告警:一次"字符串里多了个换行"引发的接口雪崩
1.1 错误日志长什么样
先还原一下我当天看到的完整异常栈,大概是这样的:
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Illegal unquoted character ((CTRL-CHAR, code 10)): has to be escaped using backslash"; nested exception is com.fasterxml.jackson.databind.JsonMappingException: Illegal unquoted character ((CTRL-CHAR, code 10)): has to be escaped using backslash" at [Source: (byte[])"{"orderId":123,"remark":"订单备注 第二行内容","amount":99.00}"; line: 1, column: 66]注意最后一段:line: 1, column: 66,这里的 row 是 1,说明不是 JSON 文档层面的换行,而是文档第一行里某个位置出了问题。如果是一个被缩进美化过的、正常带换行的 JSON,解析器通常会报line: 2或line: 3。一旦报line: 1, column: xx,基本可以断定问题是“字符串内部包含了控制字符”。
1.2 表面是解析失败,实际影响面有多大
有人可能会想:“不就是备注字段里带了个换行吗?接口偶发报错,重试一下就好了。”
实际不是这么简单。在这个场景里,告警的来源是一条数据同步链路:上游业务库把订单信息写入消息队列,消费者拉取后序列化成 JSON 字符串,再交给下游接口。备注字段只要有换行,每次消息消费都会失败,消息队列重试多少次都没用,消费者会一直卡在同一个位置,消费位点推不动,后续消息全部积压。
更麻烦的是,这种报错不是某一个字段独有。只要业务里存在用户可输入的文本字段(备注、地址、商品描述、审批意见),任何一个提交表单的人按了下回车,你的 JSON 里就会多一个\n。如果这批数据还要被反序列化到强类型对象里,异常范围会迅速扩大,从单个接口异常演变成批量任务失败。
1.3 从报错信息里能拿到的三个线索
遇到这个报错,先别急着搜全网,信息其实已经写得很清楚了:
Illegal unquoted character:非法未加引号字符。注意这里的“unquoted”不是“未闭合引号”,而是说这个字符出现在字符串值内部,但它本身不是一个合法的字符串字符。CTRL-CHAR, code 10:控制字符,码点 10。ASCII 码 10 就是LF,也就是换行符\n。has to be escaped using backslash:必须用反斜杠转义。也就是说,JSON 规范要求你把它写成\n,而不是真实的换行。
这三条线索串起来就一句话:某个字段的字符串值里出现了一个真实的换行符,但没有转义。知道了这一点,后面的方向就很明确了。
2. 为什么 JSON 字符串里不能直接放换行:控制字符转义规则才是根因
2.1 code 10 到底是哪个字符
做后端的人对 ASCII 码应该不陌生,code 10就是LF(Line Feed),在大多数系统中代表换行。在 Windows 体系里常出现的是CRLF组合,也就是code 13+code 10,如果你看到报错里写code 13,那就是\r回车符。两者的处理思路完全一致,都是控制字符问题。
我经常用一个生活化类比来解释这件事:JSON 字符串相当于一个快递包裹的“内衬板”,双引号是纸箱的封边条。如果你往纸箱里塞了一个尖角零件(换行符),封边条一压,零件直接戳穿了纸箱,整个包裹在分拣线上一过就散架。解析器要按固定规则一字节一字节地扫描,突然看到一个它不认识的裸控制字符,只能停下来原地报错。
2.2 JSON 规范对控制字符的硬性要求
JSON 格式的标准定义在 RFC 8259 里,对字符串字符集的规定是这样的:
- 字符串由双引号包裹;
- 允许的字符是
U+0020到U+10FFFF范围内的 Unicode 字符,但排除双引号"和反斜杠\; U+0000到U+001F这一段的控制字符,都不允许直接出现在字符串里,如果要表示,必须用转义序列。
这些必须转义的控制字符包括:
| 控制字符 | 名称 | 标准转义写法 | 可读写法 |
|---|---|---|---|
| U+000A | 换行 LF | \u000A | \n |
| U+000D | 回车 CR | \u000D | \r |
| U+0009 | 水平制表符 TAB | \u0009 | \t |
| U+0008 | 退格 BS | \u0008 | \b |
| U+000C | 换页 FF | \u000C | \f |
| 其他 U+0000~U+001F | 控制字符 | \u00XX | — |
简单说:代码点的值小于 0x20,在 JSON 字符串里就必须躲进“反斜杠 + 编码”的壳里。这是规范层面的硬性要求,不是某个解析器的脾气。
2.3 很多人误解的"JSON 里能换行"
这里有个高频误会,我要单独拎出来说。
格式化后的 JSON 文件长这样:
{ "title": "short", "value": "hello" }它在结构层面是有换行的,两个字段之间的换行和缩进完全合法。也正是因为平时总看到这种“换行 JSON”,很多人下意识以为 JSON 里可以随便换行。但请注意,这里的换行发生在对象的}、{、`, 之外,也就是 token 与 token 之间的空白位置。空白字符在 JSON 结构层是允许的,但在字符串内部是被禁止的。
这两者的区别,面试里经常被拿来当陷阱题:{"a": "hello\nworld"}这段文本,如果\n是两个字符(反斜杠加 n),它是合法 JSON;但如果你在编辑器里直接按回车,让文件里真的出现一行换行,它就不再合法了。我们平时在代码里写字符串时,"\n"在很多语言里会被编译器解释成一个真实的换行字符,一旦这个值没有经过 JSON 序列化函数就放进报文,直接就把服务打挂。
2.4 数据里混入换行的典型来源
一般排查下来,换行字符进入 JSON 主要有三个入口:
- 用户输入。
textarea多行文本、表单备注、地址详细描述,用户按回车是再正常不过的事。如果前端直接把这个值拼进 JSON 字符串,没有经过JSON.stringify或后端没有统一序列化,就会中招。 - 文件导入导出。CSV、Excel 导出后处理,单元格里本身就有换行,读取后拼进 JSON,很容易带上
\n或\r\n。 - 日志和告警消息。系统内部拼告警内容、日志内容时,经常用字符串模板把多行堆栈塞进一个字段,堆栈里到处都是换行,塞进 JSON 就会踩雷。
2.5 换行和回车(CRLF)同样会踩雷
很多系统从 Windows 环境拿到数据,遇到的是\r\n两个字符。前面说了,code 13对应\r,所以报了code 13或CTRL-CHAR, code 10都是同一类问题。处理时要注意:如果数据来自 Windows 文本,可能同时混有\r和\n,清洗的时候要把\r\n、\r、\n都考虑进去,不要只替换一种。
3. 完整复现与定位链路:三步定位未转义换行
3.1 最小复现:用三种语言还原现场
先把问题缩小到能稳定复现,我通常会写一个最小示例,确认“到底什么样的输入会触发这个错”。
Java + Jackson,和线上报错几乎一致
import com.fasterxml.jackson.databind.ObjectMapper; public class JsonParseErrorDemo { public static void main(String[] args) throws Exception { // 注意:这里故意拼接一个真实换行符到 JSON 字符串内部 String raw = "{\"remark\":\"第一行\n第二行\",\"amount\":99.00}"; ObjectMapper mapper = new ObjectMapper(); mapper.readValue(raw, Object.class); } }运行后就会看到和线上一样的Illegal unquoted character ((CTRL-CHAR, code 10))。这里的raw字符串里\n被 Java 编译成真实换行符,最终交给 Jackson 的字节流里就是一个裸的 LF。
Python 用标准库 json 验证
import json raw = '{"remark": "第一行\n第二行", "amount": 99.00}' data = json.loads(raw)Python 的json模块同样会抛json.decoder.JSONDecodeError: Invalid control character at: line 1 column 20。对比两个报错,会发现一个共同点:解析器都把位置指向第一行中间,而不是第二行——因为它根本还没来得及“换行”,在字符串里遇到裸换行就直接判定非法了。
JavaScript 在浏览器里直接跑
const raw = '{"remark":"第一行\n第二行","amount":99.00}'; const data = JSON.parse(raw); // Uncaught SyntaxError: Bad control character in string literal in JSON这三个语言行为一致,说明这是跨语言的 JSON 解析共识,不是某个框架的 Bug。
3.2 定位原始 payload:抓请求还是抓落库数据
复现之后,真正的难点来了:日志里只输出了异常摘要,但完整报文可能被截断,甚至被日志框架换行拆散。这时候第一步是拿到完整的原始 payload。
我自己的排查顺序是这样的:
- 先看异常里有没有
Source:或content:字段,有些框架会把原始输入打印出来,虽然会被截断,但能看出大概结构。 - 从消息队列的死信队列或消费日志里拉出原始消息体。注意,很多 MQ 管理后台默认只显示前 1000 字符,要手动下载完整内容。
- 如果告警来自网关层,去网关日志里捞请求体,网关一般记录了完整的 body。
- 实在找不到,用 traceId 在链路追踪系统里搜,把整个链路的请求参数捞出来。
拿到原始 payload 后,先不要把它格式化。很多人习惯性把 JSON 复制到在线格式化工具里,这一格式化反而把问题藏起来了,因为格式化工具要么直接报错,要么自动帮你转义,你拿到的是“被修复过”的版本,不是原始版本。
3.3 用工具把不可见字符揪出来
定位不可见字符,我一般按下面几步走。
第一步,用jq做标准解析校验:
cat original.json | jq .如果文件里有未转义换行,jq会报类似parse error: Invalid string: control characters from U+0000 through U+001F must be escaped at line 1, column 20的错误,同时会指出它在第 1 行第 20 列附近发现问题。
第二步,用cat -A查看文件隐藏字符,行尾会显示成$:
cat -A original.json如果看到字符串中间出现$,那基本可以断定这里有换行符。-A选项会把\n、\t、\r用可见符号显示出来,非常适合快速扫描。
第三步,配合od -c查看指定位置的十六进制和字符:
od -c original.json | head -50输出里如果出现\n对应位置的两列\ n,或者看到r/n,就说明对应区域有真实的回车换行。
如果是 Windows 环境,用 VSCode 或 Notepad++ 打开原始文件,开启“显示控制字符”或“显示所有字符”选项,也能直观看到换行符的地方显示为特殊的CR、LF标记。
3.4 定位到具体字段之后的确认步骤
找到可疑换行位置后,还要确认它到底属于哪个字段。我通常的做法是:把原始 payload 按line: 1, column: 66这种坐标折算到字符串里,看第 66 个字符附近是什么字段。
更省事的办法是写个小脚本,把原始 payload 逐字符遍历,凡遇到\n、\r就打印当前位置和前后 20 个字符:
with open("original.json", "r", encoding="utf-8") as f: content = f.read() for i, ch in enumerate(content): if ch in ("\n", "\r", "\t"): start = max(0, i - 20) end = min(len(content), i + 20) print(f"pos={i} char={repr(ch)} context={content[start:end]!r}")这样能直接看到“哪个字段的值里带了换行”。确认字段名之后,回到业务数据里查这个字段为什么会有换行,是用户输入,还是上游拼接,还是文件读取,这一步才是真正的根因定位。
4. 修复方案的横向对比:转义、忽略、清洗怎么选
修复方案不止一种,但选错方案会给后面埋更大的雷。我按实际项目里的决策顺序,把这几个方案横向摊开讲。
4.1 方案一:从源头把换行转义
真正的标准做法是:生成 JSON 的一方在序列化时,让库替你把换行转成\n转义序列。
大多数语言的标准 JSON 序列化函数都会自动处理:
// Java 推荐用 Jackson/Gson 序列化对象 Map<String, Object> data = new HashMap<>(); data.put("remark", "第一行\n第二行"); String json = new ObjectMapper().writeValueAsString(data); // 输出:{"remark":"第一行\n第二行"},这里的 \n 是反斜杠+n 两个字符import json data = {"remark": "第一行\n第二行"} json_str = json.dumps(data, ensure_ascii=False) print(json_str) # {"remark": "第一行\n第二行"}const data = { remark: "第一行\n第二行" }; const jsonStr = JSON.stringify(data); // {"remark":"第一行\n第二行"}这个方案的优点是从根上解决,任何合法解析器都能接受;缺点是它需要改动数据生产方,如果对方是第三方系统,可能协调周期长。
4.2 方案二:接收端清洗原始字符串
如果上游短期改不了,接收端可以在解析前做一次清洗,把裸控制字符替换成标准转义序列。
String raw = ...; // 原始payload String cleaned = raw .replace("\r\n", "\\n") .replace("\r", "\\n") .replace("\n", "\\n"); ObjectMapper mapper = new ObjectMapper(); Object result = mapper.readValue(cleaned, Object.class);这里有几个细节要注意:
- 替换顺序不能乱,先处理
\r\n,再处理单独的\r,否则\r\n会被拆成两段处理。 replace("\n", "\\n")中的第二个参数是两个字符:一个反斜杠加一个n,不是 Java 的换行。- 清洗逻辑一定要在传给解析器之前完成,不要试图用自定义反序列化器去处理已经进入解析流程的文本,那样很容易受到解析器内部状态的影响,定位问题更困难。
这个方案能快速止血,但只推荐作为过渡手段,因为它改变了数据的原始表现:如果后续有对字段内容做精确匹配、长度校验的逻辑,清洗前后的字符串不一致可能引发新的问题。
4.3 方案三:调整解析器特性(临时方案)
Jackson 提供了一个逃生舱门,允许解析器接受未转义的控制字符:
ObjectMapper mapper = new ObjectMapper(); mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS, true);开启之后,裸换行确实可以解析通过了。但这个特性不是标准 JSON 行为,它只是让 Jackson 宽容一点,不代表下游其他消费者也会宽容。
我见过最典型的反面案例:某团队紧急开了这个开关,线上报错立刻消失,大家都很满意。三个月后,另一个团队拿同一份数据去做离线分析,用 Spark 读取时又报格式错误,两边扯皮了很久。原因就是:开启特性只是掩耳盗铃,数据本身仍然是非法 JSON。
所以这个方案只适合两种情况:
- 线上正在故障,需要立刻恢复,你愿意接受“临时启动、后续立刻整改”;
- 你明确知道这份数据只在本系统内部流转,且永远不会被其他标准解析器消费。
否则,尽量不要在生产环境长期开启。
4.4 方案选择表
| 方案 | 改动方 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 源头转义 | 生产方 | 标准、可长期使用 | 协调成本高 | 能影响生产方的内部系统 |
| 接收端清洗 | 消费方 | 止血快、不改上游 | 可能影响内容一致性 | 上游短期无法改动时过渡 |
| 调整解析器特性 | 消费方 | 改一行配置就能恢复 | 非标准,后续隐患大 | 紧急故障期临时方案 |
如果让我给一个决策顺序,我的建议是:紧急时开启解析器特性或清洗先恢复服务,同时立刻拉上游 fix,最后把临时开关撤销。千万不要在“临时方案”里岁月静好,这类问题留在系统里越久,越难清理。
4.5 修复后的回归验证
修完之后,我的回归清单一般长这样:
- 重新发送包含换行的原始消息,确认不再报
Illegal unquoted character。 - 反序列化后的对象字段值恢复成“带换行的人类可读文本”,而不是转义后的字面量
\n。 - 用
jq .对修复后的 JSON 做一次完整校验,确认可以通过标准解析。 - 验证长度和去重逻辑:原本
"第一行\n第二行"的字符串字段长度是 5 个字符加一个换行,清洗或转义后如果处理不当,长度统计可能不同。 - 对下游依赖该字段做唯一索引或 MD5 的场景,检查数据是否发生重复或冲突。
5. 防再犯:从数据入口到日志链路一起治
5.1 手拼 JSON 是重灾区
很多线上 JSON 解析错误,源头都是“手拼 JSON”。我见过最离谱的代码是这样的:
String json = "{\"remark\":\"" + content + "\",\"amount\":" + amount + "}";当content来自用户输入时,这段代码就是一个定时炸弹。用户输入里只要包含换行、双引号、反斜杠,最终拼出来的字符串要么解析失败,要么被恶意注入额外字段。
正确的做法是:任何动态内容进入 JSON,都要走序列化库,绝不手工拼接。前端也一样,能用JSON.stringify就不要自己加引号和反斜杠。这个原则听着基础,但每次踩坑,十有八九都是这里出了问题。
5.2 前端提交、文件上传、导出等入口管控
数据进入系统的入口需要做校验。前端文本框可以限制多行,也可以不做限制,但提交时必须经过序列化。后端在接收参数时,如果 DTO 里有字符串字段,可以考虑对明显的控制字符做规范化处理:
@JsonSetter("remark") public void setRemark(String remark) { this.remark = remark == null ? null : remark.replace("\r\n", "\n"); }文件导入场景里,CSV/Excel 的单元格内容如果有换行,读取后也建议统一规范成\n或明确改成空格,避免后续拼 JSON、落库、导出再被折腾一遍。
5.3 日志与链路追踪的无意"污染"
一种是日志框架自动把异常堆栈塞进 JSON 字段。堆栈信息天然多行,如果写入消息队列或日志中心时没做转义,就会在“后半夜”批量引爆。
另一种是链路追踪系统把请求参数打出来时,如果参数里有换行,日志会被“撑爆”或格式错乱。我一般会建议日志组件在打印字段时对不可见字符做转义,至少在结构化日志里保证 value 是合法 JSON。可以用一个简单的工具方法:
public static String escapeJsonControl(String input) { if (input == null) return null; return input .replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\r", "\\r") .replace("\n", "\\n") .replace("\t", "\\t"); }这个方法不是给你做完整 JSON 序列化,而是在排查问题、打印日志时避免二次污染。
5.4 自动化校验与灰度放量
如果你的系统要长期接收第三方回调或外部数据,建议在接入层加一道 JSON 合法性校验中间件。不需要复杂的规则,只需要在拿到原始 body 后尝试标准解析,解析失败直接返回 400,并把原始报文保存到专门的“脏数据桶”里,方便事后排查。
如果无法对外部系统做强约束,那就把“清洗 + 解析”做成一个统一入口组件,所有消费外部数据的地方都走同一个方法,不要把 replace 逻辑散落在各个业务代码里。
测试环节可以补一个“特殊字符样例”的用例,把包含换行、回车、Tab、双引号、反斜杠的字段值都跑一遍。这个小用例成本很低,但能拦住大部分序列化问题。很多项目的单测只测“正常数据”,恰恰忘了非正常数据才是线上故障的主要来源。
5.5 最后一点个人体会
排查这类问题最耗时间的往往不是修复,而是没法拿到完整原始报文。所以我后来每到一个新项目,第一件事就是确认日志链路里有没有把原始请求体或消息体完整保存下来。很多故障发生时,我们花了一个小时找数据,真正改代码只花了十分钟。
如果你现在正被类似报错困扰,建议按这个顺序走:先捞原始报文,确认code 10是\n还是\r\n,找到具体字段,确认来源,然后处理数据、修正生成端、加好校验。这样一套下来,就不只是“把这一个错误解决了”,而是把这条链路里可能再爆的同类雷一起排掉了。