Lightdash EE 数据库迁移实战:Knex 编写规则、大表安全迁移与 Release-Safety 门禁

发布时间:2026/9/17 4:14:55
Lightdash EE 数据库迁移实战:Knex 编写规则、大表安全迁移与 Release-Safety 门禁 Lightdash EE 数据库迁移实战Knex 编写规则、大表安全迁移与 Release-Safety 门禁【免费下载链接】lightdashAgentic BI. Analytics at the speed of code ⚡️项目地址: https://gitcode.com/GitHub_Trending/li/lightdashLightdash 的 EE企业版数据库迁移存放在packages/backend/src/ee/database/migrations/目录内已积累约 180 个 Knex TypeScript 迁移覆盖 AI Agent、MCP、Deep Research、SCIM、Service Accounts、移动推送等模块。本篇以 EE 迁移规范文档 为主线完整展开它引用的全部共享规则——迁移时间冻结、DDL 绑定参数限制、主键与外键索引要求、千万级大表的安全迁移模式——并深入仓库源码印证 Release-Safety 静态门禁是如何逐条机器化执行这些规则的。读完你可以掌握一套可直接复用的 PostgreSQL 大表迁移方法论以及 Lightdash 的 CI 门禁在迁移文件上到底检查什么。一、EE 迁移规范的定位继承共享规则目录级生效EE 迁移规范文档本身只有一条核心声明EE 迁移必须遵循共享迁移编写规则即 packages/backend/src/database/migrations/CLAUDE.md其中明确写道These rules apply to all migrations, including EE migrations inpackages/backend/src/ee/database/migrations/。从仓库结构看两个目录的地位是对等的packages/backend/src/database/migrations/社区版CE迁移150 历史文件packages/backend/src/ee/database/migrations/企业版迁移包含 AI 线程20250627091539_add_ai_web_app_thread_and_prompt.ts、SCIM20241104145828_add-scim-access-tokens.ts、Service Accounts20250610142317_service_accounts.ts、MCP 工具20260707120000_create_mcp_tool_call_table.ts、移动推送20260830170000_create_mobile_push_notification_tables.ts等模块。门禁侧同样把两个目录视为整体scripts/sql-migration-lint.ts 中定义的MIGRATION_DIRS常量同时列出这两个路径约第 304-307 行因此任何针对 EE 迁移文件的 CI 检查与 CE 完全同权。此外__tests__/子目录如 20260831120000_add_live_activity_push_to_start.test.ts说明部分 EE 迁移还配有单元/集成测试验证up()/down()行为。二、规则一迁移是时间冻结的禁止导入应用代码Migrations are frozen in time — never import enums, constants, or types fromlightdash/commonor other application code.这是最容易被忽视的一条。迁移在发布那一刻起就被定格了同一段up()代码既跑在新安装实例上也可能在几个月后跑在升级实例上。如果迁移文件import { SomeEnum } from lightdash/common而后续版本修改了枚举值那么在新安装时执行的迁移实际写入的数据就会与发布当时不同——同一次迁移在不同时间点产生不同结果这是静默的、难以排查的数据事故。正确做法是把用到的值复制成迁移文件内的局部常量。例如需要某个状态字符串时// 20260716120000_...ts示意写法 const LEGACY_STATUS draft; // 冻结值禁止从 lightdash/common 导入 await knex(some_table) .where({ status: LEGACY_STATUS }) .update({ status: published });这条规则解释了为什么 Lightdash 的迁移文件几乎全是纯knex.raw 本地常量的写法而不是引用共享类型。三、规则二PostgreSQL 拒绝 DDL 中的绑定参数Postgres rejects bind parameters in DDL(CREATE INDEX,ALTER TABLE, ...).CREATE INDEX、ALTER TABLE等 DDL 语句不接受任何服务端参数。而 Knex 的?值绑定会通过 libpq 以协议级参数发送因此下面这种写法会在迁移时直接失败bind message supplies N parameters, but prepared statement requires 0错误示例// 错误? 值绑定会被发到服务端DDL 拒绝 await knex.raw( CREATE UNIQUE INDEX idx_unique ON ai_threads WHERE status IN (?, ?), [active, archived], );正确做法分两种情况字面量内联值直接写进 SQL 字符串前提是该值在编写时已确定符合时间冻结原则await knex.raw( CREATE UNIQUE INDEX idx_unique ON ai_threads WHERE status IN (active, archived), );??标识符占位符Knex 的??是客户端插值在 SQL 到达服务器前就已经替换成标识符因此是安全的。仓库中大量使用await knex.raw(CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ?? ON ?? (??), [ pkName, table, column, ]);这一模式在 20260428153355_add_primary_keys_to_analytics_and_scheduler_log.ts 中反复出现。四、规则三每张表必须有主键优先合成 UUID规范对主键的要求及其原理值得逐条展开为什么必须有 PKPostgreSQL 的逻辑复制和 CDC 工具依赖主键识别行身份没有 PK 时复制链路可能被迫降级为昂贵的REPLICA IDENTITY FULL。规范特别强调append-only 审计表/日志表也不例外。新建表首选合成 UUID 主键列名table_uuid默认值uuid_generate_v4()。这与 Lightdash 对外 REST API 的标识风格一致API 暴露 uuid 而非自增 id也避免了依赖可能变化的自然键。复合自然键主键的适用条件当且仅当每一列都是NOT NULL且本质稳定时才可用。从源码结构看这条规则已经沉淀为专门的一次性迁移 20260429105241_promote_unique_constraints_to_primary_keys.ts它把ai_agent_group_access、ai_agent_user_access、embedding三张 EE 表上已有的 UNIQUE 约束列本身已NOT NULL升级为 PRIMARY KEY。其核心技巧是把建主键拆成两个低锁步骤详见下文第五节并且down()回滚时先重建 UNIQUE 约束再删除 PK保证表在任何中间状态都不缺少行身份// down(): 先恢复 UNIQUE再 DROP PRIMARY KEY避免表短暂失去行身份 await knex.raw( ALTER TABLE ?? ADD CONSTRAINT ?? UNIQUE (${columnList}), [table, oldConstraint, ...columns], ); await knex.raw(ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??, [table, pkName]);五、规则四外键列一律建索引且两种场景处理方式不同PostgreSQL 不会为外键自动建索引。未建索引的 FK 会让每一次ON DELETE CASCADE/ON DELETE SET NULL级联、以及每次父→子 JOIN 都退化为子表全表顺序扫描——规范直言这是 PR 评审中的高频疏漏且只有在子表长到生产规模时才暴露。针对两种场景规范要求两种不同写法场景 A本迁移新建的列——零行数据直接在列定义上链式.index()成本几乎为零table .uuid(color_palette_uuid) .nullable() .references(color_palette_uuid) .inTable(organization_color_palettes) .onDelete(SET NULL) .index();场景 B已存在的有数据列补漏索引——必须走并发建索引配config { transaction: false }// 文件顶部 export const config { transaction: false }; await knex.raw( CREATE INDEX CONCURRENTLY IF NOT EXISTS ai_thread_organization_uuid_idx ON ai_threads (organization_uuid), );规范还补充即便没有 FK 约束凡是高频作为过滤/JOIN 条件的列如saved_queries.space_id也应同样建索引。仓库中确有对应实例 20260820150000_add_organization_uuid_index_to_ai_thread.ts 与 20260821024000_index_external_source_prompt_context.ts。六、规则五千万级大表的安全迁移八要点Lightdash 的自托管实例可能达到数千万行。规范为此给出了一整套防御式写法每一条都可在参考迁移 20260428153355_add_primary_keys_to_analytics_and_scheduler_log.ts 中找到逐行对应6.1 会话级关闭 statement_timeout并用 finally 恢复生产 Postgres 常配置会话statement_timeout会杀掉长时间批量操作。且当config { transaction: false }时Knex 的迁移锁在进程崩溃时不会释放操作员需要先migrate status检查、再用migrate unlock --actor who带署名逃生门解锁后重试。参考实现该文件第 131-146 行export async function up(knex: Knex): Promisevoid { await knex.raw(SET statement_timeout 0); try { for (const { table, column } of TABLES) { await addPrimaryKey(knex, table, column); } } finally { await knex.raw(RESET statement_timeout); } }6.2transaction: false的适用条件与幂等铁律只要迁移包含CREATE INDEX CONCURRENTLY、VALIDATE一个NOT VALID约束、或循环批量更新就必须声明export const config { transaction: false }CREATE INDEX CONCURRENTLY本身就无法运行在事务块内。代价是每条语句各自独立提交因此整个迁移必须幂等——部分执行后重新运行要能安全续跑。幂等手段包括IF NOT EXISTS/IF EXISTS、写约束前先查pg_constraint、重建索引前先删掉上次崩溃留下的 INVALID 索引查pg_index.indisvalid。6.3 批量回填ctid IN (SELECT ... LIMIT N)循环一次巨型UPDATE会撑爆 WAL 并长时间持有写锁。正确写法该文件第 46-60 行let totalUpdated 0; for (;;) { const result await knex.raw{ rowCount: number }( UPDATE ?? SET ?? uuid_generate_v4() WHERE ctid IN ( SELECT ctid FROM ?? WHERE ?? IS NULL LIMIT ${BATCH_SIZE} // BATCH_SIZE 10000 ), [table, column, table, column], ); const updated result.rowCount ?? 0; if (updated 0) break; totalUpdated updated; console.log( ${table}: backfilled ${totalUpdated} rows); }用ctid物理行位置做批次选择而不是LIMIT直接更新可以避免边更新边翻页造成的行漏更新rowCount 0即停止同时天然支持断点续跑。6.4 给既有列加 NOT NULL四步无锁扫描法直接ALTER COLUMN ... SET NOT NULL会以ACCESS EXCLUSIVE锁全表扫描。规范要求的替代序列参考实现第 62-93 行完整覆盖ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID——不扫描VALIDATE CONSTRAINT——只取SHARE UPDATE EXCLUSIVE读写不阻塞SET NOT NULL——PG12 因已有校验过的 CHECK 而瞬间完成不再全表扫描DROP CONSTRAINT——删除冗余 CHECK。6.5 建唯一索引/主键CONCURRENTLY USING INDEX 提升CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS期间不阻塞写入随后ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY USING INDEX ...只是短暂的ACCESS EXCLUSIVE、无扫描毫秒级完成。EE 目录的 20260429105241_promote_unique_constraints_to_primary_keys.ts 是这条链路在 EE 侧的完整示范先探测并清理上次崩溃残留的 INVALID 索引再并发建索引最后提升为 PK 并删除旧 UNIQUE 约束。6.6 每步 DDL 前console.log进度规范明确要求在每个回填后的 DDL 步骤前打日志这样运维 tail pod 日志时能精确知道迁移卡在哪一条语句。参考实现中validating not-null constraint、building unique index (concurrently)、promoting index to primary key等日志正是这一要求的落地。七、Release-Safety 门禁规范如何被机器强制执行规范文档的Release-safety declarations一节定义了门禁只检查PR 中变更的迁移文件存量未动文件自动豁免。这套规则由 scripts/sql-migration-lint.ts 静态执行无 Postgres 依赖、完全确定性并对 release-safety.declarations.json 做注册表校验。逐条对照如下1. 检测到破坏性操作 → 必须登记稳定 ID。迁移一旦包含被检测出的 breaking 操作dropColumn、renameColumn、dropNullable、dropTable、renameTable及 raw SQL 等价物DROP TABLE/RENAME TO/SET NOT NULL等必须向 release-safety.declarations.json 添加稳定 IDreason、requiredStop、migration完整迁移路径齐全。注意登记只是记录破坏不会消除检测器本身的 finding。2. 静态无法分类的 raw SQL → 文件内classification导出。需要export const classification: { kind: safe | breaking; reason: string }。EE 目录中大量迁移携带该导出例如 20260826120000_add_ai_prompt_needs_user_input.ts、20260901120000_create_scim_request_logs.ts。kind: breaking还必须同时有注册表条目否则报classified-breaking-change错误。linter 内置了已知安全白名单SET/RESET会话参数、系统目录SELECT、CREATE INDEX、CREATE TABLE、DROP INDEX、不含 NOT NULL 的ALTER TABLE ... ADD COLUMN白名单之外的 raw 语句会触发unclassified-knex-raw错误。3.transaction: false必须可续跑。linter 的isResumableBackfill函数专门检查INSERT ... ON CONFLICT、带WHERE的DELETE、带IS NULL / NOT EXISTS状态守卫的UPDATE、或LIMIT Nfor/while循环的批量更新——EE/CE 参考迁移的ctid IN (SELECT ... LIMIT 10000)循环正是按此设计的。不满足则报non-resumable-backfill错误。4.down()必须真回滚或显式抛错。规范规定不可逆迁移的down()必须抛出消息以irreversible:开头的 Error缺失或静默成功的空down()直接判失败。linter 的downState()函数把down()状态分为missing / noop / invalid-throw / real四类前三类都产生 error 级 finding。5. DDL 应设置有限的lock_timeout。未设置的 DDL 会收到missing-lock-timeoutwarning——因为排队等待的ALTER会把后续查询无限期挡在后面。6.CREATE INDEX CONCURRENTLY IF NOT EXISTS单独不构成恢复手段。被中断的并发建索引会留下 INVALID 索引而IF NOT EXISTS按名字匹配会静默跳过它。规范要求使用稳定字面量索引名linter 的concurrentIndexName会解析名字供运行时重试守护发现或显式查询pg_index.indisvalid、删除无效索引再重建用占位符或动态拼接索引名会触发concurrent-index-invalid-retry警告transaction: false下的裸CREATE INDEX CONCURRENTLY无IF NOT EXISTS更是 error 级问题。破坏模式决策树检测出 breaking 时按序执行优先尝试 expand-only 重构——现在弃用旧形态下个版本再删除只有在工程师确认产品与发布决策后才添加注册表条目reason必须描述什么坏了、对谁坏了超过 1 个词、至少 24 字符、不得使用占位文本linter 的hollow-breaking-declaration规则专门拦截空话绝不为了过 CI 而声明 breaking——一旦登记该 release 就不再 rolling-safe会建议所有自托管客户改用 Recreate 升级策略。声明的生命周期声明只在添加其 ID 的 Git 区间内激活在第一个包含它的 release 之后自动过期作为历史记录永久留在 append-only 注册表中。规范明确禁止编辑、删除、改名或复用已有 IDreleasedIn字段仅作文档用途不控制激活。根目录 CLAUDE.md 的 Release-safety declarations 一节对 API/类型破坏有对应机制不填migration字段迁移破坏则回到本节。八、回滚粒度生产环境 forward-only规范最后一条关于运行时lease 运行时把每个迁移作为独立的 Knex 批次执行而不是把一次部署的所有迁移合并成一个批次。由此推出两点运维事实开发工具如knex migrate:rollback每次调用只回退一个迁移而非整个部署批次事故发生时按逐迁移回退的粒度预期来操作生产恢复策略始终是forward-only写新迁移修复而非回滚。这也与第七节呼应正因为生产不可依赖回滚down()的正确性与transaction: false迁移的幂等性才成为门禁级要求。九、写作 EE 迁移的自检清单综合全文规范与门禁规则一份合格的 EE 迁移在提交前应对照文件内没有来自lightdash/common或应用代码的 import所有值均为冻结的本地常量所有 DDL 使用内联字面量或??标识符占位无?值绑定新表带主键合成 UUID 或全NOT NULL稳定复合键FK 优先使用*_uuid列每个.references()列都有索引新列链式.index()存量列CREATE INDEX CONCURRENTLY IF NOT EXISTStransaction: false大表操作statement_timeout关闭/恢复包裹在try/finally、每步幂等可续跑、批量回填带LIMIT与rowCount 0终止、NOT NULL 走 CHECK 四步法、PK 走USING INDEX提升、每步 DDL 前有console.log静态无法分类的 raw SQL 带classification导出检测到的破坏已按决策树处理并若不可避免在 release-safety.declarations.json 登记down()真回滚或抛出irreversible:前缀错误DDL 请求锁前设置有限lock_timeout。以上清单的每一项都能在当前仓库找到正例如第六节引用的 CE 参考迁移或反例门禁scripts/sql-migration-lint.ts 中对应的 error/warning 规则名可作为评审 EE 迁移 PR 时的逐条核对依据。【免费下载链接】lightdashAgentic BI. Analytics at the speed of code ⚡️项目地址: https://gitcode.com/GitHub_Trending/li/lightdash创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考