最近处理了一个很典型的 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_
