跳到主要内容

PostgreSQL 数据库迁移后留下废弃空表?用 information_schema 审计 schema 残留

· 阅读需 7 分钟

在一次广告数据源从日表切换到周表后,我发现 schema 里还躺着一张只在最早期的 migration 里建过、0 行数据、运行时代码从不引用的空表——还带着过时的字段名和没规范化的中文列。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。这次数据源切换后,写入端早已迁到新的周表,旧日表的清理 migration 也补了,唯独一张只在 baseline 里 CREATE 过的月表被遗漏——它既没有对应的新写入,也没有 DROP,就那样潜伏在 schema 里,带着早已废弃的字段定义。

TL;DR

废弃表的典型特征:只在早期/baseline migration 里 CREATE、当前代码 0 引用、常带旧字段或未规范化的列名。批量迁移时它们不会被自动处理,需要主动用 information_schema.tables 列出 schema 全表,再与代码引用比对,定位 orphan 表后写一条 DROP migration 清理——而不是手动 psql 删完就了事。

问题现象

一张典型的废弃表长这样:

  • 0 行数据——业务早已不再写入它;
  • 0 运行时引用——代码里 grep 不到任何 SELECT/INSERT,只剩 migration 文件里的 CREATE
  • 旧字段残留——字段名是上一版命名(如 ad_plan_id/product_id),与当前规范不一致;
  • 未规范化的列名——甚至还有中文列名没来得及改。

它不报错、不影响线上运行,所以从「线上没出问题」的视角完全无感。但它的危害是隐性的:误导后来者以为它仍在用、占用 schema 命名空间、在跨表审计时制造噪音,还可能被某个误判的 SELECT * 意外读到脏数据。

根因

数据库迁移有一个普遍的模式:migration 是「加法」的

一次数据源切换通常这样演进:

  1. 早期 baseline migration CREATE 了一批表(日表、月表);
  2. 业务跑通后,写入端开始依赖这些表;
  3. 需求变化,引入新表(周表),写入端逐步迁移过去;
  4. 旧表的写入停了,补一条 migration DROP 旧日表;
  5. 但月表/其他只在 baseline 建过、从未被写入端直接引用的表,没有对应的 DROP

问题出在第 5 步:迁移注意力集中在「现在用到的表」上——哪些表在写入、哪些 SQL 在查。而「曾经存在、但从未进入主链路」的表既不在写入端、也不在查询端,自然不会触发任何 DROP,于是成了 orphan。这类残留和 Airflow 删除 DAG 后元数据残留 是同一类问题:「删了入口、忘了清结构」,是迁移类问题的高发区。

解决方案

核心流程:列全表 → 比对引用 → 确认空表 → 写 migration DROP → 验证

步骤 1:用 information_schema 列出 schema 下所有基础表

-- 列出某 schema 下所有基础表(排除视图)
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'your_schema'
AND table_type = 'BASE TABLE'
ORDER BY table_name;

information_schema.tables 是 SQL 标准目录视图,跨 PostgreSQL/MySQL/SQL Server 通用,字段稳定,非常适合写进审计脚本。

步骤 2:grep 代码库确认运行时引用

对每张候选表,在代码库里搜索引用,排除 migration 文件本身

# 搜索运行时代码引用,排除 migrations 目录
grep -rn "ad_product_monthly_stats" src/ --include="*.py" \
| grep -v "migrations/"
# 0 行输出 → 运行时无引用,进入候选

0 引用是判定 orphan 的关键证据。注意一定要排除 migration 目录——baseline 里的 CREATE 不算「引用」。

步骤 3:确认是空表

SELECT count(*) FROM your_schema.ad_product_monthly_stats;
-- 0 → 确认无数据,可安全清理

对有数据的表要格外谨慎:先确认它真的废弃(而非只是近期没写入),有疑问就先做逻辑备份再处理。

步骤 4:写一条 migration DROP(而非手动删)

-- db-migrations/{project}/027_drop_ad_product_monthly_stats.sql
DROP TABLE IF EXISTS your_schema.ad_product_monthly_stats;

务必走 migration 文件:它会被版本控制、在所有环境(开发/预发/生产)一致重放,留下审计轨迹。手动 psql 删一次,换台机器就又长回来了。

步骤 5:验证已删除

SELECT to_regclass('your_schema.ad_product_monthly_stats');
-- 返回 NULL 表示表已不存在

to_regclass() 是验证表是否存在的标准手段,返回 NULL 即确认删除成功。

批量审计:按前缀一次性排查同类遗漏

单张表清掉后,按前缀把同类表全部列出来逐个核对,避免「清了一张、漏了兄弟」:

-- 列出某前缀下所有表,逐个走 步骤2-5
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'your_schema'
AND table_name LIKE 'ad_%'
ORDER BY table_name;

注意事项

  • DROP 前先备份/快照:生产库删表不可逆。对任何有数据的表,先确认废弃再做逻辑备份(如 CREATE TABLE ... AS SELECT 导出到归档库)。
  • 外键依赖要排查:如果有其他表的外键指向它,DROP TABLE 会失败。确认依赖已解除或有意 CASCADE——但 CASCADE 会连带删除依赖对象,生产环境慎用。
  • 走 migration,不要手动 psql:手动删除只在当前环境生效,迁移文件才能保证多环境一致并留下记录。
  • 用前缀批量审计:一次切换通常涉及一组同前缀的表(如 ad_*),清完一张后用 LIKE 'ad_%' 把兄弟表都过一遍,主动发现同类遗漏。

常见问题

怎么列出 PostgreSQL 数据库中的所有表?

information_schema.tables,过滤 table_schematable_type = 'BASE TABLE',即可列出某 schema 下所有基础表。它比 psql 的 \dt 更适合写进脚本做自动化审计,且是 SQL 标准、跨数据库通用,代码可移植性更好。

PostgreSQL 怎么找出没被使用的废弃表?

information_schema.tables 列出全部表,再与代码库或查询日志的引用做比对,运行时代码 0 引用且无写入的表即为废弃候选;空表可进一步用 SELECT count(*) 确认行数,确认无数据、无外键依赖后再写 migration DROP 清理。

information_schema 和 pg_catalog 有什么区别?

information_schema 是 SQL 标准定义的目录视图,跨 PostgreSQL/MySQL/SQL Server 通用、字段稳定不易变,适合写可移植的审计脚本;pg_catalog 是 PostgreSQL 专有目录,信息更全更细(如精确行数估算、存储细节),但版本间可能调整。做通用 schema 审计优先用 information_schema

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.dumpsdefault 参数在遇到无法序列化的对象时被调用,期望返回一个可序列化的值str 作为 default 时,会把对象交给 str()——set 就被转成了它的 Python 字面量表示 {'3', '5', '12'},作为字符串存进 JSON:

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

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

第三层:instrset 语义不同。 这是静默 bug 的核心。对 set/listx in s成员判断;对 strx 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 而非 setx in s 就从「成员判断」退化成「子串匹配」——比如 "1" in "{'3','5','12'}""12" 含字符 "1" 而返回 True,但原集合并不含 "1"。解法是序列化 list、读取时 set() 重建。

CCLEE

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

合作咨询

Airflow 触发 dagRun 静默失败?logical_date 唯一约束在作怪

· 阅读需 5 分钟

在用 Airflow REST API 重复触发同一个 DAG 做灰度验证时,请求返回了 4xx 但响应体里没有 dag_run_id,DAG 实际根本没有运行——而脚本却把它当成了成功。

在开发 AI 运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。广告决策链路的灰度切换需要在 Airflow 上反复触发同一次分析做对照,结果部分触发悄无声息地失败了。

TL;DR

Airflow 对每个 DAG 的 logical_date 有唯一约束dag_run_id 同样必须唯一)。用相同的 logical_date 重复 POST /dags/{dag_id}/dagRuns,Airflow 会拒绝并返回 4xx,响应体里没有 dag_run_id。如果你只检查 HTTP 状态码、不检查返回的 dag_run_id,就会误以为触发成功。解法:每次触发用不同的 logical_date(和不同的 dag_run_id)。

问题现象

为了对照测试,用固定日期 2026-01-01 连续触发同一个 DAG:

$ curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dag_run_id": "manual-run-1",
"logical_date": "2026-01-01T00:00:00Z"
}'
# 第一次:返回正常的 dag_run 对象,包含 dag_run_id ✅

$ curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dag_run_id": "manual-run-2",
"logical_date": "2026-01-01T00:00:00Z" # ⚠️ 同一个 logical_date
}'
# 第二次:返回错误对象,没有 dag_run_id ❌
{
"detail": "...",
"status": 400,
"title": "Bad Request",
"type": "https://airflow.apache.org/docs/apache-airflow/2/stable-rest-api-ref.html#/default/Error"
}

如果调用方只判断「HTTP 是否 2xx」就停止解析,或者直接读 JSON 不校验 dag_run_id 字段,第二次失败就会被静默吞掉——日志里看不到异常,Airflow UI 里也找不到这次 run。

根因

Airflow 用 dag_run_id 作为每次运行的主键,同时在 metadata 数据库的 dag_run 表上对 (dag_id, logical_date) 维护唯一性。logical_date 是调度的「逻辑时间」——调度器按它判断某个调度槽位是否已经跑过。一旦同一个 DAG 下已存在某 logical_date 的 run,再用相同值触发,Airflow 就会拒绝,避免重复执行。

问题在于这个失败是 HTTP 4xx + 错误 JSON,不是连接错误或 5xx。很多脚本只做 response.status_code == 200 的粗判断,或者拿到 JSON 后直接取字段而不校验是否存在 dag_run_id,于是把「拒绝创建」当成了「创建成功」。

解决方案

核心:每次触发用不同的 logical_date(以及不同的 dag_run_id)。 灰度/回放场景下,给每次触发拼一个不重复的日期即可:

# 每次循环用不同的 logical_date(2026-01-01 / 02 / 03 …)
for i in 1 2 3; do
curl -s -X POST "$AIRFLOW/api/v2/dags/my_dag/dagRuns" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"dag_run_id\": \"manual-run-$i\",
\"logical_date\": \"2026-01-0${i}T00:00:00Z\"
}"
done

更稳妥的是用递增时间戳,保证 logical_datedag_run_id 永不重复。更重要的是:必须校验响应体里的 dag_run_id 字段,把它当成「触发真正成功」的唯一证据:

import requests

def trigger_dag(dag_id: str, logical_date: str, conf: dict | None = None) -> str:
resp = requests.post(
f"{AIRFLOW}/api/v2/dags/{dag_id}/dagRuns",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
json={"dag_run_id": f"manual-{logical_date}", "logical_date": logical_date, "conf": conf or {}},
)
# ❌ 不够:只看状态码,4xx 会被当异常但容易漏判
# resp.raise_for_status()
data = resp.json()
# ✅ 正确:dag_run_id 存在才算真正创建成功
if "dag_run_id" not in data:
raise RuntimeError(f"Trigger failed: {resp.status_code} {data}")
return data["dag_run_id"]

# 每次用不同 logical_date,重复触发安全
for i in range(1, 4):
trigger_dag("my_dag", f"2026-01-0{i}T00:00:00Z")

dag_run_id 同样要保持唯一——它是主键,重复会被直接拒绝。用「前缀 + logical_date」组合是常见做法,既唯一又能在 UI 里一眼识别。

常见问题

airflow trigger_dagrun 怎么通过 REST API 触发 DAG?

POST /api/v2/dags/{dag_id}/dagRuns,请求体至少包含 dag_run_idlogical_date 两个字段(可选 conf 传参数)。这两个字段在同一个 DAG 下都必须唯一,否则 Airflow 返回 4xx。代码里推荐用 TriggerDagRunOperator,它内部也会生成唯一的 run id。

airflow 重复触发同一个 DAG 为什么失败?

因为 Airflow 在 metadata 数据库的 dag_run 表上对 (dag_id, logical_date) 维护唯一约束,dag_run_id 本身也是主键。重复的 logical_datedag_run_id 都会被拒绝并返回 4xx。要做回放或灰度对照,每次触发换一个新的 logical_date(或递增时间戳)即可。

注意事项

  • 校验 dag_run_id,不要只看状态码:4xx + 错误 JSON 是 Airflow 表达「拒绝创建」的正常方式,只判断 status_code 容易把失败误判为成功。
  • logical_date 用过去时间:填未来时间会被当成定时调度,不会立即执行;要立即跑用过去日期。
  • API 版本差异:Airflow 2.x 是 /api/v2/dags/{dag_id}/dagRuns,3.x 路径和字段有调整,迁移时务必核对对应版本的 REST API 文档。
  • 回放优先用 CLI 的 --logical-dateairflow dags trigger 支持指定日期,但对 logical_date 的唯一约束同样生效,重复日期一样会失败。

CCLEE

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

合作咨询

Airflow 删除 DAG 后它还在列表里?元数据没清干净 + 正确清理顺序

· 阅读需 7 分钟

在 Airflow 里删掉某个 DAG 的 .py 文件想下线它,Web UI 的 DAG 列表和数据库里却还挂着这个 dag_id;更诡异的是——如果先清元数据再删文件,刚刚清空的行会「复活」重新出现。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析流水线,下线旧版报表 DAG 时要把它的元数据一起清干净,否则 UI 列表和定时扫描会被残留行干扰。

TL;DR

Airflow 的 dag-processor 会定期扫描 DAG 目录重新注册,而 airflow dags reserialize 也不会清理「文件已删除」的孤儿 dag 行——所以光删 .py 文件,UI 和数据库里的 dag_id 不会自动消失;反过来先清元数据再删文件,processor 扫到文件还在,会把清空的行重新注册(「复活」)。正确顺序:①先删文件让 processor 不再注册 → ②按外键顺序 SQL DELETE 清元数据 → ③跑 airflow dags reserialize 验证

问题现象

下线 shop_report_aggregation 这个 DAG,删了它的 .py 文件后:

$ ls /opt/airflow/project/airflow_dags/shop_report_aggregation.py
ls: cannot access '.../shop_report_aggregation.py': No such file or directory

$ # 但数据库里还在
$ docker exec cclhub-db psql -U airflow -d airflow -c \
"SELECT dag_id, is_paused, is_active FROM dag WHERE dag_id='shop_report_aggregation';"
dag_id | is_paused | is_active
--------------------------+-----------+-----------
shop_report_aggregation | f | t ← 仍残留

不只 dag 表,serialized_dagdag_codedag_version 表里对应行也全在,于是 Web UI 的 DAG 列表继续显示这个已「删除」的 DAG。

更坑的是反向操作——先清元数据、后删文件:

T0  DELETE FROM dag WHERE dag_id='shop_report_aggregation';   ← 清空
T1 (此时还没删 .py 文件)
T2 dag-processor 扫描周期到达,发现文件存在、dag 表无对应行 → 重新注册
T3 SELECT ... FROM dag WHERE dag_id='shop_report_aggregation'; ← 又回来了(复活)

根因

两个机制叠加:

1. dag-processor 定期扫描并重新注册。 Airflow 的 dag-processor(Scheduler 的一部分)按 processor_poll_interval(默认约 5 分钟)周期性扫描 dags_folder 目录,解析每个 .py 文件并 upsert 进元数据表(dagserialized_dagdag_version)。只要文件还在,下一个扫描周期就会重新写入对应行。 这是「复活」的直接来源——你清了行,文件还在,processor 把它当新 DAG 重新登记。

2. reserialize 不管「文件已消失」的旧行。 airflow dags reserialize 的职责是把现有 DAG 文件重新序列化、刷新 serialized_dag;它不会去删除「文件已经不存在」的孤儿 dag 行。而 airflow dags cleanup 默认只清理过期的 dag_run 运行历史,也不动 dag / serialized_dag / dag_code / dag_version 这几张元数据表。所以删了文件后,元数据行成了无人清理的孤儿。

┌─ dag-processor ──────────────────────────────┐
│ 扫描 dags_folder │
│ ├─ 文件在 → upsert dag / serialized_dag ... │ ← 复活来源
│ └─ 文件不在 → 跳过,不删旧行 │ ← 孤儿残留
└──────────────────────────────────────────────┘

结论:要让元数据真正消失,必须让 processor 没有文件可注册(先删文件),再手动清掉残留的元数据行。

解决方案

第 1 步:先删文件

.py 文件从 DAG 目录消失,dag-processor 就不会再注册它。

# 生产环境通常经 git pull 同步到 volume 挂载的 DAG 目录
# /opt/airflow/project/airflow_dags/
git pull # 让 shop_report_aggregation.py 从仓库移除并同步到目录

# 或直接删除(确认无其他依赖后)
rm /opt/airflow/project/airflow_dags/shop_report_aggregation.py

第 2 步:按外键顺序清元数据

按外键依赖顺序 DELETE,避免约束冲突。dag_run 删除会 CASCADE 到 task_instance

BEGIN;

-- 1. 运行历史(CASCADE 带 task_instance)
DELETE FROM dag_run WHERE dag_id = 'shop_report_aggregation';

-- 2. 序列化 DAG
DELETE FROM serialized_dag WHERE dag_id = 'shop_report_aggregation';

-- 3. 版本
DELETE FROM dag_version WHERE dag_id = 'shop_report_aggregation';

-- 4. dag 主表
DELETE FROM dag WHERE dag_id = 'shop_report_aggregation';

-- 5. dag_code 按源码 hash 存,多个 DAG 可能共享同一份代码;
-- 只删已经没有任何 serialized_dag 引用的 orphan hash
DELETE FROM dag_code
WHERE dag_hash NOT IN (SELECT dag_hash FROM serialized_dag);

COMMIT;

第 3 步:验证

airflow dags reserialize

# 确认 dag 表不再重建该行
docker exec cclhub-db psql -U airflow -d airflow -c \
"SELECT count(*) FROM dag WHERE dag_id='shop_report_aggregation';"
# count
# -------
# 0 ✅

reserializedag / serialized_dag / dag_code / dag_version 对该 dag_id 全部为 0,且下一个 processor 扫描周期过去也不再重建,说明清理稳定。

顺带一提,同一条流水线上 pandas NaN 进 XCom 导致任务无声崩溃是另一个值得收藏的坑。

注意事项

注意事项

  • dag_code 是按源码 hash 共享的:多个 DAG 可能引用同一份源码 hash,删除前务必用 orphan 判定(dag_hash NOT IN (SELECT dag_hash FROM serialized_dag)),不要按 dag_id 直接删——这张表压根没有 dag_id 列。
  • 别指望 airflow dags cleanup 清元数据:它默认只删过期的 dag_run(由 max_active_runs / retention 控制),不动 dag / serialized_dag / dag_code / dag_version。清元数据得手写 SQL。
  • 删文件后等一个扫描周期再清更稳:极端竞态下,删文件和清元数据之间若正好夹一个 processor 扫描,可能在文件已被你删但 processor 还没刷新的窗口里写入。实践中按「先删文件→再清元数据→reserialize 验证」顺序操作即可,必要时清完再跑一次 reserialize 确认。
  • 删 DAG 前确认无下游依赖:其他 DAG 可能用 ExternalTaskSensor 等待这个 DAG,或 TriggerDagRunOperator 触发它。下线前 grep 一遍 dag_id 引用。

常见问题

Airflow 删除 DAG 的 .py 文件后为什么还在列表里?

因为删除文件不会清理数据库元数据。dag / serialized_dag / dag_code / dag_version 这几张表的旧行仍然存在,Web UI 读这些表来渲染列表,所以已删的 DAG 还会显示。Airflow 没有内置命令自动清这些孤儿行,需要手动按外键顺序 SQL DELETE。

Airflow 怎么彻底删除一个 DAG 及其全部元数据?

三步:①先删 .py 文件,让 dag-processor 不再注册它;②按外键顺序 SQL DELETE 清理(dag_runserialized_dagdag_versiondag → orphan dag_code);③跑 airflow dags reserialize,然后查 dag 表确认该 dag_id 行数不再重建为 0。

Airflow 清理 DAG 元数据的正确顺序是什么?为什么不能先清元数据再删文件?

必须先删文件、后清元数据。反过来操作的话,.py 文件还在,dag-processor 下一个扫描周期会重新把清空的 dag 行注册回来,元数据「复活」。只有先让文件消失、processor 无文件可注册,再清残留的元数据行,才能彻底下线。


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 整列 NULLNaN 才第一次大规模进入 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,原本 NaNad_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能力落地于真实商业场景。

合作咨询

systemctl 显示 inactive 进程却在跑?裸进程探活的 false negative

· 阅读需 5 分钟

在编写服务器监控探活时,systemctl is-active redis-server 返回 inactive,但 Redis 实际正在正常服务请求。

在开发 AI 运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。监控面板需要准确实时反映各基础组件状态,而 Redis 的状态判断一开始就给出了错误信号。

TL;DR

systemctl is-active 只对 systemd 通过 unit file 管理的服务 有效。如果 Redis(或任何服务)是裸进程启动,没有对应 .service unit,systemctl 永远查不到它的真实状态,返回 inactive(退出码 3)。可靠探活要绕开 systemctl,直接用 pgrep 查进程或 ss 查端口监听。

问题现象

探活接口对 Nginx 和 Redis 同时调用 systemctl is-active

$ systemctl is-active nginx
active # ✅ 正常

$ systemctl is-active redis-server
inactive # ❌ 看似 Redis 没跑
$ echo $?
3 # exit code 3 = inactive

但所有业务接口都能正常读写 Redis,/api/v1/server-monitor/status 却报告 Redis 宕机。

根因

systemd 是一个进程管理器,它只能感知自己启动和托管的单元(unit)。当你用 systemctl start redis-server 或让 systemd 读 redis-server.service 启动时,systemd 记录了该单元的状态,is-active 才能返回 active

而这台服务器上的 Redis 是以裸进程方式启动的——直接运行 redis-server 或通过 nohup/自定义脚本拉起,并没有注册为 systemd 服务。于是:

  • systemd 的单元列表里根本没有 redis-server.service
  • systemctl is-active redis-server 找不到该单元,按 inactive 处理,返回 exit 3;
  • Nginx 相反,是标准的 systemd 服务,所以 is-active 正常命中 active

一句话:is-active 查的是 systemd 视图,不是系统进程视图。进程在跑和 systemd 知道它在跑,是两回事。

解决方案

探活逻辑改为「systemctl 主判断 → 进程/端口降级」的链式检测。systemctl 命中则直接采用;失败时用 pgrepss 兜底确认进程真实存活。用 execFileSync(不经过 shell、参数以数组传递)避免命令注入:

import { execFileSync } from "node:child_process";

/** 安全执行单条命令(不经过 shell),非零退出统一返回 null */
function sh(cmd: string, args: string[]): string | null {
try {
return execFileSync(cmd, args, {
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000,
})
.toString()
.trim();
} catch {
return null; // inactive / 进程不存在 / 超时 都走这里
}
}

/**
* 探活某服务是否在运行:systemctl 主判断,裸进程降级。
* @param unit systemd 单元名(如 "nginx")
* @param proc 进程名(如 "redis"),用于 pgrep 降级
* @param port 监听端口(如 6379),用于 ss 降级
*/
function isServiceUp(unit: string, proc?: string, port?: number): boolean {
// 1. 先走 systemctl(标准 systemd 服务)
const st = sh("systemctl", ["is-active", unit]);
if (st && st !== "inactive" && st !== "unknown") {
return true; // active 或 activating/reloading 等中间态
}

// 2. 降级 A:pgrep 按进程名找 PID
if (proc && sh("pgrep", ["-f", proc])) return true;

// 3. 降级 B:ss 按端口确认监听
if (port) {
const listening = sh("ss", ["-lnt"]);
if (listening && listening.includes(`:${port} `)) return true;
}

return false;
}

// Nginx:标准 systemd 服务,systemctl 直接命中
const nginxUp = isServiceUp("nginx");

// Redis:可能是裸进程,传进程名 + 端口兜底
const redisUp = isServiceUp("redis-server", "redis", 6379);

对应用层而言,更稳的最终确认是让服务自己回答——Redis 的 PING 命令、PostgreSQL 的 SELECT 1、HTTP 服务的健康检查端点。端口监听只能证明"进程起来了",不能证明"服务 ready",所以关键路径上建议再加一层应用层探活:

$ redis-cli ping
PONG # 进程在跑 + 能响应 = 真正存活

常见问题

为什么 systemctl is-active 显示 inactive 但进程实际在运行?

systemctl 只查询 systemd 通过 unit file 管理的服务。如果进程是用 nohup 或直接命令启动的裸进程,没有对应 .service unit,systemd 既不认识它、也不追踪它,is-active 就只能返回 inactive(exit 3)。这是 systemd 视角的盲区,不是进程真的挂了。

怎么可靠检测一个进程是否在运行?

不要只依赖 systemctl。用 pgrep <进程名> 查 PID,或 ss -lntp | grep <端口> 确认端口监听;这些命令查的是系统进程/网络栈,与是否被 systemd 管理无关。对关键服务,再加一层应用层探活(如 redis-cli ping),既验证进程存在又验证服务可响应。

注意事项

  • 单元名 ≠ 进程名systemctl is-active redis-server 里的 redis-server 是 unit 名,可能与实际进程名(redis-serverredis)不同,别混用。
  • 生产环境注意超时:探活命令应设置短超时(如上例的 2s)并捕获异常,避免某个命令卡住拖垮整个监控接口。
  • 容器化服务另说:跑在 Docker 里的服务在宿主机 systemctl 看不到,应直接用 docker inspect 或容器健康检查 API,不要套用本文的 pgrep 降级。
  • 治本方案:把裸进程迁到 systemd unit(配 Type=Restart=always),既能让 is-active 准确,又能享受 systemd 的自动拉起能力。

CCLEE

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

合作咨询

用 Zod 校验 LLM 输出却静默失败?别用 .strict()

· 阅读需 7 分钟

用 Zod 给 LLM 的 tool_call / function call 输出做校验,模型偶尔多吐一个字段——比如你只定义了 amount / category,它顺手填了个 note——整条校验就挂了,动作被静默丢弃,用户只收到一句「没识别到」,实则是一条 tool_call 被 whole-reject。

在开发 Life 记账助手 时遇到此问题——自然语言记账健康助手,说人话就能记,AI 自动抽取金额、类目、账户;用户说「删掉昨天那杯咖啡」时,模型在 delete locator 里多塞了个 note: "咖啡" 想按备注定位。

TL;DR

Zod 的 .strict() 等于「对象不许有任何未知键,多一个就报错」。这套约束适合校验你完全控制的客户端,但 LLM 的 function call 输出是模型生成的、本质不可控——它会填入自己「以为该有」的字段,尤其当多个 tool 共用相似 schema 时。一个无关字段就把整条 tool_call 杀掉,校验返回 null,动作静默丢失。解法:去掉 .strict(),用 Zod 默认的 strip(静默删除未知键)容错,配合 safeParse 兜底。

问题现象

delete/update 的 locator schema 定义了几个已知字段,但用 .strict() 收紧:

import { z } from "zod";

// ❌ 危险:带 .strict()
const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
}).strict(); // ← 未知键一律报错

// 解析 LLM 的 tool_call 参数
function parseToolCall(raw: unknown) {
const parsed = LocatorSchema.safeParse(raw);
if (!parsed.success) {
return null; // ← 整条 tool_call 被丢弃
}
return parsed.data;
}

用户说「删掉昨天那杯咖啡」,模型给出(合理但多了一个字段的)输出:

{
"date": "昨天",
"noteContains": "咖啡",
"note": "咖啡"
}

模型同时填了 noteContains(schema 内)和 note(schema 外,它以为该有)。.strict()note 这个未知键直接判失败,parseToolCall 返回 null,这条 delete 动作被静默丢弃——用户收到「没识别到」,实际是被整条拒绝。

根因

.strict() 改的是 Zod 对未知键的策略,而 LLM 输出天然会带未知键。

Zod z.object() 对未知键有三种策略:

写法未知键行为适合场景
默认(strip)静默删除LLM 输出、宽松外部输入
.strict()报错(unknown key)你完全控制的客户端 API
.passthrough()保留原样下游要用未知键时

.strict() 的设计意图是「契约严格性」——服务端定义了什么字段,客户端就该只给什么,多给即违约。这套逻辑对传统 API 成立,因为客户端是开发者写的、可以要求守约。

但 LLM function calling 颠覆了这个前提:

  1. 输出来自模型生成,不是开发者写的客户端。 模型基于 schema 的 description 和示例猜测该填什么,跨域复用的 schema(比如 locator 在 budget / mood / todo 多个域共用)更会让它混淆,填入「它以为该有」的字段。
  2. 字段填错是常态,不是异常。 模型偶尔多吐一个 note、少吐一个可选字段,是 LLM 应用的预期行为,不该用「整条失败」来惩罚。
  3. 失败被静默吞掉。 safeParse 失败后返回 null,上游拿到 null 只能笼统地说「没识别到」,真正的根因(一个 unknown key)藏在 parsed.error 里没人看。
LLM 输出 { date, noteContains, note }


.strict() 遇到未知键 note


safeParse → { success: false }


parseToolCall 返回 null(动作丢弃)


用户收到「没识别到」(实则 whole-reject)

解决方案

1. 去掉 .strict(),用默认 strip 容错

// ✅ 推荐:不带 .strict(),Zod 默认 strip 未知键(静默删除)
const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
});
// 模型多吐的 note 会被静默删掉,已知字段照常解析

去掉 .strict() 后,「删掉昨天那杯咖啡」正常解析为 { date, noteContains },多余的 note 被 strip,delete 动作正确执行。

2. 如果未知键本身有用,用 .passthrough() 显式保留

当模型多吐的字段其实承载了你想用的语义(比如它填 note 是想表达「按备注定位」),别丢,保留下来再决定怎么消费:

const LocatorSchema = z.object({
date: z.string().optional(),
category: z.string().optional(),
noteContains: z.string().optional(),
}).passthrough(); // 保留未知键,parsed.data.note 仍可读

更好的做法是把它收编成已知字段——发现模型反复填某个未知键,说明 schema 缺了这个能力位,补上(比如这里的 noteContains 就是收编「按备注定位」需求后新增的)。

3. 失败要可观测,别静默返 null

无论哪种策略,safeParse 失败时都要把具体的 error 落日志,而不是吞成 null:

function parseToolCall(raw: unknown) {
const parsed = LocatorSchema.safeParse(raw);
if (!parsed.success) {
// 把 Zod 的具体报错(哪个键、什么问题)落日志,便于定位
logger.warn(
{ raw, issues: parsed.error.issues },
"locator parse failed"
);
return null;
}
return parsed.data;
}

这样真出问题时,日志里有完整的 issues(含 unknown key 的路径),而不是一句无从下手的「没识别到」。

修完后,「删掉昨天那杯咖啡」→ delete_record { locator: { noteContains: "咖啡" } } 正确解析,不再静默丢失。

注意事项

注意事项

  • .strict() 适合校验「你控制的客户端」,不适合「LLM 生成的输出」。 判断标准:数据来源是你写的代码 → 可以 strict;数据来源是模型生成 → 用默认 strip 或 passthrough。
  • strip 会丢失未知字段。 如果那个字段承载了模型的意图(如例子里的 note),用 .passthrough() 保留,或直接收编成已知字段,别让意图被静默删掉。
  • 永远用 safeParse 而非 parse parse 校验失败会抛异常,在 tool 调度链里可能中断整个流程;safeParse 返回结果对象,失败可控。
  • LLM tool schema 设计要给容错空间。 字段尽量 .optional()、description 写清用途、提供 few-shot 示例;预期到模型会「多填/少填」,schema 层就该兜得住。

常见问题

为什么 Zod .strict() 会让 LLM 输出校验失败?

.strict() 要求对象不含任何未知键,多一个就抛 unknown key 错误。LLM 的 function call 输出是模型基于 schema description 猜测生成的,常会填入它「以为该有」的字段(尤其跨域复用的 schema),一旦命中未知键,.strict() 就让整条校验失败、整个 tool_call 被丢弃。

用 Zod 校验 LLM function calling 输出应该用 strict 吗?

不建议。.strict() 适合校验你完全控制的客户端(开发者写的代码可以要求守约),但 LLM 输出不可控、多填少填是常态。去掉 .strict() 用 Zod 默认的 strip(静默删除未知键)容错性更好;如果未知键承载了想用的语义,用 .passthrough() 保留,或直接收编成已知字段。

Zod 默认对未知字段是 strip 还是报错?

默认是 strip——静默删除未知键、不报错;.strict() 改为遇到未知键就报错;.passthrough() 改为保留未知键原样。校验 LLM 这类不可控输出时推荐默认 strip 或 passthrough,避免 .strict() 因一个无关字段杀掉整条数据。


CCLEE

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

合作咨询

JavaScript throw; 报 SyntaxError?JS 没有 bare rethrow,重新抛出必须 throw e

· 阅读需 5 分钟

在 catch 块里想"把异常原样往上抛",顺手写了 throw;——习惯了 C# 的 bare rethrow 写法——结果 tsx/esbuild 直接转换失败:Unexpected ";"

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。

TL;DR

JavaScript 没有 bare rethrow 语法throw;(裸 throw)在 Node、tsc、esbuild 三个工具链里都是编译期 SyntaxError。要重新抛出捕获到的异常,必须 throw e(catch 块得带绑定参数);想换成新异常就 throw new Error(...)

问题现象

同一个 throw;,在不同工具链里的报错措辞不同,但全是语法错误(不是运行时错误):

try {
something();
} catch {
throw; // ← 裸重抛
}
工具链报错
tsx / esbuildERROR: Unexpected ";"(转换失败)
Node.js 原生(.js / .mjs)SyntaxError: Unexpected token ';'
TypeScript 编译器(tsc)error TS1109: Expression expected.

最迷惑的是 esbuild 那条 Unexpected ";"——很容易让人以为是 "esbuild/tsx 不支持某种新语法"。但把同一段代码丢进 Node 原生跑,报的是一模一样的 SyntaxError。这不是工具的局限,是语言本身就没有这种写法。

根因

ECMAScript 的 throw 语句语法强制要求一个表达式

ThrowStatement : throw Expression ;

也就是说 throw 后面必须跟一个值(throw errthrow new Error()throw "fail"),分号前不能为空。JavaScript 没有"裸 throw = 重新抛出当前异常"的语义——这是它和 C# / Java / Python 的关键区别:

语言重新抛出当前异常是否需要捕获变量
C#throw;不需要
Javathrow e;需要
Pythonraise不需要
JavaScriptthrow e;需要

一个常见的混淆点:ES2019 引入了 optional catch binding(catch {} 可以省略参数),但这和 bare throw 是两回事。即使 catch 带了绑定,写 throw; 依然报错——

try { f(); } catch (e) { throw; }   // 仍是 SyntaxError,参数 e 不会自动喂给 throw

实测在 tsx 里同样是 Unexpected ";"throw 后面的表达式不能省,没有例外。

解决方案

按"想干什么"对号入座:

// 1. 重新抛出原异常(rethrow)—— 最常见诉求
try {
doWork();
} catch (e) {
log(e);
throw e; // ✅ 带上 e
}

// 2. 换成新异常(wrap)
try {
doWork();
} catch (e) {
throw new Error(`处理失败: ${e.message}`); // ✅ throw + 表达式
}

// 3. 用 ES2019 的 catch {} 时,省略了参数就没法 rethrow,只能抛新异常
try {
doWork();
} catch {
throw new Error("doWork 失败"); // ✅ 这里写 throw; 是错的
}

最小可运行复现 + 修复,直接用 tsx 跑:

function risky(): void {
throw new Error("origin");
}

function rethrowOptional(): void {
try {
risky();
} catch (e) { // ← 必须接收 e
console.log("caught, rethrowing");
throw e; // ← 而不是 throw;
}
}

try {
rethrowOptional();
} catch (e) {
console.log("recovered:", (e as Error).message); // origin
}

关于调用栈:throw e 复用的是同一个 error 对象,它的 .stacknew Error 时就已经捕获,rethrow 不会覆盖;只有 throw new Error(...) 才会从当前抛出点重新生成栈。所以"rethrow 会不会丢栈"的答案是——不会,只要你不 new 一个新的。

异常处理的另一个常见坑,是 catch 块干脆把异常吞掉、对外表现为静默失败,见 Python 任务全标 failed 却不报错?try/except 吞掉了异常——跨语言都值得警惕。

注意事项

注意事项

  • 元凶不是 optional catch bindingcatch {}(ES2019)本身合法,问题只在 throw;。别为了"修 throw"去给 catch 强加参数,除非你确实要用那个变量。
  • async/await 同理try { await f() } catch (e) { throw; } 在 async 函数里同样是 SyntaxError,规则不分同步异步。
  • stack 保留throw e 保留原始栈;throw new Error(...) 刷新栈。排查时想看最早抛出点就用前者。
  • 跨语言习惯对齐:从 C#/Python 转来 JS,把 throw; / raise 直接搬过来必踩;团队里 review 时留意这个模式。

常见问题

JavaScript 怎么重新抛出(rethrow)捕获到的异常?

throw e,且 catch 必须带绑定参数:catch (e) { ...; throw e; }。JavaScript 没有 bare rethrow,单独写 throw; 是 SyntaxError,Node、tsc、esbuild 三个工具链都会在编译期拒绝,这不是任何一个工具的局限。

JavaScript 重新抛出异常会保留原始调用栈吗?

会。throw e 复用的是同一个 error 对象,它的 .stacknew Error 构造时就已经捕获,rethrow 不会覆盖或重置。只有 throw new Error(...) 才会从当前抛出点重新生成调用栈——所以排查时要看最早抛出位置,就用 throw e

JavaScript try/catch 里怎么正确 rethrow?

catch 必须先接收参数,再把它抛回:try { ... } catch (e) { log(e); throw e; }。如果用了 ES2019 的 catch {}(省略参数),就没有变量可抛,只能 throw new Error(...) 抛一个新异常。无论哪种,throw 后都必须跟表达式,throw; 永远非法。

CCLEE

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

合作咨询

Milvus 报 invalid collection name?collection 名首字符必须字母/下划线,UUID 不能直接拼

· 阅读需 6 分钟

在按租户给向量库的 collection 加前缀、用 {tenant_id}_{collection} 拼名时,第一个请求就被 Milvus 顶了回来——invalid collection name: the first character ... must be an underscore or letter,接口直接 500。

在开发 AI客服 时遇到此问题——7×24小时AI客服,快速解答产品使用问题,提供功能指导和最佳实践。

TL;DR

Milvus 对 collection 名有严格校验:首字符必须是字母或下划线、其余只允许 [a-zA-Z0-9_]禁连字符)、长度 ≤255,违反就报 invalid collection name(错误码 1100)。UUID 首字符常是数字、且自带连字符 -,两点都踩雷,所以不能把 tenant_id 这种 UUID 直接拼进 collection 名做隔离。改用原始名 + tenant 字段过滤即可。

问题现象

collection=system_product_help 的查询接口返回 500,rag-service 日志只吐一行:

pymilvus.exceptions.MilvusException: code=1100,
Invalid collection name: 00000000-0000-0000-0000-000000000001_system_product_help.
the first character of a collection name must be an underscore or letter

诡异的是,同一参数的另一个纯查日志接口(/query-logs)却返回 200——因为它只读 PostgreSQL,根本没碰 Milvus;只有真正会去 Milvus has_collection 的路径才触发校验。

根因

代码里用 f"{tenant_id}_{collection}" 拼出 collection 名,例如 00000000-0000-0000-0000-000000000001_system_product_help。这个名字同时犯了两条规:

00000000-0000-0000-0000-000000000001_system_product_help
^ ^ ^
│ │ └─ 下划线后 OK,但……
│ └─── 连字符 `-` 违规
└────────────────── 首字符是数字 `0` 违规(必须字母/下划线)

Milvus 的 collection 名校验规则(源码 nameutil.go,正则 ^[a-zA-Z_][a-zA-Z0-9_]*$,长度 ≤255):

规则要求
首字符字母或下划线 _
其余字符[a-zA-Z0-9_](字母、数字、下划线)
禁止连字符 -、空格、点号、其他特殊符号
长度1–255 个字符

UUID 几乎必然违规:标准形式 8-4-4-4-12 含 4 个连字符,且首段常以数字开头。把这样的前缀拼到 collection 名上,has_collection / describe_collection / 创建语句都会被服务端拒掉,抛 code 1100。

更要命的是:因为拼出来的名从来就不合法,所谓的"按租户前缀隔离"从未真正生效过——库里实际存在的 collection 全是没前缀的原始名,拼接逻辑与真实数据系统性脱节,纯写代码时的一个想当然。

解决方案

别把 tenant_id 拼进 collection 名。 collection 一律用原始名,租户隔离交给普通字段:

from pymilvus import MilvusClient

client = MilvusClient(uri="http://localhost:19530")

# ❌ 错误:UUID 拼前缀,首字符是数字 + 含连字符 → code 1100
tenant_id = "00000000-0000-0000-0000-000000000001"
bad_name = f"{tenant_id}_system_product_help" # 非法

# ✅ 正确:collection 用原始名,tenant_id 作为 schema 字段过滤
client.create_collection(
collection_name="system_product_help", # 合法、稳定
schema=client.create_schema(auto_id=True, enable_dynamic_field=False),
)
# 写入与查询时用 tenant_id 字段做过滤,而不是改 collection 名
client.insert(
collection_name="system_product_help",
data=[{"tenant_id": tenant_id, "text": "...", "vector": [...]}],
)

如果你确实需要"一段可读前缀"做多租户或环境隔离,把任意字符串转成安全 slug 再拼:

import re

def safe_slug(raw: str) -> str:
# 非 [a-zA-Z0-9_] 一律替成下划线,首字符若非字母/下划线则补一个
s = re.sub(r"[^a-zA-Z0-9_]", "_", raw)
if not re.match(r"^[a-zA-Z_]", s):
s = "_" + s
return s[:255] # 截到长度上限内

name = f"{safe_slug(tenant_id)}_system_product_help" # 合法

排查这类 500 时,第一眼先看 PM2 / 服务日志里的 MilvusException——错误码和"first character must be ..."这句提示基本能直接定位到名字不合法,不必往业务逻辑深处找。

顺带一提,rag-service 这类依赖 Milvus 的服务,本身还有"容器没配 restart 策略、崩溃后整条 RAG 链路起不来"的坑,见 Docker Compose 服务重启后起不来?检查 restart 策略;混合检索那侧则要注意 RRF 分数与相似度阈值不兼容

注意事项

注意事项

  • 连字符是最隐蔽的坑:很多团队习惯用 tenant-env-docs 这种 kebab-case 命名,在 Milvus 里全部非法。命名一律用下划线 snake_case
  • 不只是 collection 名:database 名、partition 名、字段名的校验规则相近(首字符、允许字符集),用 UUID 或连字符拼接时都要先过一遍校验。
  • 隔离用字段,不要用 collection 数量:按租户各建一个 collection 会让 collection 数量随租户线性膨胀,超出 Milvus 管理舒适区。把 tenant_id 做成普通字段 + 过滤,或用 partition key,是更稳的隔离方式。
  • 校验在服务端:pymilvus 客户端不一定对每个调用都前置校验,非法名可能直到请求落到 Milvus 才报 1100,本地单测容易漏。

常见问题

Milvus collection 名有哪些命名规则?

首字符必须是字母或下划线,其余字符仅允许字母、数字、下划线([a-zA-Z0-9_]),禁止连字符和空格,最长 255 个字符。Milvus 在服务端用正则强制校验,违反就抛 invalid collection name(错误码 1100),创建和寻址都会失败。命名用 snake_case 最保险。

Milvus collection 名长度上限是多少?

255 个字符。超出会被校验拒绝并报 invalid collection name(错误码 1100)。实际命名通常远短于此,真正卡上限的往往是把长 UUID 或多段路径拼了进去——这恰恰说明你不该把这类动态串拼进 collection 名。

Milvus collection 名为什么不能用 UUID 作前缀?

UUID 标准形式首段常以数字开头(违反"首字符必须字母/下划线"),且自带 4 个连字符 -(不在允许字符集),两点都违反命名规则。把 tenant_id 拼成 collection 前缀做隔离是常见误用:不仅名字非法,还会让 collection 数量随租户膨胀。正确做法是把 tenant_id 放进普通字段或分区键,collection 用稳定的原始名。

CCLEE

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

合作咨询

PostgreSQL ON CONFLICT 报 there is no unique constraint?改唯一键后 INSERT 必须同步

· 阅读需 6 分钟

在为某张表收紧唯一键(移除一个不再区分数据的列)之后,原本正常的 UPSERT 写入立刻批量报错——there is no unique or exclusion constraint matching the ON CONFLICT specification

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。

TL;DR

PostgreSQL 的 ON CONFLICT (cols) 要求 cols 精确匹配一个已存在的唯一约束或唯一索引(列与顺序都要一致,否则错误码 42P10)。一旦你 ALTER 了唯一键,所有引用它的 INSERT ... ON CONFLICT 必须同步修改;而且 migration 跑完后写入端要立刻部署,中间窗口会持续报错。

问题现象

唯一键改造一上线,定时导入任务全量失败,写库日志只剩这一条:

ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
SQL state: 42P10

业务表 0 行写入,但同一张表的其它纯 SELECT 查询完全正常——问题只出在带 ON CONFLICT 的写入路径上。

根因

ON CONFLICT (cols) 里指定的列集叫仲裁器(arbiter)。PostgreSQL 要求它精确匹配表上某个 UNIQUE 约束或唯一索引:

  • 列的集合必须相同;
  • 列的顺序也要相同;
  • 如果是带 WHERE 的部分唯一索引(partial unique index),ON CONFLICT 还要带上相同的 WHERE

找不到匹配项时,PostgreSQL 不知道用哪个索引来判断"冲突",于是抛出 42P10。

典型触发场景是收缩唯一键:原先唯一键含 3 列,你发现其中一列(比如 audience)的 4 个取值对应的指标行 100% 全等、纯属冗余,于是把唯一键降到 2 列。这是对的优化方向,但旧的 INSERT 仍写着 ON CONFLICT (c1, c2, c3),而表上只剩 (c1, c2) 的唯一约束——仲裁器找不到落点,报错。

旧唯一键: UNIQUE (store_id, metric_key, audience)
新唯一键: UNIQUE (store_id, metric_key)

旧 INSERT: ON CONFLICT (store_id, metric_key, audience) ← 找不到匹配

解决方案

下面是最小复现,建表、触发、修复一条龙,可直接在 psql 里跑:

-- 1. 带 3 列唯一键的表
CREATE TABLE daily_metric (
store_id TEXT NOT NULL,
metric_key TEXT NOT NULL,
audience TEXT NOT NULL,
value NUMERIC,
CONSTRAINT daily_metric_unique UNIQUE (store_id, metric_key, audience)
);

-- 2. 旧 UPSERT:ON CONFLICT 含 audience
INSERT INTO daily_metric (store_id, metric_key, audience, value)
VALUES ('s1', 'revenue', 'visitor', 100)
ON CONFLICT (store_id, metric_key, audience)
DO UPDATE SET value = EXCLUDED.value;

-- 3. 收缩唯一键:移除 audience
ALTER TABLE daily_metric
DROP CONSTRAINT daily_metric_unique,
ADD CONSTRAINT daily_metric_unique_new UNIQUE (store_id, metric_key);

-- 4. 再跑第 2 步的 INSERT,立刻报错 ↓
-- ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification

修复就是把 INSERTON CONFLICT 列同步收缩到 2 列;既然 audience 不再区分数据,写入端干脆把它的值固定为字面量,避免按入参凭空拼出多行:

INSERT INTO daily_metric (store_id, metric_key, audience, value)
VALUES ('s1', 'revenue', 'visitor', 100)
ON CONFLICT (store_id, metric_key) -- ← 同步收缩
DO UPDATE SET value = EXCLUDED.value;

真正容易踩的是部署顺序,不是 SQL 本身:

  1. 先发 migration(DROP 旧约束 + ADD 新约束);
  2. 紧接着发布写入端代码(INSERTON CONFLICT 改为 2 列);
  3. 两步之间不要留间隔——旧代码撞新 schema 必报 42P10,新代码撞旧 schema 同样报 42P10(找不到 2 列的唯一约束)。

如果你用 Drizzle 这类 ORM,ON CONFLICT 的列一旦在 sql 模板里写死,改 schema 时极易漏改——schema 与写入端不同步的代价,在另一篇 Drizzle + PostgreSQL 的坑里也领教过。

注意事项

注意事项

  • 列顺序敏感ON CONFLICT (a, b)UNIQUE (b, a) 不算匹配,顺序必须一致。
  • 部分唯一索引要带 WHERE:若仲裁器是 UNIQUE ... WHERE activeINSERT 里要写成 ON CONFLICT (cols) WHERE active DO ...,否则同样报 42P10。
  • 只想"冲突就跳过":用不带列的 ON CONFLICT DO NOTHING,它不指定仲裁器、无需匹配任何具体索引,能捕获所有冲突。
  • 灰度并存:新老版本写入端可能短暂共存,确保两套代码都能匹配当前 schema,或让 migration 与代码同步上线、不留窗口。

常见问题

PostgreSQL ON CONFLICT 必须有唯一约束吗?

只有指定列时才必须。ON CONFLICT (cols)cols 要精确对应一个已存在的 UNIQUE 约束或唯一索引,否则报 42P10。如果你只想"有任何冲突就跳过"、不关心具体哪个约束,用不带列的 ON CONFLICT DO NOTHING,它不需要匹配特定索引。

PostgreSQL ON CONFLICT 可以指定多个唯一约束吗?

不能。单条 INSERTON CONFLICT 只能指定一个仲裁约束(一个列集,或一个索引名)。表上可以有多个唯一键,但一条语句只能选其一做冲突判定。需要按不同唯一键分别处理时,要么拆成多次写入,要么在应用层先查再决定 INSERT 还是 UPDATE

报 there is no unique or exclusion constraint matching the ON CONFLICT specification 怎么办?

这是错误码 42P10,含义是 ON CONFLICT 指定的列集在表上找不到匹配的唯一索引。按顺序排查:确认存在覆盖这些列的 UNIQUE 约束、列与顺序完全一致、最近改过唯一键后 INSERT 已同步更新;如果用的是带 WHERE 的部分唯一索引,ON CONFLICT 还要补上相同的 WHERE 子句。

CCLEE

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

合作咨询