使用Liquibase管理数据库版本

337 阅读9分钟

介绍

Liquibase是一款支持跨数据库版本控制工具,可以让你快速安全地把开发数据库迁移到生产数据库。它提供了多种描述变更集的文件格式,包括SQL、XML、YAML和JSON。

基本上,理解了下面这个图(来自官网),就大致了解了Liquibase的组成了。

image.png

关于设计的最佳实践

选择合适的changelog structure

官方推荐了两种组织changelog文件的方式:

Object-oriented Structure

按照数据库对象(类型)进行分类管理,利用include或者includeAll从根文件来包含其他nested changelog,例如:

com
  example
    db
      changelog
        changelog-root.xml
        changelog-indexes
          my-favorite-index.xml
          that-other-index.xml
        changelog-tables
          employees.xml
          customers.xml

Release-Oriented Structure

按照发布版本进行分类管理,利用include或者includeAll从根文件来包含其他nested changelog,例如:

com
  example
    db
      changelog
        changelog-root.xml
        changelog-1.0.xml
        changelog-1.1.xml
        changelog-2.0.xml

liquibase常用命令

初探liquibase

在配置好liquibase.properties文件以后,执行下述命令

# 查看liquibase的状态,比如是否包含数据库驱动,用户名/密码是否正确
liquibase status
​
# inspect deployment sql(不会真正执行,但是会把sql文件输出)
liquibase update-sql
​
# 真正执行sql
liquibase update

常用命令

命令说明
liquibase drop-all删除当前库中的所有表,但是不包括存储过程、函数等等
liquibase generate-changelog从已有数据库生成一个xml格式的changelog文件
liquibase tag --tag=v1.0该当前状态打上tag
liquibase rollback --tag=v1.0回滚到tag=v1.0的changeset
liquibase status查看当前尚未执行的changeset
liquibase changelog-sync标记changeset已经被运行过,让多个数据库保持同步的初始状态

注:如果发现rollback并没有达到预期效果,要检查执行rollback命令使用的changelog-file与DATABASECHANGELOG里面记录的filename是否一致。

举例:

# 从已有数据库中生成changeset,并且包含数据
liquibase generate-changelog \
  --diffTypes=tables,columns,data \
  --dataOutputDirectory=myData

Changelog

Liquibase的属性们

context/contextFilter

用于过滤某些环境信息,比如开发环境/测试环境/生产环境等等。

  • context一般用于执行changeset的时候,作为传入的选项,比如liquibase update --context="test,main",或者如果在Java程序中,指定运行属性:spring.liquibase.contexts=test,main

  • contextFilter用在changelog、changeset或者include/includeAll中,用于过滤执行时传入的context值

    • 在changelog中指定的contextFilter对所有位于该chagnelog中的changeset有效
    • 在include/includeAll中指定的contextFilter对被包含的所有changelog有效
    • 在changeset中指定的contextFilter只在该changeset中有效

示例:

<databaseChangeLog ...>
  <changeSet id="3" author="jimmy" contextFilter="prod">
    <addLookupTable existingTableName="person" existingColumnName="state"
                    newTableName="state" newColumnName="id" newColumnDataType="char(2)"/>
  </changeSet>
</databaseChangeLog>

最佳实践

  • 测试数据,应该与所有其他的changeset保持同步,即:把测试数据写入到同一个changelog中,并且通过contextFilter来区分测试与生产等其他环境

    If you manage your test data with Liquibase, it is best practice to have this data in line with all your other changesets, but marked with a "test" contextFilter.

    <databaseChangeLog>
      <!-- 其他的changeset -->
      <changeSet id="4" author="jimmy" contextFilter="test">
        <!-- insert all test data -->
      </changeSet>
      <!-- 其他的changeset -->
    </databaseChangeLog>
    
  • 对于多数据库,不建议使用contextFilter,而是使用dbms这个tag来标注

    it is a best practice to use the dbms tag to differentiate changesets by database type, and then run liquibase update in your command line.

    <changeSet  id="1-lawful-good" author="jimmy" dbms="postgres">
      <createTable tableName="my_postgres_table">
        <column name="id"  type="int"/>
      </createTable>
    </changeSet>
    

Labels

为changeset指定标签,来实现筛选changeset的目的。执行liquibase的update时使用labelFilter表达式来通过标签指定要运行的changeset,在设计changeset的时候,通过labels来指定changeset的标签。

比如,在运行时(如果使用了spring liquibase)

spring: 
  liquibase:
    change-log: classpath:db/changelog/changelog.xml
    contexts: dev
    label-filter: 1.0 or (1.1 and !shopping_cart)

在changeset的配置中:

<changeSet id="2" author="jimmy" labels="1.0,2.0">
  <addColumn tableName="person">
    <column name="username" type="varchar(8)"/>
  </addColumn>
</changeSet>
<changeSet id="3" author="jimmy" labels="shopping-cart">
  <addLookupTable existingTableName="person" existingColumnName="state"
                  newTableName="state" newColumnName="id" newColumnDataType="char(2)"/>
</changeSet>

那么id=3的changeset不会执行,因为不满足条件

最佳实践

  • 使用labels来枚举或描述changeset的作用和用途,比如版本、功能等等
  • 如果想要控制过滤逻辑(filter logic),那么使用context更加合适

failOnError

默认值:true,表示遇到错误就停止运行。

最佳实践

  • 使用preconditions来控制changeset的运行,而不是用failOnError=false
  • 使用contextFilter、dbms等控制特定环境/数据库才能只从的changeset,而不是用failOnError

If you use failOnError frequently, consider whether there are any underlying issues with your database architecture that you can address instead.

logicalFilePath

Liquibase uses the following pattern to create a unique identifier for a changeset: id/author/filepath.

filepath默认是你指定的属性spring.liquibase.change-log的值,logicalFilePath可以修改这个默认值,用于如下几个场景:

  • 多个developer共享一个changelog文件,但是每个developer都有自己的路径

    • 通过logicalFilePath来指向同一个位置
  • 代码重构导致changelog文件的位置发生了变化

    • logicalFilePath应该仍然执行代码重构之前的位置
  • 多个模块都存在changelog的时候,避免id冲突

    • 比如:在logicalFilePath中加入模块名

runAlways

默认false,如果true,则表示该changeset每次都要运行,每次运行都会更新DATABASECHANGELOG表中对应的记录,使用场景包括:

  • 通过update这个change type来更新时间
  • 通过tagDatabase这个change type来标记当前数据库的状态
  • 使用sql或者sqlFile来运行特定脚本更新对象的权限

runOnChange

默认false,如果设置成ture,当每次changeset发生修改的时候,这个属性在以下场景很有用处:

  • 使用了CREATE or REPLACE语句的存储过程或者视图,可以避免每次修改都重新记录一个changset。
<changeSet  author="your.name"  id="changeset01"  runOnChange="true" >
  <createProcedure>
    . . .
  </createProcedure>
</changeSet>

runOrder

可用值:firstlast,分别用于让changeset在第一个运行和最后一个运行。

runWith

这是一个用来扩展Liquibase执行器的属性,如果默认的执行器(JDBC)不能满足需求(比如处理特定数据库的特定语法的复杂SQL),那么可以通过自己编写执行器(executor)来满足需求。

自定义executor必须继承自AbstractExecutor,然后通过SPI机制(META-INF/services)注册到Liquibase,并且指定一个名字,这个名字就是runWith需要的值。

AttributeValueNotes
runWithjdbcDefault value if none specified. See Class JdbcExecutor.
mongoshExecutor for MongoDB. See Using Liquibase with MongoDB Pro.
psqlExecutor for PostgreSQL. See Use PSQL and runWith on PostgreSQL.
sqlplusExecutor for Oracle. See Use SQL Plus and runWith on Oracle Database and Use SQL Plus and Oracle Proxy User.
sqlcmdExecutor for MSSQL Server. See Use SQLCMD and runWith on Microsoft SQL Server.
<custom>Custom executor. See Add a Native Executor.

Preconditions

在chengeset被执行之前的预检查,根据配置,如果预检查失败,可能会导致changeset不会执行或者终结整个changelog的执行。

可用的Proconditions

Precondition说明
changeLogPropertyDefined检查属性是否存在
changeSetExecuted检查是否某个changeset已经被执行过
dbms检查数据库类型
sqlCheck执行一段SQL,这段SQL必须返回一个值,将其与期望值进行比较
rowCount检查数据库表的行数是否满足条件
runningAs检查是否以某个用户运行
customPrecondition指定自定义的precondition,需要编写一个类,实现接口:liquibase.precondition.CustomPrecondition
tableExists检查表是否存在
columnExists检查某个表的某个字段是否存在
viewExists检查师徒是否存在
sequenceExists检查指定的sequence是否存在
primaryKeyExists检查主键是否存在
foreignKeyConstraintExists检查外键是否存在
uniqueConstraintExists检查唯一约束是否存在
indexExists检查索引是否存在

举例:

<changeSet id="2" author="jimmy" labels="1.0,2.0">
  <preConditions onFail="HALT" onError="HALT">
    <sqlCheck expectedResult="0">
      SELECT COUNT(*)
      FROM person
    </sqlCheck>
  </preConditions>
  <addColumn tableName="person">
    <column name="username" type="varchar(8)"/>
  </addColumn>
</changeSet>

属性替换

在changeset中,可以定义属性,就像maven中的属性一样。然后这些属性可以通过命令行、环境变量、执行参数等方式传递给changeset,以下是一个示例。

首先,在changelog中,定义如下:

    <!-- 定义一个属性 -->
    <property name="table.name" value="person"/>
​
    <!-- 在这个changeset中,使用${table.name} -->
    <changeSet id="1" author="jimmy" runInTransaction="false">
        <createTable tableName="${table.name}">
            <column name="id" type="int" autoIncrement="true">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="firstname" type="varchar(50)"/>
            <column name="lastname" type="varchar(50)">
                <constraints nullable="false"/>
            </column>
            <column name="state" type="char(2)"/>
        </createTable>
    </changeSet>
​
    <changeSet id="2" author="jimmy" labels="1.0,2.0">
        <preConditions onFail="HALT" onError="HALT">
            <!-- 在sqlCheck的sql中,也可以使用$table.name} -->
            <sqlCheck expectedResult="0">
                SELECT COUNT(*)
                FROM ${table.name}
            </sqlCheck>
        </preConditions>
        <addColumn tableName="${table.name}">
            <column name="username" type="varchar(8)"/>
        </addColumn>
    </changeSet>

然后,如果使用了spring+Liquibase,则在application.yml中,定义如下:

spring:
  liquibase:
    parameters:
      table.name: user

Search Path

查找changelog文件的一组基础路径,类似classpath的查找方式。

可以在liquibase.properties文件中指定searchPath:

liquibase.searchPath: /path/to/a,/path/to/b

暂时不清楚如何在Spring Boot程序中指定searchPath

元数据表

支撑Liquibase运行,需要两张元数据表:

  • DATABASECHANGELOG

    • 用于跟踪哪些changeset已经运行过
    • idauthorfilename三个字段唯一标识一个changeset
  • DATABASECHANGELOGLOCK

    • 用于给正在执行的操作加锁,保证同时只有一个Liquibase运行
    • 如果需要手动解锁,可以运行CLI:liquibase release-locks,当然,也可以手动执行sql语句把locked字段设置为0

最佳实践

  • 规划合适的目录结构
    • Object-oriented Structure:以数据库对象(或对象类型)为基础划分changelog
    • Release-oriented Structure:以release版本为基础划分changelog
  • 使用root changelog,然后include/includeAll其他的changelog
  • 在一个changeset中只指定一个change
    • 如果能够确保多个change能够在一个事务中运行,那么可以指定多个
  • 规划changeset id,最好是顺序增长的数字,比如:1、2、3
  • 为复杂和不能自解释的changeset增加comment
  • 规划rollback策略,在开发环境中严格测试
  • 根据环境管理你的数据
    • 比如使用context来准备测试数据,并且只能在测试环境运行

Liquibase Workflows

开发者的工作流程

假设你的liquibase.properties文件里面,url指向了本地数据库

  1. 在开发环境增加changeset到changelog中

  2. 在本地数据库应用changeset

    liquibase update
    
  3. 查看有哪些changeset尚未应用到远程数据库

    liquibase status --verbose --url=<远程数据库地址>
    
  4. 查看更新到远程数据库需要执行的sql语句

    liquibase update-sql --rul=<远程数据库地址>
    # 或者使用diff命令
    liquibase diff --url=<本地数据库地址> --referenceUrl=<远程数据库地址>
    
  5. 提交changelog到git仓库

  6. 把changeset应用到远程数据库中

    liquibase update --url=<远程数据库地址>
    

在已存在环境使用Liquibase

在开发到某个阶段以后,需要在开发环境、测试环境、UAT环境使用Liquibase同步数据库状态,此时可以使用如下步骤:

  • 从基准数据库生成changelog

    liquibase generate-changelog --changelog-file=dbchangelog.xml
    
  • 把changelog中的changeset同步到其他数据库,但是只是标记为已经执行,不做实际同步

    liquibase changelog-sync --changelog-file=dbchangelog.xml
    

对离线数据库的支持

对于远程数据库不能直连的情况(比如只能通过堡垒机才能连接),可以先利用update-sql命令来生成sql文件,然后再在远程数据库中执行这个sql文件,来达到不同changeset的目的。但是需要注意:

It is important that the database you generate the SQL from is the same as the database(s) you plan to run the SQL against.

在实际操作中,可以把远程数据中的DATABASECHANGELOG表拷贝到测试数据库,然后再在测试数据库上执行update-sql命令,来生成将来要同步到生成数据库的sql语句。