Commit 8dba340f by luoqi

fix(migrate): 多语句迁移里不能写 CONCURRENTLY —— 解测试服部署卡死

现象:测试服 deploy 挂在 migrate 步,pac-service 起不来,任何人往测试服部署
都会挂在同一处(跟部署内容无关)。

  ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001)

根因是我在 20260728020000 里写了 14 条 CREATE INDEX CONCURRENTLY。
Prisma 把整份 migration.sql 用**一次 simple query** 发给 Postgres,而 Postgres 只对
「一个查询串里多条语句」隐式开事务块 —— 分界线是**语句条数,不是 Prisma 版本**:

  20260701060607 / 20260727070000 / 20260727110000  各 1 条语句 → 无隐式事务 → 跑得通
  20260728020000                                    14 条语句   → 隐式事务   → 必挂

而 20260727070000 里那句「Prisma 6.19 已不成立(指事务包裹)」是**错的归因** ——
单条语句侥幸跑通被总结成了「新版没有事务包裹」,我照抄进 20260728020000,于是踩爆。

失败后果比失败本身更重:_prisma_migrations 留下一条 finished_at / rolled_back_at 全空、
applied_steps_count=0 的记录,Prisma 从此拒绝执行**任何**后续迁移(P3018),
整条部署流水线被堵死。同一颗雷已随 6333137a 合进 main,生产下次部署会撞一模一样的错。

改动:
- 20260728020000:14 条改为**普通** CREATE INDEX IF NOT EXISTS(可在事务里跑)。
  大表环境靠「部署前手工 CONCURRENTLY 先建」保证不锁表(两台机器 2026-07-28 已建完,
  故本迁移在两台上都是 IF NOT EXISTS 空跑);从零建库时表为空,普通 CREATE INDEX 瞬间完成。
  文件头补上完整复盘,明写"别再改回 CONCURRENTLY"。
- 20260727070000 / 20260727110000:订正那两处错误归因,改成"因为本文件只有 1 条语句"。

验证:
- 把修好的整份文件用 `psql -1 -v ON_ERROR_STOP=1`(显式单事务,正是 Prisma 那种场景)
  在测试服跑通,14 条全 skip,无 25001。
- 测试服已 `prisma migrate resolve --rolled-back 20260728020000_...` 解除 P3018 阻塞,
  14 条索引仍在(pg_indexes 计数 14),库结构未被破坏。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 765f70c1
Pipeline #3466 failed in 0 seconds
...@@ -20,9 +20,13 @@ ...@@ -20,9 +20,13 @@
-- 故用 CONCURRENTLY(不锁写)。 -- 故用 CONCURRENTLY(不锁写)。
-- --
-- ⚠️ 关于「Prisma migrate 有事务包裹所以跑不了 CONCURRENTLY」—— -- ⚠️ 关于「Prisma migrate 有事务包裹所以跑不了 CONCURRENTLY」——
-- 那是 20260701060607 里的说法,**在当前 Prisma 6.19 已不成立**:2026-07-27 本地实测 -- 本条实测能随 migrate deploy 跑通,但**别把它总结成「Prisma 新版已经没事务包裹了」**
-- `prisma migrate deploy` 直接把本文件跑通、索引建出、plan 生效。所以本条**可以**随 -- (本文件原来就是这么写的,错的归因,2026-07-28 已订正)。
-- migrate deploy 走,不需要 `migrate resolve` 那套。 -- 真正的分界线是**本文件里的语句条数**:Prisma 把整份 migration.sql 用一次 simple query
-- 发给 Postgres,而 Postgres 只对「一个查询串里多条语句」隐式开事务块。
-- 本文件只有 1 条语句 → 没有隐式事务 → CONCURRENTLY 跑得通;
-- 多条语句的迁移里写 CONCURRENTLY 必挂 25001,还会把 _prisma_migrations 堵死(P3018)。
-- 2026-07-28 的 20260728020000 就是这么炸的,那份文件头有完整复盘。
-- --
-- 但**推荐仍手动先建**,理由与事务无关,是停机时间: -- 但**推荐仍手动先建**,理由与事务无关,是停机时间:
-- deploy 时 pac-service 通过 depends_on 等 pac-migrate 退出才启动,而 33/64GB 建索引要几分钟 -- deploy 时 pac-service 通过 depends_on 等 pac-migrate 退出才启动,而 33/64GB 建索引要几分钟
......
...@@ -22,8 +22,9 @@ ...@@ -22,8 +22,9 @@
-- --
-- 【CONCURRENTLY 与上线方式】同 20260727070000(sync_log_id 那条): -- 【CONCURRENTLY 与上线方式】同 20260727070000(sync_log_id 那条):
-- 普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表数分钟,摄入与 push 全挂,故用 CONCURRENTLY。 -- 普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表数分钟,摄入与 push 全挂,故用 CONCURRENTLY。
-- 当前 Prisma 6.19 可以直接 `migrate deploy` 跑通(20260701060607 里"有事务包裹跑不了"的 -- 本条可以直接 `migrate deploy` 跑通 —— 但原因是**本文件只有 1 条语句**(Postgres 只对
-- 说法已不成立,2026-07-27 实测)。但**推荐部署前手动先建**:deploy 时 pac-service 要等 -- 多语句查询串隐式开事务块),不是「Prisma 新版没有事务包裹了」。原注释那句错误归因
-- 2026-07-28 已订正,详见 20260727070000 与 20260728020000 的文件头。但**推荐部署前手动先建**:deploy 时 pac-service 要等
-- pac-migrate 退出才启动,几分钟索引构建 = 白等的停机。手动建好后本条 IF NOT EXISTS 空跑: -- pac-migrate 退出才启动,几分钟索引构建 = 白等的停机。手动建好后本条 IF NOT EXISTS 空跑:
-- docker exec pac-postgres-1 psql -U pac -d pac -c \ -- docker exec pac-postgres-1 psql -U pac -d pac -c \
-- 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_host_id_subject_type_patient_id_idx" \ -- 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_host_id_subject_type_patient_id_idx" \
......
...@@ -45,10 +45,27 @@ ...@@ -45,10 +45,27 @@
-- 生产实测 14 条合计 **220MB**(表 5.4GB,占 4%)。写入侧同理:插一行特征至多多维护 1 条索引, -- 生产实测 14 条合计 **220MB**(表 5.4GB,占 4%)。写入侧同理:插一行特征至多多维护 1 条索引,
-- 画像重算不会被拖垮。 -- 画像重算不会被拖垮。
-- --
-- 【CONCURRENTLY 与上线方式】同 20260727110000:普通 CREATE INDEX 会 ACCESS EXCLUSIVE 锁全表, -- 【⚠️ 上线方式:大表环境必须部署前手工 CONCURRENTLY 先建】
-- 画像重算与摄入全挂,故用 CONCURRENTLY。**推荐部署前手动先建**(deploy 时 pac-service 要等 -- 本文件里写的是**普通** CREATE INDEX(会 ACCESS EXCLUSIVE 锁全表),这是被迫的,见下条。
-- pac-migrate 退出,14 条索引串行构建 = 白等的停机);手动建好后本文件全部 IF NOT EXISTS 空跑。 -- 所以对**已有数据的库**,部署前必须先手工建好,让本文件的 IF NOT EXISTS 空跑:
-- 生产实测单条 4-17s,14 条共 **88 秒**(不锁表,期间业务照常);测试服磁盘慢,单条约 42s。 -- docker exec pac-postgres-1 psql -U pac -d pac -c "CREATE INDEX CONCURRENTLY IF NOT EXISTS ..."
-- (托管 PG 无 postgres 容器,用 pac-service 容器里的 node + prisma $executeRawUnsafe 发同样的 DDL)
-- 生产实测单条 4-17s、14 条共 88 秒;测试服盘慢,单条约 42s、共 238s。全程不锁表,业务照常。
-- 从零建库的环境不用管:那时 persona_features 是空表,普通 CREATE INDEX 瞬间完成。
--
-- 【为什么不能在这里写 CONCURRENTLY —— 2026-07-28 踩爆过,别再改回去】
-- Prisma 把整份 migration.sql 用**一次 simple query** 发给 Postgres,而 Postgres 对
-- 「一个查询串里多条语句」会隐式开事务块;CONCURRENTLY 明确禁止在事务块内跑:
-- ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001)
-- 分界线是**语句条数,不是 Prisma 版本**:
-- 20260701060607 / 20260727070000 / 20260727110000 各只有 1 条语句 → 无隐式事务 → 跑得通
-- 本文件 14 条 → 隐式事务 → 必挂
-- 20260727110000 里那句「Prisma 6.19 已能直接跑通 CONCURRENTLY」是**错的归因**(单条侥幸),
-- 我照抄进本文件,结果测试服 deploy 挂在 migrate 步,pac-service 起不来。
-- 更坑的是失败后 _prisma_migrations 留下一条 finished_at/rolled_back_at 全空、
-- applied_steps_count=0 的记录,Prisma 从此拒绝执行**任何**后续迁移(P3018)——
-- 整条部署流水线被堵死,跟部署什么内容无关。解法:
-- npx prisma migrate resolve --rolled-back 20260728020000_persona_features_tag_filter_indexes
-- --
-- 【上线后实测】生产 2026-07-28 已手动建完,同口径复测(count + findMany 并发,墙钟): -- 【上线后实测】生产 2026-07-28 已手动建完,同口径复测(count + findMany 并发,墙钟):
-- contraindication:surgery 23,379ms → **84ms** (278×,命中 1,347 单) -- contraindication:surgery 23,379ms → **84ms** (278×,命中 1,347 单)
...@@ -62,33 +79,33 @@ ...@@ -62,33 +79,33 @@
-- 禁忌 4,210ms → **344ms**。那台盘慢,绝对值别拿去跟生产比,看倍数即可。 -- 禁忌 4,210ms → **344ms**。那台盘慢,绝对值别拿去跟生产比,看倍数即可。
-- ── 标量维度(equals):btree(表达式, persona_id),第二列盖住 EXISTS 的输出列 ────────────── -- ── 标量维度(equals):btree(表达式, persona_id),第二列盖住 EXISTS 的输出列 ──────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_rfm_segment_idx" CREATE INDEX IF NOT EXISTS "persona_features_rfm_segment_idx"
ON "persona_features" (((data #> '{segment}'::text[])), "persona_id") WHERE "key" = 'rfm'; ON "persona_features" (((data #> '{segment}'::text[])), "persona_id") WHERE "key" = 'rfm';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_lifecycle_stage_stage_idx" CREATE INDEX IF NOT EXISTS "persona_features_lifecycle_stage_stage_idx"
ON "persona_features" (((data #> '{stage}'::text[])), "persona_id") WHERE "key" = 'lifecycle_stage'; ON "persona_features" (((data #> '{stage}'::text[])), "persona_id") WHERE "key" = 'lifecycle_stage';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_urgency_level_level_idx" CREATE INDEX IF NOT EXISTS "persona_features_urgency_level_level_idx"
ON "persona_features" (((data #> '{level}'::text[])), "persona_id") WHERE "key" = 'urgency_level'; ON "persona_features" (((data #> '{level}'::text[])), "persona_id") WHERE "key" = 'urgency_level';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_age_bracket_bracket_idx" CREATE INDEX IF NOT EXISTS "persona_features_age_bracket_bracket_idx"
ON "persona_features" (((data #> '{bracket}'::text[])), "persona_id") WHERE "key" = 'age_bracket'; ON "persona_features" (((data #> '{bracket}'::text[])), "persona_id") WHERE "key" = 'age_bracket';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_gender_gender_idx" CREATE INDEX IF NOT EXISTS "persona_features_gender_gender_idx"
ON "persona_features" (((data #> '{gender}'::text[])), "persona_id") WHERE "key" = 'gender'; ON "persona_features" (((data #> '{gender}'::text[])), "persona_id") WHERE "key" = 'gender';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_family_structure_structure_idx" CREATE INDEX IF NOT EXISTS "persona_features_family_structure_structure_idx"
ON "persona_features" (((data #> '{structure}'::text[])), "persona_id") WHERE "key" = 'family_structure'; ON "persona_features" (((data #> '{structure}'::text[])), "persona_id") WHERE "key" = 'family_structure';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_referral_champion_type_idx" CREATE INDEX IF NOT EXISTS "persona_features_referral_champion_type_idx"
ON "persona_features" (((data #> '{type}'::text[])), "persona_id") WHERE "key" = 'referral_champion'; ON "persona_features" (((data #> '{type}'::text[])), "persona_id") WHERE "key" = 'referral_champion';
-- ── 数组维度(array_contains → @>):GIN jsonb_path_ops ──────────────────────────────────── -- ── 数组维度(array_contains → @>):GIN jsonb_path_ops ────────────────────────────────────
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_contraindication_domains_idx" CREATE INDEX IF NOT EXISTS "persona_features_contraindication_domains_idx"
ON "persona_features" USING gin (((data #> '{domains}'::text[])) jsonb_path_ops) WHERE "key" = 'contraindication'; ON "persona_features" USING gin (((data #> '{domains}'::text[])) jsonb_path_ops) WHERE "key" = 'contraindication';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_entitlement_status_types_idx" CREATE INDEX IF NOT EXISTS "persona_features_entitlement_status_types_idx"
ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'entitlement_status'; ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'entitlement_status';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_treatment_history_types_idx" CREATE INDEX IF NOT EXISTS "persona_features_treatment_history_types_idx"
ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'treatment_history'; ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'treatment_history';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_treatment_sensitivity_types_idx" CREATE INDEX IF NOT EXISTS "persona_features_treatment_sensitivity_types_idx"
ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'treatment_sensitivity'; ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'treatment_sensitivity';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_special_attention_types_idx" CREATE INDEX IF NOT EXISTS "persona_features_special_attention_types_idx"
ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'special_attention'; ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'special_attention';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_time_preference_types_idx" CREATE INDEX IF NOT EXISTS "persona_features_time_preference_types_idx"
ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'time_preference'; ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'time_preference';
CREATE INDEX CONCURRENTLY IF NOT EXISTS "persona_features_potential_treatment_types_idx" CREATE INDEX IF NOT EXISTS "persona_features_potential_treatment_types_idx"
ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'potential_treatment'; ON "persona_features" USING gin (((data #> '{types}'::text[])) jsonb_path_ops) WHERE "key" = 'potential_treatment';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment