想象这样一个常见的场景:你正在开发一个新功能,而这个功能需要添加一个新的数据库字段。你打开本地的数据库客户端,编写了一条`ALTER TABLE`语句并执行了它,代码运行得非常顺利。你将Java代码提交到版本控制仓库,然后去喝了杯咖啡。

几小时后,一位同事拉取了你的代码分支,运行应用程序后,所有功能都出现了故障。

他们通过聊天工具问道:“嘿,你是不是修改了数据库结构?”

你立刻意识到自己忘记分享了那条SQL脚本,于是将它发给了同事。他们重新运行程序后,问题果然得到了解决。然而一周后,应用程序在测试环境中再次因为同样的原因出现了故障。等到代码最终被部署到生产环境时,所有人都在问同一个令人头疼的问题:“应该执行哪条SQL脚本呢?”

这种情况被称为“数据库结构不一致”。当数据库在不同环境中的状态出现差异时,就会发生这种问题。测试环境使用一种数据库结构,生产环境使用另一种结构,而每个开发者的本地机器上又都保存着各种未经测试的数据库修改方案。

手动管理数据库变更会导致部署过程中出现诸多问题,也会影响团队协作。应用程序代码是无状态的,很容易进行替换;而数据库则是有状态的,它们的“记忆”能力非常强,一旦发生了错误的迁移操作,这些错误往往很难被纠正。

Liquibase通过为数据库变更引入版本控制机制来解决这个问题。你不需要再手动传递SQL脚本并依赖他人记得执行它们,而是可以将数据库变更定义在代码中。这些变更会随应用程序的代码库一起被提交到版本控制系统,并在需要时自动执行。

下面是这种架构的工作原理概述:

架构图显示了代码通过Git、Spring Boot和Liquibase从开发者传递到数据库的过程。

让我们来看看一条数据库变更在整个流程中的运行过程。开发者将他们的数据库修改代码与Java代码一起提交到Git仓库中。当CI/CD管道或同事拉取这些代码时,Spring Boot应用程序会开始启动。但在应用程序完全运行起来并开始接收网络请求之前,Liquibase会拦截这个进程。它充当“守门人”的角色,连接到数据库并应用所需的结构变更,从而确保数据库的状态与代码中的预期完全一致,这样在有任何用户发起请求之前,系统就能保证正常运行。

为什么数据库版本控制如此重要

如果你曾经参与过团队协作开发应用程序,你肯定见过这样的文件夹结构:

project-sql-scripts/
├── create_employee_table.sql
├── create_employee_table_final.sql
├── create_employee_table_final_v2.sql
├── add_email_column.sql
├── latest.sql
└── definitely_latest_use_this_one.sql

“手动运行这个SQL脚本”这句话引发了诸多令人难忘的麻烦事。

当依赖手动进行数据库更新时,大规模应用时出现故障几乎是必然的。新开发人员的加入往往意味着需要花费大量时间去弄清楚如何构建本地数据结构;部署过程也会变得充满压力,因为必须按照非常特定的顺序执行一系列手动查询操作。

使用版本控制机制来管理数据库变更,就能将数据结构视作代码来处理。当数据库变更与应用程序逻辑同步进行时,会带来以下几个明显的优势:

  • 一致性: 所有环境(本地开发环境、测试环境、生产环境)都会以完全相同的顺序应用相同的变更。

  • 安全性: 这可以有效避免因人为失误而遗漏某些脚本或执行过时的查询语句。

  • 可追溯性:

    通过查看Git提交记录,就能清楚地了解Java代码和数据库结构是如何共同发生变化以支持新功能的实现的。

Git为代码版本控制提供了有效的解决方案,而Liquibase则有助于防止数据库成为开发过程中带来麻烦的“捣乱者”。

什么是Liquibase?

从本质上讲,Liquibase是一种用于跟踪并执行数据结构变更的工具,它能够确保这些变更以可预测且可重复的方式完成。

与其编写杂乱的SQL脚本,不如使用“迁移文件”(也称为changeSets)来管理数据库变更。Liquibase会读取这些文件,并将其与实际数据库中的跟踪表进行对比,从而确定需要执行哪些操作才能使数据库保持最新状态。

要有效使用Liquibase,只需要了解以下几个概念术语即可:

  • changeLog: 主要配置文件。它实际上是一份列表,用于指示Liquibase应按照什么顺序执行哪些迁移文件。

  • changeSet: 对数据库进行的单一、原子级的变更。创建一个表属于一种changeSet,添加一列则属于另一种changeSet。

  • 迁移历史:Liquibase会在数据库中自动创建一个表格(名为DATABASECHANGELOG),用于记录哪些changeSets已经成功执行过。

  • 校验和: 为每个changeSet生成唯一的哈希值。Liquibase利用这些哈希值来检测是否有人在某个文件被执行后偷偷对其进行了修改。

当将Liquibase与Spring Boot集成在一起时,在应用程序启动过程中,迁移操作会完全自动完成。

Spring Boot启动过程中的序列图:Liquibase会检查跟踪表、锁定数据库、执行迁移操作,然后在释放锁之前允许HTTP请求通过。在启动过程中,Liquibase会先控制数据库系统,直到您的Web服务器被允许接收HTTP请求为止。它会检查数据库中的跟踪表,以确定哪些迁移操作已经完成。如果它在您的本地文件中发现新的迁移任务,就会锁定数据库,从而防止同时发生更新操作;执行这些新的迁移命令后,它会记录下相应的变更历史信息,最后才会解除对数据库的锁定。只有当这一整个过程完全结束后,Spring Boot才会完成启动流程。

由于Liquibase会在Spring Boot完全初始化Web服务器之前运行,因此你的应用程序永远不会使用过时的数据库模式来处理请求。如果迁移操作失败,应用程序将无法启动,从而避免系统进入异常状态。

项目设置

现在你已经了解了相关理论,让我们开始实际操作吧。我们将为员工管理API构建数据库层。

对于这个项目,我们将使用以下技术:

  • Java 17+

  • Spring Boot 3.x

  • Maven

  • Liquibase

  • H2数据库

我们选择使用H2数据库,是因为它是一种内存数据库,完全不需要进行任何安装。你可以立即运行这个项目,而无需配置Docker容器或安装数据库服务器。不过,在这里学到的所有方法同样适用于PostgreSQL、MySQL、SQL Server或Oracle等数据库。

如果你是通过Spring Initializr生成这个项目的,请选择以下依赖项:Spring Web、Spring Data JPA、Liquibase Migration以及H2 Database。

在你的pom.xml文件中,你会看到那些使该项目能够正常运行的关键依赖项:

<dependencies>
    <dependency>
        org.springframework.boot
        spring-boot-starter-web
    
    org.springframework.boot
        spring-boot-starter-data-jpa
    

    com.h2database
        h2
        runtime
    

    org.liquibase
        liquibase-core
    
</dependencies>

接下来,需要配置Spring Boot以便它能够与H2数据库进行通信,并找到Liquibase相关的文件。打开src/main/resources/application.properties文件,添加以下内容:

# H2数据库配置
spring.datasource.url=jdbc:h2:file:./data/employeedb;DB_CLOSE_DELAY=-1
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# 启用H2控制台,以便在浏览器中查看数据库信息
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Liquibase配置
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml

最后这一行内容最为重要。它告诉Spring Boot应该在哪里找到数据库变更的“主列表”文件。

注意:我们这里使用的是基于文件的H2数据库,而不是内存数据库。因为内存数据库每次重新启动Spring Boot时都会被完全清除掉数据,所以这种配置更为实用。

虽然Liquibase在每次启动时都会从头开始重新构建数据库架构,但对于本教程来说(以及在实际的本地开发环境中),使用基于文件的数据库方式会更为合适。通过这种机制,你的数据——尤其是Liquibase记录的历史变更信息——能够在应用程序重启后依然保持不变。

理解Liquibase的核心概念

在创建第一个表之前,我们首先需要了解Liquibase是如何组织文件的。Liquibase采用了一种层次结构来管理这些文件。

可以把这种结构想象成一本书:changeLog文件就相当于目录,而changeSets文件则代表具体的章节内容。

  1. 主变更日志:这是整个系统的入口点。它本身通常不包含实际的数据库操作指令,而是负责按特定顺序列出其他相关文件。

  2. 子变更日志:这些文件用于将相关的变更操作组合在一起。

  3. 变更集:它们才是真正的、可执行的数据库命令(比如创建表或添加列)。

下面是一个具体的示意图,展示了这种层次结构在真实的Spring Boot项目中的运作方式:

文件结构图:主变更日志XML文件按时间顺序引用了三个子迁移文件。

Liquibase通过层次结构来管理所有的迁移操作。你需要维护一个主文件,这个文件就相当于目录;不过这个主文件本身很少包含实际的SQL命令,而是会明确列出所有子XML文件的执行顺序。每个子文件(比如01-create-employees.xml)都包含一条或多条数据库操作指令,而Liquibase将这些指令称为“变更集”。

一个变更集由以下三个要素唯一标识:

  • id:一个唯一的字符串(通常是一个数字或Jira工单编号)。

  • 作者:编写该迁移脚本的人。

  • 文件路径:该文件所在的路径。

当Liquibase运行时,它会读取一个变更集文件,计算其内容的加密哈希值(即校验和),然后将id、作者和校验和这些信息存储到数据库中。如果在下一次启动时发现数据库中已经存在相同的id、作者和文件路径组合,Liquibase就会直接跳过这个变更集,不会执行其中的操作。

创建初始的员工表结构(版本1)

让我们开始编写第一个版本的数据库脚本吧。我们需要创建一个用于存储员工信息的表。

首先,在src/main/resources/db/changelog/db.changelog-master.xml路径下创建主文件:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <include file="db/changelog/changes/01-create-employees.xml"/>>

</databaseChangeLog>

接下来,在src/main/resources/db/changelog/changes/01-create-employees.xml路径下创建实际的迁移文件:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <changeSet id="1" author="ashutoshkrris">
        <createTable tableName="employees">
            
            
            
            

让我们看看刚才做了什么。我们定义了一个changeSet,其id为“1”,author为“ashutoshkrris”。在其中,我们使用了Liquibase的XML语法来定义一个表。

为什么使用XML而不是普通的SQL呢?因为Liquibase是与具体的数据库无关的。这段XML代码无论用于PostgreSQL(SERIAL)、MySQL(AUTO_INCREMENT)还是Oracle(IDENTITY),都会生成正确的自动递增字段定义格式。你只需要定义表的结构,Liquibase会将其转换成相应的数据库方言。

现在,运行你的Spring Boot应用程序,观察终端输出。你会看到类似这样的日志:

Liquibase启动时的终端日志

Liquibase检测到数据库是空的,因此自动创建了跟踪表(DATABASECHANGELOG),读取了我们定义的changeSet内容,执行了表创建操作,并记录下了这一变更过程。

如果你现在重新启动应用程序,Liquibase会再次运行。但这一次,它会检查DATABASECHANGELOG表,发现id="1"author="ashutoshkrris"对应的操作已经执行过,因此会直接跳过这些步骤。这样一来,你的数据库就实现了安全的版本控制。

刚刚发生了什么?

到目前为止,Liquibase给人的感觉确实像是一种“魔法”:你只需将一个XML文件放入指定文件夹中,然后启动Spring Boot应用,数据库结构就会发生改变。

但了解Liquibase内部的运作机制其实非常重要。只有理解了它的启动流程,才能在遇到问题时准确地排查并解决部署过程中出现的故障。

当你的Spring Boot应用程序启动时,它并不会立即开始接收Web请求。首先,它会初始化自身的内部组件。当创建Liquibase组件时,迁移过程才会正式开始。

下面具体说明了在启动阶段会发生哪些事情:

详细的序列图,展示了Liquibase如何检查锁表、获取锁权、执行未执行的迁移操作,以及在Tomcat启动之前释放锁权。

让我们详细追踪这个过程。当Spring Boot初始化Liquibase时,该工具首先会查询锁表,以确保没有其他应用程序实例正在对数据库进行迁移操作。如果确认没有其他竞争者,它就会获取锁权。随后,它会计算本地XML文件的加密校验和值,将这些值与数据库中的历史记录进行对比,执行任何缺失的变更操作,并将这些操作记录下来。最后,它会释放锁权,这样Tomcat Web服务器就可以安全地启动了。

这种机制能够确保在数据库模式完全准备好之前,你的应用程序绝不会开始处理用户的请求。

检查数据库:Liquibase元数据表

让我们看看在数据库内部,这些迁移历史记录和锁机制实际上是怎样的。由于我们之前已经配置好了H2 Console,因此可以直接查看相关的原始表格。

当你的Spring Boot应用程序正在运行时,打开浏览器并访问http://localhost:8080/h2-console。使用JDBC连接字符串jdbc:h2:file:./data/employeedb、用户名sa以及空密码进行连接。

进入后,你会看到employees表,同时还会看到Liquibase自动创建的另外两个表格:DATABASECHANGELOGDATABASECHANGELOGLOCK

DATABASECHANGELOG

这个表格是你的迁移策略的核心。它记录了所有曾经应用到这个数据库环境中的变更操作。

如果你运行SELECT * FROM DATABASECHANGELOG;,将会得到如下结果:

ID AUTHOR FILENAME DATEEXECUTED ORDEREXECUTED EXECTYPE MD5SUM DESCRIPTION COMMENTS TAG LIQUIBASE CONTEXTS LABELS DEPLOYMENT_ID
1 ashutoshkrris db/changelog/changes/01-create-employees.xml 2026-05-30 13:11:35.937919 1 EXECUTED 9:66e7dcffb2b1902a4e9f01670cb5f192 createTable tableName=employees null 4.31.1 null null 0126894849

让我们来详细了解一下其中最重要的几列:

  • ID、AUTHOR、FILENAME: 这三列共同构成了一个复合键,它们能够唯一地标识某次数据迁移操作。

  • DATEEXECUTED & ORDEREXECUTED: 这些字段可以准确说明脚本是在何时运行的,以及运行的具体顺序。

  • MD5SUM: 这是你的XML文件的加密哈希值。当Liquibase启动时,它会计算本地XML文件的哈希值,并与这一列中的值进行比较。如果你在迁移操作执行后偷偷修改了文件内容,那么哈希值就会不一致,此时Liquibase会自动终止启动过程,以此来保护你的数据库。

  • EXECTYPE: 通常情况下,这一列的值会是EXECUTED。但它也提供了重要的审计信息:如果你使用Liquibase命令故意跳过某次迁移操作,但仍然将其记录为已完成状态,那么这一列的值就会显示为MARK_RAN;而如果是因为某些前提条件未满足而导致迁移被跳过,这一列的值则会显示为SKIPPED

  • TAG: 可以把这一列看作是数据库架构的“Git标签”。在进行重大且风险较高的部署之前,你可以配置Liquibase为当前数据库状态添加一个标签(例如v1.4.0)。如果部署失败了,你可以执行回滚操作,让Liquibase撤销自v1.4.0标签之后所做的所有更改。

  • CONTEXTS: 这一列用于管理那些与环境相关的配置变更。你可以在changeSet中添加context属性(例如),这样只有当Spring Boot在启动时将“dev”或“qa”作为环境参数传递给Liquibase时,相应的迁移操作才会被执行;在生产环境中,这类配置变更会被安全地忽略。

  • LABELS: 虽然Contexts针对的是具体的环境,但Labels则是用于区分不同类型的工作任务。你可以用Jira工单编号(例如issue-842)或版本发布阶段(例如Q3-release)来为某次迁移操作添加标签。这样,经验丰富的团队就可以有选择地执行或回滚某些特定的功能模块,而不会影响到数据库的其他部分。

DATABASECHANGELOGLOCK

这个表格虽然规模很小,但在现代应用程序的部署过程中起着至关重要的作用。

如果你执行SELECT * FROM DATABASECHANGELOGLOCK;这条命令,你会看到只有一行结果:

ID LOCKED LOCKGRANTED LOCKEDBY
1 FALSE null null

假设你正在将Spring Boot应用程序部署到Kubernetes集群中,你让Kubernetes同时创建三个完全相同的实例,这三个实例都会连接到同一个数据库。

如果这三个实例恰好在同一时刻尝试执行CREATE TABLE这条迁移命令,那么你的数据库就会出现并发错误。而DATABASECHANGELOGLOCK表就能防止这种情况的发生:第一个连接到数据库的实例会将LOCKED的值设置为TRUE,其他两个实例在检查到这个锁标志后,就会耐心地等待。

实用的故障排除技巧:有时,在数据迁移过程中会突然出现严重问题(比如服务器断电)。在这种情况下,Liquibase可能在将LOCKED字段的值改回FALSE之前就崩溃了。

下次当你重新启动应用程序时,日志文件会陷入无限循环,不断显示“Waiting for changelog lock……”这样的信息。

如果你确定目前没有其他应用程序正在执行数据迁移操作,那么你可以在数据库客户端中运行以下简单的SQL命令来手动解决这个问题:

UPDATE DATABASECHANGELOGLOCK SET LOCKED = FALSE;

这条命令会强制解除锁定状态,从而使你的应用程序能够继续正常运行。

员工API的升级与发展

软件开发永远没有止境。在成功部署了版本1之后两周,业务团队又提出了新的需求。

由于你现在已经了解了Liquibase是如何记录数据库变更历史的,因此对数据库进行升级其实非常简单——你只需要将新的变更文件添加到主变更列表中即可。

版本2:添加电子邮件字段

人力资源部门需要能够与员工联系,因此必须添加一个用于存储电子邮件地址的字段。

src/main/resources/db/changelog/changes/02-add-employee-email.xml文件中创建一个新的变更记录:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <changeSet id="2" author="ashutoshkrris">
        <addColumn tableName="employees">
            
            
        
    

</databaseChangeLog>

将这个变更记录添加到db.changelog-master.xml文件中,放在第一个包含其他变更记录的标签之后即可。

<include file="db/changelog/changes/02-add-employee-email.xml"/>

重新启动应用程序后,Liquibase会检查DATABASECHANGELOG表。它会发现id="1"这个变更记录已经存在,因此会跳过它;而id="2"这个记录还缺失,所以Liquibase会执行这条变更命令,并在跟踪表中添加一条新记录。

版本3:添加部门功能

公司规模正在扩大,员工现在都被分配到了不同的部门。因此,你需要创建一个departments表,并设置外键约束来关联这两个表。

创建03-add-departments.xml文件:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    
            
            
            
        
    

    
        

</databaseChangeLog>

请注意,我们在同一个文件中使用了两个不同的变更集。这是一种最佳实践。每个变更集代表一个逻辑操作。如果创建外键的操作(id="4")失败了,创建部门表的操作(id="3")仍然会被记录为成功执行,只有id="4"对应的操作才会被回滚。

版本4和5:员工状态与绩效指标

最后,人力资源部门希望能够区分在职员工和离职员工,而数据库团队发现,通过姓氏进行搜索的速度越来越慢。

创建文件04-status-and-indexes.xml

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <changeSet id="3" author="ashutoshkrris">
        <createTable tableName="departments">
            
    

    
        

</databaseChangeLog>

请记得将所有新创建的文件添加到db.changelog-master.xml中。您在文件中使用的包含语句的顺序,就是Liquibase执行这些操作的顺序。

<column name="given_name" type="VARCHAR(50)"> </column>>

保存文件后,重新启动你的Spring Boot应用程序。

然而,应用程序并不会正常启动,而是会立即崩溃,终端也会显示大量的错误信息。请仔细查看错误日志的顶部,你应该会看到这样一条消息:

原因如下:liquibase.exception.ValidationFailedException:验证失败:
     1个变更集的校验和发生了变化:
          db/changelog/changes/01-create-employees.xml::1::ashutoshkrris 的原始校验和为 9:66e7dcffb2b1902a4e9f01670cb5f192,现在的校验和变为 9:2bd3ef21343d3b5c9448cc50bc35deef

出现这种情况的原因在于:一旦某个变更集在某个环境中被执行,那么这些更改就会成为不可修改的历史记录。你无法改变已经发生的事情。

当Liquibase启动时,它会计算本地XML文件的加密哈希值(即MD5校验和),然后查询DATABASECHANGELOG表,并将新计算出的哈希值与文件最初被执行时记录的哈希值进行比较。

如果你已经执行过某个变更集,而后来又修改了该文件中的任何一个字符,那么哈希值就会发生变化。Liquibase会检测到这种篡改行为,并拒绝继续启动。这样做是为了保护你的数据。例如,如果XML文件中指定列的名称为first_name,但数据库实际上是用fist_name这个名称创建的,那么你的Spring Data JPA应用程序肯定会出错。

正确的解决方法

如果你是在本地犯了这种错误,你可能会想要直接进入数据库,删除DATABASECHANGELOG表中的相关记录,然后再尝试运行应用程序。但千万不要这样做!如果这个修改被应用到了测试环境或生产环境中,你在生产服务器上是不允许手动删除数据记录的。

纠正架构错误的正确方法是回滚之前的更改

首先,取消01-create-employees.xml文件中所做的修改,使哈希值再次与数据库中的数据匹配。然后,编写一个新的变更集来应用正确的配置:

<changeSet id="7" author="ashutosh">
    <renameColumn tableName="employees" 
                  oldColumnName="first_name" 
                  newColumnName="given_name" 
                  columnDataType="VARCHAR(50)"/>
</changeSet>>

将这个新的变更集添加到主变更日志中,然后重新启动应用程序,数据库就会自动恢复到正确的状态。

使用初始数据

有时候,进行架构修改后,需要一些初始数据才能使系统正常运行。

例如,在第3个版本中,我们创建了一个departments表。但目前这个表是空的,因此新的开发人员在本地克隆代码库并启动项目时,必须手动编写SQL插入语句来测试API功能。

我们可以通过将初始数据的插入操作纳入迁移策略中来自动化这一过程。

src/main/resources/db/changelog/changes/05-seed-departments.xml文件中创建一个新的变更集:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <changeSet id="8" author="ashutoshkrris">
        <insert tableName="departments">
            /insert>
    </changeSet>

</databaseChangeLog>

请在您的db.changelog-master.xml文件中添加相应的包含语句。当您重新启动应用程序时,Liquibase会自动插入这些数据,此时您的API就可以立即使用了。

数据迁移带来的风险

虽然导入数据是一项非常强大的功能,但使用它也需要遵循一定的规范。以下是一条实用的工程原则:

适合使用Liquibase的情况包括:

  • 用于创建静态查找表(如状态代码表、国家列表、默认部门信息等)。

  • 用于存储应用程序启动所需的系统配置信息。

不适合使用Liquibase的情况包括:

  • 不要用它来生成大量虚假用户数据进行测试。

  • 不要用它来迁移海量事务数据(例如,将500万条记录从一张表转移到另一张表)。

大规模的数据迁移可能会导致数据库表被锁定数小时。如果在部署过程中锁定了核心表,应用程序将会出现严重的运行故障。因此,请确保您的变更操作仅针对数据库的结构和必要的基础数据进行。对于复杂的数据处理任务,应该使用专门的脚本或后台作业来完成。

回滚操作

在理想情况下,代码总是能够正常运行。但在现实中,您有时会部署某些会导致生产环境中的查询语句出错或数据被破坏的数据库变更。在这种情况下,就需要有一种方法来撤销这些变更。

Liquibase支持回滚功能,但您需要了解它是如何执行这些回滚操作的。

自动回滚与手动回滚

许多Liquibase命令都是可以自动恢复到初始状态的。例如,如果您编写了一个指令,Liquibase会自动理解“删除列”就是这些操作的相反操作,因此您不需要特别指示它如何撤销这些操作。

然而,有些操作本质上具有破坏性,或者其结果存在不确定性。如果您使用了自定义的标签,或者使用了指令,Liquibase就无法知道该如何恢复数据。在这种情况下,您就必须提供明确的回滚指令。

让我们模拟这样一个场景:我们添加了一列临时访问代码,但我们需要确保自己清楚如何安全地移除这一列。

创建文件《06-temporary-access.xml》:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
        http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <changeSet id="9" author="ashutosh">
        <addColumn tableName="employees">
            /rollback>
    </changeSet>

</databaseChangeLog>

将这段代码添加到你的主配置文件中,然后运行应用程序。这样就会新增那一列。

如果你是通过CI/CD管道来部署这个应用的,而部署过程中出现了问题,你可以执行Liquibase的Maven命令,来回滚到之前的某个状态(例如:`mvn liquibase:rollback -Dliquibase.rollbackCount=1`),或者回滚到我们之前讨论过的某个特定版本标签。

回滚操作的实际效果

虽然了解回滚机制非常重要,但从后端开发的实际经验来看:在生产环境中,人们经常讨论回滚操作,但实际上很少能够顺利地完成回滚过程。

删除某一列在技术上来说非常简单,但如果你在15分钟的时间内让错误的代码影响了数据库中存储的客户数据,那么恢复这些数据就会变得极其困难。

正因如此,现代的工程团队通常更倾向于采用“向前推进”的策略。如果迁移操作导致了问题,他们不会执行复杂的回滚命令,而是会迅速编写一个新的变更集来修复问题(比如添加缺失的索引或调整某些约束条件),然后再重新部署应用程序。

强烈建议在设计数据库变更时,尽量使这些变更具有累积性且不会破坏现有数据结构,这样就能避免日后需要执行复杂的回滚操作了。

初学者常犯的错误

对于任何工程团队来说,采用数据库版本控制都是一个巨大的进步,但这也需要一定的学习过程。当开发人员从编写随意的SQL脚本转向使用Liquibase时,他们往往会陷入一些常见的陷阱。

以下是初学者最常犯的一些错误,以及避免这些错误的办法。

1. “巨型”变更集

在刚开始使用Liquibase时,人们往往会想把所有的初始数据库结构都放在一个XML文件中,并用同一个changeSet》来表示这些变更。例如,你可能会在id="1"这个变更集中放入15条createTable语句和20条addForeignKeyConstraint语句。

这个想法之所以糟糕,原因很简单:会导致事务失败。

如果你的数据库引擎在处理第14张表时出现故障(也许是因为语法错误),那么前13张表会发生什么情况呢?有些数据库引擎支持事务性的数据定义语言操作,这意味着它们会自动回滚这13张表的更改。但很多数据库并不具备这种功能。

如果迁移过程在半途失败,你的数据库就会陷入混乱状态。Liquibase并没有将id="1"这条记录视为成功的操作,因此下次你启动应用程序时,它会尝试再次创建这15张表。但由于第1张表已经存在,程序会立即崩溃。

解决办法:严格遵守“每个变更集只执行一个逻辑操作”的规则。如果你需要创建3张表,就应该编写3个独立的变更集。如果其中某个变更集失败了,其他成功的变更集仍然会被永久保存下来,你只需要修复那个出问题的变更集即可。

2. 手动调整数据库结构(这个习惯极其危险)

这是最应该戒除的习惯。有时开发人员在生产环境中发现某个索引缺失,为了节省时间,他们不会编写Liquibase迁移脚本、进行代码审查或部署更改,而是直接登录生产数据库,手动执行CREATE INDEX命令来创建索引。

一周后,另一位开发人员又编写了正确的Liquibase迁移脚本来创建同一个索引并进行了部署,结果应用程序在启动时却崩溃了。因为Liquibase尝试执行CREATE INDEX命令时,数据库会提示该索引已经存在。

当你使用Liquibase进行数据库管理时,必须接受这样一个基本原则:Liquibase才是你数据库架构的最终权威依据。人类的手动操作绝不应该直接修改数据库结构。

解决办法:如果有人不小心违反了这一规则,你可以有两种方法来修复这个问题。要么手动删除数据库中的索引,让Liquibase能够重新正确地创建它;要么在Liquibase中使用标签,在尝试创建索引之前先检查该索引是否已经存在。

3. 忽视“从零开始构建”的原则

当你在某个项目上投入数月时间进行开发时,你的本地数据库会积累大量的历史数据。你会编写迁移脚本,假设某些表或测试数据已经存在。

后来,有新的开发人员加入了团队。他们拉取代码后,在空数据库中运行Spring Boot应用程序,结果迁移脚本在执行过程中却出现了故障。

造成这种问题的原因在于,这些迁移脚本依赖于某种假定的状态(比如在创建外键之前假设某条记录已经存在),而不是基于确定无误的状态来编写的。

解决办法:你应该定期在完全空的数据库环境下测试你的迁移脚本。如果你使用的是Docker,就需要销毁现有的数据库容器并重新构建它;如果你使用的是像我们之前设置的基于文件的H2数据库,只需删除项目文件夹中的./data/employeedb.mv.db文件,然后重新启动Spring Boot即可。如果应用程序在完全空的状态下无法成功启动,那就说明你的迁移脚本存在问题。

4. 将环境相关细节硬编码

初学者有时会直接将特定于环境的细节硬编码到他们的XML文件中。例如,他们可能会硬编码某个特定的模式名称(如schemaName="dev_schema"),或者为某个本地用户授予相应的权限(如GRANT ALL ON employees TO my_local_user)。

当这些代码被应用到测试环境中时,由于测试环境使用的模式名称与开发环境不同,部署就会失败。

解决方法:保持迁移脚本的抽象性,让Spring Boot通过application.properties文件来处理连接相关细节。如果确实需要在Liquibase文件中使用动态值,可以使用属性替换机制。你可以在Liquibase中定义变量,并在Spring Boot启动时将这些变量传递进去。

5. 迁移顺序出错

Liquibase会按照db.changelog-master.xml文件中列出的顺序来执行这些脚本文件。

如果开发者A在某个分支中创建了departments表,而开发者B在另一个分支中创建了与该表相关联的外键,那么谁先合并代码,谁制定的顺序就会被采纳。如果开发者B的代码被包含在主版本文件中,且其位置在开发者A的代码之前,Liquibase就会尝试在目标表还不存在的情况下就创建外键。

解决方法:主版本变更日志是控制数据库变更流程的关键环节。在进行代码审查时,务必确保<include>语句的顺序是按时间先后排列的,并且各种依赖关系也是合理的。

Liquibase、Flyway与手动SQL脚本

当决定实施数据库版本控制时,你马上就会面临一个选择。Liquibase并不是Java生态系统中唯一的工具。目前管理模式演变的三种常用方法是Liquibase、Flyway以及手动编写SQL脚本。

你需要了解每种方法的实际优缺点,这样才能为你的团队和项目挑选最合适的工具。

1. 手动SQL脚本(基础方法)

这对大多数初学者来说都是默认的选择。你只需编写一个script.sql文件,然后使用DBeaver、pgAdmin或DataGrip等工具直接在数据库上执行它即可。

  • 优点:完全不需要进行任何设置。你可以完全控制脚本的语法结构,而且所有后端开发人员都懂得如何编写SQL语句。

  • 缺点:

    这种方法根本不提供执行过程的跟踪机制。因此,不同环境之间的模式差异几乎不可避免,而且部署过程也会变得非常繁琐,因为这完全依赖于人类来确保按照正确的顺序执行相应的脚本。

  • 结论:

    对于个人在周末进行的简单项目或快速原型开发来说,手动SQL脚本是完全可行的。但一旦有第二位开发者加入团队,或者建立了测试环境,这种方法就会变成一个巨大的隐患。

2. Flyway(SQL纯化工具)

Flyway是Liquibase之外最受欢迎的选择。它不使用XML或YAML这种抽象层,而是直接使用原始SQL语句。你需要按照严格的命名规则来编写SQL文件(例如:V1__Create_employee_table.sql)。

  • 优点:无需学习新的语法结构。只要你会写SQL,就能立即使用Flyway。它的配置过程非常快速,而且与Spring Boot的集成也非常完美。

  • 缺点:由于直接使用原始SQL语句,迁移操作会紧密依赖于特定的数据库方言。如果你为MySQL编写了Flyway脚本,后来又决定将项目迁移到PostgreSQL上,那么就必须手动重新编写所有的迁移脚本。此外,在Flyway的商业版本中,自动回滚功能是需要付费才能使用的。

  • 总结:Flyway非常适合那些精通SQL、长期使用同一数据库厂商的产品、并且更重视严格规范而非灵活配置的团队。

3. Liquibase(抽象层工具)

正如我们在本教程中所学到的,Liquibase采用了一种不同的方法:它将数据库变更信息抽象为XML、YAML或JSON格式。

  1. 优点:Liquibase真正实现了与特定数据库系统的解耦。你只需定义逻辑结构,Liquibase会自动将其转换为适用于H2、PostgreSQL或Oracle等数据库的SQL语句。此外,它还提供了强大的自动回滚功能、前置条件检查机制以及部署标签等功能,而且这些功能都是免费提供的。

  2. 缺点:与Flyway相比,Liquibase的学习曲线更为陡峭。它的XML语法相对繁琐,对于那些只需要进行简单操作(如创建单个表)的项目来说,使用起来会显得比较麻烦。

  3. 总结:Liquibase在处理复杂的应用程序、多租户系统、支持多种数据库厂商的项目,以及需要精细控制CI/CD部署流程的企业环境中表现更为出色。

Liquibase的最佳实践

现在你已经了解了Liquibase的基本工作原理,接下来就需要知道如何在专业环境中使用它。仅仅在本地机器上编写能够正常运行的迁移脚本还不够,要想让整个团队都能安全地将这些脚本部署到生产环境中,就必须遵守一些规范的编程流程。

以下是在管理数据库变更时应该遵循的最佳实践。

1. 每个变更集只包含一个逻辑操作(原子性规则)

我们在前面讨论过常见的错误,但这一点仍然非常重要,需要再次强调:千万不要将创建表、创建索引和插入数据这些操作合并到同一个变更集中。

如果你要添加一个薪资字段并创建一个idx_employee_salary索引,应该把这两个操作分别放在同一个文件中的两个不同的变更集中。这样,即使索引创建失败,薪资字段的创建操作仍然能够被成功记录下来,从而避免导致数据库状态混乱的情况发生。

2. 有意义的文件组织与命名

请不要将文件命名为update1.xmlnew_changes.xml。文件名应该能够反映数据库的变化过程。

应采用严格的命名前缀规则。在我们的项目中,我们使用了01-create-employees.xml02-add-employee-email.xml这样的文件名。在实际情况中,你们也可以使用Jira工单编号或版本号来命名文件(例如v1.2.0_ticket-482_add_email.xml)。无论选择哪种规则,在代码审查时都必须严格执行。

3. 将数据库变更视为应用程序代码来处理

数据库迁移脚本应该与Java代码一起被纳入版本控制系统中。对这些脚本的审核也应采用同样的严格标准。

在审查包含Liquibase文件的拉取请求时,工程师们应当询问以下问题:

  • 这个字段是否需要创建索引?

  • 这种变更是否会导致当前应用程序出现故障(比如修改字段名称)?

  • 作者是否为自定义SQL语句提供了明确的回滚方案?

4. 将数据库迁移集成到持续集成/持续部署流程中

绝对不能由人工在生产环境中执行数据库迁移操作。这些任务应该由自动化部署流程来完成。

当您将代码合并到主分支时,CI/CD流程(如GitHub Actions或GitLab CI)会自动构建并部署Spring Boot应用程序。由于我们在应用程序的启动流程中加入了Liquibase脚本,因此应用程序在开始接收网络请求之前会自动完成数据库迁移操作。

下面是一个安全的自动化部署流程示意图:

CI/CD流程图:代码从Git仓库传输到测试环境,然后在预发布环境中运行Liquibase进行迁移,最终部署到生产环境。

在成熟的部署流程中,人类永远不会直接操作生产环境中的数据库。当合并拉取请求后,CI/CD流程会自动构建代码并运行单元测试,然后将Spring Boot应用程序部署到预发布环境。在那里,Liquibase会自动执行迁移操作;一旦验证通过,相同的部署流程就会在生产环境中再次被重复执行。

5. 绝不要通过删除历史记录来掩盖问题

如果某个数据库迁移在预发布环境或生产环境中失败了,切勿直接登录数据库删除DATABASECHANGELOG表中的相关记录,从而试图重新执行迁移操作。

<你必须遵守变更日志的不可修改性。如果你犯了错误,就需要创建一个新的变更集,该变更集要么删除有问题的表,要么修正数据类型的问题,然后按照处理Java代码中的bug的方式,将这个新的变更集提交到你的Git工作流程中。>

最后的思考

管理数据库模式的变化并不一定会导致焦虑。

如果将数据库模式视为代码来处理,就能避免手动编写SQL脚本所带来的混乱。这样也能有效防止“模式漂移”现象——即不同开发者的本地环境会导致相同的数据库模式出现差异。最重要的是,这样做能让部署过程变得可预测且流程化(而这正是我们希望看到的)。

通过这个教程,你从零开始构建了一个实用的Spring Boot应用程序。你了解了Liquibase是如何在应用程序启动时拦截相关操作、锁定数据库、计算加密校验和,并安全地应用增量变更的。你还学会了如何将一个简单的表结构发展成复杂的关系型数据库模式,如何添加初始数据,以及如何避免初学者常会犯的一些错误。

下次当你开始一个新的Spring Boot项目时,不要再使用手动SQL工具了。只需添加Liquibase依赖项,创建主变更日志文件,从项目一开始就对数据库进行版本控制。未来的你(以及你的团队)一定会为此感到庆幸的。

Comments are closed.