Spring Boot 自动执行 SQL 脚本:从原生配置到 Flyway 实战

发布时间:2026/9/8 17:08:52
Spring Boot 自动执行 SQL 脚本:从原生配置到 Flyway 实战 做后端这几年我越来越觉得 Spring Boot 项目的起步工作里最绕不开的一环就是数据库初始化。大家应该都遇到过这种场景代码拉下来启动类一跑控制台直接抛一句table xxx doesnt exist翻了一圈才发现同事早把建表 SQL 放到一个没人看过的目录里需要手动打开数据库客户端去执行。又或者是接入了某个第三方组件官方文档丢给你一份几百行的 SQL 文件让你“启动前先执行一下”然后每个新同事都要重复一遍同样的手工操作。这篇文章就来讲讲我平时在 Spring Boot 项目里直接导入 SQL 文件并且顺手在启动阶段“自动使用”的几种做法。内容不深但覆盖了我踩过不止一次的坑也包含了一些从骨感现实里总结出来的经验。适合刚接触 Spring Boot、正在被数据库脚本初始化折腾的同学也适合团队里需要搭一套初始化规范的同学参考。下面我们直接进入正题。1. 直接导入 SQL 文件的场景与路径选择1.1 你通常在什么情况下需要它先想清楚一个问题我们到底在什么场景下需要让 Spring Boot 去执行 SQL 文件而不是打开 Navicat、DataGrip 或者命令行手动跑最常见的一类需求是本地开发。一个项目多人协作每个人的数据库连接串不一样如果你总是靠口头通知“把 sql 目录下的脚本导入一下”那基本不靠谱。总有人会漏掉某个文件也总有人会在错误的库上执行最后只会换来一句“我这边起不来”。第二类是测试环境和演示环境应用每次部署后都需要有稳定的基础数据比如字典项、菜单权限、初始管理员账号这些数据要是每次都要有人手动导那自动化部署就名存实亡了。第三类是集成第三方框架很多中间件会在官方文档里附带初始化 SQL比如定时任务框架、流程引擎、权限框架的默认表结构如果拿到的是一份官方脚本而不是依赖框架自动建表就需要一个统一的导入通道。这些场景有一个共同点SQL 文件本身应该是项目的一部分导入动作应该尽量可重复、可自动化。人工执行不是不行只是一旦环境数量变多人工操作就会成为最大的不稳定因素。1.2 常见的几条技术路线对比我梳理了一下目前主流的做法基本可以分成四条路线。方案执行时机适合场景主要特点原生 spring.sql.init应用启动时自动执行本地开发、演示环境、一次性初始化配置最简单但缺少版本记录和变更管理ScriptUtils / ResourceDatabasePopulator代码里手动触发需要控制执行时机或动态加载脚本Spring JDBC 内置工具灵活但需要自己写代码Flyway / Liquibase应用启动时自动迁移生产、测试、多环境版本化迁移有历史记录、checksum、增量脚本管理最规范Navicat / mysql 命令行手动导入人工操作临时补数据、运维排障灵活但不可自动化不适合作为团队规范说实话手动导入是绝大多数项目的起点它并不坏问题是没法规模化。当你只有一个本地库、一份简单脚本的时候手动跑一次完全没问题。可一旦项目要上测试环境、预发环境或者开始多人协作手动执行就会变成一件不确定事件今天少了这张表明天缺了一列字段后天初始化数据插重了全都要靠人肉排查。1.3 我的选型结论我的习惯是分层处理本地小项目或者一次性 Demo直接用 Spring Boot 原生的spring.sql.init配置短、见效快一旦脚本需要跟代码一起维护、需要在多套环境里反复执行甚至要考虑版本升级我就会切换到 Flyway不自己造轮子。如果遇到“必须在某个特定 Bean 准备好之后再执行”这类比较拧巴的场景就用ScriptUtils或ResourceDatabasePopulator在代码里显式控制。所以下面的内容我也按照这个顺序来展开先讲 Spring Boot 原生自动化配置再讲代码里的手动控制最后讲生产可用的迁移工具。每一条我都尽量说清楚原理和为什么这样选。2. Spring Boot 原生自动导入完整实操2.1 理解 spring.sql.init 的默认机制很多同学第一次接触 Spring Boot 自动执行 SQL 文件会看到两代不同的配置。Spring Boot 2.5 之前用的是spring.datasource.initialization-modealways2.5 之后官方把相关属性统一改成了spring.sql.init.modealways。如果你照着老文章配代码可能还能跑但新版日志里会告诉你这个属性已经废弃所以新项目我建议直接使用spring.sql.init.*这一组配置。这套机制底层靠的是一个叫DataSourceScriptDatabaseInitializer的东西。它的逻辑并不复杂Spring Boot 在数据源准备完成后、业务 Bean 正式对外提供服务之前去指定的 classpath 下找 SQL 脚本文件把文件里的 SQL 语句逐条解析出来然后通过这个数据源拿到的连接执行。默认情况下如果 Spring Boot 发现你用的是内嵌数据库比如 H2、HSQLDB、Derby它会自动去执行位于 classpath 根目录下的schema.sql和data.sql。如果你配置的是外部数据库比如 MySQL、PostgreSQL、SQL Server那默认模式不会执行脚本必须显式把 mode 设成always。这里有一个很容易被忽略的细节Spring Boot 默认只在“内嵌数据库”场景下自动执行脚本目的很简单就是避免误操作把外部数据库的表结构给改了。可我们实际项目里用的基本全是外部数据库所以每次写配置都要记得检查 mode。我把常用的几个配置属性整理成了表格方便你用的时候快速查阅。配置项默认值作用spring.sql.init.modeembedded脚本执行策略可选 always、embedded、neverspring.sql.init.schema-locationsoptional:classpath*:schema.sql指定建表脚本位置spring.sql.init.data-locationsoptional:classpath*:data.sql指定初始数据脚本位置spring.sql.init.encoding系统默认编码读取 SQL 文件时使用的编码spring.sql.init.separator;脚本语句分隔符spring.sql.init.continue-on-errorfalse执行出错时是否继续执行后续语句spring.sql.init.username无执行脚本时使用的独立数据库账号spring.sql.init.password无执行脚本时使用的独立数据库密码看到这个表格你应该能感觉到Spring Boot 原生方案的定位就是“能用、够简单”它不会帮你做版本管理也不会记录哪些脚本已经执行过。每次启动如果 mode 是 always它都会按照配置重新执行一遍脚本所以脚本本身的幂等性就得靠你自己保证。2.2 一个可以直接复制的 MySQL 示例下面我直接给你一个 MySQL 环境下可用的完整配置。假设项目结构里我把 SQL 文件统一放在src/main/resources/db/目录下。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/spring_demo?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/ShanghaicreateDatabaseIfNotExisttrue username: root password: 123456 sql: init: mode: always schema-locations: classpath:db/schema.sql >DROP TABLE IF EXISTS sys_user; CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL COMMENT 用户名, nickname VARCHAR(64) DEFAULT NULL COMMENT 昵称, status TINYINT DEFAULT 1 COMMENT 状态 1正常 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 用户表;db/data.sql内容示例INSERT INTO sys_user (username, nickname, status) VALUES (admin, 管理员, 1); INSERT INTO sys_user (username, nickname, status) VALUES (test, 测试账号, 0);如果你只是本地开发用这套配置基本上够了。启动项目以后Spring Boot 会按顺序先执行schema.sql里建表语句再执行data.sql里的初始数据插入。我建议你观察一下控制台日志如果看到类似的输出就说明脚本已经执行成功。不过这里要提醒一句schema.sql里我用了DROP TABLE IF EXISTS它的作用就是让脚本可以被重复执行但代价是每次启动都会把表删掉重建。如果是本地开发这样很清爽如果这个库里有你不能丢的数据千万别这么写建议换成CREATE TABLE IF NOT EXISTS或者干脆迁移到 Flyway 去管理。2.3 用 ScriptUtils 和 DatabasePopulator 手动控制Spring Boot 的自动配置虽然方便但有些场景下它不够灵活。比如你想在项目启动完成之后、某个业务初始化逻辑执行到一半的时候再去加载某个 SQL 文件或者你希望从一个动态拼接的路径里去读文件这时候原生配置就有点力不从心了。Spring JDBC 给了一套底层工具叫ScriptUtils它就是前面spring.sql.init在背后真正干活的类。你可以直接在自定义代码里调用它这样执行时机就完全由你说了算。import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.support.EncodedResource; import org.springframework.jdbc.datasource.init.ScriptUtils; import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.nio.charset.StandardCharsets; import java.sql.Connection; Component public class DbScriptRunner implements ApplicationRunner { private final DataSource dataSource; public DbScriptRunner(DataSource dataSource) { this.dataSource dataSource; } Override public void run(ApplicationArguments args) throws Exception { try (Connection connection dataSource.getConnection()) { ScriptUtils.executeSqlScript(connection, new EncodedResource(new ClassPathResource(db/menu.sql), StandardCharsets.UTF_8)); } } }这段代码的意思很简单应用启动完成以后从 classpath 下读取db/menu.sql在同一个数据库连接里一条一条执行里面的语句。EncodedResource可以显式指定编码这一步在解决中文乱码问题时很关键。如果你有多个脚本要执行并且希望它们按顺序执行、错误处理策略一致用ResourceDatabasePopulator会更顺手它相当于把一批脚本包装成了一个统一的任务。ResourceDatabasePopulator populator new ResourceDatabasePopulator(); populator.addScript(new ClassPathResource(db/schema.sql)); populator.addScript(new ClassPathResource(db/data.sql)); populator.setContinueOnError(false); populator.setSqlScriptEncoding(UTF-8); try (Connection connection dataSource.getConnection()) { populator.execute(connection); }用代码手动执行的好处是灵活坏处是这些脚本不会像迁移工具那样被记录。如果应用重启了这段代码又跑了一次脚本里如果写了不带判断的CREATE TABLE就会直接报错。所以写这种自定义 Runner 时一定要让脚本自己具备幂等性或者用一张自定义记录表去控制开关。2.4 schema 和 data 为什么要分开Spring Boot 的习惯是把建表脚本和数据脚本分开放schema.sql管表结构data.sql管数据这不仅仅是代码规范也是为了让执行策略更容易控制。表结构变更和数据填充的风险等级完全不同。比如你想在现有表上新增一个字段这会改变表结构直接把整个 schema 脚本重放一遍数据库会报重复创建的错误而数据脚本更多是往表里插入字典、初始账号这类内容重复执行时可能因为主键冲突而爆炸。分开放以后你可以只针对某一种需求去调整。比如在测试环境你可能想每次启动都清空业务表然后重新插入测试数据希望 data 脚本重复执行但生产环境你绝对不希望应用一启动就把表删了重建。基于这个区别Spring Boot 原生配置允许你通过不同 profile 去定制。比如在 application-dev.yml 里配置 mode: always在 application-prod.yml 里配置 mode: never这样就从配置层面杜绝了误操作。如果你把建表和数据混在一个文件里这种控制粒度就很难做到了。3. SQL 文件真正执行时容易踩的坑3.1 文件编码一个看似不起眼的乱码来源讲一个我亲身经历过的案例。有次同事把一份包含中文备注的初始化 SQL 发到群里我下载下来后用 IntelliJ IDEA 打开一切正常放到 Spring Boot 里一执行数据库里的中文全变成了???甚至偶尔会把第一条 SQL 语法弄坏。后来排查发现问题出在两个地方。第一是文件的保存编码那台 Windows 机器默认把文件保存成了 GBK但 Spring Boot 以 UTF-8 去读中文字符自然就乱了。第二是 JDBC 连接串上没加characterEncodingUTF-8即使文件编码是对的到了 MySQL 这边也可能因为连接层编码不对导致乱码。所以我的建议是SQL 文件统一用 UTF-8 无 BOM 格式保存Spring Boot 配置里显式写上spring.sql.init.encoding: UTF-8MySQL 连接串里同时带上characterEncodingUTF-8建表语句统一使用utf8mb4字符集。这三层都对齐以后中文基本不会再出问题。还有一个容易被忽略的细节是 BOM 头。Windows 下某些编辑器会把文件保存成 UTF-8 with BOM文件开头会有一个看不见的\ufeff字符。这个字符出现在 SQL 文件里可能会导致第一条语句解析失败报一个看起来很诡异的语法错误。如果你用命令行导入没问题但用 Spring Boot 执行就报错可以先查查文件是不是带 BOM。3.2 存储过程、函数和 DELIMITER 的坑Spring Boot 原生脚本执行器解析 SQL 文件的方式是按分隔符把整个文件切成一条一条的语句。默认的分隔符是分号每条语句单独通过 JDBC 发送给数据库执行。这个机制本身没问题但你一旦在脚本里放了存储过程、函数或者触发器麻烦就来了。因为这类对象的定义体内部本身就包含分号执行器会误以为函数体里的分号是一条语句的结束结果一条完整的CREATE PROCEDURE被切成了好几段数据库就会报语法错误。MySQL 的客户端工具为了解决这个问题引入了DELIMITER指令用来临时把语句分隔符改成别的符号比如$$。但 Spring Boot 自带的脚本解析逻辑并不可靠它并不能像 Navicat 那样完整模拟客户端会话行为。我踩过这个坑之后现在的原则很简单如果 SQL 文件里只有建表、建索引、插入数据放心交给 Spring Boot 执行如果文件里包含存储过程、函数、触发器这类复杂对象我就不会硬塞给脚本执行器而是改用数据库客户端在目标库上单独执行一次或者把这类变更交给 Flyway由迁移工具在更接近数据库原生语义的环境里处理。在原生配置里虽然也有spring.sql.init.separator这个属性你可以把它改成一个特殊字符串来避免和函数体里的分号冲突但这不是银弹还容易把脚本里其他正常分号语句搞乱。为了省心遇到复杂脚本该人工导入就人工导入别为了追求“全自动”把时间花在调试解析器上。3.3 外键、视图与脚本执行顺序如果你的数据库表之间存在外键关系那 SQL 文件的执行顺序就不是随意的。Spring Boot 初始化脚本和你在客户端里手动执行的差异之一是它不会自动帮你处理外键检查。举个例子如果你先删父表再删子表MySQL 可能直接报外键约束错误反过来先建子表后建主表同样也可能失败。对于 MySQL解决思路有两种。第一种是在脚本开头关闭外键检查在脚本结尾重新打开。SET FOREIGN_KEY_CHECKS 0; DROP TABLE IF EXISTS order_detail; DROP TABLE IF EXISTS product; DROP TABLE IF EXISTS sys_user; CREATE TABLE product (...); CREATE TABLE order_detail (...); SET FOREIGN_KEY_CHECKS 1;这里有一个隐含前提Spring Boot 执行脚本时通常是同一条数据库连接上顺序执行这些语句所以SET FOREIGN_KEY_CHECKS0这个会话级变量在脚本执行期间是有效的。如果换成了连接池里多条连接去执行那这个设置就未必管用了。第二种思路也是我更推荐的就是直接把脚本里的语句按依赖顺序写对先删子表、再删父表建表时先建主表、再建从表。对于视图情况更特殊因为视图依赖底层表结构如果脚本里同时有建表和建视图的语句视图必须放在表结构创建完成之后。把这种依赖关系用文件顺序固化下来会比依赖会话变量更可靠。3.4 多个 SQL 文件的执行顺序怎么控制实际项目里一个初始化脚本往往不是单独一个文件。我的一个老项目里数据库脚本被分成了01_schema.sql、02_dict.sql、03_menu.sql、04_quartz.sql四个文件每个文件负责一块内容。Spring Boot 原生配置支持你通过列表指定多个文件位置顺序就按列表里的顺序来。spring: sql: init: mode: always schema-locations: - classpath:db/01_schema.sql - classpath:db/02_dict.sql这里我想提醒一句尽量别用classpath:db/*.sql这种通配符写法。虽然看起来方便但 Spring 在解析通配路径时匹配顺序并不保证和文件名数字顺序一致。今天你在本地跑得好好的明天换一台系统或者换一种部署方式顺序可能就变了。更稳妥的做法是显式列出文件位置文件名加上数字前缀纯粹为了让维护的人一眼看出依赖关系。如果你需要在两个文件之间插入一个不存在的文件或者某些环境没有某个脚本可以用optional:前缀比如optional:classpath:db/optional_data.sql。这样即使文件不存在Spring Boot 也不会把启动过程直接卡死。4. 从“能用”走向“生产可用”引入迁移工具4.1 为什么不能把 spring.sql.init 当生产同步工具看到这里可能有人会觉得既然 Spring Boot 原生配置这么简单那把 mode 设成 always不就一劳永逸了问题是这个方案只能做“全量初始化”做不了“增量变更管理”。想象一下你的项目已经上线三个月