infra/migrations/0055_work_code_case_uniq.sql 的开头注释里有一段话,是拿一次真实的线上停机换来的。写它之前我先写了一个判断,然后这个判断被生产打回来了…

先把背景说清楚,不然那条注释看起来只是一段普通的谨慎啰嗦。

要修的不是「查不到」

0001_init.sql 给 work.code 的是裸 TEXT UNIQUE,也就是 UNIQUE (code),大小写敏感。而全站读端一律按 lower(code) = lower($1) 匹配。

两者错位的后果不是「查不到」,是同一部片在库里裂成两行。目录推送方 2026-08 那批把番号从 dass-966 改推成 DASS-966,upsert_work 的 ON CONFLICT (code) 没命中,于是新插一行。生产上真裂了三对:DASS-966、DSOD-009、START-597,2026-08-29 已经人工合并完了。

裂开后的症状也不是报错,是两类静默故障:

  1. 半份数据。老行握着 source_link、work_embedding、frame_embedding,新行只有元数据。而 ... WHERE lower(w.code)=lower($1) LIMIT 1 没有 ORDER BY,取哪行由查询计划决定。DSOD-009 和 START-597 当时取到的就是没有播放源的那行——观看页返回 200,一个播放源都没有。
  2. 直接 500。任何 = (SELECT ... WHERE lower(code)=lower($1)) 形态的标量子查询,多行时报 more than one row returned by a subquery used as an expression。取消收藏、移出播单、相似作品回退都是这个形态。

0046_anime_import.sql:117-131 里当时刻意没直接建唯一索引,理由是存量脏数据会让整个 --single-transaction 迁移炸掉。数据合并干净了,0055 才来收紧。

为什么保留那个大小写敏感的约束

要建的是 CREATE UNIQUE INDEX work_code_lower_key ON work (lower(code))。那旧的 work_code_key(UNIQUE (code))能不能顺手删掉?

不能。external_source_map.code 是指向 work(code) 的外键,外键要求被引用列上有唯一约束,DROP work_code_key 会因为依赖失败。而 UNIQUE (lower(code)) 严格强于它,留着没有副作用。

0046 建的那个非唯一函数索引 idx_work_lower_code 倒是可以删——列相同,被新唯一索引完全覆盖,留着只是多一份写放大。

第一条判断:为什么全部收进一个 DO 块

因为 LOCK TABLE 只能在事务块里用。

生产迁移走 migrate.sh 的 psql --single-transaction,有事务块,没问题。但 tools/ugc-smoke/README.md 记的本地跑法是逐文件 psql -f,autocommit,没有事务块——裸 LOCK 在那边直接报 LOCK TABLE can only be used in transaction blocks,本地冒烟库就少了这道唯一约束,而它恰恰是本迁移唯一要做的事。

DO 块自带隐式事务,两种跑法下都成立。顺带说明了为什么不能把 LOCK 和 CREATE INDEX 分在两条语句里。

为什么开头就取最强的锁

CREATE UNIQUE INDEX 拿的是 SHARE 锁,能挡住写;DROP INDEX 要 ACCESS EXCLUSIVE。先建后删,就是同一事务内的二次锁升级。

中间挤进来的在线写入(increment_view 那条 UPDATE work)拿不到 RowExclusive 而排队,我们又要等它让出,成环,deadlock detected,整个迁移回滚。这个在生产库上实测复现过。

有个容易成立的假设是「迁移跑的时候反正没人写」。不成立:compose 是先跑完 migrate 才动 api 容器,迁移期间旧 api 还在服务,increment_view 一直在写。

所以开头一次 LOCK TABLE work IN ACCESS EXCLUSIVE MODE 取到最强锁、全程不再升级,环就无从形成。

第二条判断:这里犯过错

第一次上 prd 的时候,lock_timeout 后面跟的是「拿不到锁就快速失败」。当时的推理是:migrate 退出非零,api 就不重建,旧 api 继续服务,所以这是个安全的失败方向。

这个判断是错的。

docker compose up -d 并不会因为 migrate 退出非零就保住旧容器。它照常把 api/web 重建掉,只是新容器卡在 Created 起不来——depends_on: migrate: service_completed_successfully 没满足。

净结果是:迁移没生效、站点直接下线。而 prd 的 work 表一直有 increment_view 在写,15 秒内本来就未必抢得到 ACCESS EXCLUSIVE 锁。于是一次普通部署把站点打没了。

正确的做法是短超时 + 多次重试:每次只等 2 秒,让排队的写事务跑完再试。在线写入都是短事务,几轮之内必然有窗口。真到 60 秒都抢不到才 RAISE——那种情况是有长事务压着 work,本就该人工介入,而不是硬闯。

FOR i IN 1..30 LOOP
    BEGIN
        PERFORM set_config('lock_timeout', '2s', true);
        LOCK TABLE work IN ACCESS EXCLUSIVE MODE;
        EXIT;
    EXCEPTION WHEN lock_not_available THEN
        IF i = 30 THEN
            RAISE EXCEPTION '60 秒内拿不到 work 的 ACCESS EXCLUSIVE 锁(有长事务压着?先查 pg_stat_activity 再重试)';
        END IF;
        PERFORM pg_sleep(1);
    END;
END LOOP;
PERFORM set_config('lock_timeout', '0', true);

每轮独立的 BEGIN/EXCEPTION 会隐式建 savepoint,抢锁失败只回滚这一轮,不会污染整个迁移事务。

报错要给人话

建唯一索引之前还有一道闸。有重复就直接给出一句能照着做的话,而不是一句 duplicate key value:

SELECT count(*), string_agg(lc, ', ' ORDER BY lc)
  INTO dup, sample
  FROM (SELECT lower(code) AS lc FROM work GROUP BY 1 HAVING count(*) > 1 LIMIT 20) t;
IF dup > 0 THEN
    RAISE EXCEPTION
      'work 存在 % 组大小写重复 code,须先人工合并(保留握有 source_link/向量的那行)再跑本迁移:%',
      dup, sample;
END IF;

「保留握有 source_link 的那行」这句是必须写进去的——合并方向搞反了,数据照样丢,唯一索引照样建得起来,只是从此那部片没有播放源。

收尾

EXECUTE 'DROP INDEX IF EXISTS idx_work_lower_code';
EXECUTE 'CREATE UNIQUE INDEX IF NOT EXISTS work_code_lower_key ON work (lower(code))';

两条都走 EXECUTE:DO 块里本来可以直接写 DDL,但整套脚本统一用 EXECUTE 拼字符串,跟上面的变量绑在一起,前后读起来是一件事。

迁移文件末尾还有一句 ANALYZE work;,让新索引尽快被规划器看见。

现在回头看,这个文件一共 86 行,注释占了 52 行。其中最长的那段是那次停机的记录,写着「⚠️ 这一条是拿线上停机换来的」… 不写上,下一个人(或者三个月后的我)看到「2 秒 × 30 次」只会觉得啰嗦,然后把它优化回「快速失败」。