Skip to content

Spring 内嵌数据库支持:轻量级开发与测试的利器 🚀

什么是内嵌数据库?为什么需要它?

想象一下,你正在开发一个 Spring Boot 应用,需要进行数据库相关的开发和测试。传统做法是安装一个完整的数据库服务器(如 MySQL、PostgreSQL),但这会带来很多麻烦:

  • 🔧 环境配置复杂:每个开发者都需要安装和配置数据库
  • 启动时间长:数据库服务器启动需要时间
  • 🧪 测试数据污染:测试之间可能相互影响
  • 📦 部署依赖重:需要额外的数据库服务器资源

NOTE

内嵌数据库就像是一个"便携式数据库",它直接运行在你的应用进程中,无需外部数据库服务器,完美解决了上述痛点。

Spring 内嵌数据库的核心价值

Spring Framework 通过 org.springframework.jdbc.datasource.embedded 包提供了强大的内嵌数据库支持,原生支持三种主流内嵌数据库:

  • HSQL:历史悠久,稳定可靠
  • H2:性能优秀,功能丰富(推荐)
  • Derby:Apache 出品,企业级支持

核心优势一览

开发阶段的最佳伙伴

  • 轻量级:无需外部数据库服务器
  • 快速启动:几毫秒内完成数据库初始化
  • 易于配置:几行代码即可搞定
  • 测试友好:每个测试都可以有独立的数据库实例
  • 快速迭代:SQL 结构变更无需复杂的迁移过程

创建内嵌数据库的三种方式

方式一:Java 配置(推荐)

kotlin
@Configuration
class DataSourceConfig {
    
    @Bean
    fun dataSource(): DataSource {
        return EmbeddedDatabaseBuilder()
            .generateUniqueName(true)  
            .setType(EmbeddedDatabaseType.H2)  
            .addScripts("schema.sql", "test-data.sql")  
            .build()
    }
}
java
@Configuration
public class DataSourceConfig {
    
    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
            .generateUniqueName(true)  
            .setType(EmbeddedDatabaseType.H2)  
            .addScripts("schema.sql", "test-data.sql")  
            .build();
    }
}

方式二:XML 配置

xml
<jdbc:embedded-database id="dataSource" generate-name="true" type="H2">
    <jdbc:script location="classpath:schema.sql"/>
    <jdbc:script location="classpath:test-data.sql"/>
</jdbc:embedded-database>

方式三:直接构建(测试场景)

kotlin
class DatabaseTest {
    
    private lateinit var database: EmbeddedDatabase
    
    @BeforeEach
    fun setUp() {
        database = EmbeddedDatabaseBuilder()
            .generateUniqueName(true)
            .setType(EmbeddedDatabaseType.H2)
            .addDefaultScripts()  // 自动加载 schema.sql 和 data.sql
            .build()
    }
    
    @AfterEach
    fun tearDown() {
        database.shutdown()  
    }
}

IMPORTANT

记住在测试结束后调用 database.shutdown() 来释放资源,避免内存泄漏。

内嵌数据库类型选择指南

H2 数据库(强烈推荐)🌟

kotlin
@Bean
fun h2DataSource(): DataSource {
    return EmbeddedDatabaseBuilder()
        .setType(EmbeddedDatabaseType.H2)  
        .addScript("classpath:schema.sql")
        .build()
}

为什么选择 H2?

  • 🚀 性能卓越:内存模式下读写速度极快
  • 🔧 功能丰富:支持多种 SQL 标准
  • 🌐 Web 控制台:提供便捷的数据库管理界面
  • 📱 兼容性好:与主流数据库语法高度兼容

HSQL 数据库

kotlin
@Bean
fun hsqlDataSource(): DataSource {
    return EmbeddedDatabaseBuilder()
        .setType(EmbeddedDatabaseType.HSQL)  
        .addScript("classpath:schema.sql")
        .build()
}

HSQL 特点:

  • 📚 历史悠久:Spring 默认选择,稳定可靠
  • 🎯 简单直接:适合简单的测试场景

Derby 数据库

kotlin
@Bean
fun derbyDataSource(): DataSource {
    return EmbeddedDatabaseBuilder()
        .setType(EmbeddedDatabaseType.DERBY)  
        .addScript("classpath:schema.sql")
        .build()
}

Derby 特点:

  • 🏢 企业级:Apache 出品,适合企业环境
  • 🔒 事务支持:完整的 ACID 事务支持

实战场景:电商订单系统测试

让我们通过一个具体的电商订单系统来看看内嵌数据库的威力:

数据库结构定义

schema.sql - 数据库表结构
sql
-- 用户表
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 商品表
CREATE TABLE products (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    stock INT NOT NULL DEFAULT 0
);

-- 订单表
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

测试数据准备

test-data.sql - 测试数据
sql
-- 插入测试用户
INSERT INTO users (username, email) VALUES 
('john_doe', '[email protected]'),
('jane_smith', '[email protected]');

-- 插入测试商品
INSERT INTO products (name, price, stock) VALUES 
('iPhone 15', 999.99, 10),
('MacBook Pro', 2499.99, 5),
('AirPods', 199.99, 20);

-- 插入测试订单
INSERT INTO orders (user_id, total_amount, status) VALUES 
(1, 999.99, 'COMPLETED'),
(2, 2499.99, 'PENDING');

订单服务实现

kotlin
@Service
class OrderService(
    private val jdbcTemplate: JdbcTemplate
) {
    
    fun createOrder(userId: Long, productId: Long, quantity: Int): Order {
        // 检查库存
        val stock = jdbcTemplate.queryForObject(
            "SELECT stock FROM products WHERE id = ?", 
            Int::class.java, 
            productId
        ) ?: throw ProductNotFoundException("Product not found")
        
        if (stock < quantity) {  
            throw InsufficientStockException("Not enough stock")
        }
        
        // 获取商品价格
        val price = jdbcTemplate.queryForObject(
            "SELECT price FROM products WHERE id = ?", 
            BigDecimal::class.java, 
            productId
        ) ?: throw ProductNotFoundException("Product not found")
        
        val totalAmount = price.multiply(BigDecimal.valueOf(quantity.toLong()))
        
        // 创建订单
        val keyHolder = GeneratedKeyHolder()
        jdbcTemplate.update({ connection ->
            val ps = connection.prepareStatement(
                "INSERT INTO orders (user_id, total_amount, status) VALUES (?, ?, ?)",
                Statement.RETURN_GENERATED_KEYS
            )
            ps.setLong(1, userId)
            ps.setBigDecimal(2, totalAmount)
            ps.setString(3, "PENDING")
            ps
        }, keyHolder)
        
        val orderId = keyHolder.key?.toLong() 
            ?: throw RuntimeException("Failed to create order")
        
        // 更新库存
        jdbcTemplate.update(
            "UPDATE products SET stock = stock - ? WHERE id = ?",
            quantity, productId
        )
        
        return Order(orderId, userId, totalAmount, "PENDING")
    }
    
    fun findOrdersByUserId(userId: Long): List<Order> {
        return jdbcTemplate.query(
            "SELECT * FROM orders WHERE user_id = ?",
            { rs, _ ->
                Order(
                    id = rs.getLong("id"),
                    userId = rs.getLong("user_id"),
                    totalAmount = rs.getBigDecimal("total_amount"),
                    status = rs.getString("status")
                )
            },
            userId
        )
    }
}

data class Order(
    val id: Long,
    val userId: Long,
    val totalAmount: BigDecimal,
    val status: String
)

完整的集成测试

kotlin
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class OrderServiceIntegrationTest {
    
    @Autowired
    private lateinit var orderService: OrderService
    
    @Autowired
    private lateinit var jdbcTemplate: JdbcTemplate
    
    @Test
    fun `should create order successfully when stock is sufficient`() {
        // Given: 有足够的库存
        val userId = 1L
        val productId = 1L  // iPhone 15, stock = 10
        val quantity = 2
        
        // When: 创建订单
        val order = orderService.createOrder(userId, productId, quantity)
        
        // Then: 订单创建成功
        assertThat(order.userId).isEqualTo(userId)
        assertThat(order.totalAmount).isEqualTo(BigDecimal("1999.98"))
        assertThat(order.status).isEqualTo("PENDING")
        
        // And: 库存正确减少
        val remainingStock = jdbcTemplate.queryForObject(
            "SELECT stock FROM products WHERE id = ?",
            Int::class.java,
            productId
        )
        assertThat(remainingStock).isEqualTo(8)  // 10 - 2 = 8
    }
    
    @Test
    fun `should throw exception when stock is insufficient`() {
        // Given: 库存不足
        val userId = 1L
        val productId = 2L  // MacBook Pro, stock = 5
        val quantity = 10   // 请求数量超过库存
        
        // When & Then: 应该抛出库存不足异常
        assertThrows<InsufficientStockException> {
            orderService.createOrder(userId, productId, quantity)
        }
    }
    
    @Test
    fun `should find orders by user id`() {
        // Given: 用户已有订单
        val userId = 1L
        
        // When: 查询用户订单
        val orders = orderService.findOrdersByUserId(userId)
        
        // Then: 返回正确的订单列表
        assertThat(orders).hasSize(1)
        assertThat(orders[0].totalAmount).isEqualTo(BigDecimal("999.99"))
        assertThat(orders[0].status).isEqualTo("COMPLETED")
    }
}

内嵌数据库的测试最佳实践

1. 数据库隔离策略

2. 解决数据库名称冲突

WARNING

如果不使用唯一名称生成,多个测试可能会尝试创建同名数据库,导致冲突!

kotlin
@Bean
fun dataSource(): DataSource {
    return EmbeddedDatabaseBuilder()
        .setType(EmbeddedDatabaseType.H2)
        // 没有设置唯一名称,可能导致冲突
        .build()
}
kotlin
@Bean
fun dataSource(): DataSource {
    return EmbeddedDatabaseBuilder()
        .generateUniqueName(true)  
        .setType(EmbeddedDatabaseType.H2)
        .build()
}

3. 测试模板模式

kotlin
abstract class DatabaseTestTemplate {
    
    protected lateinit var database: EmbeddedDatabase
    protected lateinit var jdbcTemplate: JdbcTemplate
    
    @BeforeEach
    fun setUpDatabase() {
        database = EmbeddedDatabaseBuilder()
            .generateUniqueName(true)
            .setType(EmbeddedDatabaseType.H2)
            .addScript("classpath:schema.sql")
            .addScript("classpath:test-data.sql")
            .build()
        
        jdbcTemplate = JdbcTemplate(database)
    }
    
    @AfterEach
    fun tearDownDatabase() {
        database.shutdown()
    }
}

// 具体测试类继承模板
class UserRepositoryTest : DatabaseTestTemplate() {
    
    @Test
    fun `should find user by username`() {
        // 使用继承的 jdbcTemplate 进行测试
        val user = jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE username = ?",
            { rs, _ -> User(rs.getLong("id"), rs.getString("username")) },
            "john_doe"
        )
        
        assertThat(user?.username).isEqualTo("john_doe")
    }
}

高级定制:自定义数据库配置

有时候你需要对内嵌数据库进行更精细的控制,比如使用自定义驱动或连接参数:

kotlin
@Configuration
class CustomDataSourceConfig {
    
    @Bean
    fun customDataSource(): DataSource {
        return EmbeddedDatabaseBuilder()
            .setDatabaseConfigurer(
                EmbeddedDatabaseConfigurers.customizeConfigurer(
                    EmbeddedDatabaseType.H2,
                    this::customizeH2
                )
            )
            .addScript("schema.sql")
            .build()
    }
    
    private fun customizeH2(defaultConfigurer: EmbeddedDatabaseConfigurer): EmbeddedDatabaseConfigurer {
        return object : EmbeddedDatabaseConfigurerDelegate(defaultConfigurer) {
            override fun configureConnectionProperties(
                properties: ConnectionProperties,
                databaseName: String
            ) {
                super.configureConnectionProperties(properties, databaseName)
                // 自定义连接属性
                properties.setDriverClass(CustomH2Driver::class.java)  
                properties.setUrl("jdbc:h2:mem:$databaseName;MODE=MySQL")  
            }
        }
    }
}

扩展内嵌数据库支持

Spring 提供了两种扩展方式:

1. 支持新的数据库类型

kotlin
class SQLiteEmbeddedDatabaseConfigurer : EmbeddedDatabaseConfigurer {
    
    override fun configureConnectionProperties(
        properties: ConnectionProperties,
        databaseName: String
    ) {
        properties.setDriverClass(SQLiteDriver::class.java)
        properties.setUrl("jdbc:sqlite:memory:$databaseName")
        properties.setUsername("")
        properties.setPassword("")
    }
    
    override fun shutdown(dataSource: DataSource, databaseName: String) {
        // SQLite 特定的关闭逻辑
    }
}

2. 支持连接池

kotlin
class PooledDataSourceFactory : DataSourceFactory {
    
    override fun getConnectionProperties(): ConnectionProperties {
        return ConnectionProperties()
    }
    
    override fun createDataSource(
        properties: ConnectionProperties,
        databaseName: String
    ): DataSource {
        val pooledDataSource = HikariDataSource()
        pooledDataSource.jdbcUrl = properties.url
        pooledDataSource.username = properties.username
        pooledDataSource.password = properties.password
        pooledDataSource.maximumPoolSize = 10
        return pooledDataSource
    }
}

总结与最佳实践 🎯

内嵌数据库是 Spring 生态中的一颗明珠,它完美解决了开发和测试阶段的数据库痛点:

核心价值回顾

  1. 开发效率提升:无需复杂的数据库环境搭建
  2. 测试质量保证:每个测试都有独立、干净的数据库环境
  3. 部署简化:减少外部依赖,降低部署复杂度
  4. 快速迭代:数据库结构变更无需复杂的迁移过程

最佳实践清单

开发阶段

  • ✅ 优先选择 H2 数据库,性能和功能兼备
  • ✅ 始终使用 generateUniqueName(true) 避免命名冲突
  • ✅ 将 SQL 脚本文件放在 src/main/resources 下便于管理
  • ✅ 使用有意义的脚本命名:schema.sqldata.sqltest-data.sql

测试阶段

  • ✅ 为每个测试类创建独立的数据库实例
  • ✅ 在 @AfterEach 中及时关闭数据库释放资源
  • ✅ 使用测试模板模式减少重复代码
  • ✅ 结合 Spring TestContext Framework 实现数据库共享

注意事项

  • ⚠️ 内嵌数据库仅适用于开发和测试,生产环境请使用专业数据库
  • ⚠️ 大量数据的性能测试应该使用真实数据库环境
  • ⚠️ 注意内存使用,避免在内存中存储过多测试数据

通过合理使用 Spring 的内嵌数据库支持,你可以构建出更加健壮、高效的应用程序,让开发和测试变得更加愉快! 🚀