ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Liquibase preConditions 实践指南:数据库迁移脚本的守门员

Liquibase preConditions 实践指南:数据库迁移脚本的守门员 开车多年数据库我越来越觉得 Liquibase 的 preConditions 是很多人会忽略、但关键时刻能救命的功能。简单说preConditions 就是数据库迁移脚本里的执行前判断在真正跑一段 SQL 或一个变更集之前先反问数据库一句现在的状态允许我这么做吗比如这张表到底存不存在这个字段是不是已经有了当前连的数据库实例对不对。有朋友可能会说我写迁移脚本都是自己手动对一遍库结构最多只在本地测试环境的库上验证过生产环境直接跑 update 不就行了真这么干过的人多半都遇到过线上报错——重复建表、字段已存在、误在错误的库上执行了 DDL。这些事故其实用 preConditions 就能提前挡掉一大半。这篇文章我把 preConditions 的机制、写法、参数、坑和排查方法一并展开适合刚接触 Liquibase 的开发者也适合已经在生产环境使用、想进一步规范迁移流程的团队。1. 先搞清楚preConditions 到底解决什么问题1.1 从一次线上事故说起我印象很深的一次故障是某次凌晨发版变更脚本里有一句CREATE TABLE t_order_detail在测试环境一切正常结果上线时生产库早就存在同名表因为开发分支合并时另一条线已经把这张表建好了。执行到这一句直接报Table already existsLiquibase 默认的失败策略又是 stop整个 update 流程卡死后面的几十个变更集全部没跑。半夜对着日志一行行看真的很狼狈。如果当时给这个变更集加上一个简短的 preCondition判断表不存在时才执行建表事故当场就能避免。事后我在团队里定了一条规矩所有非幂等的 DDL、所有可能依赖于环境现状的变更一律先写 preConditions。1.2 preConditions 的本质迁移脚本的安全气囊你可以把迁移脚本理解成一份如何把数据库从 A 状态升级到 B 状态的操作手册而 preConditions 就是手册里每一项操作前的自动检查项。它不是 SQL 的一部分更像一个守卫守卫通过才放行变更集里的 SQL守卫不通过就根据你指定的策略决定是停下、跳过、警告还是标记为已执行。这个机制真正解决了两个痛点。第一是幂等性同一份 changelog 反复执行时preConditions 能拦截重复建表重复加字段这类必然报错的操作。第二是环境感知同一份脚本要部署到 dev、staging、prod 多套环境时环境之间结构可能不完全一致preConditions 可以根据现场状态自动决定哪些变更集需要执行、哪些可以直接跳过。1.3 运行时机每个变更集执行前Liquibase 的执行模型是顺序解析 changelog逐个执行其中的 changeSet。preConditions 就挂在两个层级上databaseChangeLog级别整个 changelog 解析时先做一次前置判断一旦失败整个流程可能直接终止。适合做全局性环境校验比如确认当前数据库类型是 MySQL。changeSet级别在某个变更集内部先做判断通过才执行该变更集内的 SQL。这也是最常用、最精细的用法。有一点需要注意Liquibase 在生成 updateSQL 脚本即只输出 SQL 不执行时部分 preConditions 的执行逻辑会不同这涉及到后面的onSqlOutput参数我留到第 3 节细说。2. 原理解读Liquibase 是怎么做执行前判断的2.1 从 changelog 到判断结果完整链路我建议每个用 Liquibase 的人都理解一下这条链路排查问题时非常有帮助。当你运行liquibase update时大致流程是Liquibase 解析 changelog 文件把 XML/ YAML / SQL 格式转换成内部的DatabaseChangeLog对象模型。加载并比较DATABASECHANGELOG表确定哪些 changeSet 还未执行。对每个待执行的 changeSet先检查它是否有 preConditions。如果有则连接当前数据库执行对应的判断逻辑拿到 true / false。根据判断结果和onFail/onError策略决定执行、跳过、终止或标记。执行通过后再运行 changeSet 内的 SQL并把结果记录到DATABASECHANGELOG。这个过程中preConditions 的判断是实时连库的而不是读取 changelog 里的静态内容。也就是说它查的是数据库此刻的真实状态这一点是很多新手容易误解的地方。2.2 三种范围changelog 级、changeSet 级和 SQL 级前面提到 changelog 级和 changeSet 级我再展开一下。changeLog 级 preConditions 一般写在根标签databaseChangeLog的直接子级作用于整个迁移批次。比如说我有一条团队规范只有 PostgreSQL 或 MySQL 才允许执行该 changelog就可以用dbms判断。changeSet 级则更灵活是日常用得最多的位置。它支持嵌套逻辑最外层可以是and、or、not这类逻辑容器里面再放具体的判断类型。Liquibase 内置了很多判断类型我这边常用的有判断类型作用tableExists判断表是否存在columnExists判断列是否存在viewExists判断视图是否存在indexExists判断索引是否存在schemaExists判断 schema 是否存在sequenceExists判断序列是否存在foreignKeyExists判断外键是否存在primaryKeyExists判断主键是否存在changeSetExecuted判断某个 changeSet 是否已执行过dbms判断当前数据库类型runningAs判断当前连接用户sqlCheck执行自定义 SQL用返回值与期望值对比tableEmpty判断表是否为空changeLogPropertyDefined判断 changelog 属性是否已定义SQL 级判断要单独说。sqlCheck和前面这些内置类型最大的不同是它不查元数据目录而是直接执行一段你写的 SELECT 语句然后用返回值和expect属性做对比。比如SELECT COUNT(*) FROM t_user要求结果等于 0以此决定是否执行清空表的操作。它非常灵活但也带来一个隐患判断语句本身如果写错会触发onError逻辑而不是onFail这两者处理方式完全不同。2.3 为什么有元数据判断还不够还需要 sqlCheck有人会问既然有tableExists、columnExists这些元数据判断为什么还要在迁移脚本里写自定义 SQL因为有些场景元数据接口表达不了。举例说明。假设我要做一次数据订正把t_order表中status为 1 的脏数据改成 0只允许在没有脏数据时执行。这种判断依赖的是业务数据内容不是表结构Liquibase 内置的类型就覆盖不到了必须用sqlCheck来实现。再比如只有当前表行数小于 1000 时才做数据迁移也是同样的逻辑。不过我要提醒一句sqlCheck是把双刃剑。它在每次执行该变更集时都会跑一遍 SQL如果你的判断语句写得很重比如全表扫描、大表 count性能上是有代价的。关于这点我后面避坑部分还会细说。3. 实操落地preConditions 的核心写法与参数详解3.1 最常用的前置判断写法先看一个最基础的例子。我要实现只有当表不存在时才创建表可以这样写changeSet idcreate-user-table authorzhangshan preConditions onFailMARK_RAN not tableExists tableNamet_user/ /not /preConditions createTable tableNamet_user column nameid typeBIGINT autoIncrementtrue constraints primaryKeytrue nullablefalse/ /column column namename typeVARCHAR(64)/ /createTable /changeSet这里onFailMARK_RAN的含义是如果表已存在判断失败不执行建表 SQL但把这个 changeSet 记录为已执行下次不会再重复尝试。这个策略在保证幂等的场景下非常实用可以让你放心地反复对同一个库跑 update。再看一个判断列是否存在的写法preConditions onFailHALT columnExists tableNamet_user columnNamenickname/ /preConditions如果t_user表里没有nickname列整个 update 直接停下来。这种写法的目的是强制要求前置条件必须满足避免在错误的结构假设下继续执行后续变更。3.2 组合判断and、or、not 的嵌套与运算顺序真实项目里单个判断往往不够。比如要判断表存在并且列不存在才能加列或者MySQL 环境且表存在才执行某段 SQL。Liquibase 提供了三层逻辑标签。preConditions onFailWARN and tableExists tableNamet_pay/ not columnExists tableNamet_pay columnNamerefund_time/ /not /and /preConditions这个例子的含义是t_pay表必须存在同时refund_time列必须不存在两个条件同时满足才执行。如果在 PostgreSQL 上也有同样需求但判断条件要改为表存在即可而不是列不存在可以用or或dbms做环境分流组合起来就是一套相当灵活的规则引擎。嵌套的顺序我建议从外到内这样读onFail是总策略内部and/or/not负责把多个判断的结果合并成一个布尔值。要注意not标签只能包裹一个判断如果你要把两个判断的整体取反需要先and再not而不是直接在not里写两个子标签。3.3 onFail、onError、onSqlOutput 的行为差异这是 preConditions 里最重要的一组参数也是新手最容易含糊的地方。参数可选值行为onFailHALT默认、CONTINUE、MARK_RAN、WARN判断结果为 false 时触发onError与 onFail 相同判断过程本身抛异常时触发onSqlOutputTEST默认、IGNORE生成 updateSQL 时判断是否执行 preCondition先说onFail的四个值。HALT是默认值遇到失败直接中断整个 update所有未执行的变更集都不跑了需要人工介入。CONTINUE是失败后跳过当前这个变更集继续跑后面的变更集比较温和适合某些环境没有这张表、不需要执行这段逻辑的场景。MARK_RAN上一条提到过失败也标记为已执行这是实现幂等脚本最常用的策略。WARN是只打印警告日志但不中断、不跳过当前变更集照常执行——这个值要慎用因为它是失败了还要硬执行很可能导致 SQL 本身报错。onError很多人会忽略但它很关键。我举一个真实场景sqlCheck里写的 SQL 查了一个不存在的列数据库直接抛 SQLException。这个异常不是判断结果是 false而是判断过程失败了。如果没有单独设置onError默认策略同样是 HALT整个迁移会停住。如果你希望判断语句报错时继续跑而不是停死就该单独设置onErrorCONTINUE或者onErrorWARN。onSqlOutput则是在你用liquibase updateSQL生成 SQL 脚本时生效。默认情况下生成脚本时 preConditions 也会被解析并输出对应的判断语句如果设置onSqlOutputIGNORE则生成的脚本里不会包含 preConditions 的逻辑。这个参数在开发环境直接 update、生产环境走 SQL 审批的团队里很有用。3.4 与 context、dbms 的配合preConditions 不是孤立工作的它和 Liquibase 的context、dbms常常配合使用。dbms是 preConditions 里专门判断数据库类型的标签它的值可以写一个或逗号分隔多个数据库类型。比如changeSet idcreate-sequence authorlisi dbmspostgresql preConditions onFailHALT not sequenceExists sequenceNameseq_order_id/ /not /preConditions createSequence sequenceNameseq_order_id/ /changeSetdbms既可以写在changeSet标签上直接在解析阶段过滤掉其他数据库也可以写在 preConditions 内部。两者的区别在于写在changeSet上Liquibase 在解析阶段就不会为不匹配的数据库保留这个变更集写在 preConditions 内部则是执行前动态判断。我一般建议能写在changeSet标签上就写在标签上解析期过滤掉才是最省事的。context则用于环境逻辑分组比如contextdev只在开发环境激活。preConditions 和 context 的配合方式很直接先按 context 过滤出本轮要执行的变更集再在变更集内部做前置判断。两者结合就能实现开发环境判断 A 条件生产环境判断 B 条件的复杂逻辑。4. 实战案例四个典型场景从零到一4.1 场景一幂等执行 DDL防止重复建表报错这是最刚需的场景。我从一个完整的 changeSet 说起changeSet idinit-order-tables authorzhangshan dbmsmysql preConditions onFailMARK_RAN not tableExists tableNamet_order/ /not /preConditions createTable tableNamet_order column nameid typeBIGINT autoIncrementtrue constraints primaryKeytrue nullablefalse/ /column column nameuser_id typeBIGINT/ column nameamount typeDECIMAL(10,2)/ column namestatus typeINT/ /createTable createIndex tableNamet_order indexNameidx_user_id column nameuser_id/ /createIndex /changeSet整个逻辑用一句话概述就是如果t_order表不存在就建表并建索引如果表已经存在直接把这个变更集标记为已执行跳过 SQL。这样在 CI 里反复执行liquibase update不会因为重复建表而中断。这里我特别提醒一下createIndex的隐式风险即使表已经存在、建表被跳过createIndex的 SQL 也会一并被跳过因为整个 changeSet 是一个整体。如果你希望表存在但索引缺失时补建索引那就不能把两个操作放在同一个被 preCondition 包裹的 changeSet 里而应该拆开分别写各自的 preCondition。4.2 场景二跨数据库兼容的判断很多中大型项目会同时支持 MySQL 和 PostgreSQL或者处在从 A 库迁往 B 库的过渡期。preConditions 可以用来做数据库类型分流。changeSet idenable-pg-partition authorwangwu preConditions onFailCONTINUE or dbms typepostgresql/ and dbms typemysql/ tableExists tableNamet_log/ /and /or /preConditions sql -- 这里放针对分区表的初始化逻辑 /sql /changeSet这个例子的含义是只有在 PostgreSQL 环境下才直接放行如果是 MySQL则必须额外满足t_log表存在才放行其他数据库类型全部跳过。实际执行时Liquibase 会先解析or里面的第一个判断如果是 PostgreSQL 就直接短路通过不再处理and。使用dbms时有个参数细节要注意type属性的值必须用 Liquibase 内部的数据库简称例如mysql、postgresql、oracle、mssql、h2、hsqldb。写成MySQL、PostgreSQL这种大小写不规范的写法可能导致判断不生效。4.3 场景三数据修复前检查列是否存在、数据是否为空之前我说过sqlCheck适合做数据内容判断这里给一个完整例子。假设线上t_user表历史遗留了一个remark列现在要把它改名成note。在 MySQL 5.7 以下版本不支持RENAME COLUMN语法我们要提前确认列存在并且确认视图里没有引用。这里的难点在于列存在是结构判断视图没引用是数据字典查询两者要组合changeSet idrename-remark-column authorzhaoliu preConditions onFailHALT and columnExists tableNamet_user columnNameremark/ sqlCheck expectedResult0 SELECT COUNT(*) FROM information_schema.VIEWS WHERE TABLE_SCHEMA public AND VIEW_DEFINITION LIKE %remark% /sqlCheck /and /preConditions sql ALTER TABLE t_user RENAME COLUMN remark TO note; /sql /changeSetsqlCheck的expectedResult和 SQL 查询的结果做字符串比对0 就是 01 就是 1不支持范围判断、不支持大于小于。如果你的判断条件是行数少于 1000那只能自己想办法转换成等值判断比如SELECT CASE WHEN COUNT(*) 1000 THEN 1 ELSE 0 END FROM t_user。这是sqlCheck限制最明显的地方设计时要知道。4.4 场景四与 rollback 的联动设计preConditions 和 rollback 的配合值得单独说。默认情况下rollback只回滚 changeSet 里已经执行过的 SQLpreConditions 本身不参与回滚。但你可以利用 preConditions 实现可重复回滚的脚本。举个例子。建表脚本配置了rollback是DROP TABLE那么回滚时直接删表。如果你在同一个 changeSet 里写了表不存在则跳过的 preCondition那回滚时不会重新判断表是否存在它只执行DROP TABLE。如果表已经被其他流程删除回滚就会报错。所以我的习惯是需要在回滚阶段也做安全检查时把回滚拆成独立的 changeSet并配置 表存在才执行删除 的 preCondition而不是依赖原始建表脚本的 rollback。这样回滚也是一个受控的、幂等的过程。5. 避坑指南我在生产环境踩过的 preConditions 的坑5.1 大小写与 schemaName 导致的误判这是典型的本地测试没问题、生产就翻车的坑。Liquibase 在判断tableExists时会按数据库的元数据大小写规则去查表。Oracle 默认把未加引号的表名、列名统一存储为大写如果你在tableName里写小写可能查不到而在 PostgreSQL 里未加引号的标识符会被折叠为小写两种数据库行为完全不同。我的建议是尽量在 preConditions 中显式指定catalogName和schemaName并且和建表时的写法保持一致。比如tableExists schemaNameapp_user tableNamet_order/如果在多套数据库之间复用同一份 changelog那要特别留意各数据库对 schema 的默认值处理。MySQL 的 schema 通常等同库名而 PostgreSQL 默认用public这个差异会让同一份判断在 MySQL 上生效、在 PostgreSQL 上误判。解决思路是把 schema 抽象成 Liquibase 的changelogProperty在环境配置里注入而不是硬编码在不同数据库之间有歧义的名称。5.2 onFail 默认值 HALT 会把迁移流程卡死我见过不少团队写 preConditions 时只写了判断条件没注意onFail默认就是 HALT。结果一遇到判断失败整个流水线直接中断而且中断之后没有任何 changeSet 被记录重新跑会再次卡死在同一个位置。如果只是想看情况执行我建议优先考虑CONTINUE或MARK_RAN。两者的差别我再强调一次CONTINUE是本次跳过、不记录下次还会尝试MARK_RAN是本次跳过、但记录为已执行下次不再尝试。需要一次性修复类操作时用MARK_RAN更合适需要长期反复探测环境状态时用CONTINUE更合适。还有一种情况你希望失败时既不中断也不记录但要把原因发给监控。那就用WARN配合日志采集。不过我得说实话WARN模式下 preCondition 判断失败后changeSet 内的 SQL 还是会继续执行存在 SQL 自身报错的风险所以非必要我不推荐生产环境使用WARN。5.3 sqlCheck 的副作用与性能问题sqlCheck本质上是执行一段你提供的 SQL那它就存在两大隐患。第一是副作用如果有人在判断语句里写了 INSERT / UPDATE / DELETE每跑一次变更集就会真实修改一次数据这完全违背了 preConditions 的初衷。所以我的习惯是sqlCheck里的 SQL 必须严格限定为 SELECT且只能读不能写这一点要在 Code Review 时盯住。第二是性能。preConditions 的执行频率比你想象中高得多只要这个 changeSet 还没执行成功每次liquibase update都会先跑一遍判断。如果sqlCheck里是一个大表的COUNT(*)第一次跑可能只要几十毫秒当表涨到千万行、并且迁移一直失败一直重试时每次重试都要全表 count那就是灾难。应对办法有两个一是尽量用系统视图或索引覆盖的轻量查询二是把这个判断拆到独立的变更集里执行成功后用MARK_RAN记掉避免反复触发。5.4 版本升级带来的行为变更Liquibase 从 3.x 到 4.xpreConditions 的部分行为有调整。我有一次从 3.8 升到 4.3发现原来正常的一个 changelog 在updateSQL模式下生成的内容变了原因是onSqlOutput的默认行为在不同版本之间存在差异旧版本可能默认忽略 preCondition 输出新版本则默认输出。升级前一定要看 release notes重点搜precondition关键字。另外Liquibase 4.x 之后对 yaml 格式的 preConditions 支持更完整但有些历史老项目还在用 xml两种格式在解析时的报错信息差异比较大。如果升级后遇到莫名其妙的 precondition 解析失败先用liquibase validate看具体报错不要直接怀疑是数据库的问题。6. 快速排查手册与自检清单6.1 常见报错速查表报错现象常见原因处理办法Validation failed: pre-condition failed判断条件不满足默认 HALT查看具体是哪个 changeSet调整 onFail 策略pre-condition on ... failed: ...并伴随 SQL 异常sqlCheck里的 SQL 本身出错单独执行一遍该 SQL 排查字段、权限问题Table xxx doesnt exist却走了tableExiststrue大小写或 schemaName 不匹配检查 catalog/schema 和数据库元数据大小写规则onFail设置为 WARN 却仍然整体失败变更集 SQL 自身报错改用 CONTINUE 或先确认 SQL 可执行生成的 updateSQL 中看不到 preCondition版本或onSqlOutput设置影响检查onSqlOutputIGNORE或升级行为差异同一个 changeSet 反复执行但就是没效果使用了 CONTINUE 且失败未被记录确认数据库状态是否真的满足条件或改用 MARK_RAN6.2 自检清单写 preCondition 前问自己五个问题我把自己每次写 preCondition 之前会过一遍的清单放在这里照着走能省掉不少事后麻烦。这个判断失败之后我希望整个迁移停住、跳过、标记还是只告警先想清楚onFail再动手写条件。判断是基于元数据还是业务数据如果用sqlCheckSQL 是不是只读的、会不会有性能问题同一份 changelog 会在几种数据库上跑schema 和数据库类型差异会不会导致误判这个判断条件本身如果报错了我会不会希望流程停下来如果不希望就单独设置onError。这个 changeSet 有没有对应的 rollback回滚时需不需要同样的幂等保护这套问题帮我挡掉过不少线上问题。特别是第一问很多人写 preCondition 时只关注判断条件本身忽略失败后怎么办结果条件写得越精细流程反而越容易被卡死。7. 关于 preCondition 边界的一点个人经验preConditions 虽好但不要把判断逻辑写得太胖。它本质上是数据库迁移流程里的守卫不是业务逻辑处理器。如果一个变更集会根据几十种不同的现场状态走不同分支我更建议拆分多个 changeSet让每个 changeSet 只负责一个清晰的、可独立判断的场景。判断条件越简单迁移日志越好读出问题时定位也越快。还有个小技巧我一直在用把零散的常用判断抽成公共片段不好实现但可以用 Liquibase 的 property 在不同环境注入差异值配合 preConditions 的changeLogPropertyDefined实现在属性存在时才执行的效果。另外如果确实需要深度定制判断逻辑Liquibase 支持实现自定义的CustomPrecondition接口Java 项目里可以按需使用。我个人建议优先用内置类型只有内置类型确实覆盖不了再考虑自定义毕竟自定义类多一层编译和部署成本。最后说一下心态问题。迁移脚本是数据库变更的慢镜头回放每一条都可能在几年后被另一个同事重新执行。他看到的第一个东西就是 preConditions。写清楚判断条件、写清楚失败策略本质上是在给未来的自己留一条能安全重跑的路。这个习惯值得每个做数据库变更的人认真对待。
返回列表