最近处理了一个很典型的 SqlAlchemy + MySQL 线上问题:配置管理后台的接口明明写了 WHERE payload IS NOT NULL,可一条 payload 为空的记录还是被查了出来,列表页多了一条“幽灵配置”。我一开始怀疑是缓存,后来打开 SQLAlchemy 的日志看到 INSERT 语句里绑定的参数竟然是字符串 'null'。再直接连 MySQL 看数据,payload 字段显示为 NULL,但执行 payload IS NULL 判断时返回的却是 0。这个 None 变 'null' 的问题,根源在于业务代码里的 JSON 序列化逻辑把 None 提前处理成了 JSON 文本,而 MySQL 的 JSON 类型又把这个文本解析成了 JSON 字面量 null,和真正的 SQL NULL 完全是两回事。这篇把排查链路、根因拆解和修复方案完整写一遍,对经常用 ORM 管 JSON 字段的团队很有参考价值。
1. 问题现象:记录像幽灵一样躲过了 IS NULL 过滤
1.1 线上表现与最小复现
先说线上场景。我们有一张配置表,核心字段是 payload,类型是 MySQL 的 JSON。运营在后台清空某条配置的 JSON 内容,后端代码处理逻辑是:当传入值为空时,把 payload 字段置为 None。列表接口统计有效配置用的是 payload.isnot(None) 这个条件,理论上清空后的记录不应该再出现。但实际统计结果多了一条,而且点进详情看,payload 确实是空。
这个现象用一段最小代码就能复现,问题出在业务层提前调用了 json.dumps:
python复制import json
from sqlalchemy import create_engine, Column, Integer, JSON
from sqlalchemy.orm import declarative_base, Session
Base = declarative_base()
class Config(Base):
__tablename__ = 'config'
id = Column(Integer, primary_key=True)
payload = Column(JSON)
engine = create_engine('mysql+pymysql://user:pass@localhost/db', echo=True)
Base.metadata.create_all(engine)
with Session(engine) as s:
# 错误写法:把 None 提前 dumps 成了字符串 'null'
payload = json.dumps(None)
s.add(Config(payload=payload))
s.commit()
with Session(engine) as s:
row = s.query(Config).first()
print(row.payload) # 读出的是 None,具有迷惑性
# 但这条记录用 IS NULL 根本查不到
print(s.query(Config).filter(Config.payload.is_(None)).count()) # 0
注意 json.dumps(None) 的返回值,在 Python 里不是 None,而是字符串 'null'。这个字符串被传给了 ORM 的 JSON 字段,SQLAlchemy 看到这不是 None,就继续对它做序列化,最后 MySQL 收到的是 JSON 文本 'null',解析后存成了一个 JSON null 字面量。于是应用层读出来是 None,数据库里 IS NULL 却匹配不到,逻辑直接错乱。
1.2 为什么直接看数据库会被误导
很多人排查到这一步都会被数据库客户端骗过去。用 MySQL 命令行查这条记录:
sql复制SELECT payload, payload IS NULL AS is_sql_null, JSON_TYPE(payload) AS json_type FROM config;
结果长这样:
| payload | is_sql_null | json_type |
|---|---|---|
| NULL | 0 | 'NULL' |
第一列显示 NULL,肉眼看就是个空值,常规思路会认为 IS NULL 应该返回 1。但第二列明确告诉你,MySQL 认为它不是 SQL NULL,IS NULL 判断结果是 0。第三列 JSON_TYPE 返回字符串 'NULL',这表示字段里存的是一个 JSON 文档,文档内容就是 null 字面量。
这是一个很大的迷惑点:MySQL 客户端在展示 JSON null 的时候,显示的就是 NULL,不借助 JSON_TYPE 这类函数,你根本分不清存进去的到底是 SQL NULL 还是 JSON null。正是这个视觉陷阱让问题拖了很久。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排查链路:从 ORM 日志一路挖到 MySQL 内部
2.1 第一步:打开 SQLAlchemy 的 echo 日志
排查这类问题,第一条铁律就是先看 ORM 最终执行的 SQL 和绑定参数。把引擎初始化改成 echo=True,或者临时给某个 Session 加日志,就能在控制台看到完整的 SQL 语句:
python复制engine = create_engine('mysql+pymysql://user:pass@localhost/db', echo=True)
日志里 INSERT 语句长这样:
code复制INSERT INTO config (payload) VALUES (%(payload)s)
[generated in 0.00032s] {'payload': 'null'}
关键就在最后这个参数绑定。{'payload': 'null'} 说明传给 ORM 的值已经是字符串 'null',而不是 Python 的 None。SQLAlchemy 并没有在中间把 None 变成字符串,是业务代码在传给 ORM 之前就已经序列化过了。
这里要插一句经验:排查时别急着怀疑 ORM 有 bug。SQLAlchemy 的 JSON 类型对 None 的处理很直接,正常情况不会把 None 序列化成字符串。日志里出现 'null',几乎都是上游代码的问题。看到参数里有引号包裹的 null,就要顺着数据流往业务层找。
2.2 第二步:用 SQL 直接验证 JSON 类型的行为
为了排除方言和驱动层面的干扰,可以在 MySQL 里直接跑三条验证语句,用最干净的方式确认 MySQL 对 JSON 文本的处理逻辑:
sql复制SELECT CAST('null' AS JSON) IS NULL; -- 结果是 0
SELECT CAST('null' AS JSON) IS NOT NULL; -- 结果是 1
SELECT JSON_TYPE(CAST('null' AS JSON)); -- 结果是字符串 'NULL'
结论很明确:MySQL 的 JSON 类型把字符串 'null' 解析成了 JSON 字面量 null,这个 JSON null 不等于 SQL NULL。IS NULL 只认 SQL NULL,不认 JSON null。所以无论你传 'null' 这个字符串进去,还是直接传 JSON 文本 CAST('null' AS JSON),最终都会得到同样的效果:字段看起来是空,实际却躲过了 IS NULL。
这一步验证能帮你把问题边界划清楚:不是连接参数的问题,不是驱动的问题,也不是 SQLAlchemy 版本的问题,就是 JSON 语义本身。
2.3 第三步:在 Python 代码里定位序列化环节
确认 MySQL 的行为后,回头看业务代码,定位是谁把 None 提前变成了 'null'。我们项目里有一个公共函数,统一处理 JSON 序列化,带各种类型兜底:
python复制import json
def build_payload(data):
return json.dumps(data, default=json_default, ensure_ascii=False)
问题就在这。当 data 为 None 时,json.dumps(None) 返回的是字符串 'null',而不是 None。所有走这个函数构造 payload 的写操作,只要传入 None,都会存成 JSON null。而配置清空功能正好就是传了一个 None。
定位方法很简单:在编辑器里全局搜索 json.dumps,凡是返回值直接塞给 ORM 列的位置全部标出来,逐个检查有没有对 None 做短路。很多人只检查了 dict、list、datetime 这些类型,唯独漏了 None,这个坑特别容易埋。
3. 根因拆解:SQL NULL 和 JSON null 的语义鸿沟
3.1 MySQL JSON 列对两种 null 的处理
用一个生活化类比解释:SQL NULL 相当于“这个格子里完全没有放东西”,而 JSON null 相当于“格子里放了一张写着 null 的字条”。MySQL JSON 类型的特殊之处在于,它允许这个“字条”存在,也就是 JSON null 是一个合法的 JSON 值,但它在 SQL 语义里不等于空。
两张状态的对比:
| 状态 | 存储方式 | IS NULL 结果 |
JSON_TYPE() 结果 |
含义 |
|---|---|---|---|---|
| SQL NULL | 真正的空值 | 1 | NULL | 字段从未被写入有效内容 |
| JSON null | JSON 文档字面量 | 0 | 'NULL' | 字段写入了一个值为 null 的 JSON 文档 |
这个差异会影响很多东西:IS NULL 条件、JSON_EXTRACT 子查询、索引条件下的匹配逻辑。如果写入层不统一,查询层就会非常被动。
3.2 SQLAlchemy JSON 类型的 None 处理机制
SQLAlchemy 官方提供的 sqlalchemy.JSON 类型,在把 Python 值绑定到数据库参数时有一套固定逻辑:如果值是 None,直接返回 None,让驱动把它映射成 SQL NULL;只有值不是 None 时,才会调用 json.dumps 做序列化。
所以,只要你的模型列定义用的是标准 JSON 类型,并且没有在业务代码里提前 json.dumps,直接传 None 是安全的:
python复制session.add(Config(payload=None)) # 正确,落库后是 SQL NULL
问题往往出在绕过了这套机制的场景:要么业务层手动 dumps,要么自定义了 TypeDecorator 且无条件对值做序列化。
3.3 什么代码最容易埋雷
结合我见过的情况,最容易踩的三个雷区:
第一个,业务层手动 dumps。这是最常见的,很多人会写一个工具函数统一序列化,然后插入时直接 payload=json.dumps(data)。只要 data 是 None,雷就埋下了。
第二个,自定义 TypeDecorator 重写了 bind_processor,但没有对 None 做判断。比如:
python复制from sqlalchemy.types import TypeDecorator, JSON
class MyJSON(TypeDecorator):
impl = JSON
def process_bind_param(self, value, dialect):
# 错误:None 会被 dumps 成字符串 'null'
return json.dumps(value, default=json_default)
正确做法是先判断:
python复制 def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(value, default=json_default)
第三个,为了让 ORM 能自动处理 datetime、Decimal 等类型,写了一个全局 JSON 序列化函数,却在函数入口漏掉了 None 的短路分支。这类函数由于被频繁调用,往往写着写着就没人注意边界条件了。
排查提示:只要看到 ORM 日志里的绑定参数带引号,比如
'null',基本可以断定值在到达 ORM 之前就已经不是 None 了。先去业务代码里搜json.dumps,别在 ORM 配置上死磕。
4. 完整修复:代码、数据、规范三管齐下
4.1 修复插入逻辑:让 None 保持 None
修复的第一步是把所有写入路径上的 None 还原成真正的 None。这里有三条路可以走,按推荐程度排序:
第一种,如果项目里没有特殊类型需要处理,直接砍掉手动 dumps,让 ORM 的 JSON 类型全权负责:
python复制session.add(Config(payload=None)) # 直接传原始对象,不要传字符串
第二种,如果业务层确实需要统一序列化函数,在函数入口做短路:
python复制def build_payload(data):
if data is None:
return None
return json.dumps(data, default=json_default, ensure_ascii=False)
第三种,如果项目必须保留自定义 TypeDecorator,至少在 bind 之前做判断:
python复制class MyJSON(TypeDecorator):
impl = JSON
def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(value, default=json_default)
改完之后,重新插入一条测试数据,用 SQL 验证:
sql复制SELECT payload, payload IS NULL AS is_sql_null FROM config WHERE id = NEW_ID;
这时候 is_sql_null 应该返回 1,说明 None 已经正确落成 SQL NULL。
4.2 清洗存量数据:把 JSON null 转成 SQL NULL
光改代码不够,线上已经有脏数据了。根据你的列类型,采用不同的清洗方案。
如果列是真正的 JSON 类型,脏数据是 JSON null 字面量,那不能用 payload = 'null' 这个条件去匹配。JSON 列里存的是 JSON 文档,字符串比较行为和普通列不一样,最稳的方式是用 JSON_TYPE 判断:
sql复制UPDATE config
SET payload = NULL
WHERE payload IS NOT NULL
AND JSON_TYPE(payload) = 'NULL';
执行之前先跑一条 SELECT 确认影响范围:
sql复制SELECT COUNT(*)
FROM config
WHERE payload IS NOT NULL
AND JSON_TYPE(payload) = 'NULL';
如果列之前用的是 VARCHAR 或 TEXT 手工存 JSON 字符串,脏数据就是字面意义上的字符串 'null',那更新条件不同:
sql复制UPDATE config
SET payload = NULL
WHERE payload = 'null';
无论哪种情况,建议在事务里执行,先备份表,避免批量更新把业务数据覆盖掉。
4.3 写查询条件时的双保险
修复存量数据之后,还要确保查询逻辑足够健壮。如果团队里存在历史遗留数据,短期内没有彻底清洗干净,查询条件可以加一道保险,把 SQL NULL 和 JSON null 都排除掉。
SQLAlchemy 里的写法:
python复制from sqlalchemy import and_, func
session.query(Config).filter(
and_(
Config.payload.is_not(None),
func.json_type(Config.payload) != 'NULL'
)
).all()
对应的 SQL 是:
sql复制SELECT *
FROM config
WHERE payload IS NOT NULL
AND JSON_TYPE(payload) <> 'NULL';
这个写法能同时过滤掉“真正的 SQL NULL”和“JSON null 字面量”两种情况。不过要提醒一点:如果线上数据已经全部清洗干净,这个双保险条件不是必须的,反而会带来一点额外的函数调用开销。它适合作为过渡期的保护手段,长期规范还是应该回到写入层去统一约束。
5. 同类 JSON 字段的坑:null、{}、空数组不能混为一谈
5.1 JSON 列中 null、{}、[] 的语义
处理完这个线上问题后,我又盘点了一遍项目里 JSON 字段的使用方式,发现“空”这个抽象概念在 JSON 字段里至少对应三种完全不同的存储值,业务上含义也完全不同。
| 存储值 | IS NULL 结果 |
JSON_TYPE() 结果 |
典型业务含义 |
|---|---|---|---|
| SQL NULL | 1 | NULL | 从没写入过 |
| JSON null | 0 | 'NULL' | 主动写入了空值 |
{} |
0 | 'OBJECT' | 空对象,但结构存在 |
[] |
0 | 'ARRAY' | 空数组,列表为空 |
比如一个配置项的 payload 字段,如果统一定义为对象类型,那么 {} 代表“有结构但内容为空”,null 代表“数据缺失”,SQL NULL 代表“从未设置”。这三种状态在页面上可能要渲染成不同的样子,在统计逻辑里也有不同含义。如果写入层没协商清楚,查询层就没法写了。
5.2 JSON_EXTRACT 与 JSON_TYPE 的正确用法
这类坑还会延伸到一个更隐蔽的场景:查 JSON 字段里的子元素。比如你有一个 JSON 字段 {"a": null},然后用 JSON_EXTRACT 取 a 的值:
sql复制SELECT JSON_EXTRACT(CAST('{"a": null}' AS JSON), '$.a') IS NULL;
结果还是 0。因为 JSON_EXTRACT 返回的也是一个 JSON 值,a 的子值是 JSON null,不是 SQL NULL。
正确判断方式是配合 JSON_TYPE 使用:
sql复制SELECT
JSON_TYPE(JSON_EXTRACT(CAST('{"a": null}' AS JSON), '$.a')) = 'NULL';
或者用 JSON_UNQUOTE 把 JSON null 转成 SQL NULL 再判断:
sql复制SELECT JSON_UNQUOTE(JSON_EXTRACT(CAST('{"a": null}' AS JSON), '$.a')) IS NULL;
这类细节很容易被忽略。在一个复杂查询里,你取某个 JSON 子字段做空值判断,如果子字段存的是 JSON null,IS NULL 就是不生效,排查起来会比整字段问题更难受。
5.3 给团队的几条约定
这次排查完之后,我在团队规范里加了四条硬性约定,这里也分享给你参考:
第一,JSON 列统一由 ORM 的 JSON 类型管理,业务层不允许手动对值调用 json.dumps。所有 JSON 序列化交给 ORM 统一处理。
第二,写入“空”值统一使用 Python 的 None,不允许把 'null' 字符串、JSON null 字面量或 {} 混着用。如果业务上需要区分“空对象”和“无值”,必须用不同的字段或明确的注释约定。
第三,新增查询逻辑时,先确认要判断的是 SQL NULL 还是 JSON null。如果是 JSON null,必须写 JSON_TYPE 相关条件,不能只写 IS NULL。
第四,代码 Review 时看到 json.dumps,必须确认返回值有没有可能为 'null' 字符串,以及它会不会流向 ORM 的 JSON 字段。
这四条约定成本很低,但能避免大部分类似的幽灵数据问题。
这次排查之后,我对所有 JSON 字段的写入路径都多留了一个心眼:ORM 层只接收原始对象,不接收 JSON 字符串。所有序列化都归 SQLAlchemy 的 JSON 类型处理。如果遇到那种“读出来是 None 却查不到”的诡异问题,第一步永远是看 ORM 日志里的绑定参数——参数长什么样,答案基本就在那儿。
