跳到主要内容

12 篇博文 含有标签「PostgreSQL」

查看所有标签

Airflow 删了 DAG 文件元数据还在?清表顺序错了会复活

· 阅读需 5 分钟

把某个 DAG 的 .py 文件从代码库里删掉、部署完成——Airflow 的 DAG 列表里它还稳稳挂着;手动清了元数据表,过一会儿它又回来了。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。平台的 DAG 管道迭代下线旧分析类型时,元数据残留让列表页越积越乱。

问题现象:文件删了,元数据「阴魂不散」​

两类症状,取决于操作顺序:

  • 先删文件、再查库:dag、serialized_dag、dag_code、dag_version 表里该 dag_id 的行全都还在,UI 列表照常显示;
  • 先清表、后删文件:更诡异——刚 DELETE 干净的行,过几分钟自己回来了,DAG「复活」。

如果你搜的是「Airflow delete DAG still shows in UI」「删除 DAG 后还在」「dag metadata not removed」,都是同一件事。

根因:dag-processor 扫到文件就注册,reserialize 不清旧行​

「复活」的机制是 Airflow 的设计行为,不是灵异事件:

  • dag-processor 定期扫描 DAG 目录。只要 .py 文件还在目录里,扫描进程就会解析它并重新注册元数据行——这解释了「先清表后删文件」的复活:清表时文件还在,下一次扫描立刻重建。
  • airflow dags reserialize 只做增量注册。它把「目录里现存的文件」序列化进表,但不会清理「文件已消失」的旧行——删文件后跑 reserialize,残留纹丝不动。

所以「删文件」和「清元数据」的顺序天然是单向的:文件在,清了也白清;文件没了,清一次就是最终态。

解决方案:删文件 → 按外键序清表 → reserialize 验证​

三步,顺序不能反:

  1. 先删 .py 文件。生产环境 DAG 目录是 volume 挂载的,文件要在宿主机 git pull 同步到位,而不是进容器里删:
ssh <host> "cd /root/workspace/ai_dag && git pull"
# DAG 目录(挂载进容器 /opt/airflow/dags)此刻已无该 .py
  1. 再清元数据,按外键顺序 DELETE(dag_run 的子表 task_instance 随级联清理):
DELETE FROM dag_run        WHERE dag_id = 'old_dag';      -- 级联 task_instance
DELETE FROM serialized_dag WHERE dag_id = 'old_dag';
DELETE FROM dag_code WHERE dag_id = 'old_dag';
DELETE FROM dag_version WHERE dag_id = 'old_dag';
DELETE FROM dag WHERE dag_id = 'old_dag';
  1. reserialize 一次做验证:airflow dags reserialize 跑完后查 dag 表——该 dag_id 不再重建,才算清干净。

整个过程一分钟内完成,文件已不在目录里,dag-processor 扫描时不会再注册它。

边界与变体​

  • 清表前先确认调度器/处理器没有正在解析该 DAG,避免清表与注册赛跑;低峰操作最省心。
  • dag_run 历史要留证据的话,先 SELECT 导出再删——运行历史是排查「当时发生了什么」的唯一记录,删了就没了。
  • 同一文件名换目录/换平台目录的场景,dag_id 若相同,残留逻辑同本文:先让旧文件从目录消失,再做元数据手术。
  • Airflow 各版本的元数据表结构有差异(如 dag_code/dag_version 是较新版本引入),DELETE 前先 \d 确认本环境的表与外键,别照抄表名。

注意事项

  • 顺序是铁律:文件未删先清表 = 必然复活;把「删文件」放在流程第一步,后面全是顺路。
  • SQL 直连生产元数据库属于高风险操作:WHERE 条件必须精确到 dag_id,先在事务里 BEGIN 查影响行数再提交。
  • UI 里「删除」按钮(较新版本)走的是 API 删除路径,与手工清表效果不同;本文流程针对「需要精确控制清理范围」的场景。

常见问题​

Airflow 删除 DAG 文件后为什么 UI 里还在?​

元数据残留在 dag、serialized_dag、dag_code、dag_version 等表里,UI 按表渲染;reserialize 只 upsert 现存文件、不清理文件已消失的旧行,需要按外键顺序手动 DELETE。

Airflow 清了元数据表为什么又复活?​

清表时 DAG 文件还在:dag-processor 定期扫描 DAG 目录,扫到文件就重新注册元数据。必须先删文件(git pull 同步到挂载目录),再清表,最后 reserialize 验证不再重建。

彻底删除一个 Airflow DAG 的正确顺序是什么?​

①删 .py 文件并同步到 DAG 目录;②按外键顺序清表:dag_run(级联 task_instance)→ serialized_dag → dag_code → dag_version → dag;③airflow dags reserialize 确认 dag 表不再重建。

CCLEE

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

合作咨询

SQLAlchemy 模型加了字段,线上报列不存在?Alembic 迁移三个坑

· 阅读需 7 分钟

在 FastAPI + 异步 SQLAlchemy 项目里给 ORM 模型加了字段,本地跑通,部署上线后接口一调用就报 sqlalchemy.exc.ProgrammingError: column "xxx" does not exist——模型明明改了,表却没变。

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR​

模型改了表没变(或迁移命令根本跑不起来),通常是三个坑之一:

  1. async 项目里 Alembic 还在用 async 引擎——Alembic 不在事件循环里运行,env.py 必须切到 sync URL(postgresql+asyncpg:// 换成 postgresql://,驱动用 psycopg2)
  2. down_revision 引用了链上不存在的 revision ID——alembic upgrade 直接报 Can't locate revision
  3. 只改了 ORM 模型,没生成迁移——代码层看着对,表结构纹丝不动,线上第一次用到新字段就炸

三个坑依次对应「迁移跑不了」「链断了」「没生成迁移」,排查顺序也按这个来。

问题现象​

三种表现,对应三个不同的坑:

表现一:模型加字段后线上报错

sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedColumn) column "risk_threshold" does not exist

本地开发环境正常,生产环境第一次查询就挂。

表现二:执行迁移命令直接失败

Can't locate revision identified by '001_initial'

alembic upgrade head 根本走不到你的新迁移。

表现三:autogenerate 说「没有变化」

Generating migration ...  (no changes in schema detected)

你确定改了模型,它却说 schema 没变——多半是 env.py 没把模型 import 进来,或者连的不是目标库。

根因一:Alembic 用了 async engine​

Alembic 的迁移脚本不运行在 asyncio 事件循环里。 应用层用 async SQLAlchemy(asyncpg 驱动)没问题,但 alembic 命令是同步执行的,env.py 里如果沿用应用的 async URL,要么直接报驱动错误,要么行为不可预期。

解法是让 env.py 单独用 sync 引擎:URL 从 postgresql+asyncpg:// 替换成 postgresql://,并安装 psycopg2 驱动。

pip install psycopg2-binary

alembic/env.py 关键配置(在 config 对象就绪后、engine_from_config 之前):

from sqlalchemy import pool, engine_from_config
from alembic import context

from app.config import settings
from app.database import Base
from app.models import * # noqa: F401,F403 - import all models

# Override sqlalchemy.url with sync URL for migrations
# Replace postgresql+asyncpg:// with postgresql:// for psycopg2
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", sync_url)

两个细节:

  • settings.database_url 里的 async URL 替换成 sync URL 后,用 config.set_main_option() 覆盖 ini 里的配置,alembic.ini 里就不用维护第二份连接串
  • from app.models import * 这行不能省——autogenerate 靠它把所有模型注册进 Base.metadata,漏了就是上面「no changes in schema detected」的表现三

依赖上,psycopg2-binary 装在运行 alembic 的那个环境里。CI 或容器里单独跑迁移时,这个依赖容易被漏掉。

根因二:down_revision 引用断链​

Alembic 用 revision / down_revision 维护一条单向链,upgrade head 就是沿着这条链从当前版本走到链尾。down_revision 必须逐字符引用链上真实存在的 revision 标识,凭记忆填一个「差不多」的名字,链就断了。

一个真实仓库的版本链,alembic/versions/ 下三个文件:

# 001_initial.py
revision = '001_initial'
down_revision = None

# 002_rename_model_config_to_llm_config.py
revision = '002_rename_model_config'
down_revision = '001_initial'

# 003_add_cascade_delete.py
revision = '003'
down_revision = '002_rename_model_config'

alembic upgrade 报 Can't locate revision identified by 'xxx',就是某个 down_revision 指向的字符串在链上找不到——可能是手滑写了别的迁移的文件名,可能是抄了半截 ID。

排查动作固定两步:

# 1. 看真实链条(左列是 revision ID)
alembic history

# 2. 看数据库当前停在哪个版本
alembic current

把新迁移的 down_revision 改成 alembic history 输出里链尾的真实值,再 alembic upgrade head。Alembic 会从 alembic_version 表记录的位置续传,已执行过的迁移不会重跑。

alembic history 也建议用 --verbose 看:迁移一多,光靠文件名猜链路不可靠。

注意事项

多个开发者并行建分支各写迁移,两条分支的 down_revision 指向同一个父节点时,upgrade head 会报 multiple heads。用 alembic merge 生成合并迁移解决——我们的仓库里就留着一个 merge 迁移文件,专门缝合两条并行分支。出现 multiple heads 不是异常,拖着不 merge 才是。

根因三:改了模型,没建迁移​

最常见的误区:ORM 模型是代码层的声明,它不会自己改数据库。在模型类里加一个字段:

class Agent(Base):
__tablename__ = "agent_agents"
# ...
risk_threshold = Column(String, default="medium") # 新加的

这只是改了 Python 对象的定义。数据库里的 agent_agents 表纹丝不动,第一次查询到这个字段就是 column does not exist。之所以本地常常「看着正常」,是因为开发环境可能用 create_all() 建过表、或者 SQLite 内存库每次重建——这些路径都绕过了迁移,掩盖了问题。模型层另一个容易踩的坑是 Pydantic v2 的 ORM 模式变更,迁移要点见 Pydantic v2 ORM mode 迁移。

ORM 模型新增字段后的固定三步:

# 1. 生成迁移(autogenerate 对比模型与数据库的差异)
alembic revision --autogenerate -m "add risk_threshold to agent_agents"

# 2. 人工检查生成的迁移文件(autogenerate 会漏改列、误删表,必须过目)
# alembic/versions/xxxx_add_risk_threshold_to_agent_agents.py

# 3. 应用到数据库
alembic upgrade head

第 2 步不能省。autogenerate 只做差异对比,服务器默认值、约束名这类信息它推断不全,直接 upgrade 有风险。

验证迁移生效,直接看表结构:

psql -c "\d agent_agents"   # 确认新字段存在
alembic current # 确认版本停在链尾

排查顺序:三问快筛​

「模型改了表没变」类问题(也叫字段不存在、列不存在),按这三个问题筛,一两分钟定位:

  1. 迁移命令本身能跑吗? 不能 → 坑二,查 down_revision 链
  2. 能跑,但数据库里没这个字段? → 坑三,确认 autogenerate 生成过、upgrade head 执行过、alembic current 停在链尾
  3. alembic upgrade 连接都连不上或驱动报错? → 坑一,env.py 的 sync URL 配置

团队协作场景再多加一条纪律:模型变更和迁移文件放在同一个 commit 里。只提模型不提迁移,协作者拉下代码就是线上同款报错。

常见问题​

alembic 迁移数据库的命令是什么?​

生成迁移用 alembic revision --autogenerate -m "描述",应用到数据库用 alembic upgrade head,回滚一步用 alembic downgrade -1,查看版本链用 alembic history。改完 ORM 模型的标准动作就是 autogenerate 生成、upgrade head 应用这两步,缺一步表结构都不会变。

SQLAlchemy 创建数据库和表后,改模型为什么不生效?​

SQLAlchemy 模型只是代码层的声明,不会自动修改数据库,建表改表由 Alembic 迁移承担。即使本地用 create_all() 建过表,新字段也只存在于内存元数据里,生产 PostgreSQL 在第一次用到该字段时就报 ProgrammingError: column does not exist。模型变更必须同步生成迁移文件并执行 upgrade。

alembic upgrade 报 Can't locate revision 怎么办?​

这是 down_revision 引用了链上不存在的 revision 标识,常见于手写迁移时凭记忆填短名。先跑 alembic history 拿到真实 ID,把新迁移的 down_revision 改成链尾的真实值,再 alembic upgrade head。revision 与 down_revision 必须逐字符一致,差一个字符整条链就断。

CCLEE

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

合作咨询

跨容器连 PostgreSQL 报 fe_sendauth?exec 免密≠TCP 免密

· 阅读需 5 分钟

在 DB 容器所在宿主机上 docker exec 进容器跑 psql,免密直连一切正常;同样的用户名换到另一个容器里走 TCP 连接,直接报 fe_sendauth: no password supplied。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。编排容器里的运维脚本要跨容器访问分析数据库,连接串照搬了 exec 习惯,第一跳就摔在认证上。

问题现象:exec 能连,TCP 报 fe_sendauth: no password supplied​

同一台宿主机、同一个数据库、同一个用户,两种连法两种命运。实测三方对照(Airflow 容器 → PostgreSQL 容器):

连接方式结果
docker exec cclhub-db psql -U postgres(容器内 socket)免密直连成功
跨容器 TCP,DSN 不带密码fe_sendauth: no password supplied
跨容器 TCP,DSN 带密码连接成功

如果你搜的是「fe_sendauth no password supplied」「psql 不带密码报错」「docker 连 postgres 免密失败」,都是这一类。

根因:socket 免密与 TCP 密码认证是两条 pg_hba 路径​

PostgreSQL 的认证由 pg_hba.conf 按连接类型逐条匹配,官方镜像的默认配置对两条路径给的是完全不同的规则:

  • 容器内 docker exec 走 Unix domain socket,对应 local all all trust 一类的规则——信任本地 socket,不问密码;
  • 跨容器走 TCP(host 类型规则),官方镜像默认要求 scram-sha-256 / md5 密码认证。

于是「exec 免密」建立起来的连接习惯,到了 TCP 上就是缺密码——fe_sendauth: no password supplied 的字面意思正是「服务端要密码,客户端一个都没给」。

顺带把两个容易混淆的错误分开:no password supplied 是没带密码,password authentication failed 是带了但错了。前者查连接串里有没有 password,后者查密码对不对——对症的药不同。

解决方案:DSN 带密码,别照搬 exec 的连接习惯​

跨容器脚本统一用带密码的连接串:

postgresql://postgres:<密码>@<db 主机>:5432/<库名>

密码来源推荐直接读 DB 容器的环境变量(官方镜像的 POSTGRES_PASSWORD 就是为初始化设置的),避免二次硬编码:

PW=$(docker exec cclhub-db printenv POSTGRES_PASSWORD)
psql "postgresql://postgres:${PW}@localhost:5432/postgres" -c "SELECT 1"

Python/应用侧同理,DSN 进环境变量管理:

import os
import psycopg

DSN = os.environ["APP_DB_DSN"] # postgresql://user:pass@host:5432/db
with psycopg.connect(DSN) as conn:
conn.execute("SELECT 1")

注意 URL 里的密码若含 @ : / # 等保留字符需要百分号编码——又一个特殊字符咬人的场景,生成数据库密码时规避这类字符能省掉一整类麻烦。

边界与变体​

  • 报错形态随客户端变:libpq 交互式终端(不带 -t 的 psql)会先提示 Password for user postgres: 再因无输入报 fe_sendauth;连接池、GUI 工具(如 pgAdmin)则直接在界面标认证失败——错误文本不同,根因相同。
  • .pgpass 文件与 PGPASSWORD 环境变量是 DSN 之外的两种供密方式,容器场景里 DSN/env 注入比挂 .pgpass 文件更常见。
  • pg_hba 允许改成 trust 让 TCP 也免密——技术上可行,生产环境不要做:任何能触达端口的进程都拿得到无凭据访问。
  • 认证方法版本差异:新版本官方镜像默认 scram-sha-256,老客户端库可能不支持——那是另一类错误(认证方法不支持),与本文的「没带密码」不同。

注意事项

  • 排障先看连接路径:socket 还是 TCP、命中 pg_hba 哪条规则,决定了要不要密码——别把 exec 的行为当成全局行为。
  • 错误语义要分清:no password supplied(没带)与 authentication failed(带错)的修复动作完全不同。
  • DSN 里的密码做特殊字符规避或百分号编码,URL 保留字符会静默改变解析结果。

常见问题​

fe_sendauth: no password supplied 是什么意思?​

服务端 pg_hba.conf 要求密码认证,但客户端连接串里没有提供密码。它不是「密码错误」(那是 authentication failed),是「没带密码」——检查 DSN 是否缺 password 部分。

为什么 docker exec 进容器 psql 不用密码就能连?​

容器内默认走 Unix domain socket,pg_hba 对本地 socket 配置了 trust 免密;跨容器是 TCP 连接,命中要求密码的 host 规则。同一套命令换个连接路径,认证要求完全不同。

容器里脚本连 PostgreSQL 的正确姿势是什么?​

DSN 显式带密码,如 postgresql://user:password@host:5432/db。密码从 DB 容器的 POSTGRES_PASSWORD 环境变量读取注入,不要硬编码,也不要指望 socket 免密在 TCP 上生效。

CCLEE

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

合作咨询

PostgreSQL jsonb 字段判空失效?JSON null 不是 SQL NULL

· 阅读需 5 分钟

用 IS NOT NULL 过滤 jsonb 字段的「空值」,结果 JSON null 的行照样通过——数据明明是空的,判断却为真。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。分析结果的决策快照以 jsonb 存储,规则未触发的行写入的是 JSON null,「有没有规则输出」这个判断因此失真。

问题现象:IS NOT NULL 拦不住的「空值」​

字段->'key' IS NOT NULL 对值为 JSON null 的行返回 true——过滤条件形同虚设,本该被排除的行混进了结果集。如果你搜的是「jsonb 判空不生效」「jsonb 空值过滤不掉」「IS NOT NULL 对 json 无效」,都是同一个问题。

根因:JSON null 是合法 jsonb 值,不是 SQL NULL​

PostgreSQL 里 jsonb 有两套「空」:SQL NULL 表示值不存在,JSON null 是一个合法的 jsonb 值。-> 取出一个值为 JSON null 的键,拿回来的是后者——IS NOT NULL 判断的是「拿回来的东西存不存在」,不是「拿回来的是不是有意义的值」。直接看实测矩阵(PostgreSQL 16 容器实测):

表达式结果
('{"a":null}'::jsonb -> 'a') IS NOT NULLtrue
('{"a":1}'::jsonb -> 'b') IS NULLtrue(缺键返回 SQL NULL)
jsonb_typeof('{"a":null}'::jsonb -> 'a')'null'(字符串)
jsonb_typeof('{"a":1}'::jsonb -> 'b')SQL NULL
'{"a":null}'::jsonb ? 'a'true
('{"a":null}'::jsonb ->> 'a') IS NULLtrue

两个最容易踩的组合:

  • IS NOT NULL 分不出「JSON null」和「真值」——第一行,这正是误判的来源。
  • ->> 取文本后判 IS NULL 同样分不出——JSON null 和缺键都变成 SQL NULL,第六行。想用取文本绕过这个坑,会原样掉进去。

解决方案:判键用 ?,判值用 jsonb_typeof​

两件事要在查询里分开表达,用对工具就不会再混:

-- 判「键存在」:值是不是 null 无所谓
SELECT '{"a":null}'::jsonb ? 'a'; -- true

-- 判「有真值」:断言具体 JSON 类型,'null'、缺键一律排除
SELECT jsonb_typeof(config->'rule_output') = 'array'; -- 数组才算
SELECT jsonb_typeof(config->'rule_output') = 'object'; -- 对象才算

-- 找出「写了 JSON null」的行(数据排查用)
SELECT * FROM t WHERE jsonb_typeof(config->'rule_output') = 'null';

我们踩的具体场景:规则引擎在「规则未触发」时往决策快照里写 JSON null,下游用 ->'rule_output' IS NOT NULL 统计「有规则输出的行」,统计口径全错。修复是把断言收紧为 jsonb_typeof(...) = 'array'——只认数组,JSON null、缺键、其他类型一次排除。配套的 JSON null 判定此前用的是 IS NOT NULL,同批修正。

边界与变体:JSON null 不总是错的​

先说清楚:JSON null 本身是合法设计——「键存在但值为空」和「键不存在」是两种业务语义,API 返回 "extra": null 与不返回 extra 键不是一回事。错的不是数据,是拿 IS NOT NULL 去 做「有值」判断。三种意图对应三种写法:

意图写法
键存在(不管值)jsonb ? 'key'
有指定类型的真值jsonb_typeof(x) = 'array' / 'object' / ...
值为 JSON nulljsonb_typeof(x) = 'null'

另一个隐藏差异在更新侧:jsonb_set(config, '{rule_output}', 'null') 会写入 JSON null,jsonb_set(config, '{rule_output}', NULL) 则把整个键删掉——参数给的是 JSON 还是 SQL NULL,语义完全不同。同是「查询结果与预期不符」的排查,跨查询粒度错位导致的悬空引用是另一类高频根因,可以对照着看。

注意事项

  • 存量数据先摸底再改判定:用 jsonb_typeof(...) = 'null' 跑一遍全表,确认 JSON null 行的规模和来源,再决定是修查询还是修写入方。
  • 团队内统一写法:即便某字段当前恒为合法数组、IS NOT NULL 暂时不会出错,也统一用 jsonb_typeof 断言——字段语义一变,宽松写法就是静默错误。
  • ORM/驱动层同理:应用代码里判「JSON 字段非空」时,注意驱动把 JSON null 映射成什么(多数语言映射为语言级 null/None),别把两层空混为一谈。

常见问题​

PostgreSQL jsonb 里 JSON null 和 SQL NULL 有什么区别?​

JSON null 是合法的 jsonb 值,SQL NULL 表示「值不存在」。实测 ('{"a":null}'::jsonb -> 'a') IS NOT NULL 返回 true;jsonb_typeof 对前者返回字符串 'null',对缺键返回 SQL NULL。

为什么 jsonb 字段 IS NOT NULL 过滤不掉空值?​

->'key' 对 JSON null 返回的是合法 jsonb 值而非 SQL NULL,IS NOT NULL 自然为 true。要判「有真值」用 jsonb_typeof(字段->'key') = 'array' 这类类型断言,要判「键存在」用 ? 操作符。

jsonb 怎么判断键存在还是值为 null?​

键存在用 ? 操作符:'{"a":null}'::jsonb ? 'a' 为 true,不受值是否为 null 影响。值为 JSON null 时 jsonb_typeof 返回 'null',缺键时返回 SQL NULL——二者据此区分。

CCLEE

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

合作咨询

SQL 聚合后指标暴涨几十倍?别直接 SUM 比率列

· 阅读需 6 分钟

在把按天返回的广告数据聚合成周报时,PPC、CPM、ROI 等比率指标暴涨几十倍——PPC 从日粒度实测的 4.70 变成了周报表里的 111.39。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析,自动洞察市场趋势、用户行为、销售数据,提供精准运营策略。广告数据接口按天返回明细行,入库前要聚合成周粒度供报表消费。聚合上线后首查周报:PPC 111.39(日实测 4.70)、CPM 1585.9(日实测 68.6)、ROI 169.98(日实测 1.82)——13 个比率/均值列全部失真。

TL;DR​

聚合查询里对 CTR、PPC、ROI 这类比率/均值列直接 SUM,得到的是「每天比率的总和」而不是均值,数值按聚合天数成倍放大。两条规则:聚合时只加总可加列(展现、点击、花费等总量),跳过所有比率列;聚合后用总量重算比率(花费÷点击、点击÷展现)。如果手里只有比率没有总量,用分母作权重做加权平均,绝不简单平均。

问题现象​

聚合代码对数值列「全部 SUM」,比率列被静默带进去:

-- 错误写法:所有列都 SUM
SELECT
campaign_id,
date_trunc('week', day) AS week,
SUM(clicks) AS clicks,
SUM(impressions) AS impressions,
SUM(ppc) AS ppc, -- 7 天日 PPC 之和!
SUM(ctr) AS ctr, -- 7 天日 CTR 之和!
SUM(roi) AS roi -- 7 天日 ROI 之和!
FROM daily_ad_report
GROUP BY campaign_id, date_trunc('week', day);

实测对比(某计划单周):

指标日粒度实测SUM 周值放大倍数
ppc4.70111.39~24×
cpm68.61585.9~23×
roi1.82169.98~93×

没有任何报错,数据照常入库,报表照常渲染——只有把周报和日明细并排对比,才能发现数值差了一到两个数量级。

根因​

比率与均值是不可加的派生量。 ppc = cost / clicks 的分母每天不同,把 7 天的日 PPC 直接相加,数学上得到的是「7 个相对值的和」,没有任何业务含义。CTR、ROI 同理。

「全部数值列求和」是静默陷阱。 聚合代码通常按列循环统一处理,比率列混在其中不报错、不告警,只是结果悄悄失真。列越多、比率列占比越高,越难肉眼发现。

ROI 放大 93 倍反而更具迷惑性。 它看起来像「投放效果极好」,如果下游直接消费周报做预算决策,错误的数字会一路传到运营动作里。这次事故里,下游还有只读消费方直接读这张周表——表值修对之前,所有消费方都在读错数据。

解决方案​

第一步:聚合只保留可加列​

CREATE VIEW weekly_ad_totals AS
SELECT
campaign_id,
date_trunc('week', day) AS week,
SUM(impressions) AS impressions,
SUM(clicks) AS clicks,
SUM(cost) AS cost,
SUM(gmv) AS gmv
FROM daily_ad_report
GROUP BY campaign_id, date_trunc('week', day);

可加列的特征:它们是「计数/总量」(展现、点击、花费、订单数),跨时间区间相加仍有意义。

第二步:聚合后用总量统一重算比率​

SELECT
campaign_id,
week,
impressions,
clicks,
cost,
CASE WHEN clicks > 0
THEN cost / NULLIF(clicks, 0)::numeric
ELSE 0 END AS ppc,
CASE WHEN impressions > 0
THEN clicks::numeric / NULLIF(impressions, 0)
ELSE 0 END AS ctr,
CASE WHEN cost > 0
THEN (gmv - cost)::numeric / NULLIF(cost, 0)
ELSE 0 END AS roi
FROM weekly_ad_totals;

两个细节:PostgreSQL 整数除法会截断,除法前先 ::numeric;分母为 0 统一返回 0,保持与明细层口径一致。

第三步:只有比率、拿不到总量时用加权平均​

-- 用展现量加权聚合日 CTR(展开式:SUM(ctr × impressions) / SUM(impressions))
SELECT
date_trunc('week', day) AS week,
SUM(clicks)::numeric / NULLIF(SUM(impressions), 0) AS ctr_weighted
FROM daily_ad_report
GROUP BY date_trunc('week', day);

加权平均的本质就是「还原分子分母再相除」——只要还拿得到权重列,就永远优先于简单平均。

改完后周报 13 个比率列全部与日明细实测一致,历史脏数据用同一套公式回填,下游只读消费方不改一行代码自动变对。

注意事项

「所有数值列求和」的通用聚合代码是这类事故的源头:维护一份可加列白名单,比率/均值列显式排除,新增指标列时先回答「它跨天相加还有意义吗」。

多粒度报表(周报、月报)从同一张日表派生时,把「用总量重算比率」收敛成一个函数/视图,别在每份报表 SQL 里复制公式——口径漂移往往从复制开始。

修完聚合逻辑记得回填历史数据:聚合错误通常已持续多个周期,只改代码不回填,报表会继续展示旧错值。

常见问题​

百分比可以直接 SUM 吗?​

不能。百分比/比率是相对值,各行分母不同,直接 SUM 得到的是 N 个相对值之和,按聚合天数放大且无业务含义。正确做法是聚合时跳过比率列,聚合后用总量重算(点击÷展现、花费÷点击);只有比率没有总量时,用分母作权重做加权平均。

百分比能加起来求平均吗?​

只有分母相同时才可以。分母不同的百分比直接平均等于不加权平均,结果偏向分母小的项(小流量日的极端比率会被放大)。正确做法是分别加总分子和分母再相除,数学上等价于以分母为权重的加权平均。

CCLEE

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

合作咨询

明细表有行、汇总查无此条?跨 SQL 判定集粒度分叉

· 阅读需 6 分钟

排查一个数据看板线索:某广告 offer 在「诊断明细」里被判疑似停投、并附了再投资建议,点进「建议投放」清单却查无此 offer——明细页指着一张清单,清单里没有这行。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析平台,自动洞察市场趋势、用户行为与销售数据;诊断明细与建议清单分别来自管道里两个相邻查询的产出。

TL;DR​

两个查询对同一个业务谓词(「疑似停投」)用了不同的聚合粒度:明细查询按 计划×offer 粒度判定——任一计划末周无消耗即判疑似;候选清单按 offer 整体粒度判定——挂着的全部计划末周均无消耗才入选。某 offer 在其中一个计划末周仍有消耗,于是「明细判疑似、汇总不入选」,呈现层指向的明细悬空。修法三件套:口径统一收紧到整体粒度、两侧判定 CTE 逐字同源、引用对方行集前校验包含关系。

问题现象​

两个查询各自定义「疑似停投」:

-- 查询④ 诊断明细:pair 粒度(计划 × offer)
WITH consumption_paused_q4 AS (
SELECT offer_id, campaign_id
FROM spend_weekly
GROUP BY offer_id, campaign_id -- 每个计划单独看
HAVING SUM(spend) FILTER (WHERE is_last_week) = 0
)

-- 查询⑤ 建议投放候选:offer 粒度(跨全部计划汇总)
WITH suspected_paused_q5 AS (
SELECT offer_id
FROM spend_weekly
GROUP BY offer_id -- 整个 offer 一起看
HAVING SUM(spend) FILTER (WHERE is_last_week) = 0
)

出问题的 offer 挂在多个计划下,其中一个计划末周仍有消耗(3.09):

查询④(pair 粒度):  计划A 末周消耗 0     → 判疑似 ✓,并给出建议
查询⑤(offer 粒度): 跨计划汇总末周消耗 3.09 → 不入选 ✗
呈现层:明细页说「见建议投放表」,建议投放表里没有它

任务不报错、行数对得上大数,悬空只有顺着某条明细点过去才会撞见。

根因​

「同源」契约有覆盖盲区。管道规范要求相邻查询的特征列 CTE 逐字同源——这条契约被严格遵守了;但它只约束特征列,判定集(WHERE 之前的那个业务谓词)不在契约范围内。「疑似停投」在两个查询里被独立实现了两次,粒度不同:pair 级判定对「单个计划内无消耗」敏感,offer 级判定对「所有计划都无消耗」敏感。同一个 offer 两种结论,数学上必然存在交叉带。

跨查询引用把分叉放大成了悬空:呈现层拿查询④的行当明细、查询⑤的表做入口,却没人校验过「④的行集 ⊆ ⑤的行集」。聚合口径不一致是数据仓库最经典的一致性陷阱之一——此前写过的 SUM 比率列导致聚合后指标暴涨是它的另一种形态:聚合发生在了错误的层级上。

解决方案​

步骤 1:先定口径,再写 SQL​

业务问题只有一个答案:「这个 offer 还投不投」是 offer 级决策,判定就该收紧到整体粒度——挂着的全部计划末周均无消耗、且无任何运营标注行,才判疑似。口径变更升 rule_version,可追溯。

步骤 2:判定集 CTE 逐字同源​

把判定 CTE 抽成同一段文本,两个查询直接引用;契约同步升级:逐字同源覆盖判定集(含粒度),不止特征列。从此同一谓词只有一处定义,改口径只改一处。

步骤 3:跨查询引用前,校验行集包含关系​

把「标记集 ⊆ 对方行集」做成固定校验(可入库为测试):

-- 悬空检测:④ 判了疑似、⑤ 却查无此行
SELECT q4.offer_id
FROM consumption_paused_q4 q4
LEFT JOIN suspected_paused_q5 q5 USING (offer_id)
WHERE q5.offer_id IS NULL;

这条查询返回 0 行,呈现层才有资格把两个产出拼在同一张页面上。

步骤 4:重放验证后上线​

新口径对历史快照重放:逐行核对翻转方向(疑似↔正常)全部正确、零误伤、零悬空,再重跑生产验证通过,才完成收口。

注意事项

  • 「同源」契约的覆盖面必须包含判定集粒度,特征列同源救不了谓词两处定义。
  • 同一业务谓词(判停投、判爆款、判流失…)全管道只允许一处定义;发现第二处实现即是事故预备役。
  • 新增跨查询/跨模块引用(A 的输出行指向 B 的产出表)前,先跑行集包含校验(标记集 ⊆ 对方行集),不要等用户点到悬空链接。
  • 口径变更必须升版本并对历史数据重放,只看「新数据跑通」会漏掉存量结论的翻转风险。

常见问题​

为什么两条 SQL 对同一份数据给出不同结果?​

常见三个差异:聚合粒度(GROUP BY 维度不同——本例的计划×offer 与 offer 整体)、过滤口径(WHERE/HAVING 条件不同)、取数时点(查询时间不同)。粒度分叉最隐蔽:两条 SQL 各自都对,结论却可以相反。

SQL 的 HAVING 和 WHERE 在聚合判定里怎么选?​

WHERE 在分组前过滤行,HAVING 在分组后过滤组。但选对关键字之前先选对粒度——「以什么为一组」决定了判定的灵敏度:粒度越细越容易命中(单组满足即判),越粗越保守(全部满足才判)。两条 SQL 粒度不同,判定结论就可能相反。

如何避免报表之间的数据口径不一致?​

同一业务谓词只在一处定义,判定 CTE 多查询逐字同源;跨查询引用对方行集前跑包含关系校验(A ⊆ B);口径变更升版本号并对历史数据重放验证。一致性不靠约定俗成,靠契约加校验。

CCLEE

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

合作咨询

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_schema 和 table_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能力落地于真实商业场景。

合作咨询

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_dag、dag_code、dag_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 进元数据表(dag、serialized_dag、dag_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 ✅

reserialize 后 dag / 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_run → serialized_dag → dag_version → dag → orphan dag_code);③跑 airflow dags reserialize,然后查 dag 表确认该 dag_id 行数不再重建为 0。

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

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


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

修复就是把 INSERT 的 ON 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. 紧接着发布写入端代码(INSERT 的 ON 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 active,INSERT 里要写成 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 可以指定多个唯一约束吗?​

不能。单条 INSERT 的 ON 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能力落地于真实商业场景。

合作咨询

Airflow PostgresHook 多语句 SQL 静默丢结果?按分号切分逐条执行

· 阅读需 7 分钟

在 Airflow DAG 把 .sql 模板文件整段读出后传给 PostgresHook.get_pandas_df() 时,前置 SELECT 的结果被静默丢弃——DAG 报「SQL 查询无结果」,但把同一段 SQL 复制到 psql 又能正常返回数据。

在开发 AI运营 时遇到此问题——基于大语言模型的智能分析流水线,Airflow DAG 从 SQL 模板文件读取多查询报表模板并执行。

TL;DR​

PostgresHook.get_pandas_df(sql) 内部走 pandas.read_sql(sql, conn) → psycopg2 cursor.execute(sql)。当 sql 是含多条 ; 分隔 SELECT 的单字符串时,DBAPI 只暴露最后一个结果集的游标,前置查询结果被静默丢弃且不报错。修复方法:按顶层分号切分成 list[str],逐条 get_pandas_df 收集结果,或直接传 list 让 DbApiHook 顺序执行。

问题现象​

DAG 任务执行 shop_monthly_overview.sql 报「SQL 查询无结果」:

sql_count = 1   ← 模板里明明写了 4 条查询
result = "❌ SQL 查询无结果"

但同一份 SQL 复制到 psql 直连同库同参数,4 条 SELECT 都有数据。

复现实验​

在 Airflow 容器内直接验证 get_pandas_df 对多语句的行为:

from airflow.providers.postgres.hooks.postgres import PostgresHook

hook = PostgresHook(postgres_conn_id="postgres_default")

# 三条 SELECT 串成单字符串
sql = "SELECT 1 AS a; SELECT 2 AS b; SELECT 99 AS c WHERE 1=0;"

df = hook.get_pandas_df(sql)
print(df.columns.tolist()) # ['c'] ← 只拿到末条的列
print(df) # Empty ← 末条本身 0 行

预期应得到三条结果,实际只拿到末条(SELECT 99 ... WHERE 1=0,0 行),前两条完全消失,没有任何报错或警告。

根因​

调用链是 PostgresHook.get_pandas_df → DbApiHook.get_pandas_df → pandas.io.sql.read_sql → psycopg2 cursor.execute(sql)。

DBAPI 协议(PEP 249)允许 execute 接受含多条 ; 分隔语句的字符串,PostgreSQL 服务端会依次执行全部语句,但游标只暴露最后一个结果集——这是 PostgreSQL wire protocol 的固有行为,不是 Airflow 或 pandas 的 bug。

┌────────────────────────────────────────────────────┐
│ SELECT 1; ← 执行,结果集 1 立即被丢弃 │
│ SELECT 2; ← 执行,结果集 2 立即被丢弃 │
│ SELECT 99 WHERE 1=0; ← 执行,结果集 3 暴露给游标 │
└────────────────────────────────────────────────────┘
↓
pandas.read_sql 只 fetch 到结果集 3

源头是 task_execute_sql 把 .sql 文件整段读出后当成一条字符串传进 get_pandas_df:

# ❌ 问题代码
sql_text = open(sql_path).read() # 含 4 条 SELECT 的整段
df = pg_hook.get_pandas_df(sql_text) # 只拿到末条结果

为什么 psql 能正常返回?因为 psql 前端会主动遍历所有结果集并依次打印,而 DBAPI 游标不会。

解决方案​

方案 A(推荐):按顶层分号切分后逐条执行​

适合 .sql 模板文件场景——文件含注释、引号、多查询,需要稳健的切分。

def split_sql_statements(sql: str) -> list:
"""
按顶层分号切分 SQL,正确处理:
- 单引号字符串内的分号('a;b' 不切)
- SQL 标准 '' 转义('it''s' 不切)
- -- 行注释内的分号(-- note; not split 不切)
"""
statements = []
buf = []
i, n = 0, len(sql)
in_quote = False

while i < n:
ch = sql[i]

# 在单引号字符串内
if in_quote:
buf.append(ch)
if ch == "'":
# '' = 字面量单引号,不结束字符串
if i + 1 < n and sql[i + 1] == "'":
buf.append(sql[i + 1])
i += 2
continue
in_quote = False
i += 1
continue

# 顶层
if ch == "'":
in_quote = True
buf.append(ch)
elif ch == '-' and i + 1 < n and sql[i + 1] == '-':
# 行注释,原样吞到行尾(注释里的 ; 不切分)
while i < n and sql[i] != '\n':
buf.append(sql[i])
i += 1
continue
elif ch == ';':
stmt = ''.join(buf).strip()
if stmt:
statements.append(stmt)
buf = []
i += 1
continue
else:
buf.append(ch)
i += 1

# 末尾无分号的残留块
stmt = ''.join(buf).strip()
if stmt:
statements.append(stmt)

return statements


# 调用方
sql_text = open(sql_path).read()
statements = split_sql_statements(sql_text)

# 逐条执行,收集所有结果
all_results = []
for idx, stmt in enumerate(statements, start=1):
df = pg_hook.get_pandas_df(stmt)
if not df.empty:
all_results.append({
"sql_index": idx,
"sql": stmt,
"data": df.to_dict("records"),
"columns": df.columns.tolist(),
"row_count": len(df),
})

方案 B:直接传 list 给 DbApiHook​

Airflow DbApiHook.run 和 get_records 接受 list[str] 参数会按顺序执行——但 get_pandas_df 在 list 模式下的返回行为各 provider 实现不一致,生产环境建议用方案 A 自己控制。

为什么不用 sqlparse.split?​

社区答案常推荐 sqlparse.split(sqlparse.format(sql, strip_comments=True)),但 strip_comments=True 会丢掉注释,如果你的下游 processor 依赖注释中的元信息(如 -- dimension: shop),就会丢失上下文。手写切分器保留注释原文,行为可控。

注意事项​

注意事项

  • 不要用 sql.split(';') 简单切分——会误切 WHERE name = 'a;b' 这类引号内的分号,以及 -- 注释; 行注释里的分号
  • split_sql_statements 只处理单引号字符串和 -- 行注释;如果你的 SQL 用 /* 块注释 */ 或 dollar-quoted string($$...$$),需要扩展切分器
  • 修复后下游 processor 的 sql_index 语义会变(1-based 顺序索引),同步检查所有 df.iloc[sql_index] 类用法
  • 如果你的 SQL 是程序生成而非文件读取,更安全的做法是生成时就用 list,避免后续切分
  • 顺带提一个相邻的坑:如果你在 Drizzle ORM 里也遇到过 SQL 表达式被静默参数化的问题,可以看 Drizzle sql 模板混用参数化值与 SQL 表达式——同样是「框架替你做了你没预期到的转换」类陷阱

常见问题​

Airflow PostgresHook 怎么执行多条 SQL 语句?​

传 list[str] 而不是单条字符串。DbApiHook.get_pandas_df 和 run 接受 sql 参数为 list 时按顺序逐条执行;单字符串含多条分号分隔语句时 psycopg2 只返回末条结果集。生产环境推荐自己切分后逐条调用,方便控制结果聚合和 sql_index 索引。

为什么 get_pandas_df 多语句 SQL 只返回最后一条结果?​

pandas.io.sql.read_sql 调 psycopg2 cursor.execute 执行整段字符串,DBAPI 协议对多语句只暴露最后一个结果集的游标,前置 SELECT 结果被服务端立即丢弃,不报错也不警告。psql 能正常返回是因为 psql 前端会主动遍历所有结果集,DBAPI 游标不会。

怎么安全地按分号切分含注释和引号的 SQL?​

逐字符扫描,仅在「非单引号内、非 -- 行注释内」的顶层分号处切分。单引号字面量用 SQL 标准 '' 转义;不要用 str.split(';'),会误切注释和字符串里的分号。如果用 sqlparse.split,注意 strip_comments=True 会丢掉注释原文。


CCLEE

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

合作咨询