跳到主要内容

4 篇博文 含有标签「JSON」

查看所有标签

pandas NaN 让 json.dumps 报错?异常炸在你的 try 之外

· 阅读需 6 分钟

数据管道里一个任务跑到一半戛然而止:日志的 trace 停在中间步骤,没有任何 error 记录,仿佛进程凭空消失。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。分析任务从 pandas 数据起步、以 JSON 落库收尾,NaN 是这条路上最常见的隐形地雷。

问题现象:trace 中断,无 error 记录​

任务状态是失败,但业务日志里找不到任何报错——trace 走到某一步就没了下文。异常确实发生了,只是它炸的位置不在你的 try 覆盖范围内:排查发现是 ti.xcom_push 内部做 JSON 序列化时抛的错,而 xcom_push 这一行在业务 try 块之外(即便包进去,框架内部更深层的序列化点也照样在你的 catch 半径之外)。

真身是这个报错:

ValueError: Out of range float values are not JSON compliant: nan

如果你搜的是「json.dumps NaN 报错」「Out of range float values are not JSON compliant」「任务日志中断无 error」,都是同一类问题。

根因:NaN 是 Python 的合法 float,JSON 却不认识它​

两套规则在这里错位。实测行为矩阵:

import json, math

# NaN 在 Python 世界畅通无阻
isinstance(float('nan'), float) # True —— 合法 float
float('nan') == float('nan') # False —— 连等号都测不出来

# JSON 世界:默认宽松,严格模式直接抛
json.dumps(float('nan')) # 'NaN'(非法 JSON 字面量!)
json.dumps(float('nan'), allow_nan=False) # ValueError: Out of range float values...
json.dumps(math.inf, allow_nan=False) # ValueError(±Inf 同罪)

三个要点:

  • JSON 规范(RFC 8259)的数字语法不含 NaN/Infinity。Python json 模块默认 allow_nan=True,遇到 NaN 输出 NaN 字面量——这是对规范的宽松扩展,产出的字符串下游严格解析器会拒绝。坑分两层:要么现在炸(严格模式),要么埋给下游炸(宽松模式)。
  • NaN 骗过常规判空。它不是 None、不是 0、== 自己都返回 False——if not value 一类的卫语句统统放行,数据一路走到序列化层才爆。
  • 爆点在框架代码里。业务代码把 dict 交给 xcom_push、日志 SDK、HTTP 客户端——序列化发生在这些框架内部,深于你的 try/catch 半径。这解释了「trace 中断且无 error 记录」:异常没进你的日志埋点,直接把 task 掀翻。

解决方案:边界前递归清洗,兜底交给任务级钩子​

两道防线。第一道在数据侧——跨 JSON 边界之前递归清洗,NaN 和 ±Inf 一律转 None:

import math

def json_safe(value):
"""递归把 NaN/±Inf 转成 None,其余原样返回。"""
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
return None
if isinstance(value, dict):
return {k: json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [json_safe(v) for v in value]
return value

import json
payload = json_safe(result_dict)
json.dumps(payload, allow_nan=False) # 现在永不抛

清洗之后仍保留 allow_nan=False——它从「炸雷」变成「哨兵」:万一有漏网的非法值,在自家代码里抛出来,好过流到下游。

第二道在框架侧——给任务挂失败钩子,把 catch 外的异常也落进日志体系(Airflow 场景用 task failure callback 或装饰器包住整个 task callable),保证「trace 中断无 error」变成「trace 末端有 error」。排查存量问题时,去 Airflow 任务日志翻 traceback(容器内 logs/dag_id=.../task_id=.../attempt=N.log)比翻业务日志快。

边界与变体​

  • pandas 自带的 to_json 会在输出层把 NaN 转 null,如果整条链路用它输出,可以不清洗;但数据一旦转成 dict 再走 json.dumps,就得自己清洗——坑出现在「换序列化器」的那一刻。
  • NaN == NaN 为 False,去重、断言、测试里的相等比较都会被它骗;判 NaN 只能用 math.isnan。
  • numpy 数组直接 dumps 也会炸(ndarray 不是 JSON 可序列化类型),先 .tolist();tolist 之后 NaN 还在,清洗逻辑依然需要。
  • 数值语义上 NaN→null 是一次有损转换(「测量失败」变成「没有值」),下游如果依赖这个区分,应另立字段标注,而不是硬转。

注意事项

  • 判 NaN 只用 math.isnan,任何基于 ==、if not x 的判断都不可靠。
  • 清洗放在序列化边界前统一做一次,别在每个调用点各自为战——漏一个调用点就复现一次。
  • 严格模式(allow_nan=False)当哨兵用:清洗后仍报错说明有漏网数据,这是好信号。
  • 框架兜底钩子(failure callback)不是可选品:它能接住你今天想不到的、明天一定出现的「catch 之外的异常」。

常见问题​

json.dumps 遇到 NaN 为什么报 ValueError?​

JSON 规范没有 NaN/Infinity;allow_nan=False 开启严格模式后遇到它们即抛 ValueError: Out of range float values are not JSON compliant。默认 allow_nan=True 会输出非法 JSON 的 NaN 字面量,埋给下游。

pandas 的 NaN 怎么转成 null 再序列化?​

递归遍历数据结构,把 float 类型的 NaN 和 ±Inf 替换为 None 再 dumps;pandas 层可用 df.where(df.notna(), None) 或 to_json(自带 NaN→null)。结论:清洗要在序列化边界前做,别指望 json 模块替你转。

JSON 为什么不支持 NaN 和 Infinity?​

JSON 规范(RFC 8259)的数字语法只覆盖有限数,NaN/Infinity 不是合法值。Python json 默认放行输出了 NaN 字面量,属于对规范的宽松扩展——下游严格解析器会拒绝。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Go Fyne 桌面开发踩坑:JSON 字段丢失、指纹误报与 fyne.Do

· 阅读需 10 分钟

在为客户交付一个 Go + Fyne 桌面登录工具时,从配置解析、SSH 握手到跨平台打包、GUI 验证,每个阶段各撞到一个坑。五个坑都已解决,解法全部可复用,记录如下。

这个工具是为行业龙头制造企业中国全托管项目开发的——WHM 管理入口从单一密码升级为多层门禁后,团队成员需要一条不接触 root 密码的登录通道,这个小工具就是那条通道的客户端:点一个按钮,拿一条一次性链接进 WHM。工具本身很小,但「小」不代表坑少。

TL;DR​

场景根因解法
SSH 指纹比对全部误报ssh-keygen 指纹无 padding,Go StdEncoding 带等号两侧归一化(去等号 + 补前缀)
显式超时值解析后变 0内嵌 struct 同名字段被外层遮蔽同一数据 Unmarshal 两次
fyne-cross 报 go 版本不足容器工具链旧且 GOTOOLCHAIN=local-env GOTOOLCHAIN=auto
后台 goroutine 更新 UI 无保证Fyne 2.6–2.8 严格线程模型是 opt-infyneDo=true + -tags migrated_fynedo
WSLg 下 GL 窗口截图全黑GL 直渲不走 X11 捕获路径改用服务器侧日志计数验证

场景一:SSH 指纹比对全部误报?先看末尾有没有等号​

工具的第一道安全设计是锁定 host key:客户端只信任配置文件里写死的 ed25519 指纹,防止连到被劫持的机器上把一次性链接拱手送人。指纹从服务器侧用 ssh-keyscan 取出,写入 config.json。

live 测试第一跑,所有连接全部误报 host key fingerprint mismatch。把期望值和实际值并排放在一起,肉眼才能看出差异——只差末尾一个等号:

ssh-keygen -lf / 服务器公钥  →  SHA256:tT5rFtRWhCcsvJg58hNOXt0rqYvSPmur6pL8xE2KxQ   (43 字符,无等号)
Go base64.StdEncoding → tT5rFtRWhCcsvJg58hNOXt0rqYvSPmur6pL8xE2KxQ= (44 字符,带一个 =)

根因是一条容易被忽略的编码规格差异:ed25519 公钥 32 字节,base64 编码后是 44 字符,其中最后一个字符是 padding 等号。ssh-keygen -lf 输出指纹时去掉了 padding,只剩 43 字符;而 Go 的 base64.StdEncoding 按标准补上等号。两个都「对」,拼在一起就是永远对不上。

解法是比对前两侧归一化,去掉尾部等号、统一 SHA256: 前缀:

// 归一化 SHA256 指纹:去 padding,统一前缀,顺手校验合法性
func normalizeFingerprint(fp string) (string, error) {
fp = strings.TrimPrefix(fp, "SHA256:")
fp = strings.TrimRight(fp, "=")
if _, err := base64.RawStdEncoding.DecodeString(fp); err != nil {
return "", fmt.Errorf("invalid sha256 fingerprint: %w", err)
}
return "SHA256:" + fp, nil
}

归一化之后补了一个单测(两种写法进、同一种出),live 测试四条路径——正常出链接、错误指纹、未授权密钥、黑洞超时——全部符合预期。

顺带一提,如果不想手写,x/crypto 的 ssh.FingerprintSHA256 返回的本身就是无 padding 形式,和 ssh-keygen 一致。坑不在库,在于自己另起炉灶编码时两侧没对齐。

注意事项

x/crypto 的 ssh.HandshakeError 没有 Unwrap 方法——你在握手回调里返回的类型化错误(比如指纹不匹配),出了 dial 之后用 errors.As 取不出来。要在错误分类处按错误文本做正则匹配,把双指纹从文本里提取出来重建类型化错误,才能给用户显示「期望 X 实际 Y」。这是 v1.1 加错误提示分流时实测发现的。

场景二:config.json 显式超时解析后变 0?内嵌 struct 字段被遮蔽​

配置结构长这样:大部分字段来自通用的 Config,超时项想做「没写就给默认值」的处理,于是用了内嵌 + 指针的写法:

type Options struct {
Config // 内嵌:host、port、超时等字段都在这里
ConnectTimeout *int `json:"connect_timeout_seconds"`
CommandTimeout *int `json:"command_timeout_seconds"`
}

意图很清楚:指针字段用来探测「用户写没写」,没写就落默认值。结果 live 测试直接报 command timed out after 0s——config.json 里显式写着的超时值,解析后变成了 0。

根因在 encoding/json 的同名冲突裁决规则:当多个字段映射到同一个 JSON key 且位于不同深度时,浅层(外层)胜出,深层(内嵌 struct)的同名字段永远不被填充。于是 Options.Config 里的超时字段保持零值,外层指针虽然正确拿到了显式值,但代码只在「指针为 nil 时落默认值」的分支里碰它——显式值既没写进 Config,也没人回填,就这么静默丢了。

解法:放弃一层结构里「既解析又探缺省」的贪心写法,对同一份数据 Unmarshal 两次,各干各的事:

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
ConnectTimeout int `json:"connect_timeout_seconds"`
CommandTimeout int `json:"command_timeout_seconds"`
}

type timeoutOverrides struct {
Connect *int `json:"connect_timeout_seconds"`
Command *int `json:"command_timeout_seconds"`
}

func load(raw []byte) (*Config, error) {
cfg := &Config{ ConnectTimeout: 30, CommandTimeout: 15 } // 默认值
if err := json.Unmarshal(raw, cfg); err != nil {
return nil, err
}
var ov timeoutOverrides
if err := json.Unmarshal(raw, &ov); err != nil {
return nil, err
}
if ov.Connect != nil {
cfg.ConnectTimeout = *ov.Connect
}
if ov.Command != nil {
cfg.CommandTimeout = *ov.Command
}
return cfg, nil
}

第一遍 Config 直解拿全部普通字段;第二遍纯指针结构探缺省,有显式值就覆盖默认。同时补了一条回归单测,断言「显式值不被丢」——这种坑的特点就是静默,没有单测盯着,下次重构还会回来。

JSON 的坑不只在解析侧,序列化侧我们之前也踩过一个更阴的:json.dumps 的 default=str 兜底会把 set 静默序列化成字符串,回读后 in 判断悄悄给出错误结果。共同点是都发生在类型系统看不见的地方。

场景三:fyne-cross 打包报 go.mod requires go >= 1.26.0?容器工具链被锁死​

Windows 包用 fyne-cross 交叉编译,打包阶段直接报 go.mod requires go >= 1.26.0。容器里内置的是 go 1.25.10,而依赖的 x/crypto v0.57.0 要求 go 1.26 起步;更要命的是容器默认 GOTOOLCHAIN=local——Go 1.21 引入的工具链自动切换机制被显式关掉了,本地是什么版本就死磕什么版本。

解法一行:给 fyne-cross 传环境变量,允许容器自动拉取所需工具链。

fyne-cross windows -tags migrated_fynedo -env GOTOOLCHAIN=auto

GOTOOLCHAIN=auto 让 go 命令按 go.mod 的要求自动下载并切换到对应版本的工具链,一劳永逸——以后依赖再升级,容器不用跟着动。这行参数已固化进项目的 build.sh。

场景四:后台 goroutine 更新 Fyne UI 行为无保证?2.6–2.8 的线程模型是 opt-in​

Fyne 2.6 引入了严格线程模型,UI 更新必须经 fyne.Do 调度回主线程。但有个容易忽略的时间窗:v2.6–2.8 里这套模型和 fyne.Do 都不是默认开启的,不显式启用的话,后台 goroutine 直接改 UI 控件的行为属于「无保证」;要到 v2.9 才默认开启。

工具里有网络请求跑在后台 goroutine(SSH 握手、拿一次性链接),完成后要更新状态行,正好落在这个窗口里。双保险启用:

# FyneApp.toml
[Migrations]
fyneDo = true
# 构建标签同时加上
go build -tags migrated_fynedo .

两个开关一个作用于运行时配置、一个作用于编译期,任一生效即可;两处都配置是防「只改其一」的遗漏。代码侧养成习惯,后台 goroutine 更新 UI 一律包一层:

go func() {
link, err := fetchOneTimeLink()
fyne.Do(func() {
if err != nil {
status.SetText("Failed: " + err.Error())
return
}
status.SetText(link)
})
}()

升级到 v2.9 之后这套代码不用动,迁移标记留着也无害。

场景五:WSLg 下 Fyne 窗口截图全黑?改用日志计数做客观证据​

工具的验收标准是「双击程序、点按钮、浏览器自动打开 WHM 登录页」。想像素级验证 GUI 状态时发现:WSLg 下对 Fyne(OpenGL 直渲)窗口用 scrot、import 截图,得到的都是纯黑帧——GL 窗口不走 X11 的像素捕获路径,常规截图工具拿不到内容。

这一条没有「修好」的解法,是个绕过,明说:

  • 键盘事件可以送达:xdotool 发 Tab + Return 成功激活了按钮,GUI 自动化操作这半边是通的;
  • 鼠标 XTest 点击未生效,别在它上面耗时间;
  • 像素级验证放弃,全链路验证改用服务器侧证据:按钮点击会触发服务器上的 sudo whmapi1 调用,对比操作前后的 sudo 日志条数(本次 21 → 22),一条新增记录就是「GUI 确实把整条链路走通了」的客观证据,不依赖任何屏幕像素。

对这类「GUI 只是触发器、真正动作在服务端」的工具,服务端日志计数反而是比截图更强的证据——它证明的是行为,而截图只能证明外观。

常见问题​

Go json.Unmarshal 内嵌 struct 同名字段为什么不生效?​

encoding/json 按「浅层优先」裁决同名 JSON key:外层声明的指针字段胜出,内嵌 Config 的同名字段保持零值且无人回填,显式配置静默丢失。解法是对同一份数据 Unmarshal 两次——Config 直解 + 指针探缺省,有显式值回填、无显式值才落默认,并补「显式值不被丢」回归单测。

Fyne 2.6 之后后台 goroutine 更新 UI 要注意什么?​

Fyne 2.6–2.8 的严格线程模型和 fyne.Do 都是 opt-in:须在 FyneApp.toml 写 [Migrations] fyneDo=true,或编译时加 -tags migrated_fynedo;v2.9 起才默认开启。后台 goroutine 一律用 fyne.Do 包住 UI 更新,否则行为无保证。

ssh-keygen 的 SHA256 指纹和 Go 代码比对总不一致怎么办?​

多半是 padding 差异:ssh-keygen 输出 43 字符无等号的 base64(ed25519 公钥 32 字节),Go 的 base64.StdEncoding 默认补等号。两侧统一归一化——统一去掉尾部等号、统一补 SHA256: 前缀——再比对;或直接用 x/crypto 的 ssh.FingerprintSHA256,它本身就是无 padding 形式。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Python json.dumps 序列化 set 后 in 判断静默失效?default=str 的隐藏陷阱

· 阅读需 7 分钟

在用 json.dumps(data, default=str) 把一个含 Python set 的字典持久化、再回读用 in 判断成员时,结果静默出错——没有任何报错,但 in 判断全乱。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。某次分析模板的决策重放(replay)功能里,需要把「缺失月份集合」序列化进快照、回放时再读出来判断某月是否缺失。结果重放后,本应判定为「缺失」的月份被误判为「不缺失」,而整个链路没有任何异常抛出。

TL;DR​

default=str 不是万能兜底。它会把 set 交给 str(),在 JSON 里存成 "{1, 2}" 这样的字面量字符串而非数组;回读后类型已不可逆,对它做 in 判断会退化成子串匹配,静默返回错误结果。涉及 set 时,正确做法是序列化前转 list、读取时 set() 重建。

问题现象​

下面这段代码完整复现了静默出错的过程:

import json

# 一个含 set 的字典——比如"需要补数据的缺失月份"
data = {"missing_months": {"3", "5", "12"}}

# 用 default=str 兜底序列化(常见的"别让它报错"写法)
serialized = json.dumps(data, default=str)
print(serialized)
# {"missing_months": "{'3', '5', '12'}"} ← 变成了字符串,不是数组!

# 回读
back = json.loads(serialized)
value = back["missing_months"]
print(type(value)) # <class 'str'> ← 已经不是 set 了

# 静默 bug:本想判断某月份是否在"缺失集合"里
print("1" in value) # True ← 1 根本不在 {3,5,12},但 "1" 是 "12" 的子串!
print("3" in value) # True ← 碰巧对
print("9" in value) # False

"1" in value 返回 True,但原集合 {"3", "5", "12"} 根本不含 "1"。没有异常、没有警告,判断结果就这样悄悄错了。这种 bug 在依赖判断结果做分支(如「这个月缺数据吗?缺则补采」)的链路里尤其致命。

根因​

分三层看:

第一层:set 本就不可 JSON 序列化。 JSON 只有 array(对应 list)和 object,没有集合类型。直接 json.dumps({"x": {1, 2}}) 会抛 TypeError: Object of type set is not JSON serializable。

第二层:default=str 把报错变成了静默污染。 json.dumps 的 default 参数在遇到无法序列化的对象时被调用,期望返回一个可序列化的值。str 作为 default 时,会把对象交给 str()——set 就被转成了它的 Python 字面量表示 {'3', '5', '12'},作为字符串存进 JSON:

>>> json.dumps({"m": {"3", "5", "12"}}, default=str)
'{"m": "{\'3\', \'5\', \'12\'}"}'

报错消失了,代价是类型从 set 变成了 str,且这个过程不会给你任何提示。

第三层:in 对 str 和 set 语义不同。 这是静默 bug 的核心。对 set/list,x in s 是成员判断;对 str,x in s 退化成子串匹配。回读后的值是字符串 "{'3', '5', '12'}",于是 "1" in "{'3', '5', '12'}" 判断的是字符 "1" 是否作为子串出现——而 "12" 里恰好有 "1",所以返回 True。

这和 Airflow PostgresHook 多语句 SQL 静默丢结果 是同一类陷阱:最危险的 bug 不是抛异常,而是「静默地给错结果」,因为没有任何信号提醒你去查。

解决方案​

核心原则:JSON 里只存标准类型,集合语义在读取端重建。

方案一:序列化前显式转 list(推荐)​

最直接、最可控——明确知道哪里有 set,就地转成 list:

import json

# 序列化前:set → list(标准 JSON 数组)
data = {"missing_months": list({"3", "5", "12"})}
serialized = json.dumps(data)
print(serialized)
# {"missing_months": ["3", "5", "12"]} ← 正确的 JSON 数组

# 回读后重建 set
back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False ✓
print("3" in months) # True ✓

序列化结果是一个干净的 JSON 数组,跨语言、可读、可还原。

方案二:自定义 default 函数(数据来源复杂时)​

如果数据结构较深、不确定哪里混入了 set,用一个专门处理集合类型的 default 函数,既不丢失语义,又能兜底其他非标准类型:

import json

def safe_default(obj):
# 集合类型 → list,保留为标准 JSON 数组
if isinstance(obj, (set, frozenset)):
return sorted(obj) # 排序让输出稳定可预测
# 其他无法序列化的类型再退回 str,但要清楚这会丢类型
return str(obj)

data = {"missing_months": {"3", "5", "12"}, "created_at": some_datetime}
serialized = json.dumps(data, default=safe_default)
# {"missing_months": ["3", "5", "12"], "created_at": "..."}

back = json.loads(serialized)
months = set(back["missing_months"])
print("1" in months) # False ✓

相比无脑 default=str,这个函数把「需要保真的类型」(集合)单独处理,只有真正无法表示的类型才退回 str,把静默风险控制到最小。

注意事项

  • default=str 是「静默」而非「安全」:它消除了报错,却把 set/tuple/datetime/自定义对象全部压扁成字符串,类型信息不可逆。回读后所有依赖原类型的运算(in 成员判断、算术、比较)都可能出错。
  • tuple 也有类似问题:str((1, 2)) 是 "(1, 2)",同样会让回读后的 in 退化成子串匹配。处理集合类容器的思路一致:序列化成 list。
  • 跨进程/跨语言是试金石:如果这份 JSON 会被 Node.js、Go 等读取,default=str 产出的 "{1, 2}" 在那边只是一个普通字符串,连 Python 字面量都不是,还原几乎不可能。坚持存标准 JSON 类型才能保证可移植。
  • 优先在源头转换:与其事后用 default 兜底,不如在构造数据结构时就用 list 存集合语义,从根上避免 set 进入序列化管线。

常见问题​

Python set 怎么转 json?​

set 不是 JSON 原生类型,直接 json.dumps 会抛 TypeError。正确做法是序列化前用 list(set) 转成列表,存成标准 JSON 数组;读取时再 set(back["key"]) 重建。这样既不报错,又能完整还原集合语义,跨语言也兼容。

json.dumps 报 Object of type set is not JSON serializable 怎么解决?​

根因是 set 不可 JSON 序列化。最稳妥的解法是序列化前把 set 转成 list;也可以传一个 default 函数,在里面对 isinstance(obj, (set, frozenset)) 返回 list(obj)。要避免用 default=str 兜底——它虽不报错,却把 set 存成了字符串,回读后类型无法还原。

为什么 default=str 序列化 set 后 in 判断结果错了?​

default=str 会把 set 交给 str(),变成字面量字符串 '{1, 2}' 存进 JSON。回读后值类型是 str 而非 set,x in s 就从「成员判断」退化成「子串匹配」——比如 "1" in "{'3','5','12'}" 因 "12" 含字符 "1" 而返回 True,但原集合并不含 "1"。解法是序列化 list、读取时 set() 重建。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Airflow XCom 报 'Out of range float values are not JSON compliant'?pandas NaN 惹的祸

· 阅读需 8 分钟

在 Airflow 任务用 ti.xcom_push() 把 pandas 处理后的结果推给下游任务时,任务直接崩溃——ValueError: Out of range float values are not JSON compliant: nan,而且应用自建的 logs 表里找不到任何错误记录。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析流水线,Airflow DAG 从 SQL 取数、经 pandas 处理后,通过 XCom 在任务间传递结果。

TL;DR​

XCom 底层用 JSON 序列化,而 Airflow 调用 json.dumps(..., allow_nan=False) 严格遵循 JSON 标准——标准里压根没有 NaN / Infinity。pandas 从 SQL NULL 转来的浮点 NaN 一旦进了 xcom_push 的数据,序列化立即抛 ValueError。解法:在 push 前递归遍历数据,把 NaN / ±Inf 转成 None(JSON null)。

问题现象​

某次季度报表 DAG 失败,但症状很迷惑——应用自建的 logs 表里,该 trace 的步骤 1–4 全部正常,连「分析完成」都打了两次(重试),然后就断了,步骤 5 缺失,且没有任何 error 行:

trace=91c126c3
├─ step 1 SQL 取数 ✅
├─ step 2 pandas 处理 ✅
├─ step 3 规则判定 ✅
├─ step 4 LLM 分析完成 ✅ ← 之后重试了一次
└─ step 5 XCom 推送结果 ❌ ← 缺失,无 error 记录

去 Airflow 任务日志才看到真正的 traceback:

# 容器内 /opt/airflow/logs/dag_id=ai_analysis_v2/run_id=.../task_id=analyze_results/attempt=N.log
ValueError: Out of range float values are not JSON compliant: nan
File ".../ai_analysis_tasks.py", line 142, in analyze_results
ti.xcom_push(key='sql_metadata', value=result)

崩溃点精确落在 ti.xcom_push——任务把结果推给 XCom 的那一刻。

根因​

三层叠加,缺一不可:

1. JSON 标准不含 NaN / Infinity。 RFC 8259 定义的 JSON 只允许数字字面量是有限数。虽然 Python 的 json.dumps 默认会把 NaN 写成裸 NaN、把 Infinity 写成 Infinity,但这是 Python 的私有扩展,不是合法 JSON——任何严格 parser(包括 Airflow 用的)读到都会拒绝。

2. Airflow XCom 序列化时显式 allow_nan=False。 XCom 默认走 JSON serializer,序列化时关闭了 NaN 容忍,遇到 NaN 直接抛 ValueError: Out of range float values are not JSON compliant,而不是偷偷写出非法 JSON。

3. pandas 把 SQL NULL 读成 NaN。 pandas.read_sql 对 SQL 的 NULL 列返回 float('nan')。一旦这列参与计算后被 to_dict('records') 带进结果对象,NaN 就顺着数据流进了 xcom_push:

import pandas as pd

# SQL 某行某列是 NULL → pandas 读成 NaN
df = pd.DataFrame({"ad_roi": [1.2, None, 0.8]})
records = df.to_dict("records")
# [{'ad_roi': 1.2}, {'ad_roi': nan}, {'ad_roi': 0.8}] ← nan 混了进来

# 下游任务 push 时崩
ti.xcom_push(key="result", value=records)
# ValueError: Out of range float values are not JSON compliant: nan

这次之所以长期没触发,是因为平时跑的数据那些列都有值;直到某客户某个季度完全没有广告投放、ad_roi 整列 NULL,NaN 才第一次大规模进入 XCom 路径。

为什么 logs 表没有 error? 因为崩溃发生在 XCom 序列化阶段,处于任务函数的 try/except 之外——异常直接冒泡给 Airflow 调度器,只写进 Airflow 自己的任务日志,应用层自建的 logs 表的 catch 根本没机会记录。这是这类故障最迷惑的地方:看起来「无声失败」。

解决方案​

在数据进入 XCom 前,递归清洗掉所有 NaN / ±Inf。

1. 写一个纯函数递归清洗​

import math

def json_safe_value(obj):
"""
递归把 NaN / +Inf / -Inf 转成 None,使数据可被 JSON 严格序列化。
兼容 dict / list / tuple / scalar,遇到未知类型原样返回。
"""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: json_safe_value(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [json_safe_value(v) for v in obj]
return obj

为什么不能用 df.fillna(None)?因为 pandas 的 fillna(None) 在数值列上行为依版本和 dtype 不稳定,有时会把 NaN 强制转型而非置空;而且它只处理 DataFrame,管不到已经 to_dict 之后嵌在 dict/list 里的浮点。递归清洗在「数据已变成 Python 原生结构」这一层兜底,最稳。

2. 在 push 前统一兜底​

最省心的做法是把清洗挂在所有 xcom_push 的必经之路上(比如一个归一化函数),而不是每个 push 点都记得调:

def push_safe(ti, key, value):
"""XCom push 前清洗 NaN/Inf,杜绝序列化崩溃。"""
ti.xcom_push(key=key, value=json_safe_value(value))

# 任务内
push_safe(ti, "sql_metadata", result)
push_safe(ti, "processor_output", processor_result)

3. 补上「无声失败」的可观测性​

光修序列化还不够——异常发生在 catch 外、应用 logs 表不记录这个缺口要一起补。给任务挂一个失败装饰器,顶层异常先落库再 re-raise:

import functools
import logging

logger = logging.getLogger(__name__)

def log_task_failure(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception:
logger.error("task %s failed", fn.__name__, exc_info=True)
# 这里把 traceback 写进应用自建 logs 表
raise
return wrapper

@log_task_failure
def analyze_results(**context):
...

这样即使以后再出现 catch 外的异常,应用 logs 表也能留下 error 行,不再「无声失败」。

修完后重跑同一份 conf:DAG 全绿、落库 success,原本 NaN 的 ad_roi 在库里落成 null,下游正常。

同一条 Airflow 分析流水线上,让数据悄悄出问题的坑不止这一个——PostgresHook 多语句 SQL 静默丢结果是另一个典型案例。

注意事项​

注意事项

  • json.dumps 默认 allow_nan=True 会埋雷:它会偷偷写出裸 NaN / Infinity 这个非法 JSON,当下游用严格 parser(如 Airflow XCom、JS 的 JSON.parse)读取时才崩。永远在序列化跨进程边界的数据时显式 allow_nan=False 提前暴露问题。
  • ±Infinity 同样踩雷:float('inf') / float('-inf') 和 NaN 一样被 JSON 标准排除,json_safe_value 要一并处理。
  • XCom 不止 JSON 一种 serializer:Airflow 也支持二进制对象序列化,能存任意 Python 对象,但这种 XCom 不可读、不跨版本、且反序列化任意对象有安全风险,生产环境坚持用 JSON 并把数据清洗干净。
  • 排查心法:当 logs 表 trace 中断且无 error 行时,直接去 Airflow 任务日志(容器内 /opt/airflow/logs/dag_id=.../task_id=.../)找 traceback——「应用层无日志」不等于「没出错」。

常见问题​

Airflow 报 Out of range float values are not JSON compliant 怎么解决?​

这是 XCom 用 json.dumps(allow_nan=False) 序列化时遇到了 NaN / Infinity,而 JSON 标准不含这两种值。根因通常是 pandas 把 SQL NULL 读成了 float('nan'),跟着数据流进了 xcom_push。解法是在 push 前递归把 NaN / ±Inf 转成 None(JSON null),用一个 json_safe_value 纯函数统一兜底即可。

为什么 Airflow 任务失败但自建 logs 表没有错误记录?​

如果异常发生在 XCom 序列化阶段、且位于任务函数的 try/except 之外,错误只会冒泡给 Airflow 调度器、写进 Airflow 任务日志(容器内 /opt/airflow/logs/),应用层自建的 logs 表的 catch 拿不到,于是表现为「无声失败」。排查这类情况要直接看 Airflow task log 的 traceback,别只盯应用日志。

Airflow XCom 能不能直接存 pandas 的 NaN?​

不能。XCom 默认走 JSON 序列化,而 JSON 标准只有有限数字,没有 NaN / Infinity。正确做法是 push 前把 NaN 转成 None(对应 JSON null)。换成二进制对象序列化虽能绕过类型限制,但结果不可读、不跨进程/版本、还有反序列化安全风险,生产环境不推荐。


CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询