Spring Boot 如何实现一个租户一个数据库的多租户应用
在现代软件开发中,多租户架构(Multi-Tenancy)是一种常见的解决方案,特别是在 SaaS(软件即服务)应用中。所谓多租户架构,就是通过一套应用程序为多个客户(租户)提供服务,同时确保数据隔离和安全性。在本文中,我们将深入探讨如何在 Spring Boot 中实现一种特定的多租户模式——Per-Tenant-Per-DB(每个租户一个数据库)。这种架构下,每个租户的数据存储在独立的数据库中,而应用程序通过动态路由管理所有数据库连接。所有租户数据库的表结构(Schema)保持一致。
本文将从项目初始化开始,逐步介绍实现步骤,并对代码和设计进行详细扩展和优化建议。让我们开始吧!
项目初始化
创建 Spring Boot 项目
我们首先通过 start.spring.io 创建一个 Spring Boot 项目,并添加以下依赖项:
spring-boot-starter-web
:用于构建 RESTful API。spring-boot-starter-data-jpa
:提供 JPA 支持,用于数据库操作。postgresql
:PostgreSQL 数据库驱动。
生成项目后,将其导入 IDE(如 IntelliJ IDEA 或 Eclipse)。如果你直接运行项目,会发现启动失败。这是因为 spring-boot-starter-data-jpa
需要一个数据库连接,而我们尚未配置任何数据源。
解决启动问题
为了让应用程序正常运行,我们需要定义数据库连接逻辑。Spring Boot 默认会寻找一个 DataSource
Bean,因此我们将创建一个配置类来管理所有租户的数据库连接。
配置多租户数据库连接
核心类:DBConnectionManager
以下是实现动态数据源路由的核心配置类:
@Configuration
public class DBConnectionManager {
private static final String POSTGRES_JDBC_DRIVER = "org.postgresql.Driver";
private static final String USERNAME = "postgres";
private static final String PASSWORD = "postgres";
private DataSource defaultDataSource;
private AbstractRoutingDataSource routingDataSource;
public AbstractRoutingDataSource getRoutingDataSource() {
return routingDataSource;
}
@Bean
public DataSource routingDataSource() {
routingDataSource = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return TenantResolver.getCurrentTenant();
}
};
Map<Object, Object> dataSourceMap = new HashMap<>(tenantDataSources());
routingDataSource.setDefaultTargetDataSource(defaultDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.afterPropertiesSet();
return routingDataSource;
}
private DataSource defaultDataSource() {
if (defaultDataSource == null) {
defaultDataSource = DataSourceBuilder.create()
.driverClassName(POSTGRES_JDBC_DRIVER)
.url("jdbc:postgresql://localhost:5432/postgres")
.username(USERNAME)
.password(PASSWORD)
.build();
}
return defaultDataSource;
}
private Map<String, DataSource> tenantDataSources() {
Map<String, DataSource> dataSourceMap = new HashMap<>();
try (Connection connection = defaultDataSource().getConnection()) {
PreparedStatement stmt = connection.prepareStatement(
"SELECT key, db_url, db_username, db_password FROM tenant");
ResultSet resultSet = stmt.executeQuery();
while (resultSet.next()) {
String tenantKey = resultSet.getString("key");
String dbUrl = resultSet.getString("db_url");
String dbUsername = resultSet.getString("db_username");
String dbPassword = resultSet.getString("db_password");
DataSource tenantDataSource = DataSourceBuilder.create()
.url(dbUrl)
.username(dbUsername)
.password(dbPassword)
.driverClassName(POSTGRES_JDBC_DRIVER)
.build();
try (Connection c = tenantDataSource.getConnection()) {
// 测试连接是否有效
} catch (SQLException e) {
e.printStackTrace();
}
dataSourceMap.put(tenantKey, tenantDataSource);
}
} catch (Exception e) {
e.printStackTrace();
}
return dataSourceMap;
}
}
代码解析
-
全局变量:
defaultDataSource
:默认数据源,用于连接默认数据库。routingDataSource
:基于AbstractRoutingDataSource
的动态路由数据源,用于管理所有租户的数据源。
-
动态路由逻辑:
routingDataSource()
方法创建并返回一个AbstractRoutingDataSource
Bean。determineCurrentLookupKey()
是关键方法,Spring 调用它来决定当前使用哪个数据源。我们通过TenantResolver.getCurrentTenant()
获取当前租户的标识(tenant key)。
-
数据源初始化:
defaultDataSource()
:创建默认数据库的连接。tenantDataSources()
:从默认数据库的tenant
表中读取租户信息,动态创建每个租户的数据源。
-
连接池:
- 默认情况下,Spring Boot 使用 HikariCP 作为连接池管理器,每个数据源都会被独立管理。
默认数据库与租户表
什么是默认数据库?
默认数据库是应用程序启动时连接的主数据库。它有两个主要作用:
- 备用连接:当没有指定租户时,应用程序使用默认数据库。
- 租户元数据存储:存储所有租户的连接信息。
我们需要在默认数据库中创建一个 tenant
表来存储租户信息。以下是表的 DDL:
CREATE TABLE IF NOT EXISTS tenant (
id BIGINT NOT NULL,
name VARCHAR(255),
key VARCHAR(255),
db_url VARCHAR(255),
db_username VARCHAR(255),
db_password VARCHAR(255),
CONSTRAINT tenant_pkey PRIMARY KEY (id)
);
示例数据
为测试多租户功能,我们在 tenant
表中插入两条记录:
INSERT INTO tenant (id, name, db_url, db_username, db_password, key)
VALUES (1, 'tenant one', 'jdbc:postgresql://localhost:5433/postgres', 'tenant1user', 'postgres', 'tenant1');
INSERT INTO tenant (id, name, db_url, db_username, db_password, key)
VALUES (2, 'tenant two', 'jdbc:postgresql://localhost:5434/postgres', 'tenant2user', 'postgres', 'tenant2');
注意:为了简化示例,密码以明文存储在数据库中。在生产环境中,建议使用密钥管理服务(如 AWS Secrets Manager 或 HashiCorp Vault)存储敏感信息。
设置开发环境
使用 Docker 运行 PostgreSQL
为了模拟多租户环境,我们使用 Docker 运行三个 PostgreSQL 实例:
-
默认数据库:
docker run -itd -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 --name postgresql postgres
-
租户 1 数据库:
docker run -itd -e POSTGRES_USER=tenant1user -e POSTGRES_PASSWORD=postgres -p 5433:5432 --name postgresql1 postgres
-
租户 2 数据库:
docker run -itd -e POSTGRES_USER=tenant2user -e POSTGRES_PASSWORD=postgres -p 5434:5432 --name postgresql2 postgres
启动后,应用程序会通过 DBConnectionManager
连接这三个数据库。
定义实体与 API
创建 Person
实体
我们定义一个简单的 Person
实体,用于在租户数据库中存储数据:
@Entity
@Table(name = "person")
public class Person {
@Id
private Long id;
private String name;
private String age;
// Getters 和 Setters
}
在租户数据库中创建表
在两个租户数据库中分别创建 person
表并插入测试数据:
CREATE TABLE IF NOT EXISTS person (
id BIGINT NOT NULL,
name VARCHAR(255),
age VARCHAR(255),
CONSTRAINT person_pkey PRIMARY KEY (id)
);
-- 租户 1 数据
INSERT INTO person (id, name, age) VALUES (1, 'rav', '34');
INSERT INTO person (id, name, age) VALUES (2, 'ron', '23');
-- 租户 2 数据
INSERT INTO person (id, name, age) VALUES (1, 'ramesh', '11');
INSERT INTO person (id, name, age) VALUES (2, 'suresh', '39');
创建 REST API
在 PersonController
中定义一个简单的 GET 接口:
@RestController
@RequestMapping("/persons")
public class PersonController {
@Autowired
private PersonRepository personRepository;
@GetMapping
public List<Person> getAllPersons() {
return personRepository.findAll();
}
}
对应的 PersonRepository
:
@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
}
租户解析与过滤器
TenantResolver
类
为了动态确定当前租户,我们需要一个 TenantResolver
类,使用 ThreadLocal
存储租户标识:
public class TenantResolver {
private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setCurrentTenant(String tenantKey) {
currentTenant.set(tenantKey);
}
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void clear() {
currentTenant.remove();
}
}
TenantFilter
过滤器
通过一个 Servlet 过滤器从请求头中获取租户标识:
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantKey = httpRequest.getHeader("X-Tenant-Key");
if (tenantKey != null) {
TenantResolver.setCurrentTenant(tenantKey);
}
try {
chain.doFilter(request, response);
} finally {
TenantResolver.clear();
}
}
}
测试应用程序
启动 Spring Boot 应用程序后,我们可以通过以下方式测试 API:
-
获取租户信息:
curl --location 'localhost:8080/tenants'
响应:
[ {"id": 1, "name": "tenant one", "key": "tenant1", "db_url": "jdbc:postgresql://localhost:5433/postgres", "db_username": "tenant1user", "db_password": "postgres"}, {"id": 2, "name": "tenant two", "key": "tenant2", "db_url": "jdbc:postgresql://localhost:5434/postgres", "db_username": "tenant2user", "db_password": "postgres"} ]
-
获取租户 1 的数据:
curl --location 'localhost:8080/persons' --header 'X-Tenant-Key: tenant1'
响应:
[ {"id": 1, "name": "rav", "age": "34"}, {"id": 2, "name": "ron", "age": "23"} ]
-
获取租户 2 的数据:
curl --location 'localhost:8080/persons' --header 'X-Tenant-Key: tenant2'
响应:
[ {"id": 1, "name": "ramesh", "age": "11"}, {"id": 2, "name": "suresh", "age": "39"} ]
工作流程总结
- 客户端在请求头中传递
X-Tenant-Key
(如tenant1
)。 TenantFilter
从请求头中提取租户标识并存储到ThreadLocal
。- Spring 在执行数据库查询时调用
AbstractRoutingDataSource.determineCurrentLookupKey()
。 TenantResolver.getCurrentTenant()
返回当前租户标识。- 根据租户标识,Spring 从
routingDataSource
中选择对应的DataSource
,完成数据查询。
优化与扩展建议
-
安全性:
- 将数据库凭据存储在环境变量或外部密钥管理服务中。
- 对租户密钥进行加密存储。
-
性能:
- 在
tenantDataSources()
中添加缓存机制(如 Caffeine 或 Redis),避免频繁查询默认数据库。 - 使用连接池参数优化(如 HikariCP 的
maximumPoolSize
)。
- 在
-
动态租户管理:
- 提供 API 支持动态添加或删除租户,实时更新
routingDataSource
的targetDataSources
。
- 提供 API 支持动态添加或删除租户,实时更新
-
异常处理:
- 在
TenantFilter
中添加验证逻辑,确保租户标识有效。 - 处理数据库连接失败的情况,返回友好的错误信息。
- 在
结论
通过本文,我们实现了一个基于 Per-Tenant-Per-DB 架构的多租户 Spring Boot 应用程序。核心思想是利用 AbstractRoutingDataSource
和 ThreadLocal
实现动态数据源切换,确保每个租户的数据隔离。这种方法适用于需要高隔离性、高安全性的场景,如企业级 SaaS 应用。
评论区