Spring Boot 自动执行 SQL 脚本:从原生配置到 Flyway 实战
2026/9/8 17:08:48 网站建设 项目流程

做后端这几年,我越来越觉得 Spring Boot 项目的起步工作里,最绕不开的一环就是数据库初始化。大家应该都遇到过这种场景:代码拉下来,启动类一跑,控制台直接抛一句table xxx doesn't 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 准备好之后再执行”这类比较拧巴的场景,就用ScriptUtilsResourceDatabasePopulator在代码里显式控制。

所以下面的内容我也按照这个顺序来展开:先讲 Spring Boot 原生自动化配置,再讲代码里的手动控制,最后讲生产可用的迁移工具。每一条我都尽量说清楚原理和为什么这样选。

2. Spring Boot 原生自动导入完整实操

2.1 理解 spring.sql.init 的默认机制

很多同学第一次接触 Spring Boot 自动执行 SQL 文件,会看到两代不同的配置。Spring Boot 2.5 之前,用的是spring.datasource.initialization-mode=always;2.5 之后,官方把相关属性统一改成了spring.sql.init.mode=always。如果你照着老文章配,代码可能还能跑,但新版日志里会告诉你这个属性已经废弃,所以新项目我建议直接使用spring.sql.init.*这一组配置。

这套机制底层靠的是一个叫DataSourceScriptDatabaseInitializer的东西。它的逻辑并不复杂:Spring Boot 在数据源准备完成后、业务 Bean 正式对外提供服务之前,去指定的 classpath 下找 SQL 脚本文件,把文件里的 SQL 语句逐条解析出来,然后通过这个数据源拿到的连接执行。默认情况下,如果 Spring Boot 发现你用的是内嵌数据库,比如 H2、HSQLDB、Derby,它会自动去执行位于 classpath 根目录下的schema.sqldata.sql。如果你配置的是外部数据库,比如 MySQL、PostgreSQL、SQL Server,那默认模式不会执行脚本,必须显式把 mode 设成always

这里有一个很容易被忽略的细节:Spring Boot 默认只在“内嵌数据库”场景下自动执行脚本,目的很简单,就是避免误操作把外部数据库的表结构给改了。可我们实际项目里用的基本全是外部数据库,所以每次写配置都要记得检查 mode。我把常用的几个配置属性整理成了表格,方便你用的时候快速查阅。

配置项默认值作用
spring.sql.init.modeembedded脚本执行策略,可选 always、embedded、never
spring.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?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true 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 连接串上没加characterEncoding=UTF-8,即使文件编码是对的,到了 MySQL 这边也可能因为连接层编码不对导致乱码。

所以我的建议是:SQL 文件统一用 UTF-8 无 BOM 格式保存,Spring Boot 配置里显式写上spring.sql.init.encoding: UTF-8,MySQL 连接串里同时带上characterEncoding=UTF-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_CHECKS=0这个会话级变量在脚本执行期间是有效的。如果换成了连接池里多条连接去执行,那这个设置就未必管用了。

第二种思路,也是我更推荐的,就是直接把脚本里的语句按依赖顺序写对,先删子表、再删父表;建表时先建主表、再建从表。对于视图,情况更特殊,因为视图依赖底层表结构,如果脚本里同时有建表和建视图的语句,视图必须放在表结构创建完成之后。把这种依赖关系用文件顺序固化下来,会比依赖会话变量更可靠。

3.4 多个 SQL 文件的执行顺序怎么控制

实际项目里,一个初始化脚本往往不是单独一个文件。我的一个老项目里,数据库脚本被分成了01_schema.sql02_dict.sql03_menu.sql04_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,不就一劳永逸了?问题是,这个方案只能做“全量初始化”,做不了“增量变更管理”。

想象一下,你的项目已经上线三个月

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询